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
: 抖音开放平台申请的应用IDscope
: 授权的权限范围, 此处为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)
}