登录鉴权-ai

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


一、整体架构设计

text 复制代码
┌─────────────┐
│  客户端 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 关键部分)

xml 复制代码
<!-- 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 模块)

java 复制代码
@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 登录接口
java 复制代码
@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 登录核心逻辑
java 复制代码
@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:网关统一鉴权(最核心)

java 复制代码
@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 解决异步丢失)
java 复制代码
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
java 复制代码
@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 注册拦截器
java 复制代码
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserContextInterceptor())
                .addPathPatterns("/**");
    }
}

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

java 复制代码
@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)

java 复制代码
// 自定义注解
@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 还在有效期内,必须同时兼容。

java 复制代码
@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

java 复制代码
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 立即失效。

发送端(权限管理服务)
java 复制代码
@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)
java 复制代码
@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最大有效期一致
        );
    }
}
网关增加"用户级失效时间"校验
java 复制代码
// 在网关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 包装线程池。

java 复制代码
@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);
    }
}

使用示例:

java 复制代码
@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
java 复制代码
@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)
java 复制代码
// 在 UserContextInterceptor.preHandle 中追加
String traceId = request.getHeader("X-Trace-Id");
if (StringUtils.hasText(traceId)) {
    MDC.put("traceId", traceId);
}

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

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

java 复制代码
@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 相关)

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

网关限流配置(Sentinel)

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

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

json 复制代码
[
  {
    "resource": "/auth/login",
    "count": 5,
    "intervalSec": 60,
    "paramItem": { "parseStrategy": 3 }
  }
]

七、整体调用时序总结

text 复制代码
登录:
客户端 → 网关(白名单放行) → 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 单点登录跨域方案 的实现。

相关推荐
阿无,1 小时前
RabbitMQ面试题
分布式·rabbitmq
盛世宏博智慧档案4 小时前
全国100个分布式机房环境温湿度智能监测系统建设方案
分布式·机房·温湿度
六bring个六5 小时前
分布式设备连接对端失败问题分析
分布式·分布式软总线
天涯明月199310 小时前
ray深度研究报告
大数据·人工智能·分布式·ray
Rain的Java大神之路20 小时前
介绍一下分布式事务
java·分布式·后端·spring·spring cloud·架构·springcloud
大菠萝爱上小西瓜1 天前
【大数据实战】Spark 2.2.2 完全分布式集群搭建
大数据·分布式·spark
肠畔码农1 天前
深度解析 RocketMQ 消费端限流与重平衡(Rebalance):分布式队列分配与流控防雪崩本质
分布式·rocketmq
海兰1 天前
【 Kafka进阶3】Apache Kafka 分布式事件流平台:架构原理与微服务解耦机制简要分析
分布式·架构·kafka
麻瓜记录Jackson2 天前
深入剖析 ZooKeeper 分布式 互斥锁:Curator 之 InterProcessMutex 源码全解(附图解)
java·spring boot·分布式·后端·zookeeper·java-zookeeper
Keystone_Onion2 天前
跨境电商架构演进:基于美国海外仓的中大件分布式仓网路由优化方案
分布式·架构