【直播切片工作台】第17章:JWT 鉴权:签发、解析与中间件

2 阅读4分钟

第17章:JWT 鉴权:签发、解析与中间件

本章把 internal/pkg/jwt 与 internal/middleware/auth.go 合起来讲:Claims 里内嵌用户信息的用意、HS256 签名与算法白名单校验(防 alg 混淆攻击)、Bearer 解析的逐层拒绝,以及 AuthUser 上下文的注入与读取。鉴权是除登录外所有接口的统一前置,本章也是理解前端 401 拦截(第 48 章)的后端侧镜像。

流程

一次受保护请求的完整旅程:

  1. 客户端带 Authorization: Bearer <token> 请求任意 v1 受保护接口;
  2. JWTAuth(secret) 中间件先做三层格式校验:头缺失 → 拒;非「Bearer xxx」形 → 拒;token 空 → 拒(每层都有独立的中文错误信息,便于客户端定位问题在格式而非签名);
  3. jwtpkg.ParseToken 验签并解析 Claims(校验签名算法、有效期);
  4. 成功则把 AuthUser{ID, Username, Nickname, Avatar, Roles} 写入 gin 上下文(c.Set("auth_user", ...)),c.Next() 放行;
  5. handler 通过 middleware.GetAuthUser(c) 取出用户,作为 created_by 等字段写入。

失败任何一步立即 response.Unauthorized + c.Abort()——信封统一(第 16 章),前端拦截器识别 401 跳登录。

签发侧在登录成功后(第 18 章):GenerateToken(secret, expiresIn, UserClaims{...}) 组装 RegisteredClaims(iat/exp)并以 HS256 签名。密钥来自 APP_JWT_SECRET,过期时间配置默认 7200 秒。

实现

安全实现的关键细节:

  • 算法白名单:ParseToken 的 keyfunc 里 if token.Method != jwtlib.SigningMethodHS256 { return nil, ... }。这不是多余代码——JWT 的经典漏洞是攻击者伪造 alg: none 或把 HS256 换成 RS256 让服务端把公钥当 HMAC 密钥(算法混淆攻击)。先比对 Method 再给密钥,是教科书级防御。
  • Claims 内嵌业务信息(包注释:「以减少数据库查询」):UserID/Username/Nickname/Avatar/Roles 直接进 payload,handler 拿用户信息零查库。代价是改昵称/头像后旧 token 里的信息过期(最长 2 小时),对本系统可接受;同时 payload 可被 base64 解码读到(JWT 不是加密),所以不放敏感字段(无邮箱、无手机号)——正好与 Account 序列化时 json:"-" 隐藏密码的取向一致。
  • 密钥与过期时间双重防御:GenerateToken 对空密钥、非正过期时间直接报错(「JWT 密钥不能为空」「过期时间必须大于 0」),把配置错误挡在签发口而不是运行期。
  • GetAuthUser 的防御式断言:类型断言双层检查(Get 到 + 断言成 AuthUser),handler 侧拿到 ok=false 时可自行降级。
  • Claims 结构与 AuthUser 结构分离:jwt 包定义 UserClaims(含 RegisteredClaims),middleware 定义 AuthUser(纯业务四字段 + Roles)——解码层与上下文层解耦,中间件是唯一翻译点。

鉴权范围在路由层显式声明(第 19 章):login、asr、chat 三个同步接口免鉴权(内部工具属性),其余全部挂 JWTAuth 分组。注意 ASR 同步接口免鉴权是有意的取舍——它需要直接传密钥级别的音频数据,暴露面由部署层(网关)控制。

📌 设计决策

  • HS256 单密钥而非 RS256 非对称:单体自签自发,无第三方验签需求,对称密钥运维最简;若未来拆出需要验签的服务,迁移点集中在 jwt 包一处。
  • 2 小时短过期 + 无 refresh token:直播切片工作台的使用节奏(上班期间连续操作),过期重登一次的摩擦可接受;换 refresh 机制会增加一张表与一条刷新链路。
  • 中间件闭包捕获 secret(middleware.JWTAuth(jwtSecret))而非全局变量:密钥仅存在于组合根装配链上,测试可用假密钥。

代码示例

签发与解析的核心防御:

// internal/pkg/jwt/jwt.go(节选)
func GenerateToken(secret string, expiresIn int, claims UserClaims) (string, error) {
    if secret == "" {
        return "", errors.New("JWT 密钥不能为空")
    }
    if expiresIn <= 0 {
        return "", errors.New("JWT 过期时间必须大于 0")
    }
    now := time.Now()
    claims.RegisteredClaims = jwtlib.RegisteredClaims{
        IssuedAt:  jwtlib.NewNumericDate(now),
        ExpiresAt: jwtlib.NewNumericDate(now.Add(time.Duration(expiresIn) * time.Second)),
    }
    token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
    return token.SignedString([]byte(secret))
}

func ParseToken(secret, tokenString string) (*UserClaims, error) {
    token, err := jwtlib.ParseWithClaims(tokenString, &UserClaims{},
        func(token *jwtlib.Token) (interface{}, error) {
            if token.Method != jwtlib.SigningMethodHS256 { // 算法白名单
                return nil, fmt.Errorf("不支持的签名算法: %v", token.Header["alg"])
            }
            return []byte(secret), nil
        })
    // ...
}

中间件的上下文注入:

// internal/middleware/auth.go(节选)
claims, err := jwtpkg.ParseToken(secret, token)
if err != nil {
    response.Unauthorized(c, "Token 无效或已过期")
    c.Abort()
    return
}
c.Set(authUserContextKey, AuthUser{
    ID: claims.UserID, Username: claims.Username,
    Nickname: claims.Nickname, Avatar: claims.Avatar, Roles: claims.Roles,
})
c.Next()

小结

  • 三层格式校验 + 算法白名单 + 短过期 = 务实的 JWT 安全基线。
  • Claims 内嵌用户信息换取零查库;敏感字段不入 payload。
  • 中间件是 Claims→AuthUser 的唯一翻译点,handler 统一 GetAuthUser 取用。

思考题

  1. 引入 refresh token 的最小改动方案是什么?AuthUser 上下文需要哪些配套变化?
  2. 免鉴权的 /v1/asr 与 /v1/chat 直通外部付费 API,除网关外还有哪些收敛暴露面的手段?

项目信息

  • GitHub仓库:live-mixer