权限校验差点把我劝退微服务: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 跨域直接丢了。

改了半天 withCredentialsAllowedOrigin,好不容易能带上了,又发现——

问题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 个要点:

  1. SessionCreationPolicy.STATELESS:告诉 Spring Security 永远不要创建 HttpSession,也不要从 Session 获取认证信息。所有认证信息都从 JWT Token 里拿。
  2. csrf().disable():JWT 方案下,CSRF 防护是多余的。因为 JWT Token 存在 Authorization 头里,不是 Cookie,攻击者没法通过用户浏览器自动带上。
  3. @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 就不会过期。如果用户超过过期时间没操作,就需要重新登录。

这种方式叫 滑动过期策略,用户体验好,适合电商这种高频操作场景。


九、写在最后

微服务的权限认证说难不难,说简单也不简单。核心就三件事:

  1. 选对方案:Session 时代已经过去了,微服务就用 JWT
  2. 搭好架构:Gateway 统一路由 + 公共 security 模块嵌入每个服务 + @PreAuthorize 注解鉴权
  3. 想好异常:未登录怎么返回、权限不足怎么返回、Token 过期怎么续

以上代码全部来自 mall-Pro 项目的真实实现,我把它做成了一个公共模块 mall-security,所有微服务只需要引入依赖、配置一下 JWT 密钥和过期时间,就能拥有统一的权限校验能力。

如果你正在做或准备做微服务,希望这篇能把弯路帮你省下来。


欢迎评论区交流,你的微服务权限踩过哪些坑?

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐