前阵子组里把之前那个臃肿的 SSR 项目给拆了,转成纯前端静态托管加微服务架构。本来以为能轻松不少,结果在身份认证这块踩了个大坑。最初各下游微服务自己去校验 Session,不仅每个服务都要写一套重复的鉴权逻辑,而且数据库和 Redis 的 I/O 压力大得惊人,QPS 一上去服务器就直接卡死。最后折腾了一周,我们干脆把鉴权逻辑全部上移到 API 网关(Gateway),由网关统一拦截、解密 JWT 并通过定制 Header 分发用户信息,下游服务只需要闭着眼睛信任网关发来的 Header 就行。
但这里有个极其恶心的问题:JWT 本身是无状态的,一旦颁发,在有效期内就无法主动失效。如果用户改了密码或者点击了登出,怎么在网关层实时拦截?总不能每次请求都去查一遍数据库吧,那样搞微服务分布式校验就没有意义了。
我们的解决方案是“网关拦截校验 + Redis 吊销列表(Blacklist)”的混血模式。网关直接持有 JWT 校验的公钥,解密成功后再去 Redis 里确认该 Token 的 JTI(JWT ID)是否在黑名单中。下面是我们生产环境正在跑的 Go 语言网关鉴权中间件的完整实现,直接上了生产级别的校验和异常处理:
package middleware
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
)
type UserClaims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
type GatewayAuthMiddleware struct {
redisClient *redis.Client
jwtPublicKey []byte
}
func NewGatewayAuthMiddleware(rdb *redis.Client, publicKey []byte) *GatewayAuthMiddleware {
return &GatewayAuthMiddleware{
redisClient: rdb,
jwtPublicKey: publicKey,
}
}
func (m *GatewayAuthMiddleware) Authenticate() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing authorization header"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token format"})
return
}
tokenStr := parts[1]
parsedToken, err := jwt.ParseWithClaims(tokenStr, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
key, err := jwt.ParseRSAPublicKeyFromPEM(m.jwtPublicKey)
if err != nil {
return nil, err
}
return key, nil
})
if err != nil || !parsedToken.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
claims, ok := parsedToken.Claims.(*UserClaims)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Failed to parse user claims"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
isBlacklisted, err := m.redisClient.Exists(ctx, fmt.Sprintf("blacklist:token:%s", claims.ID)).Result()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Internal security check failed"})
return
}
if isBlacklisted > 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token has been revoked"})
return
}
userInfo, err := json.Marshal(map[string]interface{}{
"user_id": claims.UserID,
"username": claims.Username,
"roles": claims.Roles,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Context serialization failed"})
return
}
c.Request.Header.Set("X-User-Info", string(userInfo))
c.Next()
}
}
下游的各个微服务(比如订单服务、支付服务)在接收到请求时,就不需要再去碰任何 JWT 库或者身份中心了。我们直接在微服务内部写个简单的一层拦截器,去读取 X-User-Info 这一特定 Header,反序列化成用户信息对象。为了安全起见,所有微服务的内网安全组必须配置死:除了网关 IP 外,严禁任何外网或者直接内网请求携带 X-User-Info 头,网关转发前也必须无脑抹除客户端传来的同名 Header,防止伪造身份漏洞。
这样一来,鉴权的性能瓶颈直接被压缩在网关和 Redis 的极速查询里,下游服务完全解耦,爽得很。不过现在我们正在纠结是继续用公私钥解密,还是统一换成 HMAC 对称加密,大家在微服务网关上都是怎么处理秘钥下发与轮转的心智负担的?