Home
avatar
bg

CatOi

微服务鉴权中心设计: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 TokenRefresh 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,但要做两件事:

  1. 从内部头恢复认证上下文,供 @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);
    }
}
  1. 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 黑名单)、凡是头部都不可信(先剥离再注入)。基础设施类的代码,这三条原则比任何具体技术选型都更经得起时间考验。

JWT Spring Security Nacos SpringCloud 网关