Go 项目接入 OAuth2 授权服务
前言
最近给后端项目接入了一套 OAuth2 授权服务器(Authorization Server),踩了一些坑,也去掉了一个过度设计的页面——服务端渲染的授权确认页。这篇文章把最终定型的方案讲透,不贴具体业务代码,你照着做,系统半小时就能接完。
目标读者:已经有一套基于 Gin + GORM 的后端,想用最短路径把"第三方应用用我们账号登录"这件事做出来的工程师。
一、先厘清概念:OAuth2 不是登录,它是委托授权
很多人上来就写 /login,这是错的。OAuth2 解决的是一个非常具体的问题:
用户(Resource Owner)已经登录了你的系统。现在有一个第三方应用(Client)想以用户的身份访问你的 API。用户不想把账号密码给第三方,而是点一下"同意",第三方就拿到一个有时效的令牌去调接口。
整个流程是 OAuth2 里最主流的 授权码模式 + PKCE(Authorization Code Grant + PKCE),也是我们唯一实现的模式:
┌──────────┐ 1. GET /authorize?client_id=..&redirect_uri=..&scope=..&state=..&code_challenge=..
│ 第三方 │ ───────────────────────────────────────────────────────────────────────────────────►┌──────────────────┐
│ Client │ │ 授权服务器(我们) │
└──────────┘ ◄───────────────────────────────────────────────────────────────────────────────────│ 302 → /login │
▲ └────────┬─────────┘
│ 2. 未登录 │
│ ▼
│ ┌──────────────────┐
│ │ 前端 Login 表单 │
│ └────────┬─────────┘
│ │ 3. 登录成功
│ ▼
│ ┌──────────────────┐
│ 4. 302 → redirect_uri?code=xxx&state=xxx │ 签发 auth code │
│ ◄───────────────────────────────────────────────────────────────────────────────────────│ │
│ └──────────────────┘
│
│ 5. POST /token (code + code_verifier + client_id + client_secret) [后端对后端]
├────────────────────────────────────────────────────────────────────────────────────────────────────►
│ 6. { access_token, refresh_token, expires_in, scope, token_type }
│ ◄────────────────────────────────────────────────────────────────────────────────────────────────────
│
│ 7. GET /userinfo Authorization: Bearer xxxx
├────────────────────────────────────────────────────────────────────────────────────────────────────►
│ 8. { sub, username, name, email, role, scope, client_id }
│ ◄────────────────────────────────────────────────────────────────────────────────────────────────────
三个你必须刻在脑子里的点:
- 授权页和登录页是两回事。登录页是你自家已有的前端登录表单;授权同意页(Consent)在"可信客户端 / 自家生态"场景里完全可以省掉。
- code 是一次性、短命的(默认 10 分钟),只用来换 token;真正调用 API 的是 access_token。
- client 必须事先注册——每个第三方应用都有
client_id、client_secret、允许的redirect_uri域。
二、技术选型:别自己造轮子,用 go-oauth2/v4
Go 生态里最成熟的 OAuth2 服务端库是 go-oauth2/oauth2(v4)。它把"生成 code、换 token、刷新 token、校验 token"这些重活都干了,你只需要插两块存储:
- ClientStore:
client_id→ client 信息(存你自己的 DB)。 - TokenStore:code / access_token / refresh_token 的读写(也存你 DB,方便多实例部署)。
依赖:
go get github.com/go-oauth2/oauth2/v4
go get github.com/gin-contrib/sessions
三、数据模型:三张表就够
3.1 oauth2_clients:注册的第三方应用
type OAuth2Client struct {
Id int `gorm:"primaryKey"`
Name string `gorm:"type:varchar(128);not null"` // 这就是 client_id
Secret string `gorm:"type:varchar(256);not null"` // client_secret(JSON 永远不输出)
Domain string `gorm:"type:varchar(512);default:''"` // 允许的 redirect_uri 前缀
Public bool `gorm:"default:false"` // public client(SPA / 移动 App 无 secret)
Scopes string `gorm:"type:varchar(512);default:''"` // 该 client 允许的 scope
CreatedAt time.Time
UpdatedAt time.Time
}
关键设计:
Name就是client_id——不要用自增 ID,那是内部主键,对外暴露不友好。Secret的 JSON tag 用json:"-",List/Get 接口永远不会把 secret 漏出去;只有创建 / 重置时单独返回一次明文。Domain用来做 redirect_uri 前缀校验(见第五章)。- 让这个 model 直接实现
oauth2.ClientInfo接口(GetID/GetSecret/GetDomain/IsPublic/GetUserID),省一层 DTO。
3.2 oauth2_tokens:code / access / refresh 共存一张表
type OAuth2Token struct {
Id int64 `gorm:"primaryKey;autoIncrement"`
ClientID string `gorm:"type:varchar(128);index"`
UserID string `gorm:"type:varchar(128);index"`
RedirectURI string `gorm:"type:varchar(512)"`
Scope string `gorm:"type:varchar(512)"`
Code string `gorm:"type:varchar(256);index"` // 普通 index,不是 uniqueIndex
CodeChallenge string `gorm:"type:varchar(256)"`
CodeChallengeMethod string `gorm:"type:varchar(32)"`
CodeCreateAt int64 `gorm:"default:0"`
CodeExpiresIn int64 `gorm:"default:0"`
Access string `gorm:"type:varchar(256);index"`
AccessCreateAt int64 `gorm:"default:0"`
AccessExpiresIn int64 `gorm:"default:0"`
Refresh string `gorm:"type:varchar(256);index"`
RefreshCreateAt int64 `gorm:"default:0"`
RefreshExpiresIn int64 `gorm:"default:0"`
RawExtension string `gorm:"type:text"`
CreatedAt time.Time
}
这次 commit 专门修的坑:一开始我给 Code 加了 uniqueIndex。理论上 code 用一次就被 RemoveByCode 物理删除不会重复,但 PKCE 校验失败、网络重发、用户双击等场景下,同一条 code 可能被插入两次,第二次直接 500。改成普通 index,让消费侧 RemoveByCode 用 WHERE code = ? DELETE 做幂等删除,容错高得多。access / refresh 同理——库内部用高熵随机字符串保证唯一性,DB 层不必强制唯一索引。
时间字段的坑:go-oauth2 用 time.Time 和 time.Duration,DB 里存 int64(UnixNano),读写都要做转换。
PKCE 字段:CodeChallenge、CodeChallengeMethod 必须保留,否则 PKCE 流程用不了(现代 SPA / 移动 App 的标配)。
RawExtension:存 PKCE 等扩展字段(url-encoded),否则 refresh token 时这些信息会丢。
3.3 oauth2_authorizations:用户授权记录(可选)
留着这张表(client_id + user_id + scope 建复合索引),将来做"已授权应用列表""撤销授权"时用得上;极简方案可以不写入。
四、两个 Store:把 go-oauth2 接到你的 GORM 上
4.1 ClientStore:一个方法
// pkg/oauth2server/client_store.go
type ClientStore struct{}
func NewClientStore() *ClientStore { return &ClientStore{} }
func (s *ClientStore) GetByID(ctx context.Context, id string) (oauth2.ClientInfo, error) {
var client model.OAuth2Client
// 注意:用 Name 作为 client_id 查询,不是主键 Id
if err := model.DB.Where("name = ?", id).First(&client).Error; err != nil {
return nil, err
}
return &client, nil
}
var _ oauth2.ClientStore = (*ClientStore)(nil)
4.2 TokenStore:七个方法 + 一个适配器
Create 写库、Get* 查库 + 适配、Remove* 按字段删库。最容易漏的是 PKCE 扩展字段:
func (s *TokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error {
t := &model.OAuth2Token{
ClientID: info.GetClientID(),
UserID: info.GetUserID(),
RedirectURI: info.GetRedirectURI(),
Scope: info.GetScope(),
Code: info.GetCode(),
CodeChallenge: info.GetCodeChallenge(),
CodeChallengeMethod: string(info.GetCodeChallengeMethod()),
CodeCreateAt: info.GetCodeCreateAt().UnixNano(),
CodeExpiresIn: int64(info.GetCodeExpiresIn()),
Access: info.GetAccess(),
AccessCreateAt: info.GetAccessCreateAt().UnixNano(),
AccessExpiresIn: int64(info.GetAccessExpiresIn()),
Refresh: info.GetRefresh(),
RefreshCreateAt: info.GetRefreshCreateAt().UnixNano(),
RefreshExpiresIn: int64(info.GetRefreshExpiresIn()),
}
// ★ 关键:PKCE 等扩展字段必须走 ExtendableTokenInfo 存进 RawExtension
if ext, ok := info.(oauth2.ExtendableTokenInfo); ok {
t.RawExtension = ext.GetExtension().Encode()
}
return model.DB.Create(t).Error
}
GetByCode / GetByAccess / GetByRefresh 都是 DB.Where(field=?).First + toTokenInfo。Remove* 都是 DB.Where(field=?).Delete(&model.OAuth2Token{})。
toTokenInfo 把 DB 记录转回 oauth2.TokenInfo:
func (s *TokenStore) toTokenInfo(t *model.OAuth2Token) oauth2.TokenInfo {
ext := make(url.Values)
if t.RawExtension != "" {
ext, _ = url.ParseQuery(t.RawExtension)
}
return &tokenInfoAdapter{
clientID: t.ClientID,
userID: t.UserID,
redirectURI: t.RedirectURI,
scope: t.Scope,
code: t.Code,
codeChallenge: t.CodeChallenge,
codeChallengeMethod: oauth2.CodeChallengeMethod(t.CodeChallengeMethod),
codeCreateAt: time.Unix(0, t.CodeCreateAt),
codeExpiresIn: time.Duration(t.CodeExpiresIn),
access: t.Access,
accessCreateAt: time.Unix(0, t.AccessCreateAt),
accessExpiresIn: time.Duration(t.AccessExpiresIn),
refresh: t.Refresh,
refreshCreateAt: time.Unix(0, t.RefreshCreateAt),
refreshExpiresIn: time.Duration(t.RefreshExpiresIn),
extension: ext,
}
}
tokenInfoAdapter 就是一坨 getter/setter(IDE 一键生成即可)。必须同时实现 oauth2.TokenInfo 和 oauth2.ExtendableTokenInfo 两个接口(后者多了 GetExtension/SetExtension),否则 PKCE 扩展字段存不进 RawExtension,会出现"access token 能发但 refresh 神秘失败"的诡异 bug。最后加编译期接口检查:
var _ oauth2.TokenInfo = (*tokenInfoAdapter)(nil)
var _ oauth2.ExtendableTokenInfo = (*tokenInfoAdapter)(nil)
五、Server 初始化:让你"能删 Consent 页"的三行关键代码
// pkg/oauth2server/server.go
var (
Manager *manage.Manager
Srv *server.Server
)
type contextKey string
const userIDContextKey contextKey = "oauth2_user_id"
// WithUserID 供 Authorize 控制器在确定用户身份后,把 userID 注入 request context
func WithUserID(r *http.Request, userID string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), userIDContextKey, userID))
}
func Init(tokenStoreCfg *manage.Config, refreshCfg *manage.RefreshingConfig) {
manager := manage.NewDefaultManager()
manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
manager.SetClientTokenCfg(manage.DefaultClientTokenCfg)
if tokenStoreCfg != nil {
manager.SetAuthorizeCodeTokenCfg(tokenStoreCfg)
}
// ① 严格 redirect_uri 校验:精确匹配 + 同域前缀匹配
manager.SetValidateURIHandler(func(baseURI, redirectURI string) error {
if baseURI == "" {
return nil // 管理员没配 domain 时先放行,方便灰度
}
if redirectURI == baseURI {
return nil
}
if strings.HasPrefix(redirectURI, strings.TrimSuffix(baseURI, "/")+"/") {
return nil
}
common.SysLog("OAuth2 invalid redirect URI: base=" + baseURI + " got=" + redirectURI)
return errors.ErrInvalidRedirectURI
})
manager.MapTokenStorage(NewTokenStore())
manager.MapClientStorage(NewClientStore())
Manager = manager
srv := server.NewDefaultServer(manager)
srv.SetAllowGetAccessRequest(true)
srv.SetClientInfoHandler(server.ClientFormHandler) // client_id/secret 从 form 读(标准)
// ② UserAuthorizationHandler 返回 userID = 用户已同意
// 这是能删除 Consent 页的根本原因——只要用户已登录,直接视为授权
srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) {
uid, _ := r.Context().Value(userIDContextKey).(string)
return uid, nil
})
Srv = srv
}
func InitDefault() { Init(nil, nil) }
为什么能删 Consent 页?
老逻辑里 UserAuthorizationHandler 返回空字符串,库会抛出"需要授权"错误,我们捕获后 302 到一个服务端渲染的 /api/oauth2/consent HTML 页面,用户点"允许"按钮 POST 回来,再在 session 里打一个 oauth2_consented=true 的标记,最后 302 回 /authorize 真正签发 code。这一整套往返需要:
- 把 7 个 authorize 参数存进 session(client_id、redirect_uri、scope、state、response_type、code_challenge、code_challenge_method)
- 一个 GET 渲染 HTML 同意页
- 一个 POST 处理"允许/拒绝"
- 从 session 重建 authorize URL 再重定向
对自家生态、内网系统、第一方客户端来说,这纯粹是过度设计——用户已经登录了你的系统,为什么还要再点一次"同意"?直接把 userID 返回给库,一步到位签发 code。删掉这部分省了 ~200 行代码、两个端点、一坨 session 字段,还顺便消灭了一个潜在的开放重定向风险(HTML 页里 redirect_uri 拼错就翻车)。
如果将来真要接第三方不可信应用怎么办? 在这里加一层:从 session 里读一个"consented"标记,没标就 302 到你前端的 React/Vue 授权同意页(而不是服务端拼 HTML 字符串),用户点同意后由前端带标记重定向回 /authorize。架构上留好口子,不提前实现。
其他细节:
SetAllowGetAccessRequest(true)允许 access_token 作为 query 参数传,方便/userinfo兼容一些老客户端。SetClientInfoHandler(server.ClientFormHandler)让 client_id/secret 从 POST body 读(标准 OAuth2 写法),大部分 SDK 默认就是这种模式。- redirect_uri 校验默认是"字符串完全相等",太死。改成"精确匹配 + 前缀匹配"后,client 配一个
https://app.example.com,回调https://app.example.com/cb、https://app.example.com/auth/callback都能过,而https://evil.com/cb会被拦截并打日志。
在 main.go 里调 oauth2server.InitDefault() 即可启动。
六、Controller:四个核心端点 + 客户端管理
6.1 /authorize(GET)——整个流程最复杂的一个
// GET /api/oauth2/authorize
func OAuth2Authorize(c *gin.Context) {
session := sessions.Default(c)
username := session.Get("username")
id := session.Get("id")
role := session.Get("role")
// ① 没登录但带了 Bearer token(方便 Postman/后端调试),尝试用 token 鉴权
if username == nil {
if auth := c.Request.Header.Get("Authorization"); auth != "" {
if token := strings.TrimPrefix(auth, "Bearer "); token != auth {
if user, err := model.ValidateAccessToken(token); err == nil && user != nil {
username, id, role = user.Username, user.Id, user.Role
}
}
}
}
// ② 还没登录 → 把当前 URL 塞 session,302 到前端登录页
if username == nil {
fullURL := c.Request.URL.String()
session.Set("oauth2_pending_authorize_url", fullURL)
_ = session.Save()
c.Redirect(http.StatusFound, "/login?redirect="+url.QueryEscape(fullURL))
c.Abort()
return
}
// ③ 已登录:把 userID 注入 context,交给 go-oauth2 处理
c.Set("username", username)
c.Set("id", id)
c.Set("role", role)
c.Set("group", session.Get("group"))
var userIDStr string
switch v := id.(type) {
case int: userIDStr = strconv.Itoa(v)
case string: userIDStr = v
}
if userIDStr == "" {
renderOAuth2Error(c, "server_error", "unable to determine user id")
return
}
c.Request = oauth2server.WithUserID(c.Request, userIDStr)
_ = c.Request.ParseForm()
if err := oauth2server.Srv.HandleAuthorizeRequest(c.Writer, c.Request); err != nil {
if !c.Writer.Written() {
renderOAuth2Error(c, "invalid_request", err.Error())
}
}
c.Abort()
}
重点解释:
- 支持 Bearer token 直接授权:在 Header 里带
Authorization: Bearer <api-token>可以跳过浏览器登录流程,方便 Postman / 后端服务调试。 - 登录回跳:
session["oauth2_pending_authorize_url"]在登录成功后由setupLogin读取,放进响应 JSON 的data.redirect字段,前端拿到后window.location.href = data.redirect,回到/authorize?...时 session 已有 username,直接走 ③ 签发 code。 - 不再做 consent 中转:这是这次 commit 删除的主要代码(约 140 行 HTML + 两个 handler)。
- userID 类型兼容:session 里的
id可能是int也可能是string(取决于你怎么存),switch 处理一下。 c.Request.ParseForm()必须调:HandleAuthorizeRequest读 query 参数依赖 form 被解析过。
6.2 /token(POST)——一行代码
// POST /api/oauth2/token
func OAuth2Token(c *gin.Context) {
if err := oauth2server.Srv.HandleTokenRequest(c.Writer, c.Request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": err.Error(),
})
}
}
路由上必须加 CORS 和严格限流:
oauth2Route.POST("/token", middleware.CriticalRateLimit(), controller.OAuth2Token)
这是第三方后端拿 client_secret 换 token 的入口,被暴力撞库就完蛋。CORS 也要开,否则前端用 fetch 调 /token(public client 场景)会被拦。
6.3 /userinfo(GET/POST)——OIDC 风格的用户信息端点
go-oauth2 不内置 userinfo 端点,得自己写。逻辑很直白:取 access token → 从 Manager 加载 → 取 userID → 查 DB → 返回标准字段。
// GET/POST /api/oauth2/userinfo
func OAuth2UserInfo(c *gin.Context) {
accessToken := c.Request.Header.Get("Authorization")
if accessToken == "" {
accessToken = c.Query("access_token")
}
if accessToken == "" {
accessToken = c.PostForm("access_token")
}
if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_request"})
return
}
accessToken = strings.TrimPrefix(accessToken, "Bearer ")
tokenInfo, err := oauth2server.Manager.LoadAccessToken(c.Request.Context(), accessToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
userID, _ := strconv.Atoi(tokenInfo.GetUserID())
user := model.User{Id: userID}
if err := user.FillUserById(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"sub": strconv.Itoa(user.Id), // OIDC 规范:sub 必填,即 subject(用户唯一 ID)
"username": user.Username,
"name": user.DisplayName,
"email": user.Email,
"role": user.Role,
"scope": tokenInfo.GetScope(),
"client_id": tokenInfo.GetClientID(),
})
}
按 OIDC 规范,sub 字段是必须的,代表用户的唯一标识;其余字段(username、email、name 等)按需提供。支持三种取 token 方式(Header / query / form)是因为很多 SDK 默认走 query 传 access_token。
6.4 /revoke(POST)——吊销令牌
// POST /api/oauth2/revoke
func OAuth2RevokeToken(c *gin.Context) {
token := c.PostForm("token")
hint := c.PostForm("token_type_hint")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
ctx := c.Request.Context()
switch hint {
case "refresh_token":
_ = oauth2server.Manager.RemoveRefreshToken(ctx, token)
default:
if err := oauth2server.Manager.RemoveAccessToken(ctx, token); err != nil {
_ = oauth2server.Manager.RemoveRefreshToken(ctx, token)
}
}
c.JSON(http.StatusOK, gin.H{})
}
RFC 7009 规定返回 200 即使 token 不存在(防止用吊销结果探测 token 有效性),所以这里不判断删除是否成功。hint 是可选的"提示",没传时先尝试 access 再 refresh。
6.5 /logout(GET,可选)——OIDC RP-Initiated Logout
第三方客户端跳这个端点可以让用户在授权服务器这边也退出登录。要注意防开放重定向:回调地址必须匹配某个已注册 client 的 domain。
func OAuth2Logout(c *gin.Context) {
session := sessions.Default(c)
// 可选:吊销 hint 里带的 token
if hint := c.Query("id_token_hint"); hint != "" {
ctx := c.Request.Context()
_ = oauth2server.Manager.RemoveAccessToken(ctx, hint)
_ = oauth2server.Manager.RemoveRefreshToken(ctx, hint)
}
session.Clear()
_ = session.Save()
uri := c.Query("post_logout_redirect_uri")
state := c.Query("state")
if uri != "" && isPostLogoutURIAllowed(uri) {
u, _ := url.Parse(uri)
if state != "" {
q := u.Query(); q.Set("state", state); u.RawQuery = q.Encode()
}
c.Redirect(http.StatusFound, u.String())
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
isPostLogoutURIAllowed 会遍历所有已注册 client,判断 post_logout_redirect_uri 的 host 是否和某个 client 的 Domain 匹配。没有白名单就 302 到重定向地址——这是典型的开放重定向漏洞,绝对不能省。
6.6 客户端 CRUD(管理员接口)
五个接口,挂在 AdminAuth 中间件后面:
GET /api/oauth2/clients列出所有 client(返回时不包含 secret)GET /api/oauth2/clients/:id查单个 client(返回 secret,管理员用)POST /api/oauth2/clients创建 client(自动生成 32 位随机 secret,只在这一次返回明文)DELETE /api/oauth2/clients/:id删除 client(级联删掉这个 client 的所有 token)POST /api/oauth2/clients/:id/reset-secret重置 secret(使老 secret 和所有 token 立即失效)
secret 生成用 common.GetRandomString(32),不要用 UUID——熵不够。重置 secret 时要级联删除该 client 所有 token,否则用老 secret 签出去的 access_token 还能用,等于没重置。
七、路由注册
oauth2Route := apiRouter.Group("/oauth2")
{
oauth2Route.Use(middleware.CORS()) // /token 和 /userinfo 需要 CORS
oauth2Route.GET("/logout", controller.OAuth2Logout)
oauth2Route.GET("/authorize", controller.OAuth2Authorize)
oauth2Route.POST("/revoke", controller.OAuth2RevokeToken)
oauth2Route.POST("/token", middleware.CriticalRateLimit(), controller.OAuth2Token)
oauth2Route.GET("/userinfo", controller.OAuth2UserInfo)
oauth2Route.POST("/userinfo", controller.OAuth2UserInfo)
oauth2Clients := oauth2Route.Group("/clients")
oauth2Clients.Use(middleware.AdminAuth(), middleware.OperationLog())
{
oauth2Clients.GET("/", controller.ListOAuth2Clients)
oauth2Clients.GET("/:id", controller.GetOAuth2Client)
oauth2Clients.POST("/", controller.CreateOAuth2Client)
oauth2Clients.DELETE("/:id", controller.DeleteOAuth2Client)
oauth2Clients.POST("/:id/reset-secret", controller.ResetOAuth2ClientSecret)
}
}
注意:
/authorize不要挂UserAuth()中间件——我们要自己处理"未登录"的情况(302 到登录页),挂了中间件就会直接返回 401,浏览器没法跳。/token必须挂限流,这是暴力撞库的重灾区。/clients/*必须挂AdminAuth(),普通用户不能看到或操作 client 列表。- 整个
/oauth2group 要开 CORS,但Allow-Origin不要设成*——生产环境应做白名单。
八、登录回跳的前端配合(一个不起眼但容易漏的细节)
OAuth2Authorize 在用户未登录时会 302 到 /login?redirect=<urlencoded_authorize_url>。登录成功后,后端 setupLogin 会把这个 URL 放进响应 JSON:
data := map[string]any{
"id": user.Id, "username": user.Username, /* ... */
}
if pendingRedirect, _ := session.Get("oauth2_pending_authorize_url").(string); pendingRedirect != "" {
session.Delete("oauth2_pending_authorize_url")
data["redirect"] = pendingRedirect
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
前端的登录表单在收到 data.redirect 时,必须 window.location.href = data.redirect,让浏览器带着 session cookie 重新访问 /authorize。这一步是整个 OAuth2 流程"断点续传"的关键——少了它,用户登录完会停在首页,永远拿不到 code。
九、功能自检:6 个请求验证你的服务
1. 创建 client
curl -X POST http://localhost:8080/api/oauth2/clients \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"name":"my-app","domain":"http://localhost:3000","public":false,"scopes":"read"}'
# 返回 {id, name, secret, domain, ...} — 记住 secret,只出现一次
2. 浏览器打开 authorize URL(会跳登录页)
http://localhost:8080/api/oauth2/authorize?
client_id=my-app
&redirect_uri=http://localhost:3000/cb
&response_type=code
&scope=read
&state=xyz123
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
登录成功后会 302 到:http://localhost:3000/cb?code=<auth_code>&state=xyz123
3. 用 code 换 token(后端对后端)
curl -X POST http://localhost:8080/api/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=<auth_code>" \
-d "redirect_uri=http://localhost:3000/cb" \
-d "client_id=my-app" \
-d "client_secret=<secret>" \
-d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
# 返回 {"access_token":"...","refresh_token":"...","expires_in":7200,"token_type":"Bearer","scope":"read"}
code_verifier是 PKCE 校验串,code_challenge = BASE64URL(SHA256(code_verifier))。生成方法见 RFC 7636。
4. 用 access_token 取用户信息
curl http://localhost:8080/api/oauth2/userinfo \
-H "Authorization: Bearer <access_token>"
# 返回 {"sub":"123","username":"alice","name":"Alice","email":"...","role":1,"scope":"read","client_id":"my-app"}
5. 用 refresh_token 刷新
curl -X POST http://localhost:8080/api/oauth2/token \
-d "grant_type=refresh_token&refresh_token=<refresh_token>&client_id=my-app&client_secret=<secret>"
6. 吊销 token
curl -X POST http://localhost:8080/api/oauth2/revoke \
-d "token=<access_token>"
六个请求全通,你的 OAuth2 授权服务就正式上线了。
十、常见坑位小结(别再踩了)
| 坑 | 症状 | 解法 |
|---|---|---|
Code 字段加了 uniqueIndex |
同 code 重发时 500 | 改成普通 index,靠 RemoveByCode 幂等删 |
没实现 ExtendableTokenInfo |
refresh 神秘失败、PKCE 丢失 | adapter 必须同时实现两个接口 |
| redirect_uri 用默认精确匹配 | client 换路径就 invalid | 自定义 ValidateURIHandler 加前缀匹配 |
UserAuthorizationHandler 返回空 |
永远弹 consent 或报错 | 已登录直接返回 userID,自家生态可省 consent |
/authorize 挂了 UserAuth 中间件 |
未登录直接 401,浏览器没法跳登录页 | 自己处理未登录分支,302 到登录页 |
/token 没加限流 |
被暴力撞库 client_secret | 挂 CriticalRateLimit() |
/logout 不校验 redirect host |
开放重定向漏洞 | 白名单校验 post_logout_redirect_uri 的 host |
| secret 用 UUID 生成 | 熵不够,易被猜 | 用 32 字节的 crypto/rand 随机串 |
| 重置 secret 不删 token | 老 token 依然能用 | 级联 DELETE oauth2_tokens WHERE client_id = ? |
前端没处理 data.redirect |
登录完停首页,code 永远拿不到 | 登录表单收到 redirect 就 window.location.href = redirect |
时间字段直接存 time.Time |
SQLite/PG 时区错乱 | 统一存 UnixNano int64,time.Unix(0, ns) 还原 |
十一、什么时候该把 Consent 页加回来?
删掉 consent 页有一个隐含前提:所有 client 都是你自家或信任的。如果未来:
- 要开放给第三方开发者注册应用
- 要做"用 XX 账号登录"这种面向公网的按钮
- 需要让用户细粒度选择授权 scope(比如"只授权读昵称、不授权读邮箱")
那就在 UserAuthorizationHandler 之前加一层:检查 DB 里 oauth2_authorizations 是否存在该 user_id+client_id+scope 的记录,没有就 302 到前端 React/Vue 页面(别再用服务端拼 HTML 字符串)做 consent,用户同意后写入 oauth2_authorizations 再重定向回 /authorize。架构上这条口子一直留着,但不要过早实现。
十二、完整文件清单
按这篇博客接完,你的项目里会多这些东西:
pkg/oauth2server/
├── server.go # Manager/Srv 初始化,3 个关键 handler
├── client_store.go # GORM 版 ClientStore
└── token_store.go # GORM 版 TokenStore + tokenInfoAdapter
model/
└── oauth2_client.go # OAuth2Client / OAuth2Token / OAuth2Authorization 三个 model
controller/
└── oauth2_server.go # Authorize/Token/Userinfo/Revoke/Logout + Clients CRUD
router/
└── api-router.go # /oauth2/* 路由注册(参考第七章)
加上 main.go 里一行 oauth2server.InitDefault(),总计大约 600~700 行代码——对一个完整的、支持 PKCE 的 OAuth2 授权服务器来说,这已经非常精简了。
写在最后
很多人接 OAuth2 觉得它很复杂,是因为 RFC 6749 写得像法律文书、一上来就讲 4 种授权模式、各种扩展 RFC(PKCE、OIDC、Token Revocation、Token Introspection…)。但你真正要落地的其实只有一种模式——Authorization Code + PKCE——而且 go-oauth2 这个库已经帮你把协议层面的脏活累活都干了。
你要做的事本质上只有三件:
- 定义好数据模型(client 和 token 存哪里)
- 把 userID 正确地喂给库(处理好"未登录→登录→回跳"这条链路)
- 把安全细节堵上(redirect_uri 校验、限流、开放重定向、secret 熵)
理解了这三点,你就不是在"复制粘贴代码接 OAuth2",而是真的在设计一个授权服务器。下次再遇到要接 OAuth2 的系统,从这篇博客抄过去改改模型字段,半小时上线。

浙公网安备 33010602011771号