Spring Security Token认证实战:从过滤器链到JWT落地
1. 项目概述为什么我最终还是选了Spring Security做Token认证做后端接口开发的朋友应该都有过这种纠结项目要接登录认证网上方案一大堆Shiro轻量、JWT自己写一套也就几十行、Spring Security看着就头大配置多、概念多、源码绕。我也一样前阵子做一个前后端分离的管理系统后端是Spring Boot前端Vue单独部署得先想清楚认证怎么做。本来想图省事自己撸一个拦截器加JWT工具类后来看到需求里还有角色权限控制、密码加密、接口防刷这些就知道自己写早晚要爆炸最后老老实实用Spring Security把Token认证接了进去。这篇文章就把整个落地过程完整复盘一遍。核心关键词就三个SpringSecurity、token、认证。我会从为什么选择它、整体设计怎么拆、具体怎么配置、过程中踩过哪些坑、怎么排查一条线讲清楚。项目是Spring Boot 2.7 Spring Security 5.7 JJWT 0.11.5没有用OAuth2那套因为现阶段就是一个自建系统的登录认证用不着引入授权服务器的复杂度。适合正在做前后端分离项目、被登录认证折磨过的同学参考也适合刚接触Spring Security、但对它那套过滤器链和SecurityContext机制感到困惑的初学者。看完之后你至少能明白Token认证在Spring Security里到底是怎么串起来的以及出了问题该从哪儿开始查。2. 认证方案选型不自己造轮子的理由2.1 自研拦截器方案的痛点在决定用Spring Security之前我先把自己写方案的代码草稿铺开看了一眼。无非就是写一个LoginController登录成功生成JWT返回前端写一个拦截器从请求头里取Authorization解析token查用户放行或拒绝。听起来很简单但接着往下想就头疼了。第一个问题是密码处理。数据库里存的密码不能是明文得用BCrypt加密。BCrypt的校验逻辑、盐的处理、强度参数自己写很容易漏。Spring Security里PasswordEncoder已经把这些处理好了直接用就行不用重复造轮子。第二个问题是权限控制。系统里除了登录还要管角色比如管理员能删数据、普通用户只能看数据。自己写拦截器的话要么在代码里到处写if判断要么自己搞一套注解扫描和权限校验机制。而Spring Security的PreAuthorize注解配合全局方法安全配置天然支持这种需求而且表达式语法灵活。第三个问题是会话固定攻击、CSRF、CORS这些安全细节。别看平时不起眼一旦上线被扫描出漏洞就得加班补。Spring Security这些都有现成的开关和过滤器实现安全配置能覆盖绝大多数场景。我在这种对比下得出的结论很直接自己撸一套认证系统核心登录可能一百行代码就够但后续的扩展性、安全性、维护成本全部压在自己身上。而Spring Security虽然有学习曲线但它是行业标准方案出问题能搜到大量案例不必从零debug。2.2 为什么没有直接用OAuth2项目热词里出现了“springsecurity oauth2.1”但我在这个项目中并没有用OAuth2来做认证授权。原因是这个系统没有第三方接入需求也没有独立的认证服务器和资源服务器分离的场景。OAuth2更适合开放平台、多应用授权、第三方登录这类复杂场景。就一个自建后台管理系统我自己生成token、自己校验token就够了。如果硬上OAuth2会引入授权码模式、客户端注册、Scope管理一堆概念对于现在的项目是过度设计。但Spring Security的架构好在哪呢它是过滤器链搭建起来的我完全可以只使用它底层的认证过滤器机制自己实现Token认证逻辑同时继续享受密码加密、权限注解、SecurityContext管理等能力。这也是很多Spring Boot项目实际采用的折中做法:不用OAuth2但用Spring Security做基础认证框架。2.3 Token格式选择JWT与Opaque TokenToken认证里面Token本身有两种常见形态。一种是JWT这种自包含令牌token里直接携带用户信息、过期时间、签名后端解析验证签名后就能信任内容。另一种是不透明Token后端生成一个随机字符串把用户会话存在Redis里每次请求拿token查Redis。我最后选了JWT主要基于三点考虑。第一无状态服务端不需要存session水平扩展时不用考虑session共享。第二携带信息方便用户名、用户ID、角色都可以放进claim里减少查询数据库的次数。第三社区生态成熟JJWT库使用简单。但JWT也有需要特别注意的地方比如无法主动失效除非引入黑名单机制。我会在后文具体讲怎么结合Redis做登录退出和token续期。3. Spring Security核心概念梳理过滤器链、Authentication与SecurityContext3.1 过滤器链的工作方式Spring Security本质上是一堆过滤器组成的链。请求进来按顺序经过各个过滤器每个过滤器只负责一件事最终决定这个请求是否被放行到Controller。默认情况下Spring Security会在Spring容器里自动装配一条过滤器链。我们自定义配置类继承WebSecurityConfigurerAdapterSpring Security 5.7之前或者使用SecurityFilterChain Bean5.7之后来覆盖默认行为。我这个项目用的是5.7所以自定义配置是通过SecurityFilterChain方式写的这也是目前推荐写法WebSecurityConfigurerAdapter已经过时了。在过滤器链里与Token认证关系最密切的过滤器是UsernamePasswordAuthenticationFilter和BasicAuthenticationFilter。前者默认处理表单登录后者处理HTTP Basic。我们不用这两种方式而是需要插入一个自定义过滤器专门从请求头里取token并做认证。这个自定义过滤器通常加在UsernamePasswordAuthenticationFilter之前因为我们需要先解析到token才能设置SecurityContext后续的授权判断才能拿到当前用户信息。过滤器链的执行顺序很关键大致是先经过SecurityContextHolderFilter或旧的SecurityContextPersistenceFilter把之前的SecurityContext取出来放入当前线程然后经过匿名认证过滤器、异常处理过滤器再到我们自定义的Token过滤器最后到FilterSecurityInterceptor做授权判断。如果哪一环顺序不对就会出现“明明写了过滤器却不执行”或者“SecurityContext里拿不到用户信息”这种诡异问题。3.2 Authentication接口到底在传什么Spring Security的认证核心是一个Authentication接口。它代表“当前用户是谁、有没有被认证”这件事。接口里主要有几个方法getPrincipal()返回用户主体通常是我们自定义的UserDetails对象或者用户名getCredentials()返回凭证通常是密码或者tokengetAuthorities()返回权限集合isAuthenticated()表示是否已经认证。认证过程可以简单理解成请求来了携带一个未认证的Authentication对象principal是用户名、credentials是密码或token然后交给AuthenticationManager去校验。校验通过后我们得到一个已认证的Authentication对象把它塞进SecurityContext后续授权就基于这个SecurityContext来做判断。Token认证的实现思路就是自定义过滤器里从请求头拿出token解析成功后就构造一个已认证的Authentication对象直接放入SecurityContext不需要走AuthenticationManager。这样做比较轻量因为JWT自带签名和有效期签名验证通过就意味着身份可信不需要再查数据库验证密码。3.3 SecurityContextHolder和线程绑定SecurityContextHolder是一个静态工具类默认使用ThreadLocal保存SecurityContext。这意味着同一个线程里前面的过滤器存入SecurityContext后面的业务代码就能通过SecurityContextHolder.getContext().getAuthentication()拿到当前用户信息。这里有一个容易踩坑的地方如果代码里有异步线程或者线程池ThreadLocal不会自动传递。比如在子线程里想获取当前登录用户会得到一个null。解决办法要么是使用Spring Security提供的DelegatingSecurityContextExecutor包装线程池要么在进入子线程前手动把Authentication对象传进去。我项目里暂时没有这种需求但提前知道了这个问题后面遇到不至于抓瞎。4. 整体设计从登录到鉴权的完整链路先画一下我设计的认证流程方便后面看代码时不迷路。用户访问登录接口提交用户名和密码。后端接收到请求后用AuthenticationManager校验用户名密码这里其实是调用我们自己写的UserDetailsService查库再通过PasswordEncoder比对密码。校验通过后生成一个JWT返回给前端。前端后续的每次请求都在请求头里带上Authorization: Bearer 。后端有一个自定义过滤器JwtAuthenticationTokenFilter专门拦截请求、解析token、校验签名和过期时间解析成功后把用户信息和权限放入SecurityContext。最后Spring Security的授权过滤器根据接口上的权限注解或URL配置判断当前用户有没有权限访问。整个链路中登录是入口Token是凭证过滤器是闸门SecurityContext是当前请求的身份证。要注意登录接口、注册接口、验证码接口这些不需要认证的路径要在配置里放行。其他接口默认都要经过Token验证。这个设计最大的好处是干净业务代码里不需要手动判断用户登录状态只需要从SecurityContextHolder取用户即可。权限控制通过注解声明在Controller方法上一眼就能看出接口的权限要求。5. 核心代码实现一步步落地Token认证5.1 项目依赖引入我用的Spring Boot 2.7Spring Security版本是5.7.x。在pom.xml里需要引入Spring Security和JJWT。!-- Spring Boot Starter Security -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JJWT 系列 -- 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要拆成api、impl、jackson三个包。api是公共接口和注解impl是具体实现jackson是负责JSON序列化的适配器。运行时必须同时有impl和jackson不然会报SerializationException之类的错。如果你用0.9.x的老版本则只需要一个jjwt包但那个版本有已知的依赖安全问题不建议新项目使用。5.2 JWT工具类设计JWT工具类负责生成token和解析token。我把它设计成两个核心方法generateToken根据用户名生成tokenparseToken从token中解析出用户名和过期时间。Component public class JwtUtil { // 实际项目中密钥不要写死在代码里放到配置文件中并使用Base64编码 Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private Long expiration; private SecretKey getSecretKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } /** * 生成token */ public String generateToken(String username) { Date now new Date(); Date expiryDate new Date(now.getTime() expiration); return Jwts.builder() .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSecretKey(), SignatureAlgorithm.HS256) .compact(); } /** * 从token中获取用户名 */ public String getUsernameFromToken(String token) { Claims claims Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); } /** * 校验token是否有效 */ public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }设计时有两个细节要注意。第一个是密钥长度HS256算法要求密钥长度至少256位也就是32字节。如果你配置的secret太短运行时会报WeakKeyException。我当时第一次配了一个“my-secret-key”这种短字符串直接启动报错后来换成了Base64编码的长字符串才通过。第二个是过期时间我这里设置的是2小时也就是7200000毫秒。实际项目中如果对安全要求高可以把过期时间调短比如30分钟然后配合refresh_token做续签。5.3 UserDetailsService数据库用户加载Spring Security认证过程中需要从数据库加载用户信息这个任务由UserDetailsService接口完成。我实现了这个接口通过用户名从数据库查用户然后把用户信息和角色权限封装成Spring Security的UserDetails对象。Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private UserMapper userMapper; Autowired private RoleMapper roleMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } // 查询角色编码列表比如 ADMIN、USER ListString roleCodes roleMapper.selectRoleCodesByUserId(user.getId()); // 转换为 GrantedAuthority注意 ROLE_ 前缀 ListGrantedAuthority authorities roleCodes.stream() .map(roleCode - new SimpleGrantedAuthority(ROLE_ roleCode)) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities); } }这里比较关键的是角色前缀。Spring Security的hasRole(ADMIN)表达式实际校验的是authorities里是否有“ROLE_ADMIN”所以我们在给用户添加权限时必须在角色编码前面加上“ROLE_”前缀。如果漏掉这个前缀就会出现代码写着PreAuthorize(hasRole(ADMIN))但是管理员账号也访问不了的情况。密码校验这块由于我数据库存的密码是BCrypt加密后的字符串所以只要在SecurityConfig里配置PasswordEncoder为BCryptPasswordEncoderSpring Security就会自动调用它比对密码。密码加密我是在用户注册时用passwordEncoder.encode()处理的这里不展开讲业务只要记住密码绝不能明文入库也绝不能自己写md5加盐之类的逻辑BCrypt就是默认最佳实践。5.4 自定义Token认证过滤器这个过滤器是整个Token认证的核心。它做的事情非常单纯从请求头里取出Authorization判断是否以Bearer开头是的话就截取token解析token从SecurityContext里拿旧认证信息做对比如果当前没有认证信息且token有效就构造Authentication对象放入SecurityContext。Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { Autowired private JwtUtil jwtUtil; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { String username jwtUtil.getUsernameFromToken(token); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); // 如果token有效构造认证对象并设置到SecurityContext if (jwtUtil.validateToken(token)) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { // 解析失败或用户不存在不设置认证信息后续会进入匿名认证或抛出401 logger.warn(JWT token解析失败: e.getMessage()); } } filterChain.doFilter(request, response); } }我为什么不直接在过滤器里丢异常因为一旦丢异常异常处理逻辑会直接返回错误响应但有些请求可能是匿名可访问的接口比如首页展示、公开查询等不该因为带了一个无效token就直接拒绝。所以我的策略是解析失败就不放行认证信息让后续的授权过滤器来决定这个接口是否需要登录。如果接口本身是公开的匿名就能访问如果接口需要登录由于SecurityContext里没有认证信息会触发AuthenticationEntryPoint返回401。这样既保证了安全也保留了灵活性。还有一个点为什么继承OncePerRequestFilter而不是普通的Filter。OncePerRequestFilter保证同一个请求在过滤器链中只执行一次。有些容器环境下请求可能会被多次转发到同一个过滤器如果不加这个限制可能导致重复执行认证逻辑。这是Spring Security社区推荐的做法。5.5 SecurityConfig核心配置配置类是Spring Security的集合点。我在这里配置了哪些路径放行、哪些需要认证、过滤器加在哪个位置、无认证处理逻辑、密码加密方式等。Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig { Autowired private UserDetailsService userDetailsService; Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login, /api/auth/register, /api/public/**).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() .and() .exceptionHandling() .authenticationEntryPoint(restAuthenticationEntryPoint()) .and() .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public RestAuthenticationEntryPoint restAuthenticationEntryPoint() { return new RestAuthenticationEntryPoint(); } }配置里有几个关键决策值得说明。关闭csrf是因为我们用的是Token认证而不是Cookie会话CSRF攻击主要针对Cookie机制Token放在请求头里第三方站点无法自动携带所以CSRF防护的意义不大。如果你做的是小程序或者App不存在浏览器Cookie问题同样可以关闭。关闭session设置STATELESS是因为我们用JWT做认证服务端不保存会话状态。每次请求都是无状态的服务器不需要为每个用户创建Session。这样水平扩展时不需要做Session同步。EnableGlobalMethodSecurity(prePostEnabled true)这个注解开启了方法级权限控制。开启之后Controller的方法上就能用PreAuthorize(hasRole(ADMIN))和PostAuthorize等注解做细粒度权限判断。如果不开这个注解即使配置了角色过滤方法注解也不会生效。addFilterBefore的位置很讲究。我把它加在UsernamePasswordAuthenticationFilter之前这个位置的意义在于如果请求带了有效token在我这个过滤器就完成了认证后面的授权过滤器直接就能看到Authentication。如果请求没有token那它落到UsernamePasswordAuthenticationFilter时也找不到表单登录参数最终进入匿名认证流程。5.6 登录接口实现登录接口本身并不在Spring Security的默认过滤器范围内我们自定义一个Controller来处理。流程是接收用户名密码、调用AuthenticationManager校验、生成token返回。RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtUtil jwtUtil; PostMapping(/login) public Result login(RequestBody LoginRequest loginRequest) { // 1. 构建认证请求 UsernamePasswordAuthenticationToken authenticationToken new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword()); // 2. 交给AuthenticationManager校验 Authentication authentication authenticationManager.authenticate(authenticationToken); // 3. 校验通过后生成token返回 String token jwtUtil.generateToken(authentication.getName()); // 这里可以从authentication对象里拿到用户详情、权限等 return Result.success(Collections.singletonMap(token, token)); } }AuthenticationManager是Spring Security的认证总指挥。它内部会通过DaoAuthenticationProvider调用UserDetailsService去查用户然后用PasswordEncoder比对密码。如果用户名不存在或者密码错误authenticate方法会抛出AuthenticationException。我们只需要在全局异常处理器里捕获这个异常返回对应的错误提示即可。这里需要理解一个细节AuthenticationManager是从哪里来的在Spring Security 5.7中AuthenticationManager不是默认注入的Bean。需要在配置类里通过AuthenticationConfiguration的getAuthenticationManager()方法暴露它。如果不暴露直接Autowired会报找不到Bean的错误。这也是很多新手写登录接口时会卡住的地方。5.7 退出登录设计JWT无状态下的无奈与妥协由于JWT是无状态的服务端无法直接让token失效。最简单的做法是前端直接删除本地token这样后续请求自然就不会携带了。但这样不够严谨如果token被截获在过期之前依然有效。所以我在项目中引入了Redis黑名单机制来实现退出登录。具体做法是在Redis中维护一个黑名单集合key为blacklist:token:tokenvalue为过期时间戳。当用户请求退出接口时后端把当前token加入黑名单并设置与token过期时间一致的TTL。自定义过滤器解析token时先去检查这个token是否在黑名单中如果在就直接拒绝认证。这样既能实现主动失效又不会占用太多Redis空间。// 退出登录示例 PostMapping(/logout) public Result logout(HttpServletRequest request) { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); long remaining jwtUtil.getRemainingExpiration(token); if (remaining 0) { redisTemplate.opsForValue().set(blacklist:token: token, 1, remaining, TimeUnit.MILLISECONDS); } } return Result.success(); }在黑名单存储格式上有一个更节省内存的做法不把整个token字符串作为key而是计算token的MD5值作为key。因为token通常比较长直接作为Redis key会浪费内存。这个优化在token量大以后效果明显。我实际项目里就是用MD5后的值作为key几乎不影响查询速度。5.8 权限控制从URL到方法我的项目中权限控制分为两个层面。第一个层面是URL级控制在SecurityConfig里用antMatchers配置比如/api/admin/**要求ADMIN角色。第二个层面是方法级控制在Controller方法上使用PreAuthorize注解做更细粒度的判断。RestController RequestMapping(/api/user) public class UserController { GetMapping(/list) PreAuthorize(hasRole(ADMIN)) public Result listUsers() { return Result.success(userService.listAll()); } GetMapping(/profile) PreAuthorize(hasAnyRole(ADMIN, USER)) public Result profile() { // 通过SecurityContextHolder获取当前用户 String username SecurityContextHolder.getContext().getAuthentication().getName(); return Result.success(userService.getByUsername(username)); } }PreAuthorize注解的表达式里除了hasRole、hasAnyRole还有hasAuthority、hasPermission等。hasRole和hasAuthority的区别在于hasRole会自动补全“ROLE_”前缀而hasAuthority是直接匹配。如果我在UserDetailsService构建authorities时加了“ROLE_”前缀那么用hasRole写起来更自然。如果你不加前缀直接用hasAuthority(“ADMIN”)也行。关键是要保持一致否则会出现永远匹配不上的问题。使用SecurityContextHolder.getContext().getAuthentication().getName()获取当前用户时注意一个陷阱我记得在token解析成功但没有设置主对象为UserDetails时getName可能返回的是username字符串而设置UserDetails后getName返回的实际上是UserDetails里的getUsername()返回值。所以建议大家一定要把完整的UserDetails对象作为principal传入Authentication这样后续获取用户信息更规范。6. 实测排查token失效与登录报错的典型案例6.1 用户token失效后返回什么在无状态JWT模式下token失效有两种常见情况。第一种是token过期解析时抛出ExpiredJwtException。第二种是token被篡改签名验证不通过抛出SignatureException。这两种异常在自定义过滤器里都会被捕获然后不设置认证信息。最终请求到达需要认证的接口时由于SecurityContext为空Spring Security的ExceptionTranslationFilter会调用AuthenticationEntryPoint返回401响应。我实现的RestAuthenticationEntryPoint是这样处理的Component public class RestAuthenticationEntryPoint 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({\code\:401,\message\:\未登录或token已失效\}); } }统一返回JSON结构而不是Spring Security默认的HTML错误页对前后端分离项目是必须的。前端拿到401状态码后跳转登录页或发起refresh_token续签请求。6.2 token exchange failed这类报错与Spring Security无关热词里大量出现“sign-in could not be completed token exchange failed”之类的报错这其实是很多第三方登录工具比如某些AI编程助手、云服务CLI在OAuth2授权码交换阶段网络或区域不匹配导致的错误跟Spring Security的Token认证实现没有关系。我看到这个词条被搜得很多想提醒一下如果你在自研系统里看到这个报错先查自己的登录接口和token解析逻辑如果你是在用某个第三方工具时看到这个报错那大概率是外网授权服务的问题不要混为一谈。6.3 常见的Scoped权限问题在实现过程中我还踩过一个和角色相关的坑。系统里有两个角色管理员和普通用户。我登录管理员账号访问普通用户能访问的接口没问题但访问管理员专属接口返回403。排查半天最后发现是UserDetailsService里查角色时SQL写错了查询条件把用户ID写成了用户对象本身导致查出来的角色永远是空集合。所以后来我总结了一个排查套路当接口返回403时先看日志里有没有打印出当前用户权限列表如果权限列表为空问题大概率出在UserDetailsService的数据库查询上而不是Spring Security配置。6.4 常见问题速查表问题现象可能原因排查方向启动报WeakKeyExceptionJWT密钥长度不足32字节配置更长的随机字符串建议Base64编码登录接口返回401AuthenticationManager未正确注入检查SecurityConfig是否暴露了AuthenticationManager Bean带token请求仍然401自定义过滤器没生效检查过滤器是否注册到SecurityFilterChain中带token请求仍然403用户权限列表为空或角色前缀不一致检查UserDetailsService返回的GrantedAuthoritytoken过期了还在访问过期时间没写到token中检查JwtUtil中setExpiration是否执行异步线程里获取不到用户ThreadLocal未传递使用DelegatingSecurityContextExecutor或手动传参前端登录后接口报CORS错误跨域配置缺失或顺序不对在SecurityConfig中添加CorsConfigurationSource并http.cors()方法注解不生效未开启方法安全添加EnableGlobalMethodSecurity(prePostEnabled true)密码错误但没报错PasswordEncoder未配置或类型不匹配确认配置类中PasswordEncoder为BCryptPasswordEncoder6.5 过滤器顺序的一个隐藏坑Spring Security的过滤器链中添加自定义过滤器时addFilterBefore和addFilterAfter的参数是某个特定的过滤器类。如果你想在UsernamePasswordAuthenticationFilter之前插入就写addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)。但如果你想把过滤器加在整个过滤器链的最前面用addFilterBefore(jwtFilter, SecurityContextHolderFilter.class)也能实现。区别在于加在UsernamePasswordAuthenticationFilter之前会先经过很多框架自带的过滤器比如CorsFilter、CsrfFilter等加在最前面则连这些都不经过。不过不建议一上来就把过滤器放到最前面除非你明确知道不需要框架的某些前置处理。我习惯就是放在UsernamePasswordAuthenticationFilter之前这是社区最常见的位置也是文档里推荐的。7. 登录并发与Token续签从热词延伸到实战7.1 如何设计Token续签JWT过期时间不能设得太长否则泄露后风险很高。也不能设得太短否则用户用着用着就掉线体验很差。一个常见的方案是短期token加长期refresh token。登录成功后同时返回access_token和refresh_tokenaccess_token有效期短比如30分钟refresh_token有效期长比如7天。当access_token过期时前端拿着refresh_token去刷新接口换取新的access_token。在Spring Security实现中refresh_token一般都是一个随机字符串存储在Redis里关联用户ID和access_token的会话状态。刷新成功后旧refresh_token作废换发新token。如果refresh_token也过期了就要求重新登录。这个方案比我当前项目的单token更完善但实现起来多一张表或一组Redis存储。热词里“jwt实现token续签”被搜索很多说明大家都有这个需求。这里我给出一个最小可用思路不需要额外引入框架自己在登录时生成两个tokenaccess_token用JWTrefresh_token用UUID存Redis并设置过期时间。在刷新接口里先校验refresh_token是否在Redis中如果是则重新生成access_token返回同时更新refresh_token的过期时间。7.2 登录互踢与单点登录后台管理系统通常有“同账号只允许一个地方登录”的需求通常叫做单端登录或互踢机制。实现思路很简单登录成功后在Redis里以用户ID为key保存当前最新的token。每次请求进来时在自定义过滤器里把当前请求的token和Redis里存的token做对比不一致就拒绝。这个方案实现成本低能解决账号共享问题。反过来如果你希望同一个账号可以多端登录那就不做互踢只用黑名单做退出控制。这两种方案取决于业务场景没有绝对的好坏。管理系统一般倾向于互踢对外用户端一般允许多端登录。8. 踩坑总结与优化建议8.1 设计中的几个关键经验整个项目做完我最想提醒大家的是三件事。第一密钥和配置别写死在代码里。JWT的secret一定要放到配置文件并且通过环境变量或配置中心注入不同环境的值。本地开发一套secret测试一套生产又不同避免因为密钥泄露造成token伪造风险。第二异常处理要统一。Spring Security的异常和业务异常要分开处理。AuthenticationEntryPoint负责认证异常AccessDeniedHandler负责权限不足异常业务异常交给RestControllerAdvice。这样才能保证前端拿到的JSON结构一致。第三过滤器链里不要做耗时操作。JWT的签名验证本身很快但如果你的UserDetailsService里每次请求都查数据库那压力会不小。我建议在JWT的claim里直接放上用户必要的权限信息这样解析token后就可以直接构造Authentication对象避免每次请求都查库。但要注意如果权限变更了token里的旧权限信息在有效期内依然生效这在某些快速变更权限的场景下算是缺点。折中做法是权限对实时性要求不高时用claim缓存要求高时还是查库。8.2 Spring Security版本差异带来的坑Spring Boot 2.7用的Spring Security是5.7而Spring Boot 3.x用的是Spring Security 6.x。两者在写法上有几个明显变化。第一WebSecurityConfigurerAdapter在5.7标记过期6.x移除必须使用SecurityFilterChain方式。第二5.7里antMatchers在6.x变成了requestMatchers。如果你现在参考的是老教程复制过来的代码在Spring Boot 3项目里编译都过不了。第三方法安全注解从EnableGlobalMethodSecurity变成了EnableMethodSecurity。所以如果你用的是Spring Boot 3不要直接照抄我上面的配置要做对应替换。我为什么用Spring Boot 2.7因为项目业务依赖的一些库还没有适配Spring Boot 3而且整体迁移成本较高。对于新项目我建议直接上Spring Boot 3 Spring Security 6毕竟技术栈更新长期维护更省心。如果你还是用2.xSpring Security 5.7的写法也能用只是WebSecurityConfigurerAdapter不要再继承用SecurityFilterChain就行。8.3 日志与监控建议认证系统最怕出了问题无从查起。我建议在自定义过滤器里增加日志输出记录请求路径、是否携带token、解析是否成功、当前用户是谁。同时在登录接口里记录登录成功和失败的日志包括IP、用户名、时间。这些日志不仅有助于排错也是安全审计的重要依据。我改造后的项目里日志输出大概长这样2025-01-15 10:23:45 [http-nio-8080-exec-7] INFO JwtAuthenticationTokenFilter - 请求路径:/api/user/profile, token有效, 用户:admin 2025-01-15 10:23:44 [http-nio-8080-exec-5] WARN JwtAuthenticationTokenFilter - 请求路径:/api/admin/delete, token无效: JWT expired at 2025-01-15T10:13:45Z有了这些日志定位问题就快了。否则某天用户反馈“我明明登录了为什么接口报401”你只能干瞪眼。8.4 后续扩展方向如果后续系统要接入第三方应用比如开放平台API、小程序授权登录那就可以在此基础上引入Spring Security OAuth2。到时认证服务器和资源服务器可以拆分token也可以继续用JWT但整个模型会复杂很多。到那时候本文这套自定义过滤器的思路依然适用因为你已经把Spring Security的过滤器链机制摸透了学习OAuth2只是在这个骨架里填充更多过滤器而已。9. 写在项目末尾的个人体会这次把Spring Security接入项目之后我最大的感受是这个框架的学习曲线确实陡但它把安全领域积累下来的最佳实践都沉淀成了过滤器机制。你不需要懂每一层过滤器的实现细节但必须理解认证信息是怎么流转的。JWT工具类写了一百多行、配置类写了四十多行看起来工作量不小但换来的是一套可持续扩展的认证框架。后来我在另一个项目里复用这套代码只改数据库查询和密钥配置半天就搞定了登录认证部分前期投入成本被摊薄了。最后再分享一个调试技巧如果你在配置Spring Security时遇到“过滤器不生效”、“认证信息丢失”、“权限不匹配”这类玄学问题直接在配置类里临时加一个自定义过滤器在控制台输出当前过滤器链上有哪些过滤器。具体做法是注入一个Filter在doFilter里打印request的URI和SecurityContext里的Authentication。这样一圈跑下来你很快就能定位是哪一环出了偏差。安全框架不像业务代码那样容易打断点调试靠日志和过滤链信息推断才是最高效的手段。