You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Go后端OAuth2令牌存储、用户关联及受保护路由实现咨询

解决OAuth2受保护路由的核心问题

一、从令牌推断用户标识(无需额外调用userinfo)

你目前通过access_token调用Google的userinfo接口获取用户信息,其实Google OAuth2返回的token里包含ID Token(JWT格式),直接解析就能拿到邮箱、用户唯一ID等核心信息,无需额外发起请求。

步骤实现:

  1. 确保Google OAuth配置包含openid scope(必须添加,否则无法获取ID Token):
func GoogleConfig() *oauth2.Config {
    return &oauth2.Config{
        ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
        ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
        RedirectURL:  os.Getenv("GOOGLE_REDIRECT_URL"),
        Scopes: []string{
            "openid", // 必须添加该scope才能获取ID Token
            "email",
            "profile",
        },
        Endpoint: google.Endpoint,
    }
}
  1. 解析ID Token提取用户信息:
import "github.com/golang-jwt/jwt/v5"

// 在Callback函数中,拿到token后执行以下逻辑
idToken, ok := token.Extra("id_token").(string)
if !ok {
    return c.SendString("Failed to get ID Token")
}

// 解析并验证JWT签名(使用Google公钥)
parsedToken, err := jwt.Parse(idToken, func(token *jwt.Token) (interface{}, error) {
    // 验证签名算法是否为RS256
    if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    }
    // 从Google获取公钥(生产环境建议缓存公钥,避免重复请求)
    certResp, err := http.Get("https://www.googleapis.com/oauth2/v3/certs")
    if err != nil {
        return nil, err
    }
    defer certResp.Body.Close()
    certBytes, _ := io.ReadAll(certResp.Body)
    var certMap map[string]string
    json.Unmarshal(certBytes, &certMap)
    // 根据token header中的kid匹配对应公钥
    kid, ok := token.Header["kid"].(string)
    if !ok {
        return nil, fmt.Errorf("kid not found in token header")
    }
    cert := certMap[kid]
    return jwt.ParseRSAPublicKeyFromPEM([]byte(cert))
})

if err != nil || !parsedToken.Valid {
    return c.SendString("Invalid ID Token")
}

// 提取用户核心信息
claims, _ := parsedToken.Claims.(jwt.MapClaims)
email := claims["email"].(string)
emailVerified := claims["email_verified"].(bool)
googleUserID := claims["sub"].(string) // Google分配的用户唯一标识

二、令牌的存储方案

1. 给客户端的会话令牌(用于受保护路由验证)

不要直接将Google的access_token返回给客户端,应该生成自定义服务端会话令牌(比如JWT),通过HttpOnly Cookie或Authorization Header传递给客户端:

示例:生成自定义JWT并返回给客户端

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

// 自定义JWT声明
type CustomClaims struct {
    UserID uint   `json:"user_id"`
    Email  string `json:"email"`
    jwt.RegisteredClaims
}

// 在Callback函数的用户创建/查询逻辑之后
var targetUser models.User
if existingUser.ID != 0 {
    targetUser = existingUser
} else {
    targetUser = newUser // 注意:你原代码中创建newUser后未赋值给existingUser,需修正此逻辑
}

// 生成会话JWT
expirationTime := time.Now().Add(24 * time.Hour)
claims := &CustomClaims{
    UserID: targetUser.ID,
    Email:  targetUser.Email,
    RegisteredClaims: jwt.RegisteredClaims{
        ExpiresAt: jwt.NewNumericDate(expirationTime),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        Issuer:    "your-app-name", // 替换为你的应用名称
    },
}

// 使用自定义密钥签名
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
if err != nil {
    return c.SendString("Failed to generate session token")
}

// 推荐使用HttpOnly Cookie存储令牌(防止XSS攻击)
c.Cookie(&fiber.Cookie{
    Name:     "session_token",
    Value:    tokenString,
    Expires:  expirationTime,
    HTTPOnly: true,
    Secure:   true, // 生产环境开启,仅HTTPS传输
    SameSite: fiber.CookieSameSiteStrictMode,
})

// 也可以返回令牌让客户端存在Authorization Header中
// return c.JSON(fiber.Map{"token": tokenString})

2. 存储Google的OAuth2令牌(用于后续API调用)

如果需要后续调用Google的服务API(比如用户日历、云端硬盘),需将Google返回的token(包含refresh_token)存入数据库,关联用户ID:

// 给User模型添加字段
type User struct {
    // ... 原有字段
    GoogleAccessToken  string    `gorm:"type:text"`
    GoogleRefreshToken string    `gorm:"type:text"`
    TokenExpiresAt     time.Time `gorm:"type:datetime"`
}

// 在Callback函数中保存令牌
targetUser.GoogleAccessToken = token.AccessToken
targetUser.GoogleRefreshToken = token.RefreshToken
targetUser.TokenExpiresAt = token.Expiry
database.DB.Db.Save(&targetUser)

三、实现受保护路由(中间件验证)

通过Fiber中间件验证会话令牌,验证通过后将用户信息注入上下文,供后续路由使用:

// 定义身份验证中间件
func AuthMiddleware() fiber.Handler {
    return func(c *fiber.Ctx) error {
        // 从Cookie获取令牌,或从Authorization Header提取
        tokenString := c.Cookies("session_token")
        if tokenString == "" {
            authHeader := c.Get("Authorization")
            if len(authHeader) > 7 && strings.HasPrefix(authHeader, "Bearer ") {
                tokenString = authHeader[7:]
            } else {
                return c.Status(fiber.StatusUnauthorized).SendString("Unauthorized")
            }
        }

        // 解析并验证JWT
        claims := &CustomClaims{}
        token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
            return []byte(os.Getenv("JWT_SECRET")), nil
        })

        if err != nil || !token.Valid {
            return c.Status(fiber.StatusUnauthorized).SendString("Invalid token")
        }

        // 将用户信息存入上下文
        c.Locals("user_id", claims.UserID)
        c.Locals("user_email", claims.Email)

        // 继续执行后续路由逻辑
        return c.Next()
    }
}

// 配置路由
func SetupRoutes(app *fiber.App) {
    app.Get("/callback", Callback)
    // 受保护路由组
    protected := app.Group("/api")
    protected.Use(AuthMiddleware())
    // 示例受保护路由:获取用户信息
    protected.Get("/profile", func(c *fiber.Ctx) error {
        userID := c.Locals("user_id").(uint)
        var user models.User
        if err := database.DB.Db.First(&user, userID).Error; err != nil {
            return c.Status(fiber.StatusNotFound).SendString("User not found")
        }
        return c.JSON(user)
    })
}

注意事项

  • 生产环境必须使用HTTPS,防止令牌被劫持
  • JWT密钥需妥善保管,存入环境变量,禁止硬编码
  • Google的refresh_token需加密存储,避免泄露
  • 解析ID Token时,需验证签名、过期时间、受众(aud)等字段,确保令牌有效

内容的提问来源于stack exchange,提问作者Zev Ross

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 21:43:14