Go 认证与 JWT

Go 服务中基于 JWT 的无状态认证:Claims 与签名、创建解析、中间件注入、刷新令牌、算法混淆与存储安全。

#type / concept #status / growing #tech / dev / backend #resource / go

[!info] 关联笔记

Go 认证与 JWT

这个概念为什么会出现

HTTP 本身无会话。服务端要在多次请求间识别“谁在调用”,常见两条路:

  1. 服务端会话:Cookie 存 session id,状态在服务端(内存/Redis)。
  2. 客户端凭证:把可验证的声明(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。

结合场景再看三个关注点

  1. 必须校验签名算法,防止 alg=none / 算法替换。
  2. 身份用自定义 context key 类型,避免字符串键冲突。
  3. Access Token 宜短命;刷新机制见下文。

核心概念与准确模型

JWT 三段结构

base64url(header).base64url(payload).base64url(signature)
内容
Headeralg(HS256/RS256…)、typ
PayloadClaims:subexpiatiss、自定义字段
Signatureheader.payload 的 MAC 或非对称签名

Payload 只是编码不是加密。能拿到令牌的人都能读 Claims。

对称 vs 非对称

HS256RS256 / ES256
密钥共享 secret私钥签、公钥验
适合单体、内网服务多服务验签、对外签发
风险secret 泄漏=可伪造私钥保护与轮换

刷新令牌(Refresh Token)

典型模式:

  • Access Token:5–15 分钟,放 Authorization 头
  • Refresh Token:更长,仅用于换发 access;宜存服务端可撤销存储,或做 rotation(用一次换一对新令牌并作废旧 refresh)

无状态 JWT 无法主动作废已签发 access。缓解:短 TTL、黑名单(牺牲无状态)、版本号/jti 吊销表。

中间件职责边界

中间件只做:

  1. 提取凭证
  2. 验签 + 过期 + 必要 issuer/audience
  3. 写入 context

鉴权(能不能做某操作) 应在 service 或专门 authorize 层,不要把业务规则全塞进 JWT 中间件。

设计动机

  1. 水平扩展:验签不依赖粘性会话。
  2. 跨服务传递身份:网关验一次,下游信任 Claims(或二次验签)。
  3. 与 OAuth2/OIDC 生态对齐:access token 常以 JWT 形态出现。

边界情况与反直觉行为

1. exp 时钟偏差

多机时钟不同步会导致偶发 401。可用库提供的 leeway,但根本是 NTP。

2. MapClaims 类型断言脆弱

claims["sub"].(string) 在类型不对时 panic。优先自定义 Claims 结构体 + ParseWithClaims

浏览器:

  • localStorage:易被 XSS 读走
  • HttpOnly + Secure + SameSite Cookie:防 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,鉴权模型可复用

工程实践

  1. 密钥管理:环境变量/密钥管理服务注入;支持轮换(kid)。
  2. 统一错误:对外 401,不区分“过期/伪造”细节给攻击者(日志可细分)。
  3. HTTPS only:明文信道上的 Bearer 等于密码。
  4. 测试:固定时钟或注入 time 函数测过期;表驱动测错误 alg。
  5. 与分层配合:AuthService 签发;中间件只校验;Handler 读 context。
  6. 审计:登录成功/失败、刷新、吊销打结构化日志(勿打印完整 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 键冲突。
  • 下一步请求验证 保证登录输入合法;中间件 组合鉴权链。

自测题

概念题

  1. JWT 三段分别是什么?Payload 是否保密?
  2. 为什么 keyFunc 必须检查 t.Method
  3. Access 与 Refresh 的典型职责划分?

代码推理题

中间件解析成功后执行 next.ServeHTTP(w, r) 而不是 r.WithContext(ctx),下游读得到 user_id 吗?

工程思考题

多实例 API 要支持“强制下线某用户所有会话”,纯无状态 JWT 如何演进?

参考答案

展开
  1. Header、Payload、Signature;Payload 仅 Base64URL,默认不保密。
  2. 防止算法混淆(如 none 或非对称/对称错配)导致伪造成功。
  3. 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本库落地组合

创建于 2026/6/25 更新于 2026/7/15