Go 认证与 JWT
Go 服务中基于 JWT 的无状态认证:Claims 与签名、创建解析、中间件注入、刷新令牌、算法混淆与存储安全。
[!info] 关联笔记
Go 认证与 JWT
这个概念为什么会出现
HTTP 本身无会话。服务端要在多次请求间识别“谁在调用”,常见两条路:
- 服务端会话:Cookie 存 session id,状态在服务端(内存/Redis)。
- 客户端凭证:把可验证的声明(Claims)签成令牌,服务端只验签与过期。
微服务、SPA、移动端与 API 网关场景下,跨进程共享会话成本高,JWT(JSON Web Token,RFC 7519) 成为主流无状态方案。Go 侧通常用 github.com/golang-jwt/jwt(v5)签发与解析,并在中间件里把身份写入 context。
[!abstract] 一句话理解 JWT 是三段 Base64URL 文本(Header.Payload.Signature);服务端用密钥验签并检查
exp等声明后,把sub/角色放进请求上下文,后续 handler 不再重复查登录态。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:登录发 token + 鉴权中间件保护 /me
用户登录成功后,服务端签发短命 Access Token(JWT)。
后续请求带 Authorization: Bearer <token>;鉴权中间件验签、查过期,把 user_id 写入 context,业务 handler 直接取身份。
依赖:github.com/golang-jwt/jwt/v5(保留第三方库)。
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// ctxKey:自定义 context 键类型,避免与其他包的 string 键冲突。
type ctxKey string
const userIDKey ctxKey = "user_id"
// secret:HMAC 密钥;生产从密钥管理注入,勿写进仓库。
var secret = []byte(mustEnv("JWT_SECRET"))
func mustEnv(k string) string {
v := os.Getenv(k)
if v == "" {
// 演示兜底;生产禁止硬编码
return "dev-only-change-me-32bytes-min!!"
}
return v
}
// Claims:业务声明 + 标准 RegisteredClaims(sub/exp/iat/iss)。
type Claims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
// issueToken:登录成功后签发 Access Token。
func issueToken(userID, role string, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID, // 谁:用户 ID
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), // 短命
Issuer: "demo-api",
},
}
// 固定 HS256,解析时也必须校验 Method,防算法混淆
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString(secret)
}
// parseToken:验签 + 过期 + 算法白名单。
func parseToken(tokenStr string) (*Claims, error) {
t, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
// 拒绝 alg=none 或 RS256 等替换攻击
if t.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}
claims, ok := t.Claims.(*Claims)
if !ok || !t.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
// authMiddleware:鉴权中间件——提 Bearer、验 token、注入 user_id。
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
http.Error(w, "missing bearer token", http.StatusUnauthorized)
return
}
claims, err := parseToken(strings.TrimPrefix(h, "Bearer "))
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// 身份进 context;细粒度鉴权留给 service 层
ctx := context.WithValue(r.Context(), userIDKey, claims.Subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// loginHandler:登录发 token(演示省略真实密码校验)。
func loginHandler(w http.ResponseWriter, r *http.Request) {
token, err := issueToken("user-42", "admin", 15*time.Minute)
if err != nil {
http.Error(w, "issue failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": token})
}
// meHandler:受保护资源;只从 context 取已验证身份。
func meHandler(w http.ResponseWriter, r *http.Request) {
uid, _ := r.Context().Value(userIDKey).(string)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"user_id": uid})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /login", loginHandler)
// /me 套鉴权中间件
mux.Handle("GET /me", authMiddleware(http.HandlerFunc(meHandler)))
log.Fatal(http.ListenAndServe(":8080", mux))
}
建议运行(需已 go get github.com/golang-jwt/jwt/v5):
go run .
# 终端 2:
curl -s -X POST http://localhost:8080/login
# 把返回的 access_token 代入:
curl -s -H "Authorization: Bearer <token>" http://localhost:8080/me
期望输出类似:
{"access_token":"eyJ..."}
{"user_id":"user-42"}
无 token 或坏 token 访问 /me 应返回 401。
结合场景再看三个关注点
- 必须校验签名算法,防止
alg=none/ 算法替换。 - 身份用自定义 context key 类型,避免字符串键冲突。
- Access Token 宜短命;刷新机制见下文。
核心概念与准确模型
JWT 三段结构
base64url(header).base64url(payload).base64url(signature)
| 段 | 内容 |
|---|---|
| Header | alg(HS256/RS256…)、typ |
| Payload | Claims:sub、exp、iat、iss、自定义字段 |
| Signature | 对 header.payload 的 MAC 或非对称签名 |
Payload 只是编码不是加密。能拿到令牌的人都能读 Claims。
对称 vs 非对称
| HS256 | RS256 / ES256 | |
|---|---|---|
| 密钥 | 共享 secret | 私钥签、公钥验 |
| 适合 | 单体、内网服务 | 多服务验签、对外签发 |
| 风险 | secret 泄漏=可伪造 | 私钥保护与轮换 |
刷新令牌(Refresh Token)
典型模式:
- Access Token:5–15 分钟,放 Authorization 头
- Refresh Token:更长,仅用于换发 access;宜存服务端可撤销存储,或做 rotation(用一次换一对新令牌并作废旧 refresh)
无状态 JWT 无法主动作废已签发 access。缓解:短 TTL、黑名单(牺牲无状态)、版本号/jti 吊销表。
中间件职责边界
中间件只做:
- 提取凭证
- 验签 + 过期 + 必要 issuer/audience
- 写入 context
鉴权(能不能做某操作) 应在 service 或专门 authorize 层,不要把业务规则全塞进 JWT 中间件。
设计动机
- 水平扩展:验签不依赖粘性会话。
- 跨服务传递身份:网关验一次,下游信任 Claims(或二次验签)。
- 与 OAuth2/OIDC 生态对齐:access token 常以 JWT 形态出现。
边界情况与反直觉行为
1. exp 时钟偏差
多机时钟不同步会导致偶发 401。可用库提供的 leeway,但根本是 NTP。
2. MapClaims 类型断言脆弱
claims["sub"].(string) 在类型不对时 panic。优先自定义 Claims 结构体 + ParseWithClaims。
3. Cookie vs localStorage
浏览器:
localStorage:易被 XSS 读走HttpOnly+Secure+SameSiteCookie:防 XSS 读,但要注意 CSRF
API-only 移动端常用 Authorization 头。
4. 注销不等于令牌失效
客户端删除令牌 ≠ 服务端作废。需要短 TTL 或吊销列表。
常见误区
[!warning] 常见误区:把敏感数据放进 Payload
错误:密码哈希、身份证号进 JWT。
正确:只放标识与授权所需最小声明。
[!warning] 常见误区:不检查
alg
错误:keyFunc无条件返回 HMAC secret。
正确:拒绝非预期Method。
[!warning] 常见误区:超长有效期 access token
错误:7 天 access 且无吊销。
正确:短 access + 可撤销 refresh。
[!warning] 常见误区:用字符串
"user_id"做 context key
错误:与其他包冲突、难静态检查。
正确:type ctxKey string包内私有常量。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| Session + Cookie | 服务端有状态,易吊销;扩展要共享存储 |
| mTLS | 传输层身份,不替代应用 Claims |
| API Key | 简单长期密钥,粒度粗,适合机器调用 |
| gRPC metadata | 可传同样的 Bearer,鉴权模型可复用 |
工程实践
- 密钥管理:环境变量/密钥管理服务注入;支持轮换(
kid)。 - 统一错误:对外 401,不区分“过期/伪造”细节给攻击者(日志可细分)。
- HTTPS only:明文信道上的 Bearer 等于密码。
- 测试:固定时钟或注入
time函数测过期;表驱动测错误 alg。 - 与分层配合:AuthService 签发;中间件只校验;Handler 读 context。
- 审计:登录成功/失败、刷新、吊销打结构化日志(勿打印完整 token)。
// 从 context 取用户的惯用写法
func UserIDFromCtx(ctx context.Context) (string, bool) {
v, ok := ctx.Value(userIDKey).(string)
return v, ok && v != ""
}
可验证实验
实验 1:篡改 Payload
解码 JWT 中间段,改 sub 再请求受保护接口——应 401(签名失败)。
实验 2:算法替换
构造 alg: none 或把 RS256 头配 HS 密钥验证路径,确认被拒绝。
实验 3:过期
把 exp 设为过去时间,确认解析失败。
实验 4:中间件 context
登录拿 token,访问 /me,确认返回签发时的 sub。
本节总结
- 本质:可验证的自包含声明 + 签名,不是加密容器。
- 关键规则:验
alg、验exp、短 TTL、最小 Claims。 - 最易错:当加密用、无法吊销却发长寿 token、context 键冲突。
- 下一步:请求验证 保证登录输入合法;中间件 组合鉴权链。
自测题
概念题
- JWT 三段分别是什么?Payload 是否保密?
- 为什么
keyFunc必须检查t.Method? - Access 与 Refresh 的典型职责划分?
代码推理题
中间件解析成功后执行 next.ServeHTTP(w, r) 而不是 r.WithContext(ctx),下游读得到 user_id 吗?
工程思考题
多实例 API 要支持“强制下线某用户所有会话”,纯无状态 JWT 如何演进?
参考答案
展开
- Header、Payload、Signature;Payload 仅 Base64URL,默认不保密。
- 防止算法混淆(如 none 或非对称/对称错配)导致伪造成功。
- Access 短命访问 API;Refresh 换发 access,宜可撤销/轮转。
代码题:读不到——必须把带 value 的 context 绑回Request。
工程题:引入jti/token 版本号黑名单或会话表;或缩短 TTL 并集中刷新点吊销 refresh。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| RFC 7519 — JWT | 标准 | Claims 语义 |
| golang-jwt/jwt | 库 | 解析与签名 API |
| Package context | 标准库 | 请求级身份传递 |
| Package net/http | 标准库 | 中间件与 Header |
| go-http-middleware · go-context · go-configuration | 本库 | 落地组合 |