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 }
     │ ◄────────────────────────────────────────────────────────────────────────────────────────────────────

三个你必须刻在脑子里的点:

  1. 授权页和登录页是两回事。登录页是你自家已有的前端登录表单;授权同意页(Consent)在"可信客户端 / 自家生态"场景里完全可以省掉
  2. code 是一次性、短命的(默认 10 分钟),只用来换 token;真正调用 API 的是 access_token。
  3. client 必须事先注册——每个第三方应用都有 client_idclient_secret、允许的 redirect_uri 域。

二、技术选型:别自己造轮子,用 go-oauth2/v4

Go 生态里最成熟的 OAuth2 服务端库是 go-oauth2/oauth2(v4)。它把"生成 code、换 token、刷新 token、校验 token"这些重活都干了,你只需要插两块存储:

  • ClientStoreclient_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,让消费侧 RemoveByCodeWHERE code = ? DELETE 做幂等删除,容错高得多。access / refresh 同理——库内部用高熵随机字符串保证唯一性,DB 层不必强制唯一索引。

时间字段的坑go-oauth2time.Timetime.Duration,DB 里存 int64(UnixNano),读写都要做转换。

PKCE 字段CodeChallengeCodeChallengeMethod 必须保留,否则 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.TokenInfooauth2.ExtendableTokenInfo 两个接口(后者多了 GetExtension/SetExtension),否则 PKCE 扩展字段存不进 RawExtension,会出现"access token 能发但 refresh 神秘失败"的诡异 bug。最后加编译期接口检查:

var _ oauth2.TokenInfo = (*tokenInfoAdapter)(nil)
var _ oauth2.ExtendableTokenInfo = (*tokenInfoAdapter)(nil)

// 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/cbhttps://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 列表。
  • 整个 /oauth2 group 要开 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 页有一个隐含前提:所有 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 这个库已经帮你把协议层面的脏活累活都干了。

你要做的事本质上只有三件:

  1. 定义好数据模型(client 和 token 存哪里)
  2. 把 userID 正确地喂给库(处理好"未登录→登录→回跳"这条链路)
  3. 把安全细节堵上(redirect_uri 校验、限流、开放重定向、secret 熵)

理解了这三点,你就不是在"复制粘贴代码接 OAuth2",而是真的在设计一个授权服务器。下次再遇到要接 OAuth2 的系统,从这篇博客抄过去改改模型字段,半小时上线。

posted @ 2026-07-07 09:24  牛奔  阅读(4)  评论(0)    收藏  举报