SpringSecurity + JWT 权限认证实战:从过滤器链原理到落地

发布时间:2026/10/2 7:41:08
SpringSecurity + JWT 权限认证实战:从过滤器链原理到落地 SpringSecurity JWT 实现权限认证功能从原理到落地一套能直接用的方案接了个前后端分离的新项目用户体系、角色权限都要从零搭。说实话权限这块我第一反应就是 SpringSecurity理由很简单这玩意是 Java 生态里的事实标准跟 Spring Boot 配合几乎没有磨合成本。但老一套的 Session 方案在前后端分离场景下太别扭了——跨域、移动端、多端登录样样都是坑。所以最终方案定为 SpringSecurity JWT前端拿 Token 调接口后端无状态校验项目跑起来之后效果很稳。这篇文章就把整个落地过程掰开揉碎讲清楚从选型逻辑到核心代码到踩过的坑和排查经验一次说透。这篇文章适合谁看如果你正在做前后端分离项目、微服务拆分、或者移动端接口改造想在 Spring 体系里快速接一套权限认证又不想在 Session、Redis 方案之间反复摇摆那这篇内容正好命中你的需求。我会把 SpringSecurity 的过滤器链机制、JWT 的生成与校验流程、核心配置类、登录接口、自定义异常处理、Token 续签方案全部串起来最后再给你我实际踩坑的排查实录。看完能直接照着抄改改业务逻辑就能用。1. 方案选型为什么是 SpringSecurity JWT1.1 Session 认证在前后端分离场景下的痛点传统的 Session 认证流程大家都不陌生用户登录成功之后服务端把用户信息存进 Session再把 JSESSIONID 通过 Cookie 写回浏览器。后续每次请求浏览器自动带上 Cookie服务端按 ID 找 Session找到就认为是已登录。这套机制在纯服务端渲染的年代很好用但到了前后端分离、接口化、多端并行的阶段问题就来了。第一是跨域问题。前端域名和后端域名一旦不一致Cookie 的跨域携带就涉及 SameSite、CORS 配置稍不注意就出现登录成功但请求不带 Cookie的诡异现象排查起来特别费劲。第二是移动端适配。App 里没有浏览器的 Cookie 管理机制你得手动把 Session ID 存起来塞进请求头这本身就是把 Session 方案硬往无状态场景上搬体验很差。第三是服务端状态膨胀。用户量一大Session 全堆在内存里要么做 Session 集群同步要么引入 Redis 做集中式 Session。这些方案都能跑但每加一个组件就多一批运维成本和故障点。我在几个项目里都见过一个典型场景团队一开始用 Session后来因为跨域问题被迫折腾 CORS又因为多端登录问题引入 Redis最后 Redis 挂了整个系统登录全废。技术上没毛病但复杂度完全是被方案带偏的。所以到了新项目我直接就往无状态 Token 方向选型。1.2 JWT 方案的优势与代价JWTJSON Web Token的核心思想是把用户身份信息经过签名后生成一段 Token 发给客户端客户端每次请求把 Token 放在请求头里带回来服务端验签通过就认账。这套方案最大的优势是无状态——服务端不需要保存任何登录会话信息用户是谁、有什么权限全在这段 Token 里带着跑。具体拆开看好处很直观天然跨域友好。Token 放在请求头里前端用 axios 或 fetch 设置 header 即可不存在 Cookie 跨域问题。多端兼容。Web、App、第三方系统只要能在请求头里带 Token就能接入同一套认证体系。服务端无需会话存储。省掉了 Redis 保存 Session 的成本也少了一个集中失效点。天然适合微服务。下游服务拿到 Token 自己验签不需要回调认证中心查询会话状态这在微服务链路里能省掉大量的内网调用。但 JWT 也不是没有代价。最麻烦的一点是过期之前没法主动让它失效。Session 方案里你随时可以把某个用户的 Session 干掉JWT 不行——只要 Token 还没过期服务端验签就是通过的。所以业界一般给 JWT 设置较短的过期时间比如 30 分钟再配 Refresh Token 做续签把风险窗口压缩到可接受范围。另外 JWT 本身是自包含的能不放的信息尽量别放敏感数据一律不往 Token 里塞。这些细节后面实操部分会详细展开。1.3 为什么是 SpringSecurity 而不是 Shiro既然要做权限认证光有 JWT 还不够认证和授权这套框架还是要选一个。Java 生态里最主流的就是 SpringSecurity 和 Shiro 两个选项。Shiro 胜在轻量、上手快API 通俗易懂很多老项目都在用它。但 Shiro 有个实际问题和 Spring Boot 的整合深度不如 SpringSecurity尤其当你需要复杂的注解鉴权、动态权限、OAuth2 扩展时Shiro 的支持力度和社区方案数量都差一截。SpringSecurity 虽然学习曲线陡峭过滤器链机制、AuthenticationManager、SecurityContext 这些概念第一次接触时会比较懵但它的设计太完整了——认证、授权、会话管理、CSRF 防护、OAuth2、方法级安全全给你包好了。你在 Spring Boot 生态里做项目选 SpringSecurity 基本不会走弯路。而且现在网络上关于 SpringSecurity 的踩坑记录、扩展案例非常多遇到问题搜一下就能找到答案。我这套方案最终就是 SpringSecurity 负责认证与授权的骨架JWT 负责身份凭证的载体。SpringSecurity 管流程JWT 管凭证两者分工明确。下面从原理开始拆。2. 核心原理过滤器链与 Token 结构2.1 SpringSecurity 的过滤器链机制SpringSecurity 表面上看起来复杂本质其实是一条过滤器链Filter Chain。你在配置文件里写的那一堆配置最终都会转化成一串过滤器按固定顺序依次执行。请求进来先经过链头的过滤器做各种检查最后才到达你的 Controller。理解这条链是理解整个框架的关键。默认情况下SpringSecurity 会注册一条包含几十个过滤器的链路。常见的比如 UsernamePasswordAuthenticationFilter处理表单登录、BasicAuthenticationFilter处理 HTTP Basic 认证、ExceptionTranslationFilter捕获认证和授权异常、FilterSecurityInterceptor做最终的授权决策。你自定义的过滤器可以通过 addFilterBefore 或 addFilterAfter 插到链路任意位置。我们的 JWT 认证过滤器就插在 UsernamePasswordAuthenticationFilter 之前——这样请求进来先校验 TokenToken 有效就把用户信息放进 SecurityContext后续的授权过滤器再去读 SecurityContext 判断有没有权限。这条链的工作逻辑用一句话总结过滤器链负责把请求变成一个已认证的请求。Token 校验过滤器把 Token 解析成用户身份写进 SecurityContext到了授权环节框架从 SecurityContext 里拿身份信息去比对当前请求需要的权限。整个流程走完要么放行要么抛异常。提示调试 SpringSecurity 问题时最好的手段就是把日志级别调到 DEBUG观察过滤器链的执行过程。我靠这个手段解决了至少一半的疑难杂症。2.2 JWT 结构、签名与校验JWT 由三段组成用点号分隔Header.Payload.Signature。Header 里存算法类型比如 HS256Payload 里存业务负载数据比如用户 ID、用户名、过期时间Signature 是对前两段的签名。签名的作用是防篡改——服务端持有密钥对 Header 和 Payload 做 HMAC 或 RSA 签名客户端拿到 Token 之后如果改动了 Payload 里的任何数据验签就会失败。常见的签名算法分两类对称的 HS256 和非对称的 RS256。HS256 用同一个密钥既签名又验签实现简单适合单体应用RS256 用私钥签名、公钥验签适合在微服务场景里由认证中心统一发证、下游服务只做验签公钥可以公开发布。我这次做的是单体应用选了 HS256密钥放在配置文件里够用。校验流程其实就三步先解 Base64 拿到 Header 里的算法标识然后拿着密钥对 Header.Payload 重新算一遍签名跟 Token 第三段比对一致就通过。同时还要检查 exp过期时间字段——这一步经常被新手漏掉导致过期 Token 依然被当成有效身份。Spring Security 的 JWT 支持库 Java JWTjjwt里解析时可以直接传一个过期时间宽松值解析器默认就会校验过期时间抛 ExpiredJwtException。这个异常需要单独捕获后面在过滤器里专门处理。2.3 一次带 Token 请求的完整生命周期把 SpringSecurity 的过滤器链和 JWT 校验机制串起来看一次完整的带 Token 请求是这么走的请求进来命中 SpringSecurity 的过滤器链。JWT 认证过滤器从请求头里取 Authorization 字段剥掉Bearer前缀拿到原始 Token。用密钥解析 Token拿到 Payload 里的用户标识和权限数据。把解析结果封装成 Authentication 对象塞进 SecurityContextHolder。请求继续往下走FilterSecurityInterceptor 检查这个请求需要的权限跟 SecurityContext 里用户的权限比对。权限够放行到 Controller不够抛 AccessDeniedException。这套流程里最关键的一点是第 4 步。SpringSecurity 的授权机制完全不关心你的 Token 是怎么来的它只认 SecurityContext 里的 Authentication 对象。所以 JWT 过滤器的作用就是把 Token 翻译成框架能识别的身份信息翻译成功后面全是框架原生逻辑。这也是我推荐的整合思路——不要自己去实现一套授权逻辑只做认证层的适配。3. 工程落地从零实现一套可运行的权限认证3.1 工程结构与依赖准备先交代一下工程环境Spring Boot 3.2、Java 17、Maven 项目。Spring Boot 3 的 SpringSecurity 已经走到了 6.x配置方式跟 Spring Boot 2 时代差异比较大尤其是 WebSecurityConfigurerAdapter 已经彻底移除必须走 SecurityFilterChain 的 Bean 方式配置。如果你用的是 Spring Boot 2沿用 WebSecurityConfigurerAdapter 也可以跑但建议新项目直接上 3.x。依赖就三个核心spring-boot-starter-security安全框架本体。spring-boot-starter-webWeb 支持。jjwt-api、jjwt-impl、jjwt-jacksonJWT 的生成、解析与 JSON 序列化支持。pom.xml 里关键部分长这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency注意jjwt 的 impl 和 jackson 模块 scope 设为 runtime 即可编译期只需要 api。版本冲突问题在我这没遇到但如果你项目里同时有老版本 jjwt 0.9.x建议统一升级到 0.11.xAPI 变化比较大混用必出问题。3.2 用户模型与 UserDetailsServiceSpringSecurity 里做认证核心是你提供一个 UserDetailsService 实现。它的唯一职责是根据用户名查数据库返回一个 UserDetails 对象。框架拿这个对象去比对密码认证通过后把它塞进 SecurityContext。我这里的用户对象结构比较简单Users 表存了用户名、密码、状态和角色 ID角色单独一张表。为了贴合 SpringSecurity 的体系用户实体需要实现 UserDetails 接口或者单独做一个适配对象。我习惯单独做一个 LoginUser 类实现 UserDetails里面不直接暴露数据库实体避免把密码等字段带进 Token 或其他地方。public class LoginUser implements UserDetails { private Long userId; private String username; private String password; private ListGrantedAuthority authorities; Override public Collection? extends GrantedAuthority getAuthorities() { return authorities; } Override public String getPassword() { return password; } Override public String getUsername() { return username; } Override public boolean isAccountNonExpired() { return true; } Override public boolean isAccountNonLocked() { return true; } Override public boolean isCredentialsNonExpired() { return true; } Override public boolean isEnabled() { return true; } }UserDetailsService 的实现也很直接查数据库、组装 LoginUser、返回。这里有一个我在实际项目里踩过的坑数据库里用户不存在时要抛 UsernameNotFoundExceptionSpringSecurity 才能正确走用户不存在的异常流程。如果你直接返回 null框架会报一个很隐晦的 NullPointerException排查成本很高。Service public class UserDetailsServiceImpl implements UserDetailsService { Resource private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListGrantedAuthority authorities userMapper.selectRolesByUserId(user.getId()) .stream().map(s - new SimpleGrantedAuthority(s)).collect(Collectors.toList()); return new LoginUser(user.getId(), user.getUsername(), user.getPassword(), authorities); } }3.3 JWT 工具类生成、解析、续签JWT 的生成与解析逻辑单独抽一个工具类不要散落到业务代码里。工具类里封装三块根据用户信息生成 Token、解析 Token 拿用户信息、检查 Token 是否有效。我在设计工具类时特别强调了一点Payload 里只存必要信息包括用户 ID、用户名、过期时间。角色权限信息要不要放看场景。我这次项目把权限信息放进了 Token因为用户量不大权限变更不频繁这样能减少每次请求查数据库的开销。但如果你的系统里权限变动很频繁、或者用户量很大导致 Token 体积膨胀建议 Token 里只放用户 ID权限每次从缓存或数据库拉。这是典型的空间换时间与时间换空间的取舍。Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; // 单位毫秒 private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } // 生成 Token把 userId 和 username 放进去 public String generateToken(Long userId, String username) { Date now new Date(); Date expireTime new Date(now.getTime() expire); return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(username, username) .setIssuedAt(now) .setExpiration(expireTime) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } // 解析 Token拿到 Claims public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .build() .parseClaimsJws(token) .getBody(); } // 判断 Token 是否过期 public boolean isExpired(String token) { try { parseToken(token); return false; } catch (ExpiredJwtException e) { return true; } } }这里有几个细节值得单独说。第一secret 必须足够长。HS256 算法要求密钥至少 256 位也就是 32 个字节以上如果密钥太短jjwt 在启动时就会直接抛 WeakKeyException。我配置里的 secret 是一串 40 多字符的随机字符串。第二expire 时间不要设太长。生产环境我一般设 30 分钟开发环境为了方便可以设 24 小时。续签方案我们后面单独讲。第三parseToken 方法在 Token 过期时会抛 ExpiredJwtException在过滤器里要单独处理否则异常会穿透到全局异常处理器返回的响应格式会很粗糙。3.4 认证过滤器把 Token 变成 Authentication过滤器是整个方案的核心组件。它的任务说出来很简单从请求头拿 Token解析成功就构建 Authentication 塞进 SecurityContext解析失败就放行让后续的异常处理逻辑去处理。但这个环节的细节非常多。我写的过滤器继承 OncePerRequestFilter保证每次请求只执行一次。核心逻辑Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { Resource private JwtUtil jwtUtil; Resource private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token getTokenFromRequest(request); if (StringUtils.hasText(token)) { try { Claims claims jwtUtil.parseToken(token); String username claims.getSubject(); // 从数据库拉最新的用户信息保证权限实时性 UserDetails userDetails userDetailsService.loadUserByUsername(username); if (userDetails ! null) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (ExpiredJwtException e) { // Token 过期不设置认证信息让后续逻辑返回 401 request.setAttribute(jwtExpired, true); } catch (JwtException e) { // 签名错误或格式不对 request.setAttribute(jwtInvalid, true); } } filterChain.doFilter(request, response); } private String getTokenFromRequest(HttpServletRequest request) { String header request.getHeader(Authorization); if (StringUtils.hasText(header) header.startsWith(Bearer )) { return header.substring(7); } return null; } }这里有一个设计取舍需要解释为什么解析出 Token 里的 userId 之后还要再去数据库查一遍用户因为 JWT 是无状态的但用户的状态不是无状态的——你可能被禁用、角色可能变了。如果完全信任 Token 里的旧数据一个被停用的账号在 Token 过期前还能继续访问接口这是安全隐患。所以我在过滤器里拿到 username 之后永远以数据库的最新数据为准。这个逻辑会多一次数据库查询但权限系统本身就是高频率读取加上用户量不大性能完全能接受。如果你的系统要求极致性能可以用本地缓存或 Redis 缓存用户信息缓存失效时间控制在几分钟以内。注意如果用户不存在了loadUserByUsername 会抛 UsernameNotFoundException这个异常要包一层 try-catch否则会直接导致请求 500。我第一版就踩了——用户被删掉之后旧 Token 调接口直接抛异常后来加了 catch 才算处理干净。3.5 安全配置类过滤器链、放行规则与异常入口配置类是这个方案里信息密度最高的一块。Spring Boot 3 Security 6 的写法相比旧版本变化很大WebSecurityConfigurerAdapter 那个时代已经过去了现在的主流写法是定义 SecurityFilterChain 的 Bean。Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Resource private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; Resource private AuthenticationEntryPointImpl authenticationEntryPoint; Resource private AccessDeniedHandlerImpl accessDeniedHandler; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/refresh).permitAll() .requestMatchers(/doc.html, /webjars/**, /v3/api-docs/**).permitAll() .requestMatchers(/api/user/**).hasRole(USER) .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .exceptionHandling(ex - ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) ) .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }逐行解释一下关键配置。csrf 禁用是必须的。JWT 方案本身就通过请求头携带 Token没有 Cookie 机制CSRF 攻击失去了目标。开着反而会干扰接口调用尤其是 POST 请求会被 403 拦截排查半天才发现是 CSRF 在作祟。sessionManagement 设置为 STATELESS让 SpringSecurity 不创建也不读取 Session。这是 JWT 方案的核心配置——告诉框架别管 Session 了你自己只做过滤器校验。authorizeHttpRequests 里定义路由的访问规则。permitAll 放行登录、刷新接口如果用 Swagger 也放行文档相关路径hasRole 按角色控制访问anyRequest 表示其余请求全部需要认证。这里的 hasRole(USER) 意味着用户必须拥有 ROLE_USER 权限。我在 UserDetailsService 里组装权限时加了 ROLE_ 前缀所以这里的角色判断能直接命中。exceptionHandling 配置了两个关键接口AuthenticationEntryPoint 负责处理未认证请求返回 401AccessDeniedHandler 负责处理已认证但权限不足的请求返回 403。自定义这两个处理器是为了统一 API 响应格式——不让框架返回默认的 HTML 错误页或简单状态码而是返回项目统一的 JSON 结构。3.6 登录接口与受保护接口登录接口是整个认证链路的入口。逻辑是接收用户名和密码通过 AuthenticationManager 做认证成功之后生成 Token 返回前端。这里用到了 SpringSecurity 的 AuthenticationManager它会把请求交给 UserDetailsService 加载用户、然后用 PasswordEncoder 比对密码。认证成功的结果是一个完整填充的 Authentication 对象。RestController RequestMapping(/api/auth) public class AuthController { Resource private AuthenticationManager authenticationManager; Resource private JwtUtil jwtUtil; PostMapping(/login) public Result? login(RequestBody LoginRequest request) { UsernamePasswordAuthenticationToken token new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); Authentication authenticate authenticationManager.authenticate(token); LoginUser loginUser (LoginUser) authenticate.getPrincipal(); String jwt jwtUtil.generateToken(loginUser.getUserId(), loginUser.getUsername()); return Result.success(jwt); } }这里有两个点需要额外说明。AuthenticationManager 的 Bean 定义网上很多资料会漏掉。你需要手动注入 AuthenticationManager需要先定义 AuthenticationConfiguration然后在配置类里写一个 BeanBean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }其次登录成功后我们把用户的原始密码返回到了 LoginUser 里这是没问题的因为 PasswordEncoder 比对密码时拿的是认证请求里的明文跟数据库存的 BCrypt 密文比较跟 LoginUser 里的字段无关。但要注意不要把 LoginUser 直接塞进 JWT 的 Payload只放用户 ID 和用户名就够了。受保护接口的写法聚合了 SpringSecurity 的注解鉴权能力。在方法上直接标注RestController RequestMapping(/api/user) public class UserController { PreAuthorize(hasRole(ADMIN)) GetMapping(/list) public Result? list() { return Result.success(userService.list()); } }EnableMethodSecurity 开启了方法级安全之后PreAuthorize 注解才能生效。这个注解允许你在方法层面做细粒度鉴权比路由层面更灵活。比如路由层面只要求已登录方法层面还可以进一步要求必须是管理员。3.7 自定义异常处理把错误响应做成统一格式认证和授权过程中的异常默认情况下 SpringSecurity 会返回框架自己的错误结构前端对接起来非常痛苦。我在项目里做了两个自定义组件把异常响应统一成项目标准格式。AuthenticationEntryPoint 处理的是未认证请求。比如你带着一个无效的 Token 访问接口框架进入这个入口Component public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(JSON.toJSONString(Result.error(401, 未登录或登录已过期))); } }AccessDeniedHandler 处理的是有认证身份但权限不足的请求。这两个处理器必须都实现漏掉任何一个都会在某些特殊场景下暴露默认错误页。我第一版只做了 AuthenticationEntryPoint结果用普通用户账号访问管理员接口时直接弹了个空白的 403 页面前端拿不到任何 JSON 结构排查了将近一个小时才意识到 AccessDeniedHandler 没配置。4. 常见问题与排查实录4.1 登录成功但访问接口仍然 401这个问题的出现概率极高我复盘下来基本集中在两个原因。第一个原因Token 在请求头里的命名或格式不对。前端必须在 Authorization 请求头里带上Bearer前缀注意 Bearer 和 Token 之间有个空格后端过滤器取 Token 时再把前缀剥掉。前后端只要有一边格式不一致后端就取不到 Token请求自然变成 401。排查手段很简单后端过滤器的入口打一行日志打印从请求头取到的原始字符串一眼就能看出格式对不对。第二个原因过滤器执行顺序有误。JWT 认证过滤器必须加在 UsernamePasswordAuthenticationFilter 之前否则请求先走到授权过滤器发现 SecurityContext 里没有认证信息直接拒绝。我遇到过项目里有人把过滤器加在链尾结果登录成功之后所有请求全部 401完全没法用。配置里的 addFilterBefore 位置是硬要求别改。4.2 403 与角色/权限配置不符403 的场景比较单一用户有身份但权限不够。排查思路从权限数据倒着捋。第一步看你数据库里存的角色是什么名称。第二步看 UserDetailsService 组装权限时加的什么前缀——你是 SimpleGrantedAuthority(ROLE_ roleName) 还是 SimpleGrantedAuthority(roleName)第三步看接口上用的什么注解——hasRole(ADMIN) 会自动拼接 ROLE_ 前缀去找权限所以你的权限数据里必须有 ROLE_ADMINhasAuthority(ADMIN) 则不会加前缀找的是精确匹配 ADMIN。三步对不齐403 是必然的。我还遇到过一种隐蔽情况同一个角色权限在 UserDetailsService 里组装时用了中文角色名结果 Redis 或 Token 缓存里序列化出了乱码。这种问题靠打日志看 GrantedAuthority 列表就能发现不要只看数据库。4.3 Token 过期处理与无感续签方案JWT 不像 Session 那样能服务端主动续期过期时间到了就断了。实际项目里如果让用户每隔 30 分钟就重新登录一次体验是非常差的。我们项目最终用了 Refresh Token 方案登录时同时返回一个 Access Token过期短和一个 Refresh Token过期长比如 7 天。流程是这样的Access Token 过期后前端接口收到 401自动拿 Refresh Token 去调刷新接口。服务端验证 Refresh Token 有效就重新生成 Access Token 返回前端拿到后立刻重放刚才的业务请求。整个过程用户无感知。Refresh Token 的存储策略上我建议把它放在 HttpOnly Cookie 里而不是放在前端 localStorage——这样能降低 XSS 攻击导致 Token 泄露的风险。这个方案的实现复杂度比单 Token 方案高一些但用户体验和安全性都更可控。如果你的项目是管理后台这类内部系统用户容忍度高单 Token 较长过期时间也能凑合。做 C 端产品建议还是上 Refresh Token 方案。4.4 过滤器执行顺序与 Bean 注入坑SpringSecurity 的过滤器如果作为 Spring Bean 被 Component 托管同时又在配置类里 addFilterBefore 手动添加会出现过滤器被注册两次的问题——一次是 Spring Boot 自动注册的 Servlet Filter一次是 SpringSecurity 链里的过滤器。结果就是你发现请求被过滤了两次甚至 Token 被解析了两遍。解决方式有两种一是过滤器不加 Component 注解只在配置类里手动 new 出来加进链里这种写法对依赖注入需要处理一下我的习惯是保留 Component在过滤器类上额外加 RequestScope 注解避免多线程下共享状态问题同时通过配置类里的 addFilterBefore 引用同一个 Bean这样不会重复注册。不过最稳妥的确认方式还是启动后查日志看 SpringSecurity 的过滤器链里有没有重复项。另外如果过滤器中注入了 UserDetailsService但整个配置类的 Bean 初始化顺序有问题可能启动时报循环依赖。我遇到过 SecurityConfig 注入 JwtAuthenticationTokenFilter过滤器又注入 UserDetailsServiceUserDetailsService 又依赖 MyBatis Mapper链路长了一点Spring 启动时险些循环引用。实际解决就是拆分配置类把过滤器的创建和 Security 配置分开。4.5 静态资源与白名单放行有些静态资源比如 Swagger 文档、前端静态页面是不需要认证的。如果配置里没有放行你会发现文档页面根本打不开而且因为入口是动态的容易跟业务接口混淆。我建议把白名单集中到一个常量类里维护。比如public class WhitespaceUrls { public static final String[] URLS new String[]{ /api/auth/login, /api/auth/refresh, /doc.html, /webjars/**, /v3/api-docs/** }; }然后在配置类里循环 permitAll。这样做的好处是后续加白名单只需要改这一处不用在过滤器和配置里各改一遍。还有一个容易忽略的点放行的接口如果内部再次转发转发路径有没有命中过滤器链我遇到过一次登录接口放行了但登录成功后内部跳转到另一个接口又被拦截排查半天发现是 Forward 路径也要放行。4.6 每次请求都会查一次数据库怎么办这个问题随着用户量增长会越来越明显。JWT 过滤器里每次请求都调 UserDetailsService 查数据库对一个每天百万级的接口量来说数据库压力很大。我最终的做法是加了一层轻量缓存用户信息和权限数据的本地缓存Caffeine设置过期时间 5 分钟角色变更或用户禁用时主动清缓存。这样既保证了权限变更的实时性又极大降低了数据库压力。如果你们系统已经接入了 Redis直接把用户信息放 Redis 也行key 设计为login:user:{userId}。token invalid 时用户下线也可以主动删除这个 key实现更快的权限实时性。这一步是给高并发场景预留的优化小项目不着急做。5. 安全加固与工程化建议5.1 从 JWT 漏洞总结反推防御要点网上关于 JWT 漏洞的总结非常多我挑选几个真实项目中可能踩到的逐个说明防御方式。第一个是密钥泄露问题。HS256 的密钥一旦泄露任何人都能伪造任意身份。防御手段密钥必须通过配置中心或环境变量管理不能写死进代码仓库定期更换密钥并做好新旧密钥的平滑过渡。我见过有人把 secret 直接写在 application.yml 里提交到 Git简直是灾难。第二个是算法混淆攻击。攻击者把 Header 里的 alg 改成 none服务端如果对算法参数过于信任可能跳过签名校验。jjwt 这类成熟库默认不允许 none 算法但如果你自己手写了 JWT 解析逻辑千万别忽略算法校验。第三个是敏感信息泄露。JWT 的 Payload 只是 Base64 编码不是加密任何拿到 Token 的人都能直接解码看到内容。所以 Payload 里只放非敏感的用户标识不要放手机号、身份证号、密码这类数据。第四个是 Token 过期设置过长。过期时间越长风险窗口越大。生产环境建议 Access Token 控制在 15-30 分钟。很多安全问题不是被攻破的而是被拖死的——Token 有效期一个月泄露了之后别人拿着你的身份登录了一个月这是完全不可接受的。5.2 多端登录、踢人下线与账号状态变更无状态 JWT 方案在多端登录上有一层天然的困扰同一账号在 App、Web、后台同时登录你无法从服务端区分这些 Token自然也没法单独踢掉某一个端。解决思路我给几个可行的方案。第一个做法最简单登录时把用户 ID、设备类型、Token 标识存 Redis踢人下线时按用户 ID 删除对应端的所有记录在该用户下次请求时强制重新登录。这个方案粗暴但有效。第二个做法是按设备维度生成独立的 TokenPaylaod 里带一个 sessionIdUUIDRedis 里存 sessionId 与用户 ID 的映射。踢人时只需删除某台设备的 sessionId该设备的 Token 下次请求通过过滤器里的 Redis 校验发现不存在直接拒绝。这个方案灵活可以让用户指定退出其他设备或仅退出当前设备。第三个做法是版本号方案用户表里存一个 tokenVersion 字段JWT 里带这个版本号。修改密码、踢人下线时把版本号加一过滤器校验时发现 Token 里的版本号和数据库不一致直接拒绝。这个方案实现最简单但只能做到全局踢不能做到按端踢。另外密码修改或账号禁用后旧 Token 立即失效是刚需。我们的处理方式是修改密码时递增 tokenVersion禁用账号时把 Redis 里该用户的缓存记录清除这两个操作都能让下一次请求立即失效。5.3 密钥配置与后续扩展方向最后说一下配置管理。jwt.secret 和 jwt.expire 从配置文件读取这个见仁见智但我强烈建议至少把 secret 独立到环境变量或配置中心不要明文出现在代码库。后续如果你的系统从单体拆微服务SpringSecurity JWT 这套方案可以直接迁移。认证服务负责发 Token下游服务只需配置同一个公钥或共享密钥就能离线验签完全不需要每次调用认证中心。更进一步的扩展是接入 Spring Security OAuth2.1 体系可以在此基础上拓展出授权码模式、password 模式等标准授权流程。如果你打算做 OAuth2 方向建议以本方案为基础把登录接口替换成授权的 Token 端点把 JwtAuthenticationTokenFilter 替换成框架自带的 OAuth2ResourceServerJwtAuthenticationConverter。框架已经把验签、解析 Token、构造 Authentication 的流程封装好了你要做的只是配置 JwtDecoder 和权限映射规则。最后分享一点我实际使用的体会。权限认证这东西方案选型只是第一步真正的难点在细节——Token 过期了怎么续、用户角色变了怎么同步、异常响应怎么统一、过滤器顺序怎么排。这些问题如果你不看源码、不实际踩坑光看官方文档很容易翻车。我的建议是先用最小的 demo 把整条链路跑通每一步打日志看清楚再逐步加上 Redis 缓存、踢人下线、刷新令牌这些高级功能。上手之后你会发现 SpringSecurity 其实没想象中那么难它只是把安全这件事的复杂度都集中放在了一条清晰的过滤器链上你只需要搞清楚自己的过滤器该挂在哪、做什么剩下的交给框架就好。