news 2026/10/2 7:40:55

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringSecurity + JWT 权限认证实战:从过滤器链原理到落地

SpringSecurity + JWT 实现权限认证功能:从原理到落地,一套能直接用的方案

接了个前后端分离的新项目,用户体系、角色权限都要从零搭。说实话,权限这块我第一反应就是 SpringSecurity,理由很简单:这玩意是 Java 生态里的事实标准,跟 Spring Boot 配合几乎没有磨合成本。但老一套的 Session 方案在前后端分离场景下太别扭了——跨域、移动端、多端登录,样样都是坑。所以最终方案定为 SpringSecurity + JWT,前端拿 Token 调接口,后端无状态校验,项目跑起来之后效果很稳。这篇文章就把整个落地过程掰开揉碎讲清楚,从选型逻辑到核心代码,到踩过的坑和排查经验,一次说透。

这篇文章适合谁看?如果你正在做前后端分离项目、微服务拆分、或者移动端接口改造,想在 Spring 体系里快速接一套权限认证,又不想在 Session、Redis 方案之间反复摇摆,那这篇内容正好命中你的需求。我会把 SpringSecurity 的过滤器链机制、JWT 的生成与校验流程、核心配置类、登录接口、自定义异常处理、Token 续签方案全部串起来,最后再给你我实际踩坑的排查实录。看完能直接照着抄,改改业务逻辑就能用。


1. 方案选型:为什么是 SpringSecurity + JWT

1.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 方案的优势与代价

JWT(JSON 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 之前——这样请求进来先校验 Token,Token 有效就把用户信息放进 SecurityContext,后续的授权过滤器再去读 SecurityContext 判断有没有权限。

这条链的工作逻辑,用一句话总结:过滤器链负责把请求变成一个"已认证的请求"。Token 校验过滤器把 Token 解析成用户身份,写进 SecurityContext;到了授权环节,框架从 SecurityContext 里拿身份信息去比对当前请求需要的权限。整个流程走完,要么放行,要么抛异常。

提示:调试 SpringSecurity 问题时,最好的手段就是把日志级别调到 DEBUG,观察过滤器链的执行过程。我靠这个手段解决了至少一半的疑难杂症。

2.2 JWT 结构、签名与校验

JWT 由三段组成,用点号分隔:Header.Payload.Signature。Header 里存算法类型(比如 HS256);Payload 里存业务负载数据(比如用户 ID、用户名、过期时间);Signature 是对前两段的签名。签名的作用是防篡改——服务端持有密钥,对 Header 和 Payload 做 HMAC 或 RSA 签名,客户端拿到 Token 之后如果改动了 Payload 里的任何数据,验签就会失败。

常见的签名算法分两类:对称的 HS256 和非对称的 RS256。HS256 用同一个密钥既签名又验签,实现简单,适合单体应用;RS256 用私钥签名、公钥验签,适合在微服务场景里由认证中心统一发证、下游服务只做验签,公钥可以公开发布。我这次做的是单体应用,选了 HS256,密钥放在配置文件里,够用。

校验流程其实就三步:先解 Base64 拿到 Header 里的算法标识,然后拿着密钥对 Header.Payload 重新算一遍签名,跟 Token 第三段比对,一致就通过。同时还要检查 exp(过期时间)字段——这一步经常被新手漏掉,导致过期 Token 依然被当成有效身份。Spring Security 的 JWT 支持库 Java JWT(jjwt)里,解析时可以直接传一个过期时间宽松值,解析器默认就会校验过期时间,抛 ExpiredJwtException。这个异常需要单独捕获,后面在过滤器里专门处理。

2.3 一次带 Token 请求的完整生命周期

把 SpringSecurity 的过滤器链和 JWT 校验机制串起来看,一次完整的带 Token 请求是这么走的:

  1. 请求进来,命中 SpringSecurity 的过滤器链。
  2. JWT 认证过滤器从请求头里取 Authorization 字段,剥掉Bearer前缀拿到原始 Token。
  3. 用密钥解析 Token,拿到 Payload 里的用户标识和权限数据。
  4. 把解析结果封装成 Authentication 对象,塞进 SecurityContextHolder。
  5. 请求继续往下走,FilterSecurityInterceptor 检查这个请求需要的权限,跟 SecurityContext 里用户的权限比对。
  6. 权限够,放行到 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-web:Web 支持。
  • jjwt-api、jjwt-impl、jjwt-jackson:JWT 的生成、解析与 JSON 序列化支持。

pom.xml 里关键部分长这样:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>

注意:jjwt 的 impl 和 jackson 模块 scope 设为 runtime 即可,编译期只需要 api。版本冲突问题在我这没遇到,但如果你项目里同时有老版本 jjwt 0.9.x,建议统一升级到 0.11.x,API 变化比较大,混用必出问题。

3.2 用户模型与 UserDetailsService

SpringSecurity 里做认证,核心是你提供一个 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 List<GrantedAuthority> 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、返回。这里有一个我在实际项目里踩过的坑:数据库里用户不存在时,要抛 UsernameNotFoundException,SpringSecurity 才能正确走"用户不存在"的异常流程。如果你直接返回 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("用户不存在"); } List<GrantedAuthority> 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 负责处理未认证请求(返回 401),AccessDeniedHandler 负责处理已认证但权限不足的请求(返回 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,然后在配置类里写一个 Bean:

@Bean 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;charset=UTF-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_ADMIN;hasAuthority("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,过滤器又注入 UserDetailsService,UserDetailsService 又依赖 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 删除对应端的所有记录,在该用户下次请求时强制重新登录。这个方案粗暴,但有效。

第二个做法是按设备维度生成独立的 Token,Paylaod 里带一个 sessionId(UUID),Redis 里存 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 其实没想象中那么难,它只是把安全这件事的复杂度都集中放在了一条清晰的过滤器链上,你只需要搞清楚自己的过滤器该挂在哪、做什么,剩下的交给框架就好。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 7:40:50

用Materials Studio片段库高效构建有机金属配合物的实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 7:40:36

aistudio云端解压zip全指南:乱码、提速与避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 7:40:06

五角星坐标计算:极坐标法生成稳定DX差分序列

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 7:40:06

三代测序怎么选?一文读懂Illumina、PacBio HiFi与Nanopore

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 7:39:51

COMSOL网格剖分核心原理与BAW/锂电池高效建模实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 7:38:51

智能车差速转向与PID控制:从运动学基础到调参实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华