引言
在 Spring Cloud 微服务架构中,认证与用户信息的传递是一个高频问题:
- JWT 应该在哪一层解析?
- 用户 ID、角色、租户信息如何在服务间传递?
- 为什么不能使用 Request Attribute?
- Feign 调用为什么会丢失用户上下文?
- ThreadLocal 在整个链路中扮演什么角色?
本文围绕“JWT 用户上下文传播”这一主线,梳理 Spring Cloud 项目中的完整实现方案。
一、整体链路设计
典型调用链路:
Client
↓
Gateway
↓
User Service
↓
Order Service
↓
Product Service
完整上下文传播流程:
客户端
↓ JWT
Gateway
↓
解析 JWT
↓
写入 Header
↓
Service A
↓
MVC Interceptor
↓
ThreadLocal(UserContext)
↓
业务代码
↓
Feign Interceptor
↓
Header
↓
Service B
核心思想:
- 跨服务传递 → Header
- 服务内共享 → ThreadLocal
二、为什么选择 Header 而不是 Attribute
很多人最开始会想到:
request.setAttribute("userId", 1001L);
然后:
Long userId = (Long) request.getAttribute("userId");
这种方式只能在当前 JVM 内部有效。
原因是:
request.setAttribute(...)
本质只是给当前 HttpServletRequest 对象增加一个属性。
它不会进入:
- HTTP Header
- HTTP Body
- URL
- Cookie
因此:
Service A
↓ Feign
Service B
当 Feign 发起新的 HTTP 请求时,Attribute 会完全丢失。
所以:
- Attribute → 服务内共享
- Header → 服务间传播
三、JWT 应该在哪一层解析
方案一:每个服务都解析 JWT
Gateway
↓
UserService解析JWT
↓
OrderService解析JWT
↓
ProductService解析JWT
缺点:
- 验签重复
- 代码重复
- 权限逻辑分散
方案二:Gateway 统一解析(推荐)
Client
↓ JWT
Gateway
↓
校验JWT
解析JWT
↓
Header
↓
业务服务
优点:
- 统一认证
- 减少重复计算
- 权限逻辑集中管理
四、Gateway:JWT → Header
4.1 解析 JWT
@Component
public class AuthGlobalFilter implements GlobalFilter {
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
GatewayFilterChain chain) {
String token = exchange.getRequest()
.getHeaders()
.getFirst("Authorization");
Long userId = JwtUtil.getUserId(token);
String role = JwtUtil.getRole(token);
ServerHttpRequest request = exchange.getRequest()
.mutate()
.header("X-User-Id", String.valueOf(userId))
.header("X-Role", role)
.build();
return chain.filter(
exchange.mutate()
.request(request)
.build());
}
}
4.2 防止 Header 伪造
客户端可能主动传:
X-User-Id: 1
X-Role: ADMIN
因此必须先删除再写入:
.headers(headers -> {
headers.remove("X-User-Id");
headers.remove("X-Role");
})
原则:
外部 Header 不可信,JWT 解析结果可信。
同时,业务服务不应直接暴露公网,只允许 Gateway 调用。
五、服务内上下文管理
5.1 获取 Header
Controller 中可以直接读取:
@GetMapping("/orders")
public String list(
@RequestHeader("X-User-Id")
Long userId) {
return userId.toString();
}
或者:
@GetMapping("/orders")
public String list(HttpServletRequest request) {
return request.getHeader("X-User-Id");
}
5.2 为什么需要 ThreadLocal
如果业务代码频繁依赖:
request.getHeader(...)
会导致业务层与 Web 层耦合。
更好的设计:
Header
↓
Interceptor
↓
ThreadLocal
↓
Business
5.3 UserContext
public class UserContext {
private static final ThreadLocal<UserInfo>
LOCAL = new ThreadLocal<>();
public static void set(UserInfo user) {
LOCAL.set(user);
}
public static UserInfo get() {
return LOCAL.get();
}
public static Long getUserId() {
return LOCAL.get().getUserId();
}
public static void clear() {
LOCAL.remove();
}
}
5.4 MVC Interceptor
@Component
public class UserContextInterceptor
implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) {
UserContext.set(
new UserInfo(
Long.valueOf(
request.getHeader("X-User-Id")),
request.getHeader("X-Role"),
null));
return true;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
UserContext.clear();
}
}
5.5 注册拦截器
@Configuration
public class WebMvcConfig
implements WebMvcConfigurer {
@Override
public void addInterceptors(
InterceptorRegistry registry) {
registry.addInterceptor(
new UserContextInterceptor());
}
}
六、Feign:继续传播上下文
为什么需要 Feign 拦截器
当 Service A 调用 Service B 时:
Service A
↓
Feign
↓
Service B
Feign 会创建新的 HTTP 请求。
此时必须把当前用户上下文重新写入 Header。
Feign RequestInterceptor
@Configuration
public class FeignConfig {
@Bean
public RequestInterceptor
userContextInterceptor() {
return template -> {
UserInfo user = UserContext.get();
if (user == null) {
return;
}
template.header(
"X-User-Id",
String.valueOf(
user.getUserId()));
template.header(
"X-Role",
user.getRole());
};
}
}
这样:
orderFeignClient.list();
自动变成:
GET /orders
X-User-Id: 1001
X-Role: ADMIN
MVC Interceptor 与 Feign Interceptor 的职责
MVC Interceptor:
Header
↓
ThreadLocal
Feign Interceptor:
ThreadLocal
↓
Header
一个负责接收上下文,一个负责传播上下文。
七、生产环境中的关键问题
ThreadLocal 必须清理
请求结束后必须:
UserContext.clear();
否则线程池复用时可能出现用户串号问题。
异步线程上下文丢失
以下场景不会自动继承 ThreadLocal:
@Async
CompletableFuture
线程池
MQ消费者
定时任务
如果异步逻辑需要用户信息,需要额外做上下文传递。
Header 内容控制
推荐传递:
X-User-Id
X-Role
X-Tenant-Id
X-Trace-Id
不推荐传递:
密码
手机号
身份证号
完整用户对象
Header 应仅承载必要上下文。
用户上下文与链路上下文分离
通常存在两类上下文:
用户上下文:
UserId
Role
TenantId
链路上下文:
TraceId
SpanId
RequestId
前者用于鉴权,后者用于日志追踪。
总结
Spring Cloud 中最常见的 JWT 上下文传播方案可以概括为:
Gateway
↓ JWT解析
Header
↓
MVC Interceptor
↓
ThreadLocal(UserContext)
↓
Business
↓
Feign Interceptor
↓
Header
↓
下游服务
职责划分如下:
| 组件 | 职责 |
|---|---|
| Gateway | JWT 校验与解析 |
| Header | 跨服务传播上下文 |
| MVC Interceptor | Header → ThreadLocal |
| UserContext | 服务内共享用户信息 |
| Feign Interceptor | ThreadLocal → Header |
| Business | 消费用户上下文 |
一句话总结:
Gateway 负责解析 JWT,Header 负责跨服务传播,ThreadLocal 负责服务内共享,Feign Interceptor 负责继续传播上下文,共同构成完整的微服务认证链路。
