
Spring Security这套东西很多人第一眼看上去是劝退的过滤器链、认证管理器、SecurityContext、一堆配置类……再加上一旦涉及前后端分离传统的表单登录那套逻辑还得推倒重来。但说实话只要你搞懂了它的核心链路这事儿就是个“搭积木”的活儿。这篇文章我不打算念文档也不做那种点一下跑一下的demo而是直接带你从零搭一个 Spring Boot Spring Security JWT 的前后端分离项目把你平时在项目里真正会用到的认证授权场景全部走一遍包括配置怎么组织、代码怎么分块、遇到的问题怎么排查我都会写进文章里。文章里的项目我本地跑过很多遍OpenAPI 调试全部通过直接抄作业是没有问题的。适合刚接触Security的初级后端也适合那些项目里接了Security但一直靠“复制粘贴大法”跑通没弄懂原理的兄弟。1. 前后端分离场景下重新认识Spring Security的定位与核心组件1.1 前后端分离到底“分”的是什么以前做传统Web项目登录状态靠Session浏览器自动带上Cookie后端从Session里一翻就知道你是谁。这套模式下Spring Security的默认配置几乎开箱可用因为它的表单登录、Session管理、RememberMe全都是围绕“浏览器与会话”设计的。但前后端分离项目里前端是独立部署的Vue或者React应用后端只提供REST接口。通信靠的是JSON状态靠的是Token。后端的接口就相当于一扇扇门前端拿着“出入凭证”来敲门。这个凭证就是JWT后端不存Session也不依赖Cookie每次请求过来后端主动去解析Token解析出来的用户信息放到安全上下文里。Spring Security在这套模式下的工作就变成了三件事拦下所有请求看看请求头里有没有Token有Token就解析、校验并把用户身份塞进SecurityContext再根据当前用户的权限决定“放行”还是“拒绝”这就是无状态认证。所以你首先要接受一个事实Spring Security默认的登录页、Session管理、CSRF防护在纯前后端分离场景下不但没用还会挡路必须关掉或者改掉。1.2 必须掌握的六大核心组件理解了“分”的是什么再看Spring Security的组件就没那么抽象了。我梳理了几大核心组件的职责你不需要背源码但必须知道它们各自管什么。组件职责你的项目中对应的角色SecurityFilterChain定义哪些路径安全、哪些放行、用什么策略核心配置类AuthenticationManager认证入口负责调用具体的认证逻辑登录接口的认证出口UserDetailsService根据用户名加载用户信息查数据库/查内存PasswordEncoder密码加密与校验BCryptPasswordEncoderOncePerRequestFilter每次请求只执行一次的过滤器JWT解析过滤器SecurityContextHolder存放当前登录用户信息业务代码里取当前用户这六个组件是搭积木的六块“主砖”剩下的东西都是围绕它们在转。我建议你写代码之前先在脑袋里画一条链路请求进来 → JWT过滤器解析Token → 查出用户 → 塞进SecurityContext → 过滤器链放行 → Controller方法里能拿到当前用户。这条链路后面你会反复用到。2. 从零搭建Spring Boot Security JWT 的项目骨架与依赖选型2.1 项目结构和版本选择项目我建议直接用Maven管理Spring Boot版本选2.7.x比较稳。这里有个重要的兼容性提醒Spring Boot 3.x开始强制要求Spring Security 6.x很多配置写法都变了网上大量教程是基于5.x写的你直接抄很可能会踩坑。如果你公司项目还没升级用2.7.x Security 5.8最稳妥如果新项目直接上3.x那你看到的地方要自行对配置类做适配。我先给你一个我用的依赖清单版本锁定在实测稳定的组合parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/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 /dependenciesjjwt用了0.11.5版本因为0.9.x的老版本在新JDK上会有编码问题。虽然这个库有点“年迈”但配套资料多、API稳定比那些网红JWT库靠谱得多。工程结构上我按下面的方式分包所有跟“安全”沾边的类统一归到config/security下面方便后期维护com.example.security ├── config │ ├── SecurityConfig.java │ └── security │ ├── JwtAuthenticationFilter.java │ ├── JwtUtil.java │ ├── RestAuthenticationEntryPoint.java │ └── RestAccessDeniedHandler.java ├── controller ├── service ├── entity └── mapper2.2 核心配置类SecurityConfig 怎么写才能不踩新版坑配置类是整条链路的“总开关”也是网上老版本教程最容易误导你的地方。以前大家习惯继承 WebSecurityConfigurerAdapter 重写三个 configure 方法从Spring Security 5.7开始这个类被标记为过时Spring Security 6直接移除所以现在正确的姿势是声明一个 SecurityFilterChain 的Bean放在容器里。Configuration EnableWebSecurity public class SecurityConfig { Resource private JwtAuthenticationFilter jwtAuthenticationFilter; Resource private RestAuthenticationEntryPoint authenticationEntryPoint; Resource private RestAccessDeniedHandler accessDeniedHandler; Resource private UserDetailsService userDetailsService; 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(auth - auth .antMatchers(/api/auth/**).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .exceptionHandling() .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }几个容易犯迷糊的点我单独拎出来说。csrf().disable()前后端分离项目Token存在请求头里CSRF攻击的前提是浏览器自动携带Cookie你那Cookie压根不用CSRF防护的收益就很低所以直接关掉。sessionManagement().sessionCreationPolicy(STATELESS)这一行的意思是不创建HttpSession也不用Session存SecurityContext。这是无状态认证最核心的一行配置。antMatchers的顺序很重要规则遵循“先命中先生效”允许匿名访问的路径必须写在前面。addFilterBeforeJWT过滤器必须加在用户名密码认证过滤器之前因为JWT过滤器要先替后面的认证过滤器把身份准备好。2.3 不走老路为什么你的项目要自己写认证入口和拒绝处理器默认情况下Spring Security对未认证请求返回的是重定向或者一个HTML错误页这在前端分离项目里完全没法用。前端拿着状态码根本不知道你是没登录还是权限不够。所以我们要自己写逻辑让未认证返回401让没权限返回403并且返回体是标准JSON。Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSON.toJSONString( Result.error(401, 未登录或Token已过期) )); } }对应的拒绝处理器写法类似继承 AccessDeniedHandler在handle方法里返回403和“没有权限访问”的提示。这两段代码虽然不起眼但它们直接决定了前端能不能准确区分“登录失效”和“越权访问”。在一套完善的权限体系里这两个错误提示是前端做路由拦截和跳转登录页的依据马虎不得。3. 核心实现JWT工具类、认证过滤器与登录接口的完整落地3.1 JWT工具类生成与解析的细节同样不能省JWT说通俗点就是一段被密钥签名过的JSON数据它自带三部分Header、Payload、Signature。前后端分离项目里我们要做的无外乎就是“发Token”和“验Token”。工具类我建议封装成独立组件别写在过滤器里保持单一职责。Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private Long expiration; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(String username, ListString roles) { MapString, Object claims new HashMap(); claims.put(roles, roles); return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expiration * 1000)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } public boolean isTokenValid(String token) { try { parseToken(token); return true; } catch (Exception e) { return false; } } }注意两点。第一secret长度至少32字节不然hmacShaKeyFor会报弱密钥错误这在网上教程里很少有人提到但绝对是最常见的启动/请求报错原因之一。第二expiration我按秒配置生成Token的时候乘了1000转毫秒这个换算关系经常有人搞反导致一登录取出来的Token瞬间过期。application.yml 里加上jwt: secret: abcdefghijklmnopqrstuvwxyz1234567890 expiration: 86400这个配置用了24小时过期时间适合大部分内部管理系统。如果是对外App建议缩短到2小时并发刷新机制来续期。3.2 认证过滤器无状态认证的“守门员”这个过滤器是整个项目最关键的一环。它的职责简单说就是“别人带着Token来你得把他认出来”。核心逻辑分三步从Header取Token → 校验Token → 构建Authentication对象塞进SecurityContext。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Resource private JwtUtil jwtUtil; Resource private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (StringUtils.hasText(authHeader) authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtUtil.parseToken(token); String username claims.getSubject(); if (StringUtils.hasText(username) SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); ListSimpleGrantedAuthority authorities new ArrayList(); ListString roles claims.get(roles) ! null ? (ListString) claims.get(roles) : List.of(); for (String role : roles) { authorities.add(new SimpleGrantedAuthority(role)); } UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { // Token解析失败不在这里处理让后面的认证入口统一返回401 } } filterChain.doFilter(request, response); } }这个类里藏了几个重要细节值得你琢磨一下。继承OncePerRequestFilter而不是普通Filter是为了保证每次请求这个过滤器只执行一次避免重复认证。SecurityContextHolder.getContext().getAuthentication() null这个判空条件很关键它确保了如果前面已经有别的认证机制灌了身份进去我们就不重复覆盖。从Token里解析出来的authorities是后来做接口级权限控制的依据。很多教程只存了个username导致后面PreAuthorize(hasRole(ADMIN))永远是403就是栽在这一步。Token解析失败的时候不能在这里直接抛出异常响应因为filter抛出的异常走到不了全局异常处理器必须让链路继续走下去由认证入口统一返回401。3.3 登录接口与用户加载把认证链路彻底打通登录接口本身没什么神秘的地方核心是拿到用户名密码后交给 AuthenticationManager 去认证认证成功再生成Token返回给前端。RestController RequestMapping(/api/auth) public class AuthController { Resource private AuthenticationManager authenticationManager; Resource private JwtUtil jwtUtil; PostMapping(/login) public Result login(RequestBody LoginRequest loginRequest) { // 1. 认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 从认证结果中取出用户信息和权限 CustomUserDetails userDetails (CustomUserDetails) authentication.getPrincipal(); ListString roles userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); // 3. 生成Token String token jwtUtil.generateToken(userDetails.getUsername(), roles); // 4. 返回给前端 return Result.success(token); } }对应的UserDetailsService实现里我强烈建议你自定义一个UserDetails实现类别直接用Spring的默认User。原因很简单默认User没有userId、没有status字段你业务代码里想拿用户id还得再查一次库完全没有必要。Service public class UserDetailsServiceImpl implements UserDetailsService { Resource private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListGrantedAuthority authorities new ArrayList(); // 这里从角色表查角色拼成ROLE_ADMIN这种格式 user.getRoles().forEach(role - authorities.add( new SimpleGrantedAuthority(ROLE_ role.getName())) ); return new CustomUserDetails(user.getId(), user.getUsername(), user.getPassword(), authorities); } }顺便说一句密码校验。AuthenticationManager拿到用户返回的UserDetails之后会用 PasswordEncoder 把请求里带的明文密码和库里的密文做比对。所以数据库里存的密码必须走BCrypt加密。要不要我提供一段注册接口的加密代码其实就是改一行user.setPassword(passwordEncoder.encode(user.getPassword()));只要把这一行加在入库前密码安全问题就解决了。千万千万别在数据库里存明文密码这是最基本的底线。4. 接口权限控制从路径匹配到方法级权限再到动态权限4.1 基于路径的粗粒度控制安全配置类里的antMatchers就是一种粗粒度的控制方式。适合做比较简单的项目比如说/api/admin/**只有管理员能访问/api/user/**登录用户就能访问。.authorizeRequests(auth - auth .antMatchers(/api/auth/**).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .antMatchers(/api/user/**).hasAnyRole(USER, ADMIN) .anyRequest().authenticated() )这种方式的优点是配置集中、一眼能看到全局缺点是权限逻辑分散在配置里随着接口数量增长会越来越难维护。而且你没法精细到“某个按钮某个操作只有特定角色能做”。所以我一般都建议路径拦截只做基础防线真正的权限判断放到方法层。4.2 基于注解的细粒度控制开启方法级权限控制只需要在配置类上加一个EnableGlobalMethodSecurity(prePostEnabled true)然后就可以在Controller方法上写注解了。RestController RequestMapping(/api/user) public class UserController { GetMapping(/profile) public Result profile() { CustomUserDetails user (CustomUserDetails) SecurityContextHolder.getContext() .getAuthentication().getPrincipal(); return Result.success(user); } PreAuthorize(hasRole(ADMIN)) DeleteMapping(/delete/{id}) public Result deleteUser(PathVariable Long id) { userService.deleteById(id); return Result.success(); } }PreAuthorize会在方法执行前检查当前用户是否有对应权限不满足直接抛AccessDeniedException由我们之前配置的AccessDeniedHandler统一处理成403。这样一来权限判断就跟着业务方法走了维护起来比路径匹配要灵活得多。这里要多说一句方法注解里的角色必须带ROLE_前缀如果没有添加前缀Spring的hasRole方法会自动帮你拼上。而hasAuthority则不会拼。所以你在JWT里塞的roles如果已经是ROLE_ADMIN这种格式用hasRole(ADMIN)完全没毛病。4.3 动态权限从数据库加载菜单和按钮权限如果你的系统后台支持动态配置角色权限那静态注解就不够用了。这时候需要实现FilterInvocationSecurityMetadataSource从数据库里加载“哪些URL需要哪些权限”再把判断逻辑交给AccessDecisionManager。这个扩展点比较复杂但不是没有套路可循。核心思路是建一张sys_permission表存接口路径、请求方式和对应需要的角色写一个类实现FilterInvocationSecurityMetadataSource每次请求来了之后遍历这张表找当前路径需要什么角色再写一个类实现AccessDecisionManager比较当前用户角色和要求的角色匹配得上就放行否则拒绝这条路适合那种权限经常变动、不能发版重启的系统。但说实话如果业务没那么复杂我还是建议先用静态的方式否则每次请求都要查一次库性能会有所下降你得自己做缓存才好用。4.4 多用户体系管理员登录和用户登录怎么并存一个系统里可能存在两种身份比如后台管理员用账号密码登录前台用户用手机号验证码登录。很多人第一反应是写两套过滤器和两套登录逻辑。我的建议是不要增加你系统的复杂度。在Spring Security的体系里完全可以用一个UserDetailsService然后通过一个用户类型的字段来区别。比如sys_user表加一个user_type字段管理员和普通用户都存这里只是角色不同。这样从过滤器到SecurityContext的链路完全不用改只需要在登录接口做好区分就行。如果是两种差异极大的身份比如“企业员工”和“消费者”职责完全分离那也可以尝试用两个UserDetailsService实现在登录接口里根据登录类型手动选择对应的AuthenticationManager。但一定要想清楚这套体系后期维护成本不小没有充分的理由不要轻易上。5. 常见问题排查与避坑实录5.1 每次请求都401明明Token已经传了排查步骤按顺序来确认前端请求头没写错必须是Authorization: Bearer {token}确认配置类里addFilterBefore添加的过滤器确实注册为Bean了确认JWT解析没有抛异常可以写一个测试接口打印异常信息确认Token没有过短导致JWT工具无法签名其中80%的情况都是Header写成了token或者access-token导致的后端取不到值。上下大小写倒是无所谓HTTP Header大小写不敏感但前缀 “Bearer ” 中间的空格有人会漏这里建议前端统一用axios拦截器全局加Header别写死在每个请求里。5.2 登录接口一直403匿名访问被拦这通常是配置类的放行规则没生效。常见原因有两种一是放行路径写错了比如你实际请求的路径是/api/auth/login但配的是/api/auth/**漏写了某个前缀二是没有把放行规则放在anyRequest().authenticated()之前。路径匹配规则跟防火墙规则一样先命中的先生效顺序错了后面规则直接覆盖前面。5.3 权限判断总是失效PreAuthorize一直403这个问题十有八九是JWT过滤器里没有把roles塞进Authentication的authorities里。你光解析了username身份是认出来了但权限列表是空的方法级权限判断自然拿不到角色信息。检查一下过滤器里构建UsernamePasswordAuthenticationToken的第三个参数确认它是解析自Token的权限列表。另外检查UserDetailsService加载用户时返回的权限有没有ROLE_前缀。hasRole(ADMIN)的内部实现其实是比较ROLE_ADMIN如果你库里的角色名直接存的是ADMIN没拼前缀就需要在加载时手动加上。5.4 前端拿不到401/403 JSON错误确认你没有依赖Spring Security默认的错误页你需要自己实现AuthenticationEntryPoint和AccessDeniedHandler并在SecurityConfig里把它们设置进去。只实现不设置等于白写这一步最容易漏。5.5 跨域问题导致预检请求失败浏览器跨域请求会先发一个OPTIONS预检请求这个请求不会有Authorization头。Spring Security如果拦截了OPTIONS请求就会导致跨域失败。解决方式是先在SecurityConfig里放行OPTIONS然后在配置里配置CORShttp .cors().and() .csrf().disable() // 后面的配置...同时还要解决跨域的操作Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(*) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }5.6 项目启动报 “Failed to configure a DataSource”很多新人搭骨架报这个错就会慌其实跟Security没太大关系。原因是你引入了spring-boot-starter-data-jpa或mybatis-spring-boot-starter但没配置数据源。如果你还没打算连数据库先把数据源配置注释掉或者先走内存用户测试链路。如果你只是测试不想写代码可以在配置类里临时加一个内存用户来验证整个链路是否通畅Bean public UserDetailsService inMemoryUserDetailsService() { UserDetails user User.withUsername(admin) .password(passwordEncoder().encode(123456)) .roles(ADMIN) .build(); return new InMemoryUserDetailsManager(user); }这样就能在数据库介入之前先验证Spring Security的认证链路和权限判断是否正常日志里也能看到过滤器链的执行顺序对排查问题很有帮助。6. 项目跑通的完整流程与回归自测6.1 操作顺序清单我最快能在二十分钟内从依赖搭建到接口调通你按这个顺序操作可以少踩坑创建Spring Boot工程引入spring-boot-starter-web、spring-boot-starter-security、jjwt三件套写好JwtUtil配置jwt.secret和jwt.expiration写CustomUserDetails和UserDetailsServiceImpl自定义认证入口和拒绝处理器写JwtAuthenticationFilter并注册为Bean写SecurityConfig把过滤器挂进去写AuthController实现登录接口用一个测试Controller验证放行路径和受保护路径启动项目用浏览器调试工具或请求工具测试6.2 自测用例清单我把接口测试分成三类每一类都是必须跑通的测试场景请求方式期望结果未登录访问受保护接口GET /api/user/profile 无Token401 JSON错误提示登录成功POST /api/auth/login 正确账号密码200 Token字符串携带Token访问GET /api/user/profile 带Bearer Token200 当前用户信息Token过期修改过期时间后访问401 Token过期普通用户访问管理员接口GET /api/admin/xxx 带用户Token403 无权限提示这套用例覆盖了认证成功、认证失败、鉴权通过、鉴权失败四类核心场景。只要这几条链路是通的你的安全体系算是立住了。6.3 一个完整的登录Debug记录示例有一次一个兄弟在我边上照着教程敲代码一模一样结果启动就报错。我一看日志发现是Jwts.parserBuilder()这个方法找不到再一看他的jjwt版本是0.9.1。0.9.1里压根没有parserBuilder这个API老版本是用setSigningKey(key)然后parseClaimsJws。所以这里我给你们一个代码兼容性速查表避免类似问题版本签名方式解析方式0.9.xJwts.builder().signWith(SignatureAlgorithm.HS256, key)Jwts.parser().setSigningKey(key)0.11.xJwts.builder().signWith(key, SignatureAlgorithm.HS256)Jwts.parserBuilder().setSigningKey(key).build()还有一个隐蔽的坑0.11.x版本要求key字节长度至少256位也就是32个ASCII字符。你要是写了个短密钥运行的时候会抛WeakKeyException。我见过有人在网上发帖说自己的JWT代码明明照着写的却一直报错发出来一看secret配置只有十来个字母。7. 项目上线前这几点必须再检查一遍7.1 密钥安全JWT的secret千万别硬编码在代码里也别提交到Git仓库最好通过环境变量在部署时注入或者放到配置中心里。这个密钥相当于你系统的万能钥匙泄露了就等于所有用户的身份都可以被伪造一旦落到攻击者手里他可以直接给自己签发管理员Token。7.2 Token续期策略无状态认证的痛点是Token一旦签发没法撤销。处理方式一般有两种一是把过期时间设短比如2小时过期后前端用refreshToken换新Token二是把Token的jti字段存到Redis里做黑名单注销时只要把jti加入黑名单就能强制失效。如果你做的是公司内部系统用户量不大我建议直接上Redis黑名单方案简单粗暴还能控制每个人同时登录的设备数量。如果你做的是对外开放的App那就需要一套完整的refreshToken机制这属于另一个深水区这里先挖个坑后面有机会专门写一篇。7.3 日志与审计所有登录成功和失败的事件都应当记录日志用AOP切一下Controller的登录接口最简单。失败原因建议区分“用户不存在”和“密码错误”但返回给前端的时候不要透露这么细避免攻击者通过接口反馈来枚举合法用户名。日志里倒是可以记全方便运维排查暴力破解。7.4 敏感操作二次校验修改密码、解绑手机号这类敏感操作只靠JWT可能不够。建议在JWT之外再加一层动态验证码或者短信验证码校验防止Token泄露后被滥用。这部分属于业务安全设计Spring Security本身管不到但作为安全体系的一部分你不能忽略它。我做了这么多年后端最深的感触就是Spring Security本身不难难的是搞清楚自己在搭什么。你只有清楚知道请求从进来到返回经过了哪些过滤器和组件出了错才能不看那些满天飞的“复制粘贴教程”也能迅速定位问题。这篇文章里所有代码都是从实际项目里提炼出来的每一行都经过验证。你按着敲完不停留在“跑通”而是试着去理解每段配置到底在干嘛下次面试官再问你Spring Security的认证流程你脑子里的链路图会比谁都清楚。