登录鉴权-ai
·
我将为你提供一套完整的、可直接落地的分布式登录与统一鉴权方案,包含完整代码、详细解释和生产环境注意事项。
一、整体架构设计
┌─────────────┐
│ 客户端 App │
└──────┬──────┘
│ ① 登录请求 (账号/密码)
▼
┌─────────────────────┐
│ Auth-Service 认证中心 │ ──→ 校验密码、生成双Token、写Redis
└──────┬──────────────┘
│ ② 返回 AccessToken + RefreshToken
▼
┌─────────────────────┐
│ Spring Cloud Gateway │ ──→ 验签JWT、查黑名单、解析用户信息
│ (网关) │ ──→ 清洗伪造Header、写入X-User-*
└──────┬──────────────┘
│ ③ 透传明文用户信息(Header)
▼
┌─────────────────────┐ Feign透传 ┌──────────────┐
│ Order-Service │ ────────────────────→ │ Product-Service│
│ (拦截器→ThreadLocal) │ │ │
└─────────────────────┘ └──────────────┘
设计核心思想:
- 认证集中:只有 Auth-Service 接触密码和用户库。
- 验签下沉到网关:网关统一验 JWT,内部服务不再重复验签(性能关键)。
- 内部无状态传播:网关把解析好的用户信息以明文 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; // 高优先级,先于其他过滤器
}
}
解释(这里全是生产坑点):
- Header 清洗在最前面:必须先删除客户端传来的
X-User-*,否则黑客直接伪造X-User-Id:1就能冒充管理员。 - 两级校验:本地验签(强)+ Redis 黑名单(弱)。Redis 超时/异常时降级放行,保证核心可用,同时报警。
- 超时控制:
timeout(200ms)防止 Redis 慢查询拖垮整个网关。 - 中文编码:用户名含中文必须 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 配置轮换流程:
- 第 0 天:
current=keyB,previous=keyA(keyA 是上个周期的) - 新签发用 keyB,老 Token(keyA 签)仍能通过 fallback 校验
- 等 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 - 所有拦截器
afterCompletion已clear()ThreadLocal 和 MDC - 异步线程池全部用
TtlExecutors包装 - Redis 查询均设超时 + 降级逻辑 + 报警
- 全链路 HTTPS,登录接口已限流
- 密码 BCrypt 存储,登录失败有锁定机制
- TraceId 全链路打通,日志可检索
- 黑名单 TTL 与 Token 有效期对齐,无内存泄漏
- 压测验证网关 QPS 与 Redis 降级表现
这套方案在性能(验签下沉、Redis 降级)、安全(Header 清洗、双失效机制、密钥轮换)、可用性(降级、限流)三方面都做到了大厂生产标准,可直接作为脚手架使用。如果需要,我可以再补充 OAuth2 第三方登录对接 或 SSO 单点登录跨域方案 的实现。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)