微服务鉴权中心设计:JWT 双令牌、Spring Security 与 Nacos 动态路由
在电商微服务平台,我负责鉴权授权中心和网关动态路由两块基础设施。这篇文章记录完整的设计决策:为什么选 JWT 而不是 Session、双令牌怎么设计注销才不形同虚设、Nacos 动态路由如何做到配置推送秒级生效,以及三个只有上线之后才会暴露的坑。
一、总体架构:为什么鉴权收敛到网关
服务拆到十几个之后,鉴权有两种做法:每个微服务各自校验,或者收敛到网关统一做。我们选后者,理由很直接:
- 规则只写一遍。URL 级权限如果散在各服务,改一条规则要发 N 个服务;
- 无效请求在最外层被拦。不带合法 token 的请求连内网带宽都不用消耗;
- 下游服务只信网关传递的身份头,不再解析 token,业务代码干净。
客户端 → Gateway(鉴权过滤器 + 动态路由)→ 账户/商品/订单服务
↓ 白名单放行
鉴权中心(签发 / 刷新 / 注销)架构上有个必须先想清楚的问题:网关校验完 JWT,下游怎么知道”这是谁”?我们的约定是网关校验通过后,把解析出的用户信息放进内部请求头(X-User-Id、X-User-Roles)再转发,同时在网关层强制剥离外部传入的同名头——否则任何人构造一个 X-User-Id 头直连内网服务就能伪造身份。这个剥离逻辑是安全审计里必查项。
二、JWT 双令牌设计
2.1 Access Token 与 Refresh Token 的分工
单 token 方案有个死结:token 有效期长则注销后风险窗口大,有效期短则用户频繁重新登录。双令牌解耦了这两件事:
| Access Token | Refresh Token | |
|---|---|---|
| 有效期 | 2 小时 | 7 天(滑动续期) |
| 携带方 | 每次业务请求 | 仅刷新接口 |
| 存储位置 | 内存 / 请求头 | Redis + 客户端安全存储 |
| 校验方 | 网关(无状态验签) | 鉴权中心(查 Redis) |
签发实现的核心部分:
public TokenPair issue(User user) {
long now = System.currentTimeMillis();
String access = Jwts.builder()
.setSubject(String.valueOf(user.getId()))
.claim("roles", user.getRoles())
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + accessTtl))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// refresh token 只存 ID 映射,不携带角色等业务声明
String refresh = UUID.randomUUID().toString().replace("-", "");
redis.setex("refresh:" + refresh, refreshTtl, String.valueOf(user.getId()));
redis.setex("user_token:" + user.getId(), refreshTtl, refresh); // 单点登录约束
return new TokenPair(access, refresh);
}注意 user_token: 这个 key——它实现了单点登录:同一用户新登录会覆盖旧 refresh token,旧会话自然失效。这在交易类和合规要求严格的系统里很常见。
2.2 注销:JWT 的天然缺陷必须用 Redis 补
JWT 最大的问题是签出去就收不回——验签是无状态的,token 在有效期内永远合法。所以”注销”必须靠黑名单:
public void logout(String accessJti, long remainingTtl) {
// 黑名单存活时间 = token 剩余有效期,到期自动清理
redis.setex("blacklist:" + accessJti, remainingTtl, "1");
}
// 网关过滤器里验签之后查黑名单
if (redis.exists("blacklist:" + jti)) {
return unauthorized("token 已注销");
}黑名单的 TTL 取 token 剩余有效期而不是固定值,这是细节但很重要:token 过期后黑名单条目自动消失,Redis 不会无限膨胀。
2.3 网关鉴权过滤器
@Component
public class AuthFilter implements GlobalFilter, Ordered {
private static final List<String> WHITE_LIST = List.of(
"/auth/login", "/auth/refresh", "/actuator/health");
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (isWhite(path)) return chain.filter(exchange);
String token = resolveToken(exchange.getRequest());
if (token == null) return reject(exchange, "缺少凭证");
Claims claims = jwtUtil.parse(token); // 验签 + 过期校验
if (jwtUtil.isBlacklisted(claims.getId())) // 黑名单
return reject(exchange, "token 已注销");
// 剥离外部伪造的身份头,写入网关裁决过的身份
ServerHttpRequest mutated = exchange.getRequest().mutate()
.headers(h -> { h.remove("X-User-Id"); h.remove("X-User-Roles"); })
.header("X-User-Id", claims.getSubject())
.header("X-User-Roles", claims.get("roles", String.class))
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
}h.remove(...) 那两行前面强调过:先剥离再注入,顺序不能反,这是防伪造的关键。
三、Spring Security 在下游服务的二次防线
网关做了统一鉴权,下游还要不要 Spring Security?要——纵深防御。下游不再校验 JWT,但要做两件事:
- 从内部头恢复认证上下文,供
@PreAuthorize使用:
public class GatewayTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws Exception {
String userId = req.getHeader("X-User-Id");
if (userId != null) {
List<SimpleGrantedAuthority> authorities = Arrays.stream(
req.getHeader("X-User-Roles").split(","))
.map(SimpleGrantedAuthority::new).toList();
var auth = new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(req, res);
}
}- URL 级权限模型,角色到 URL 的映射放在 Nacos 配置里,
@EnableMethodSecurity+ 自定义AuthorizationManager实现热更新,改权限不用重启服务。
四、Nacos 动态路由:配置推送秒级生效
4.1 原理
Spring Cloud Gateway 的路由由 RouteDefinitionRepository 接口提供。默认从配置文件读,改路由要重启。要热更新,思路是把路由定义放 Nacos 配置中心,监听配置变更事件,刷新路由表并发布 RefreshRoutesEvent:
@Component
public class NacosRouteRepository implements RouteDefinitionRepository {
private final Map<String, RouteDefinition> routes = new ConcurrentHashMap<>();
@NacosConfigListener(dataId = "gateway-routes.json")
public void onRouteChange(String config) {
List<RouteDefinition> defs = JSON.parseArray(config, RouteDefinition.class);
routes.clear();
defs.forEach(d -> routes.put(d.getId(), d));
// 触发网关刷新路由
applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));
}
@Override
public Flux<RouteDefinition> getRouteDefinitions() {
return Flux.fromIterable(routes.values());
}
}4.2 生产细节
- 路由变更加版本号与操作人,Nacos 配置历史就是审计日志;
- 发布前用
RouteDefinitionWriter做预校验,一条非法路由(比如断言表达式写错)会导致整个路由表刷新失败,所有服务一起 404——我们用”先加载到影子 Map、校验通过再原子替换”避免这个问题; - 灰度顺序:先改测试 namespace 验证,再推生产;核心服务的路由变更安排在低峰期。
五、三个上线后才暴露的坑
坑 1:时钟偏移导致 token 提前失效。JWT 的 exp 校验依赖各节点系统时钟,有台机器 NTP 没配好偏差 90 秒,表现为”用户随机性被登出”。解法:验签时允许 60 秒时钟偏移(setAllowedClockSkewSeconds),同时把 NTP 校准纳入部署清单。
坑 2:网关转发后 Feign 调用丢内部头。服务 A 通过 OpenFeign 调服务 B 时,默认不会透传 X-User-Id,B 拿到的全是匿名身份。补一个 Feign 拦截器从 RequestContextHolder 取当前请求头透传即可,但要小心异步线程里 RequestContextHolder 为空的问题(和 Seata 的 XID 传播是同一类坑)。
坑 3:Refresh Token 的并发刷新竞态。前端页面并发发起多个请求都遇到 401 时,会同时触发多次 refresh,旧 refresh token 被第一个请求消费掉,其余全失败,用户被踢出登录。解法是前端做单飞(promise 复用同一个刷新请求),后端对刷新接口加短时互斥窗口:同一 token 的第二次刷新在 2 秒内直接返回第一次的结果。
六、小结
这套体系运行下来,网关层拦截了全部无效流量,权限变更全部热更新,从签发到注销的整条链路无状态可水平扩展。回头看最值得强调的设计原则有三条:鉴权收敛但纵深防御(网关拦大头,下游保留二次防线)、无状态的便利必须用有状态的补偿来补(JWT 黑名单)、凡是头部都不可信(先剥离再注入)。基础设施类的代码,这三条原则比任何具体技术选型都更经得起时间考验。