权限校验差点把我劝退微服务:3天踩坑,Spring Security + JWT + Gateway 统一认证实战复盘
权限校验差点把我劝退微服务:3天踩坑,Spring Security + JWT + Gateway 统一认证实战复盘
一、先说背景:单体一把梭,微服务全是泪
以前做 mall 单体项目的时候,权限校验爽得一批——HttpSession 一存,Filter 一拦,齐活。
后来项目拆了微服务,user 一个服务、order 一个服务、product 一个服务……问题来了:用户登录态怎么跨服务共享?
我在 mall-Pro 里重构权限系统的时候,整整踩了三天坑。这篇文章就把摸索过程、踩坑记录、最终方案全部分享出来。
如果你正在做微服务权限,或者面试被问到"微服务认证怎么做",看完这篇就够了。
二、核心痛点:微服务拆分后,Session 直接废了
单体架构下,Session 存在同一台服务器的内存里,用户一登录,Cookie 一传,后端 session.getId() 就能拿到用户信息。
但微服务拆完之后:
- 用户登录在 user 服务,下单请求打到 order 服务
- order 服务根本拿不到 user 服务的 Session
- 部署多实例的话,负载均衡一轮询,Session 都找不着妈
我当时第一反应:“加个 Spring Session + Redis 共享不就完了?”
然后就被坑了。
三、错误尝试:Session 共享,越搞越复杂
方案1:Spring Session + Redis
把 Session 存到 Redis,所有服务共享。
听起来很美好,实际接入后发现一堆问题:
问题1:浏览器跨域,Cookie 带不过去
前端 localhost:5173,后端 localhost:8081,Cookie 跨域直接丢了。
改了半天 withCredentials、AllowedOrigin,好不容易能带上了,又发现——
问题2:CSRF 防护跟微服务八字不合
Spring Security 默认开 CSRF 防护,每个 POST 请求都得带一个 _csrf token。前端每次都要先 GET 一下拿 token,再 POST 带过去。而且 Gateway 路由转发的时候,CSRF token 在服务之间怎么同步?又是一笔烂账。
问题3:Session 丢失排到头秃
生产环境多实例部署,偶尔出现"用户明明登录了,请求突然说没登录"。查了一下午,发现是某些请求没经过 Gateway,直接打到某个服务实例上去了——运维配的路由规则漏了几个路径。
结论:Session 共享方案在微服务下就是一条死路。
四、正确姿势:JWT + Gateway 统一认证
踩完 Session 的坑之后,我彻底转向了 JWT(JSON Web Token)。
JWT 为什么适合微服务?
| 对比项 | Session | JWT |
|---|---|---|
| 存储位置 | 服务端内存/Redis | 客户端(浏览器/本地) |
| 跨服务共享 | 需要 Redis 同步 | Token 本身包含用户信息,直接传 |
| 扩展性 | 每加一个服务都要连 Redis | 只要验签就能用,服务无状态 |
| 性能 | Redis 查询有网络开销 | 本地验签,毫秒级 |
JWT 的核心思想就是:用户登录后,服务端签发一个加密的 Token,客户端每次请求都带上它,服务端只需要验签,不需要查 Session。
五、最终架构:Gateway 统一认证 + 服务各自鉴权
在 mall-Pro 里,我最终落地的架构是三层:
客户端(浏览器/App)
│
▼
【Spring Cloud Gateway】(统一入口,路由转发)
│
▼
【各个微服务】(mall-user、mall-order、mall-product...)
│
▼
【mall-security 公共模块】(JWT 工具 + 过滤器 + 配置)
核心流程
1. 用户 POST /auth/login → Gateway 路由到 user 服务 → 验证用户名密码 → 返回 JWT Token
2. 前端把 Token 存到 localStorage,每次请求在 Authorization 头带上 `Bearer xxx`
3. Gateway 转发请求到具体微服务
4. 每个微服务引入 mall-security 模块,里面的 JwtUserTokenFilter 拦截请求
5. 解析 Token → 校验签名 → 查询用户权限 → 设置 SecurityContext
6. Controller 上用 @PreAuthorize 注解控制接口权限
六、关键代码逐段拆解(可直接复制用)
6.1 JWT 工具类 —— 签发和解析 Token
@Component
public class JwtTokenUtil {
@Value("${jwt.expiration}")
private Long expiration;
// ⚠️ 关键:用 Keys.secretKeyFor 自动生成 HS512 安全密钥
// 千万不要自己写固定字符串!!!
private static final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS512);
// 生成 Token
public String generateToken(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(SECRET_KEY)
.compact();
}
// 从 Token 解析用户名
public String getUsernameFromToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
// 验证 Token 是否有效(签名正确 + 未过期)
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
// 刷新 Token:到期前自动续签
public String refreshToken(String token) {
if (!validateToken(token)) return null;
String username = getUsernameFromToken(token);
if (username == null) return null;
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username);
}
}
踩坑点:
- 密钥不要写死在代码里!线上环境应该配置到 Nacos 配置中心
Keys.secretKeyFor(SignatureAlgorithm.HS512)每次启动会生成新密钥,所以重启后所有已签发的 Token 都会失效——生产环境一定要固定密钥
6.2 JWT 过滤器 —— 每个请求都要过这道坎
@Component
public class JwtUserTokenFilter extends OncePerRequestFilter {
@Resource
private JwtTokenUtil jwtTokenUtil;
@Resource
private AdminUserDetailsService adminUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 从请求头获取 Token
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// 2. 校验 Token 签名
String token = authHeader.substring(7);
if (!jwtTokenUtil.validateToken(token)) {
filterChain.doFilter(request, response);
return;
}
// 3. 查询数据库获取用户权限
String username = jwtTokenUtil.getUsernameFromToken(token);
UserDetails userDetails;
try {
userDetails = adminUserDetailsService.loadUserByUsername(username);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
// 4. 构造认证信息,塞进 SecurityContext
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 5. 自动刷新 Token(续签)
String newToken = jwtTokenUtil.refreshToken(token);
if (newToken != null) {
response.setHeader("Authorization", "Bearer " + newToken);
}
filterChain.doFilter(request, response);
}
}
为什么要用 OncePerRequestFilter?
因为普通 Filter 在转发(forward)场景下会被执行多次,而 OncePerRequestFilter 保证每个请求只执行一次过滤,避免重复认证。
6.3 Security 配置 —— 全链路打通
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启 @PreAuthorize
public class SecurityConfig {
@Resource
private AdminUserDetailsService userDetailsService;
@Resource
private JwtUserTokenFilter jwtUserTokenFilter;
@Resource
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Resource
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 关闭 CSRF(JWT 不需要 CSRF 防护)
.csrf(customizer -> customizer.disable())
// 2. 无状态 Session —— 这是最关键的!!
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 3. 放行所有请求,鉴权交给 @PreAuthorize 注解
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.cors(cors -> {});
http.authenticationProvider(daoAuthenticationProvider());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfig) throws Exception {
return authenticationConfig.getAuthenticationManager();
}
}
配置层面有 3 个要点:
SessionCreationPolicy.STATELESS:告诉 Spring Security 永远不要创建 HttpSession,也不要从 Session 获取认证信息。所有认证信息都从 JWT Token 里拿。csrf().disable():JWT 方案下,CSRF 防护是多余的。因为 JWT Token 存在Authorization头里,不是 Cookie,攻击者没法通过用户浏览器自动带上。@EnableGlobalMethodSecurity(prePostEnabled = true):开启方法级别权限控制,Controller 上写@PreAuthorize("hasRole('ADMIN')")就能控制接口权限。
6.4 Gateway 路由配置
@Configuration
public class GatewayRouteConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("mall-user-route", r -> r
.path("/api/user/**", "/api/auth/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://mall-user"))
.route("mall-product-route", r -> r
.path("/api/product/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://mall-product"))
.route("mall-order-route", r -> r
.path("/api/order/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://mall-order"))
.build();
}
}
Gateway 的职责只有一个:统一入口,路由转发。认证逻辑不放在 Gateway 里做,是因为这样每个服务就无法独立部署和测试了。
6.5 全局异常处理 —— 401 和 403 统一返回 JSON
Spring Security 默认的未登录返回的是 HTML 重定向,微服务前后端分离下需要改成 JSON:
// 未登录 -> 401
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSON.toJSONString(
CommonResult.unauthorized("未登录,请先登录")));
response.getWriter().flush();
}
}
// 已登录但权限不足 -> 403
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSON.toJSONString(
CommonResult.forbidden("该用户权限不足")));
}
}
七、还有一个大坑:Feign 调用时 Token 丢了
这是很多人做到最后才会发现的问题:
场景:order 服务通过 Feign 调用 user 服务查询用户信息
结果:user 服务收到请求后在 JWT 过滤器里拿不到 Token,返回"未登录"
原因:Feign 调用是服务间内部调用,不会自动把上游请求的 Header 带过去。
解决方案:写一个 Feign 请求拦截器,把当前请求的 Authorization 头传递下去。
@Component
public class FeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 从当前请求上下文中获取 Authorization 头
ServletRequestAttributes attributes = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String authHeader = request.getHeader("Authorization");
if (authHeader != null) {
template.header("Authorization", authHeader);
}
}
}
}
⚠️ 注意:服务间内部调用要不要带 Token,要看业务场景。如果是用户发起的请求链路(如"查询订单详情,顺便查用户信息"),应该透传。如果是定时任务或后台批处理,应该用服务账号认证而不是用户 Token。
八、避坑经验总结(面试也能用)
这三天踩坑最大的收获不是代码怎么写,而是以下几个思维转变:
1. JWT 不是银弹,有得必有失
| JWT 的优点 | JWT 的缺点 |
|---|---|
| 无状态,服务间不需要共享存储 | 无法服务端主动注销(没有"踢人下线"功能) |
| 跨服务传 Token 简单 | Token 体积比 Session ID 大很多 |
| 天生支持跨域 | 密钥管理不当会出大问题 |
解决方案:维护一个 Redis 黑名单,用户注销或修改密码时把 Token 的 jti(唯一标识)加入黑名单,过滤器里多一步检查。虽然牺牲了"无状态"的纯粹性,但换来了可控性。
2. 无状态 Session 是 JWT 的命门
❌ 错误:sessionManagement(customizer -> customizer.sessionCreationPolicy(
SessionCreationPolicy.IF_REQUIRED))
✅ 正确:sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
如果不设置 STATELESS,Spring Security 会先去 Session 里找认证信息,找不到才走过滤器。一旦之前某个请求意外创建了 Session,后续请求就可能跳过 JWT 校验——这个 bug 我排查了一下午。
3. 权限数据不要每次都查数据库
上面 JwtUserTokenFilter 的写法里,每次请求都查一次数据库 loadUserByUsername,这在高并发下扛不住的。
优化方向:把用户权限信息编码进 JWT Token 的 claims 里,过滤器里直接解析 claims 就能拿到权限列表,不需要查数据库。权限变更时重新签发 Token 即可。
4. 开发阶段记得关认证
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
项目初期开发和调试阶段,如果每个接口都要带 Token 会很烦。用 permitAll() 先放行所有请求,靠 @PreAuthorize 注解控制关键接口,上线前再把全局认证打开。
5. 密钥管理要有预案
线上环境密钥不能写死在代码里,建议:
- 放在 Nacos 配置中心,动态刷新
- 定期轮换密钥(RocketMQ 通知所有服务更新)
- 旧密钥保留一个窗口期,避免正在使用的 Token 突然全部失效
6. 面试高频题:JWT 续签怎么做?
很多面试官会问:“你们的 Token 过期时间设多久?过期了怎么办?”
mall-Pro 的做法:每次请求都在过滤器中自动刷新 Token(代码见上文第5步),只要用户一直操作,Token 就不会过期。如果用户超过过期时间没操作,就需要重新登录。
这种方式叫 滑动过期策略,用户体验好,适合电商这种高频操作场景。
九、写在最后
微服务的权限认证说难不难,说简单也不简单。核心就三件事:
- 选对方案:Session 时代已经过去了,微服务就用 JWT
- 搭好架构:Gateway 统一路由 + 公共 security 模块嵌入每个服务 + @PreAuthorize 注解鉴权
- 想好异常:未登录怎么返回、权限不足怎么返回、Token 过期怎么续
以上代码全部来自 mall-Pro 项目的真实实现,我把它做成了一个公共模块 mall-security,所有微服务只需要引入依赖、配置一下 JWT 密钥和过期时间,就能拥有统一的权限校验能力。
如果你正在做或准备做微服务,希望这篇能把弯路帮你省下来。
欢迎评论区交流,你的微服务权限踩过哪些坑?
更多推荐




所有评论(0)