微服务安全:JWT在Gateway统一鉴权与下游服务信息传递的完整方案
微服务安全:JWT在Gateway统一鉴权与下游服务信息传递的完整方案
适读人群:有微服务实战经验的后端工程师 | 阅读时长:约26分钟 | Spring Boot 3.2 / Spring Cloud 2023.0
开篇故事
我见过不少公司的微服务安全架构存在一个典型问题:每个微服务都自己做鉴权。订单服务有一套JWT验证逻辑,用户服务有一套,支付服务又有一套,代码重复不说,密钥管理也是乱的,有些服务甚至把JWT密钥直接硬编码在代码里。
更严重的问题是:这种"分散鉴权"的架构下,如果JWT密钥泄露了,需要改所有服务;如果要实现Token黑名单(强制下线某个用户),需要在所有服务里都加判断逻辑;如果要换签名算法,需要同时更新所有服务。
正确的微服务安全架构是:在网关层做统一鉴权,解析JWT并提取用户信息,然后把用户信息以HTTP Header的形式传递给下游服务。下游服务信任网关传来的Header,不需要自己验证JWT。
这套方案的关键在于:下游服务不做JWT验证,但必须确保用户信息Header只能由可信的网关传入,不能被外部绕过网关直接传入伪造的用户信息。今天把这套完整方案写出来。
一、核心问题分析
网关统一鉴权方案需要解决的核心问题有三个:
问题一:如何确保安全边界。下游服务信任网关传来的用户信息Header,那么如果攻击者绕过网关直接调用下游服务,并伪造X-User-Id: admin这样的Header,该怎么办?解决方案是:下游服务的端口不对外暴露(只有网关对外),网关在转发请求时覆盖(而不是透传)用户信息Header,确保Header里的值只来自JWT解析结果。
问题二:如何传递复杂的用户信息。简单的userId传起来容易,但如果要传用户角色、所属部门、权限列表等复杂信息,放在Header里有大小限制,而且每次都传完整信息也比较浪费。合理的做法是只传userId和角色,其他信息让下游服务自行从用户服务查询(并缓存)。
问题三:Token刷新和黑名单问题。JWT是无状态的,一旦签发就无法主动失效(除非到期)。如果要实现强制下线,需要维护一个Token黑名单(存Redis),在网关层查询黑名单来拦截已失效的Token。
二、原理深度解析
2.1 网关统一鉴权架构
2.2 JWT Token的生命周期
2.3 下游服务获取用户信息的流程
三、完整代码实现
3.1 认证服务(发放JWT)
package com.laozhang.auth.service;
import com.laozhang.auth.dto.LoginRequest;
import com.laozhang.auth.dto.LoginResponse;
import com.laozhang.auth.dto.TokenPair;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class AuthService {
@Value("${jwt.access-token.secret}")
private String accessTokenSecret;
@Value("${jwt.access-token.expiration:3600}")
private long accessTokenExpiration; // 秒
@Value("${jwt.refresh-token.secret}")
private String refreshTokenSecret;
@Value("${jwt.refresh-token.expiration:604800}")
private long refreshTokenExpiration; // 秒,默认7天
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final StringRedisTemplate redisTemplate;
public AuthService(
UserRepository userRepository,
PasswordEncoder passwordEncoder,
StringRedisTemplate redisTemplate
) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.redisTemplate = redisTemplate;
}
public LoginResponse login(LoginRequest request) {
// 验证用户名密码
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new RuntimeException("用户名或密码错误");
}
// 生成TokenPair
TokenPair tokenPair = generateTokenPair(user);
log.info("用户登录成功,userId={}", user.getId());
return LoginResponse.builder()
.userId(user.getId())
.accessToken(tokenPair.getAccessToken())
.refreshToken(tokenPair.getRefreshToken())
.expiresIn(accessTokenExpiration)
.build();
}
public void logout(String accessToken) {
// 解析Token获取剩余有效时间
try {
Claims claims = parseToken(accessToken, accessTokenSecret);
long remainingSeconds = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
if (remainingSeconds > 0) {
// 将Token加入黑名单,TTL等于Token剩余有效期
String blacklistKey = "token:blacklist:" + accessToken;
redisTemplate.opsForValue().set(blacklistKey, "1", remainingSeconds, TimeUnit.SECONDS);
log.info("Token已加入黑名单,剩余有效期={}s", remainingSeconds);
}
} catch (Exception e) {
log.warn("Token解析失败,忽略logout操作", e);
}
}
public boolean isTokenBlacklisted(String token) {
String blacklistKey = "token:blacklist:" + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(blacklistKey));
}
private TokenPair generateTokenPair(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", user.getRole());
claims.put("username", user.getUsername());
String accessToken = buildToken(
String.valueOf(user.getId()),
claims,
accessTokenExpiration,
accessTokenSecret
);
String refreshToken = buildToken(
String.valueOf(user.getId()),
Map.of("type", "refresh"),
refreshTokenExpiration,
refreshTokenSecret
);
return new TokenPair(accessToken, refreshToken);
}
private String buildToken(
String subject,
Map<String, Object> extraClaims,
long expirationSeconds,
String secret
) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.setClaims(extraClaims)
.setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expirationSeconds * 1000))
.signWith(key)
.compact();
}
private Claims parseToken(String token, String secret) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}3.2 Gateway鉴权过滤器(完整版)
package com.laozhang.gateway.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Component
public class JwtAuthGlobalFilter implements GlobalFilter, Ordered {
@Value("${jwt.access-token.secret}")
private String accessTokenSecret;
private static final List<String> WHITE_LIST = Arrays.asList(
"/auth/login", "/auth/register", "/auth/refresh",
"/public/**", "/actuator/**", "/doc.html", "/v3/api-docs/**"
);
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final ReactiveStringRedisTemplate redisTemplate;
public JwtAuthGlobalFilter(ReactiveStringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (isWhiteListed(path)) {
// 白名单:清除可能的伪造Header后放行
return chain.filter(removeUserHeaders(exchange));
}
String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) {
return error(exchange, HttpStatus.UNAUTHORIZED, "401", "缺少Authorization Header");
}
String token = authorization.substring(7);
// 先检查黑名单(响应式Redis操作)
String blacklistKey = "token:blacklist:" + token;
return redisTemplate.hasKey(blacklistKey).flatMap(isBlacklisted -> {
if (Boolean.TRUE.equals(isBlacklisted)) {
return error(exchange, HttpStatus.UNAUTHORIZED, "401002", "Token已失效,请重新登录");
}
// 验证JWT
try {
Claims claims = parseToken(token);
String userId = claims.getSubject();
String role = claims.get("role", String.class);
String username = claims.get("username", String.class);
// 构建携带用户信息的请求(覆盖任何原有的X-User-*头,防止伪造)
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.headers(headers -> {
// 移除所有X-User-*头(防止外部伪造)
headers.remove("X-User-Id");
headers.remove("X-User-Role");
headers.remove("X-User-Name");
// 重新注入经过验证的用户信息
headers.add("X-User-Id", userId);
headers.add("X-User-Role", role != null ? role : "");
headers.add("X-User-Name", username != null ? username : "");
})
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (io.jsonwebtoken.ExpiredJwtException e) {
return error(exchange, HttpStatus.UNAUTHORIZED, "401001", "Token已过期,请刷新");
} catch (Exception e) {
log.warn("JWT验证失败,path={},error={}", path, e.getMessage());
return error(exchange, HttpStatus.UNAUTHORIZED, "401003", "Token无效");
}
});
}
private ServerWebExchange removeUserHeaders(ServerWebExchange exchange) {
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.headers(headers -> {
headers.remove("X-User-Id");
headers.remove("X-User-Role");
headers.remove("X-User-Name");
})
.build();
return exchange.mutate().request(mutatedRequest).build();
}
private Claims parseToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(accessTokenSecret.getBytes(StandardCharsets.UTF_8));
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
private boolean isWhiteListed(String path) {
return WHITE_LIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
}
private Mono<Void> error(ServerWebExchange exchange, HttpStatus status, String code, String msg) {
var response = exchange.getResponse();
response.setStatusCode(status);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"code\":\"%s\",\"message\":\"%s\"}", code, msg);
DataBuffer buffer = response.bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return 100;
}
}3.3 下游服务:从Header获取用户信息
package com.laozhang.common.security;
import lombok.Builder;
import lombok.Data;
/**
* 当前登录用户信息
* 从Gateway注入的Header中构建
*/
@Data
@Builder
public class CurrentUser {
private String userId;
private String username;
private String role;
public boolean isAdmin() {
return "ADMIN".equals(role);
}
public boolean hasRole(String requiredRole) {
return requiredRole.equals(role);
}
}package com.laozhang.common.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 下游服务的用户信息提取过滤器
* 从Gateway注入的Header中提取用户信息,存入ThreadLocal
* 注意:这里不做JWT验证,信任网关传来的Header
*/
@Component
public class UserContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String userId = request.getHeader("X-User-Id");
String username = request.getHeader("X-User-Name");
String role = request.getHeader("X-User-Role");
if (userId != null && !userId.isEmpty()) {
CurrentUser currentUser = CurrentUser.builder()
.userId(userId)
.username(username)
.role(role)
.build();
UserContext.set(currentUser);
}
try {
filterChain.doFilter(request, response);
} finally {
UserContext.clear();
}
}
}package com.laozhang.common.security;
/**
* 用户上下文ThreadLocal持有者
*/
public class UserContext {
private static final ThreadLocal<CurrentUser> USER_HOLDER = new ThreadLocal<>();
public static void set(CurrentUser user) {
USER_HOLDER.set(user);
}
public static CurrentUser get() {
return USER_HOLDER.get();
}
public static String getUserId() {
CurrentUser user = USER_HOLDER.get();
return user != null ? user.getUserId() : null;
}
public static void clear() {
USER_HOLDER.remove();
}
}3.4 权限控制注解
package com.laozhang.common.security;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireRole {
String[] value();
}package com.laozhang.common.security;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.Arrays;
@Slf4j
@Aspect
@Component
public class RoleCheckAspect {
@Around("@annotation(requireRole)")
public Object checkRole(ProceedingJoinPoint pjp, RequireRole requireRole) throws Throwable {
CurrentUser currentUser = UserContext.get();
if (currentUser == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "未登录");
}
String[] requiredRoles = requireRole.value();
boolean hasRole = Arrays.stream(requiredRoles)
.anyMatch(role -> role.equals(currentUser.getRole()));
if (!hasRole) {
log.warn("权限不足,userId={},当前角色={},需要角色={}",
currentUser.getUserId(), currentUser.getRole(), Arrays.toString(requiredRoles));
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "权限不足");
}
return pjp.proceed();
}
}四、生产配置与调优
4.1 JWT密钥管理
# 生产环境:密钥从环境变量读取,不硬编码
jwt:
access-token:
secret: ${JWT_ACCESS_SECRET}
expiration: 3600 # 1小时
refresh-token:
secret: ${JWT_REFRESH_SECRET}
expiration: 604800 # 7天密钥长度:HS256至少256位(32字节),HS512至少512位(64字节)。建议使用工具生成随机密钥,而不是手写字符串。
五、踩坑实录
坑一:下游服务没有移除来自外部的X-User-Id Header,被伪造攻击。
这是最严重的安全漏洞。如果网关在白名单接口上没有清除X-User-*头,攻击者可以直接构造带这些Header的请求,绕过鉴权获取其他用户的数据。
解决方案:网关在转发所有请求时,不管是不是白名单接口,都先把请求里原有的X-User-*头全部移除,再根据JWT解析结果重新注入。白名单接口移除Header后不注入,下游服务收到的Header里就没有用户信息。
坑二:Token黑名单存Redis,黑名单key没设TTL,Redis内存持续增长。
Token退出登录加入黑名单后,必须设置TTL等于Token的剩余有效时间。Token过期后,黑名单记录就没有意义了(过期的Token本身就无法通过时间验证),不需要继续保留,自动过期可以避免Redis内存无限增长。
坑三:RefreshToken和AccessToken用了同样的密钥,RefreshToken可以当AccessToken用。
应该给AccessToken和RefreshToken分别使用不同的密钥,并在解析时明确区分。如果用同一个密钥,RefreshToken就能通过AccessToken的验证,安全漏洞。
坑四:UserContext在异步线程里为空。
ThreadLocal在新开线程里是空的。如果Controller里有@Async方法,在异步方法里调用UserContext.get()会得到null。解决方案同Tracing的TraceId传播:在提交异步任务时显式把用户信息传给子线程。
六、总结
微服务安全的核心是网关统一鉴权加用户信息Header传递。安全关键点:网关在转发时必须覆盖X-User-*头(不能透传),防止外部伪造;Token黑名单用Redis存储,TTL设为Token剩余有效时间;AccessToken和RefreshToken用不同密钥。下游服务从Header获取用户信息,配合ThreadLocal和AOP切面做权限控制,代码简洁且安全。
