Skip to content

oauth授权流程(授权码模式)

约 1894 字大约 6 分钟

2025-04-14

OAuth授权基础流程

详细解析

示例: 抖音 OAuth授权流程

此处我们以今日头条通过抖音OAuth为例, 示例网站: PC端今日头条, 点击登录, 登录方式为Github OAuth授权,

  • 点击 右上角登录今日头条
  • 选择 第三方登录中的抖音登录今日头条
  • 跳转至抖音授权页面, 页面链接为: https://open.douyin.com/platform/oauth/connect/?client_key=awggm9lb4j3epp3d&scope=user_info&optionalScope=&state=&response_type=code&redirect_uri=https://www.toutiao.com/?wid=1744616706800&from_aweme=1 , 链接中的参数含义如下:
    • client_key: 抖音开放平台申请的应用ID
    • scope: 授权的权限范围, 此处为 user_info, 表示授权用户的基本信息
    • optionalScope: 可选参数, 代表给用户呈现的授权页面, 授权项是否要窜中
    • response_type: 授权类型, 此处为 code, 表示授权码模式
    • redirect_uri: 授权成功后, 跳转的页面链接, 此处为 我们发起登录的头条页面地址, 只是query中拼接一下串联上下文参数
    • state: 状态参数, 用于防止CSRF攻击, 此处未设置 但是建议设置
  • 在抖音授权登录页面进行抖音登录, 此处扫码登录或者手机号登录均可, 此处的本质是登录抖音账号 今日头条
  • 登录抖音后, 观察浏览器地址栏, 会出现一个中间地址跳转(此处更建议开启抓包软件,观察更清晰),链接格式为: https://www.toutiao.com/?code=f7f2a28d9425a0c7EvKt4yZmQF3czxIrqdDW_hj&from_aweme=1&state=&scopes=user_info
    • code: 抖音授权码, 用于获取授权Token
    • from_aweme: 头条页面参数
    • state: 状态参数
    • scopes: 授权的权限范围, 此处为 user_info, 表示授权用户的基本信息
  • 服务端通过 授权 code 获取 Token 及 refresh_token等信息, 并存储在服务端, 同时重定向至触发登录的页面

到此为止, oauth授权登录已经完成

设计一个OAuth服务

本示例在通过简易代码示例实现一个OAuth服务, 用于演示OAuth授权流程, 实际应用中, 会更复杂, 但是核心流程以及原理是不变的, 理解核心流程, 实际应用时, 本质是一样的, 在核心流程舔砖加瓦即可, 下面直接以代码作为示例说明, 详细含义参见代码注释.

package main

import (
	"crypto/rand"
	"encoding/base64"
	"net/http"
	"sync"

	"github.com/gin-gonic/gin"
)

var (
    // client_id 与 client_secret 用于验证调用方是谁.
    // 实际应用中, 一般存储于数据库中
	clients = map[string]string{
		"client_id_123": "client_secret_456",
	}
    // 如果不通过外部传入重定向地址, 而是降低至绑定在cliebt_id上, 此处格式应为
    // 但是固定死回调地址在交互上显得僵硬, 比如: 
    // 用户葱末篇文章详情页登录, 授权后却重定向只首页
    // 这种场景可以优化为, 限制回调host而不是限制回调地址, 这体验上更好
    // 种种可能的变形, 按实际调整即可
    /*clients = map[string]map[string]string{
        "client_id_123": map[string]string{
            "redirect_uri": "Your_Redirect_Uri",
            "cliebt_secret": "client_secret_456",
        }
    }*/

	// 用于存储授权码和授权Token.
    // 实际应用中, 一般存储于redis中, 并设置有效期, 到期自动失效, 此处为了方便演示, 存储于内存中
	authCodes  = make(map[string]string)
    // 申请的授权token.
    //  实际应用中, 一般存储于redis中, 并设置有效期, 到期自动失效, 此处为了方便演示, 存储于内存中
	authTokens = make(map[string]string)
	mutex      sync.RWMutex
)

func main() {
	r := gin.Default()

	r.GET("/authorize", authorizeHandler)
	r.POST("/token", tokenHandler)
	r.GET("/resource", resourceHandler)

	r.Run(":8080")
}

// 授权码模式, 对饮流程中的第一步, 生成code, 并跳转到指定的回调地址
func authorizeHandler(c *gin.Context) {
    // 应用ID, 用于识别调用方是谁
	clientID := c.Query("client_id")
    // 回调地址, 验证之后重定向地址, 示例中重定向地址通过参数传入.
    // 实际应用中, 也可以将回调地址绑定在client_id上, 不接受外部传入, 更加方便管理
	redirectURI := c.Query("redirect_uri")
	// 随机字符串, 用于防止CSRF攻击
    state := c.Query("state")

	if _, ok := clients[clientID]; !ok {
        // 非法的client_id
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
            "error": "Invalid client_id",
        })
		return
	}

    // 生成授权码, 示例中是随机数, 实际应用中, 按照所在技术团队的标准与规范替换生成算法即可
	code := generateRandomString(16)
	mutex.Lock()
    // 存储授权码以及对应的client_id,用于调用authorization时验证授权码是否有效
	authCodes[code] = clientID 
	mutex.Unlock()

    // 重定向至回调地址, 并携带授权码和随机字符串, 用于后续验证
	c.Redirect(http.StatusFound, redirectURI+"?code="+code+"&state="+state) 
}

// 授权码模式, 对饮流程中的第二步, 验证授权码, 并生成授权Token
func tokenHandler(c *gin.Context) {
	clientID := c.PostForm("client_id")
	clientSecret := c.PostForm("client_secret")
	code := c.PostForm("code")

    // 验证client_id和client_secret是否匹配.
    // 实际应用中, 可能是通过签名算法进行验证, 签名通过即匹配, 而不是显示的传递client_secret
	if secret, ok := clients[clientID]; !ok || secret != clientSecret {
        // 传入的client_secrte与分配的client_secret不匹配, 或者client_id不存在
		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
            "error": "Invalid client credentials",
        })
		return
	}

	mutex.RLock()
	storedClientID, ok := authCodes[code]
	mutex.RUnlock()

    // 验证授权码是否有效, 此次简单验证验证码绑定的client_id是否为当前传入的clieng_id
	if !ok || storedClientID != clientID {
        // 授权码与client_id不匹配, 或者授权码已过期
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
            "error": "Invalid authorization code",
        })
		return
	}

    // 生成授权Token, 示例中是随机数. 
    // 实际应用中, 按照所在技术团队的标准与规范替换生成算法即可
	token := generateRandomString(32)
	mutex.Lock()
    // 存储授权Token以及对应的client_id,用于调用resource时验证授权Token是否有效
	authTokens[token] = clientID
    // 授权码已使用, 进行删除. 
    // 如不删除, 则有效期内可以多次使用授权码, 可能会导致授权码被滥用.
    // 在实际应用中可以根据实际情况取舍授权码是否可以重用, 如果重用, 建议授权码有效期短一些
	delete(authCodes, code) 
	mutex.Unlock()

	c.JSON(http.StatusOK, gin.H{
		"access_token": token,
		"token_type":   "Bearer",
		"expires_in":   3600,
	})
}

// 通过授权token获取资源, 对应流程中的第三步, 验证授权Token, 并返回资源
func resourceHandler(c *gin.Context) {
	authHeader := c.GetHeader("Authorization")
	if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
            "error": "Missing or invalid authorization header",
        })
		return
	}

	token := strings.TrimPrefix(authHeader, "Bearer ")
	mutex.RLock()
	_, ok := authTokens[token]
	mutex.RUnlock()

	if !ok {
		c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
            "error": "Invalid access token",
        })
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data": "This is protected resource",
	})
}

// 生成随机字符串, 用于模拟生成授权码和授权Token
func generateRandomString(length int) string {
	b := make([]byte, length)
	rand.Read(b)
	return base64.URLEncoding.EncodeToString(b)
}

Released under the MIT License.