我将为你提供一套完整的、可直接落地的分布式登录与统一鉴权方案,包含完整代码、详细解释和生产环境注意事项。


一、整体架构设计

┌─────────────┐
│  客户端 App   │
└──────┬──────┘
       │ ① 登录请求 (账号/密码)
       ▼
┌─────────────────────┐
│  Auth-Service 认证中心 │ ──→ 校验密码、生成双Token、写Redis
└──────┬──────────────┘
       │ ② 返回 AccessToken + RefreshToken
       ▼
┌─────────────────────┐
│ Spring Cloud Gateway │ ──→ 验签JWT、查黑名单、解析用户信息
│       (网关)          │ ──→ 清洗伪造Header、写入X-User-*
└──────┬──────────────┘
       │ ③ 透传明文用户信息(Header)
       ▼
┌─────────────────────┐       Feign透传        ┌──────────────┐
│  Order-Service       │ ────────────────────→ │ Product-Service│
│ (拦截器→ThreadLocal)  │                       │               │
└─────────────────────┘                       └──────────────┘

设计核心思想:

  1. 认证集中:只有 Auth-Service 接触密码和用户库。
  2. 验签下沉到网关:网关统一验 JWT,内部服务不再重复验签(性能关键)。
  3. 内部无状态传播:网关把解析好的用户信息以明文 Header 传给内部,内部服务"信任内网",直接读 Header。

二、技术栈选型

功能 技术 说明
网关 Spring Cloud Gateway WebFlux 异步非阻塞,高吞吐
注册/配置中心 Nacos 服务发现 + 配置(密钥放这里)
认证框架 Spring Security 仅 Auth-Service 使用
Token JWT (jjwt) 无状态,自包含用户信息
状态/缓存 Redis Cluster 黑名单、RefreshToken、限流
内部调用 OpenFeign 上下文透传
上下文传递 TransmittableThreadLocal 解决异步线程丢失上下文

三、核心代码实现

模块 0:公共依赖(pom 关键部分)

<!-- JWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<!-- 阿里 TTL,解决异步线程上下文丢失 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.4</version>
</dependency>

模块 1:JWT 工具类(建议放在 common 模块)

@Slf4j
public class JwtUtils {

    /**
     * 生成 Access Token
     * @param secret 从配置中心读取的密钥(Base64编码,至少256位)
     */
    public static String generateAccessToken(LoginUser user, String secret, long expireMs) {
        Date now = new Date();
        Date expire = new Date(now.getTime() + expireMs);
        SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));

        return Jwts.builder()
                .setId(UUID.randomUUID().toString())          // jti:用于黑名单精确定位
                .setSubject(user.getUserId())                 // sub:用户ID
                .claim("username", user.getUsername())
                .claim("tenantId", user.getTenantId())
                .claim("roles", String.join(",", user.getRoles()))  // 角色逗号分隔
                .setIssuedAt(now)
                .setExpiration(expire)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    /**
     * 解析并验签 Token,失败抛异常
     */
    public static Claims parseToken(String token, String secret) {
        SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 仅校验签名是否合法(不抛出,返回boolean),用于降级场景
     */
    public static boolean validate(String token, String secret) {
        try {
            parseToken(token, secret);
            return true;
        } catch (ExpiredJwtException e) {
            log.warn("Token expired: {}", e.getMessage());
        } catch (Exception e) {
            log.warn("Token invalid: {}", e.getMessage());
        }
        return false;
    }
}

解释:

  • 使用 jti(JWT ID)作为唯一标识,注销时只需把 jti 加入黑名单,而不是整个长 Token,节省 Redis 空间。
  • 密钥用 Keys.hmacShaKeyFor 强制要求长度 ≥ 256 位,防止弱密钥。
  • 区分 ExpiredJwtException 和其他异常,便于触发"刷新 Token"逻辑。

模块 2:Auth-Service 认证中心

2.1 登录接口
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public Result<TokenVO> login(@RequestBody @Valid LoginDTO dto, HttpServletRequest request) {
        // 传入IP用于限流和风控记录
        String clientIp = IpUtils.getClientIp(request);
        return Result.success(authService.login(dto, clientIp));
    }

    @PostMapping("/logout")
    public Result<Void> logout(@RequestHeader("Authorization") String authorization) {
        authService.logout(authorization);
        return Result.success();
    }

    @PostMapping("/refresh")
    public Result<TokenVO> refresh(@RequestBody RefreshDTO dto) {
        return Result.success(authService.refresh(dto.getRefreshToken()));
    }
}
2.2 登录核心逻辑
@Service
@Slf4j
@RequiredArgsConstructor
public class AuthService {

    private final UserMapper userMapper;
    private final StringRedisTemplate redisTemplate;
    private final PasswordEncoder passwordEncoder; // BCryptPasswordEncoder

    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.access-expire:1800000}")   // 30分钟
    private long accessExpire;
    @Value("${jwt.refresh-expire:604800000}") // 7天
    private long refreshExpire;

    // 限流:登录失败次数Key前缀
    private static final String LOGIN_FAIL_KEY = "login:fail:";
    private static final String REFRESH_TOKEN_KEY = "refresh:token:";
    private static final int MAX_FAIL_COUNT = 5;

    public TokenVO login(LoginDTO dto, String clientIp) {
        // 1. 登录失败次数限流(防暴力破解)
        String failKey = LOGIN_FAIL_KEY + dto.getUsername();
        String failCountStr = redisTemplate.opsForValue().get(failKey);
        int failCount = failCountStr == null ? 0 : Integer.parseInt(failCountStr);
        if (failCount >= MAX_FAIL_COUNT) {
            throw new BizException("账号已锁定,请15分钟后再试");
        }

        // 2. 查询用户
        User user = userMapper.selectByUsername(dto.getUsername());
        // 3. 校验密码(即使用户不存在也走一次加密,防止时序攻击枚举用户)
        if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
            // 失败计数 +1,过期时间15分钟
            redisTemplate.opsForValue().increment(failKey);
            redisTemplate.expire(failKey, 15, TimeUnit.MINUTES);
            throw new BizException("用户名或密码错误");
        }
        if (user.getStatus() != 1) {
            throw new BizException("账号已被禁用");
        }

        // 4. 登录成功,清除失败计数
        redisTemplate.delete(failKey);

        // 5. 查询权限角色
        List<String> roles = userMapper.selectRolesByUserId(user.getId());

        // 6. 构建 LoginUser 并生成 Token
        LoginUser loginUser = LoginUser.builder()
                .userId(String.valueOf(user.getId()))
                .username(user.getUsername())
                .tenantId(String.valueOf(user.getTenantId()))
                .roles(roles)
                .build();

        String accessToken = JwtUtils.generateAccessToken(loginUser, secret, accessExpire);
        String refreshToken = UUID.randomUUID().toString().replace("-", "");

        // 7. RefreshToken 存 Redis(与AccessToken独立,可随时吊销)
        redisTemplate.opsForValue().set(
                REFRESH_TOKEN_KEY + refreshToken,
                JSON.toJSONString(loginUser),
                refreshExpire, TimeUnit.MILLISECONDS);

        log.info("用户登录成功 userId={}, ip={}", user.getId(), clientIp);

        return TokenVO.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .expiresIn(accessExpire / 1000)
                .build();
    }

    /**
     * 注销:把 AccessToken 的 jti 加入黑名单
     */
    public void logout(String authorization) {
        if (authorization == null || !authorization.startsWith("Bearer ")) return;
        String token = authorization.substring(7);
        try {
            Claims claims = JwtUtils.parseToken(token, secret);
            String jti = claims.getId();
            long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
            if (ttl > 0) {
                // 黑名单TTL = Token剩余有效期,到期自动清理,不占内存
                redisTemplate.opsForValue().set(
                        "token:blacklist:" + jti, "1", ttl, TimeUnit.MILLISECONDS);
            }
        } catch (Exception e) {
            log.warn("注销时Token已失效,忽略");
        }
    }

    /**
     * 刷新 Token:用 RefreshToken 换新的 AccessToken
     */
    public TokenVO refresh(String refreshToken) {
        String userJson = redisTemplate.opsForValue().get(REFRESH_TOKEN_KEY + refreshToken);
        if (userJson == null) {
            throw new BizException(401, "RefreshToken已过期,请重新登录");
        }
        LoginUser loginUser = JSON.parseObject(userJson, LoginUser.class);
        String newAccessToken = JwtUtils.generateAccessToken(loginUser, secret, accessExpire);
        return TokenVO.builder()
                .accessToken(newAccessToken)
                .refreshToken(refreshToken) // RefreshToken 可滑动续期,这里复用
                .expiresIn(accessExpire / 1000)
                .build();
    }
}

解释:

  • 双 Token 机制:AccessToken 短效(30min)用于鉴权;RefreshToken 长效(7天)存 Redis,可被服务端随时吊销。
  • 防暴力破解:用 Redis 计数失败次数,超 5 次锁定 15 分钟。
  • 防时序攻击:用户不存在时也尽量走相同耗时路径(生产可补充虚拟 hash)。
  • 黑名单存 jti 而非全 Token:节省空间,TTL 设为剩余有效期,自动清理。

模块 3:网关统一鉴权(最核心)

@Component
@Slf4j
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private final ReactiveStringRedisTemplate redisTemplate; // 注意是Reactive版本

    @Value("${jwt.secret}")
    private String secret;

    // 白名单
    private static final List<String> WHITE_LIST = Arrays.asList(
            "/auth/login", "/auth/register", "/auth/refresh"
    );
    // 需要清洗的伪造Header前缀
    private static final String INTERNAL_HEADER_PREFIX = "X-User-";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();

        // === 安全第一步:清洗客户端伪造的内部Header(防Header注入越权)===
        ServerHttpRequest cleanedRequest = request.mutate()
                .headers(headers -> headers.entrySet()
                        .removeIf(e -> e.getKey().startsWith(INTERNAL_HEADER_PREFIX)))
                .build();
        exchange = exchange.mutate().request(cleanedRequest).build();

        // 1. 白名单放行
        if (isWhiteList(path)) {
            return chain.filter(exchange);
        }

        // 2. 取 Token
        String authorization = cleanedRequest.getHeaders().getFirst("Authorization");
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            return unauthorized(exchange, "缺少Token");
        }
        String token = authorization.substring(7);

        // 3. 本地验签(强校验,不依赖外部服务)
        Claims claims;
        try {
            claims = JwtUtils.parseToken(token, secret);
        } catch (ExpiredJwtException e) {
            return unauthorized(exchange, "Token已过期");
        } catch (Exception e) {
            return unauthorized(exchange, "Token非法");
        }

        String jti = claims.getId();

        // 4. 查黑名单(弱校验,Redis异常时降级放行)
        ServerWebExchange finalExchange = exchange;
        return redisTemplate.hasKey("token:blacklist:" + jti)
                .timeout(Duration.ofMillis(200))  // 200ms超时,防止Redis拖垮网关
                .onErrorResume(e -> {
                    // === 降级:Redis挂了,记录日志报警,但放行(依赖本地验签)===
                    log.error("查询黑名单Redis异常,触发降级放行 jti={}", jti, e);
                    return Mono.just(false);
                })
                .flatMap(isBlack -> {
                    if (Boolean.TRUE.equals(isBlack)) {
                        return unauthorized(finalExchange, "Token已注销");
                    }
                    // 5. 验签通过 → 写入可信的用户信息Header,透传下游
                    ServerHttpRequest authedRequest = finalExchange.getRequest().mutate()
                            .header("X-User-Id", claims.getSubject())
                            .header("X-User-Name", encode(claims.get("username", String.class)))
                            .header("X-User-Roles", claims.get("roles", String.class))
                            .header("X-Tenant-Id", claims.get("tenantId", String.class))
                            .build();
                    return chain.filter(finalExchange.mutate().request(authedRequest).build());
                });
    }

    private boolean isWhiteList(String path) {
        return WHITE_LIST.stream().anyMatch(path::endsWith);
    }

    // 中文用户名需URL编码,防止Header非法字符报错
    private String encode(String value) {
        if (value == null) return "";
        return URLEncoder.encode(value, StandardCharsets.UTF_8);
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = String.format("{\"code\":401,\"msg\":\"%s\"}", msg);
        DataBuffer buffer = response.bufferFactory()
                .wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }

    @Override
    public int getOrder() {
        return -100; // 高优先级,先于其他过滤器
    }
}

解释(这里全是生产坑点):

  1. Header 清洗在最前面:必须先删除客户端传来的 X-User-*,否则黑客直接伪造 X-User-Id:1 就能冒充管理员。
  2. 两级校验:本地验签(强)+ Redis 黑名单(弱)。Redis 超时/异常时降级放行,保证核心可用,同时报警。
  3. 超时控制timeout(200ms) 防止 Redis 慢查询拖垮整个网关。
  4. 中文编码:用户名含中文必须 URL 编码,否则下游 Tomcat 解析 Header 报错。

模块 4:内部服务接收上下文

4.1 用户上下文(用 TTL 解决异步丢失)
public class UserContextHolder {
    // 使用 TransmittableThreadLocal,支持线程池/异步传递
    private static final TransmittableThreadLocal<LoginUser> CONTEXT =
            new TransmittableThreadLocal<>();

    public static void set(LoginUser user) {
        CONTEXT.set(user);
    }

    public static LoginUser get() {
        return CONTEXT.get();
    }

    public static String getUserId() {
        LoginUser u = CONTEXT.get();
        return u == null ? null : u.getUserId();
    }

    public static void clear() {
        CONTEXT.remove(); // 必须清理,防止线程池复用导致串号
    }
}
4.2 拦截器解析 Header
@Slf4j
public class UserContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) {
        String userId = request.getHeader("X-User-Id");
        if (StringUtils.hasText(userId)) {
            String rolesStr = request.getHeader("X-User-Roles");
            List<String> roles = StringUtils.hasText(rolesStr)
                    ? Arrays.asList(rolesStr.split(","))
                    : Collections.emptyList();

            LoginUser user = LoginUser.builder()
                    .userId(userId)
                    .username(decode(request.getHeader("X-User-Name")))
                    .tenantId(request.getHeader("X-Tenant-Id"))
                    .roles(roles)
                    .build();
            UserContextHolder.set(user);
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex) {
        UserContextHolder.clear(); // 关键:请求结束清理
    }

    private String decode(String value) {
        if (value == null) return "";
        return URLDecoder.decode(value, StandardCharsets.UTF_8);
    }
}
4.3 注册拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserContextInterceptor())
                .addPathPatterns("/**");
    }
}

模块 5:Feign 调用透传上下文

@Component
public class FeignAuthInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        LoginUser user = UserContextHolder.get();
        if (user != null) {
            template.header("X-User-Id", user.getUserId());
            template.header("X-User-Name",
                    URLEncoder.encode(user.getUsername(), StandardCharsets.UTF_8));
            template.header("X-User-Roles", String.join(",", user.getRoles()));
            template.header("X-Tenant-Id", user.getTenantId());
        }
    }
}

模块 6:方法级权限控制(注解 + AOP)

// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    String[] value();
}

// AOP 切面
@Aspect
@Component
public class RoleCheckAspect {

    @Before("@annotation(requireRole)")
    public void check(JoinPoint joinPoint, RequireRole requireRole) {
        LoginUser user = UserContextHolder.get();
        if (user == null) {
            throw new BizException(401, "未登录");
        }
        List<String> userRoles = user.getRoles();
        boolean hasRole = Arrays.stream(requireRole.value())
                .anyMatch(userRoles::contains);
        if (!hasRole) {
            throw new BizException(403, "无权限访问");
        }
    }
}

// 业务使用
@RequireRole({"ADMIN", "OPERATOR"})
@GetMapping("/admin/data")
public Result<?> adminData() {
    return Result.success(...);
}

四、大厂生产环境必须落地的 12 个要点

# 要点 关键措施
1 防 Header 伪造越权 网关入口先清洗所有 X-User-* Header,再由网关重写
2 ThreadLocal 内存泄漏/串号 afterCompletion 中必须 clear();用 TTL 支持异步
3 Redis 宕机降级 黑名单查询设 200ms 超时,异常时本地验签后放行 + 报警
4 密钥安全 JWT 密钥放 Nacos 加密配置,禁止硬编码;支持密钥轮换(新旧双密钥过渡)
5 Token 主动失效 jti 黑名单机制,注销/改密/封号时拉黑
6 JWT 体积控制 只放 userId/roles/tenantId,避免

接着上面的"大厂生产环境必须落地的 12 个要点"表格继续:

# 要点 关键措施
6 JWT 体积控制 只放 userId/roles/tenantId,避免 Header 超限(Tomcat 默认 8KB)。权限码多时只放角色 Code,详细权限走 Redis 缓存
7 双 Token 无感刷新 AccessToken 30min,RefreshToken 7天。前端拦截 401 自动调 /refresh
8 登录防刷 失败次数 Redis 计数锁定 + 网关层 IP/手机号滑窗限流
9 权限变更实时生效 后台改权限 → MQ 广播 → 清缓存 + 拉黑旧 Token,强制重登
10 链路追踪 TraceId 全链路透传,便于排查鉴权问题
11 异步线程上下文 @Async/CompletableFuture/线程池场景用 TTL + TtlExecutors 包装
12 密码安全 BCrypt 加盐存储;传输强制 HTTPS;登录接口防时序攻击

下面针对上表中尚未给出代码的关键要点,补充完整的生产级实现。


五、要点补充实现

5.1 密钥轮换:网关支持新旧双密钥过渡(要点4)

生产环境每 90 天换一次密钥,但旧 Token 还在有效期内,必须同时兼容。

@Component
@Slf4j
@RefreshScope  // 配合Nacos实现配置热更新
public class JwtKeyManager {

    /**
     * 主密钥(当前签发用),从Nacos读取
     */
    @Value("${jwt.secret.current}")
    private String currentSecret;

    /**
     * 旧密钥(过渡期校验用),轮换后保留一个周期,之后置空
     */
    @Value("${jwt.secret.previous:}")
    private String previousSecret;

    /**
     * 签发统一用当前密钥
     */
    public String getSigningSecret() {
        return currentSecret;
    }

    /**
     * 校验:先用当前密钥,失败再尝试旧密钥
     */
    public Claims parseWithFallback(String token) {
        try {
            return JwtUtils.parseToken(token, currentSecret);
        } catch (ExpiredJwtException e) {
            throw e; // 过期直接抛,不需要试旧密钥
        } catch (Exception e) {
            // 当前密钥验签失败,可能是轮换前签发的,尝试旧密钥
            if (StringUtils.hasText(previousSecret)) {
                try {
                    Claims claims = JwtUtils.parseToken(token, previousSecret);
                    log.debug("使用旧密钥验签成功,建议用户刷新Token");
                    return claims;
                } catch (Exception ignored) {
                    // 旧密钥也失败,说明是真非法
                }
            }
            throw new BizException(401, "Token非法");
        }
    }
}

网关 Filter 改用 parseWithFallback

Claims claims;
try {
    claims = jwtKeyManager.parseWithFallback(token);
} catch (ExpiredJwtException e) {
    return unauthorized(exchange, "Token已过期");
} catch (Exception e) {
    return unauthorized(exchange, "Token非法");
}

Nacos 配置轮换流程:

  1. 第 0 天:current=keyBprevious=keyA(keyA 是上个周期的)
  2. 新签发用 keyB,老 Token(keyA 签)仍能通过 fallback 校验
  3. 等 keyA 签的 Token 全部过期(30min 后)→ 清空 previous

5.2 权限变更实时生效:MQ 广播清缓存 + 拉黑(要点9)

当管理员修改了某用户的角色,必须让该用户的现有 Token 立即失效。

发送端(权限管理服务)
@Service
@RequiredArgsConstructor
public class PermissionService {

    private final RocketMQTemplate rocketMQTemplate;
    private final UserMapper userMapper;

    @Transactional
    public void updateUserRoles(Long userId, List<String> newRoles) {
        // 1. 更新数据库角色
        userMapper.updateRoles(userId, newRoles);

        // 2. 发送权限变更事件(广播模式,所有节点都收到)
        PermissionChangeEvent event = new PermissionChangeEvent();
        event.setUserId(String.valueOf(userId));
        event.setChangeType("ROLE_UPDATE");
        event.setTimestamp(System.currentTimeMillis());

        // 事务消息,保证DB提交后才真正投递
        rocketMQTemplate.syncSend("PERMISSION_CHANGE_TOPIC", event);
    }
}
接收端(认证中心,负责拉黑该用户所有 Token)
@Component
@Slf4j
@RequiredArgsConstructor
@RocketMQMessageListener(
        topic = "PERMISSION_CHANGE_TOPIC",
        consumerGroup = "auth-permission-consumer",
        messageModel = MessageModel.BROADCASTING  // 广播:每个实例都消费
)
public class PermissionChangeListener implements RocketMQListener<PermissionChangeEvent> {

    private final StringRedisTemplate redisTemplate;

    @Override
    public void onMessage(PermissionChangeEvent event) {
        String userId = event.getUserId();
        log.info("收到权限变更事件 userId={}, type={}", userId, event.getChangeType());

        // 方案:写一个"用户级失效时间戳",网关校验Token签发时间是否早于该时间
        // 比逐个拉黑jti更简单,适合"用户所有Token失效"场景
        redisTemplate.opsForValue().set(
                "user:invalidate:" + userId,
                String.valueOf(event.getTimestamp()),
                7, TimeUnit.DAYS  // 与RefreshToken最大有效期一致
        );
    }
}
网关增加"用户级失效时间"校验
// 在网关Filter黑名单校验通过后,再加一层用户级失效校验
private Mono<Boolean> checkUserInvalidate(Claims claims) {
    String userId = claims.getSubject();
    long tokenIssuedAt = claims.getIssuedAt().getTime();

    return redisTemplate.opsForValue().get("user:invalidate:" + userId)
            .timeout(Duration.ofMillis(200))
            .map(invalidateTime -> {
                long invalidTs = Long.parseLong(invalidateTime);
                // Token签发时间早于失效时间 → 失效(权限已变更)
                return tokenIssuedAt >= invalidTs;
            })
            .defaultIfEmpty(true)            // 没有失效记录 → 有效
            .onErrorReturn(true);           // Redis异常 → 降级放行
}

对比两种失效方案:

  • jti 黑名单:精确到单个 Token,适合"注销当前设备"。
  • 用户级失效时间戳:一键失效该用户所有 Token,适合"改密码/改权限/封号"。生产中两者结合使用。

5.3 异步线程上下文传递(要点11)

@Async 或自建线程池会导致 UserContextHolder 取不到值,必须用 TTL 包装线程池。

@Configuration
public class AsyncThreadPoolConfig {

    @Bean("bizExecutor")
    public Executor bizExecutor() {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                10, 50, 60L, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(1000),
                new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
                new ThreadPoolExecutor.CallerRunsPolicy()
        );
        // 关键:用 TtlExecutors 包装,自动传递 TransmittableThreadLocal
        return TtlExecutors.getTtlExecutor(executor);
    }
}

使用示例:

@Service
@RequiredArgsConstructor
public class OrderService {

    @Resource(name = "bizExecutor")
    private Executor bizExecutor;

    public void asyncProcess() {
        // 主线程有用户上下文
        String userId = UserContextHolder.getUserId();

        CompletableFuture.runAsync(() -> {
            // 子线程仍能取到(因为用了TtlExecutors)
            String asyncUserId = UserContextHolder.getUserId();
            log.info("异步线程获取userId={}", asyncUserId); // 不为null
        }, bizExecutor);
    }
}

注意:TTL 在线程池里需要"任务装饰"机制才能正确传递并清理。TtlExecutors.getTtlExecutor 已封装好任务的 copy 与 restore,无需手动 clear。


5.4 链路追踪 TraceId 全链路透传(要点10)

网关注入 TraceId
@Component
public class TraceIdFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String traceId = exchange.getRequest().getHeaders().getFirst("X-Trace-Id");
        if (!StringUtils.hasText(traceId)) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        ServerHttpRequest request = exchange.getRequest().mutate()
                .header("X-Trace-Id", traceId)
                .build();
        return chain.filter(exchange.mutate().request(request).build());
    }

    @Override
    public int getOrder() {
        return -200; // 比鉴权Filter更早执行
    }
}
内部服务拦截器写入 MDC(日志自动带 TraceId)
// 在 UserContextInterceptor.preHandle 中追加
String traceId = request.getHeader("X-Trace-Id");
if (StringUtils.hasText(traceId)) {
    MDC.put("traceId", traceId);
}

// afterCompletion 中清理
MDC.remove("traceId");
logback 配置输出 TraceId
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
Feign 透传 TraceId(在 FeignAuthInterceptor 中追加)
String traceId = MDC.get("traceId");
if (StringUtils.hasText(traceId)) {
    template.header("X-Trace-Id", traceId);
}

5.5 全局异常处理(统一返回鉴权错误)

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public Result<Void> handleBiz(BizException e) {
        // 401/403 不打error日志,避免污染日志
        if (e.getCode() == 401 || e.getCode() == 403) {
            log.warn("鉴权异常: {}", e.getMessage());
        } else {
            log.error("业务异常", e);
        }
        return Result.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleOther(Exception e) {
        log.error("系统异常 traceId={}", MDC.get("traceId"), e);
        return Result.fail(500, "系统繁忙,请稍后重试");
    }
}

六、关键配置汇总

Nacos 共享配置(jwt 相关)

jwt:
  secret:
    current: ${JWT_SECRET_CURRENT}    # 环境变量注入,禁止明文
    previous: ${JWT_SECRET_PREVIOUS:} # 轮换过渡期使用
  access-expire: 1800000              # 30分钟
  refresh-expire: 604800000           # 7天

网关限流配置(Sentinel)

spring:
  cloud:
    sentinel:
      datasource:
        flow:
          nacos:
            server-addr: ${NACOS_ADDR}
            data-id: gateway-flow-rules
            rule-type: gw-flow

对应 Nacos 规则(登录接口按 IP 限流):

[
  {
    "resource": "/auth/login",
    "count": 5,
    "intervalSec": 60,
    "paramItem": { "parseStrategy": 3 }
  }
]

七、整体调用时序总结

登录:
客户端 → 网关(白名单放行) → Auth-Service
       → 校验密码 → 查角色 → 签发AccessToken+RefreshToken → Redis存RefreshToken
       ← 返回双Token

访问业务:
客户端(带AccessToken) → 网关
       → 清洗伪造Header
       → 本地验签(双密钥fallback)
       → 查jti黑名单(200ms超时,异常降级)
       → 查用户级失效时间戳
       → 写X-User-*可信Header → 内部服务
内部服务 → 拦截器解析Header → TTL上下文 → AOP权限校验 → 业务
       → Feign调用 → 透传Header+TraceId → 下游服务

注销/改权限:
注销 → jti入黑名单
改权限 → MQ广播 → 写user:invalidate时间戳 → 该用户所有旧Token失效

八、落地检查清单(上线前必须确认)

  • JWT 密钥通过环境变量/KMS 注入,代码无硬编码
  • 网关入口已清洗 X-User-* 伪造 Header
  • 所有拦截器 afterCompletionclear() ThreadLocal 和 MDC
  • 异步线程池全部用 TtlExecutors 包装
  • Redis 查询均设超时 + 降级逻辑 + 报警
  • 全链路 HTTPS,登录接口已限流
  • 密码 BCrypt 存储,登录失败有锁定机制
  • TraceId 全链路打通,日志可检索
  • 黑名单 TTL 与 Token 有效期对齐,无内存泄漏
  • 压测验证网关 QPS 与 Redis 降级表现

这套方案在性能(验签下沉、Redis 降级)、安全(Header 清洗、双失效机制、密钥轮换)、可用性(降级、限流)三方面都做到了大厂生产标准,可直接作为脚手架使用。如果需要,我可以再补充 OAuth2 第三方登录对接SSO 单点登录跨域方案 的实现。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐