第17章:JWT 鉴权:签发、解析与中间件
本章把 internal/pkg/jwt 与 internal/middleware/auth.go 合起来讲:Claims 里内嵌用户信息的用意、HS256 签名与算法白名单校验(防 alg 混淆攻击)、Bearer 解析的逐层拒绝,以及 AuthUser 上下文的注入与读取。鉴权是除登录外所有接口的统一前置,本章也是理解前端 401 拦截(第 48 章)的后端侧镜像。
流程
一次受保护请求的完整旅程:
- 客户端带
Authorization: Bearer <token>请求任意 v1 受保护接口; JWTAuth(secret)中间件先做三层格式校验:头缺失 → 拒;非「Bearer xxx」形 → 拒;token 空 → 拒(每层都有独立的中文错误信息,便于客户端定位问题在格式而非签名);jwtpkg.ParseToken验签并解析 Claims(校验签名算法、有效期);- 成功则把
AuthUser{ID, Username, Nickname, Avatar, Roles}写入 gin 上下文(c.Set("auth_user", ...)),c.Next()放行; - 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 取用。
思考题
- 引入 refresh token 的最小改动方案是什么?AuthUser 上下文需要哪些配套变化?
- 免鉴权的
/v1/asr与/v1/chat直通外部付费 API,除网关外还有哪些收敛暴露面的手段?
项目信息
- GitHub仓库:live-mixer