
Spring Security 这块内容我前后折腾了小半个月从最开始照着老教程写WebSecurityConfigurerAdapter被各种报错劝退到后面摸清 Spring Security 6 的配置套路再把 JWT 无状态认证、前后端分离的流程整个跑通踩过的坑确实不少。这篇教程我尽量把思路和代码串起来讲不光是怎么配更多的是为什么这么配这样后面你遇到问题排查起来也有方向。先说清楚这篇东西适合谁项目用的是 Spring Boot 3.x Spring Security 6.x前端是 Vue/React 这类独立部署的工程需要做登录认证、接口鉴权希望接口返回 JSON 而不是跳转页面。如果你是做传统的服务端渲染页面或者用的是 Spring Boot 2.x部分写法会有差异但核心思路是一致的我也尽量在关键地方把差异标出来。1. 项目环境与基础准备1.1 版本选型Spring Boot 3.x 带来的配置迁移很多人在 Spring Security 这里卡住第一个原因就是版本。网上搜出来的教程一大半还在教extends WebSecurityConfigurerAdapter、重写configure(AuthenticationManagerBuilder auth)这种写法这套在 Spring Boot 3.x Spring Security 6.x 里面已经完全不适用了强行写会直接报错或者方法都找不到。Spring Boot 3.x 基于 Spring Framework 6Security 版本也升到了 6.x最大的变化是两个一是之前那个万能的WebSecurityConfigurerAdapter被彻底废弃移除官方推荐直接用SecurityFilterChainBean 的方式来声明过滤链二是很多spring.factories里的自动配置迁移到了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports导致某些老配置项失效比如spring.security.filter.order这类配置就不再起作用了。所以如果你的项目是新建的直接选 Spring Boot 3.x 就好没必要用 2.x 起步再迁移一遍。一个是我实际用下来的体验另一个是 3.x 对 GraalVM Native Image 和 Jakarta EE 的支持是以后的大方向后续升别的依赖也少一些兼容性问题。当然如果你的项目被迫留在 Spring Boot 2.x那还是得用WebSecurityConfigurerAdapter或者SecurityFilterChain的早期写法等下我在关键代码旁边会把两版的差异讲一下。1.2 Maven 依赖引入基础依赖就两个一个是 Web一个是 Security。额外要加 JWT 处理和注解支持的话下面这个清单可以一起放进去。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JWT 工具包 -- 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 !-- 方法级权限注解PreAuthorize 等 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency很多人把jjwt-jackson漏掉结果运行时解析 token 直接报JwtException因为缺了 JSON 序列化实现。这个坑不大但比较隐蔽先记一下。如果你要做数据库用户校验再加spring-boot-starter-data-jpa或mybatis-plus、mysql-connector-j这几个依赖这块根据你项目自身的持久层选型来不是 Security 的核心我不展开。2. 认证流程与核心原理拆解2.1 过滤器链到底是个什么Spring Security 的核心机制是过滤器链这一点必须要先理解透不然后面配置看了会一头雾水。每个请求进来之后会按顺序经过一长串过滤器每个过滤器只负责一件事。比如UsernamePasswordAuthenticationFilter负责从表单登录请求里捞用户名密码然后做认证ExceptionTranslationFilter负责捕获认证/授权异常并决定是返回 401 还是 403FilterSecurityInterceptor负责做最终的授权判断。这个设计很像流水线作业——请求就是一块原材料每个工位过滤器加工一道工序全部通过之后才到达你的 Controller。Spring Security 6 里这条链的核心是一个DefaultSecurityFilterChain你通过SecurityFilterChainBean 配置的其实就是哪些请求路径走哪些过滤器。可以存在多个SecurityFilterChainBean 按顺序匹配不过大多数项目一个就够了。这里我给你的建议是先大致认识几个关键过滤器在链上的位置不需要一次性全背下来。等写完了配置实际调试中遇到某类问题再回来看对应的过滤器理解效率要高很多。2.2 AuthenticationManager 和 AuthenticationProvider 的关系很多教程一上来就让你自定义AuthenticationManager但没说清楚它是干嘛的。我用人话解释一下。AuthenticationManager是认证的总入口你问它这个用户信息是否合法它不会亲自去查而是把问题抛给它手下的AuthenticationProvider们谁有能力处理谁来处理。默认情况下DaoAuthenticationProvider负责处理用户名密码这种认证方式它会调用你提供的UserDetailsService去数据库捞用户信息然后用PasswordEncoder比对密码是否一致。所以如果你要自定义的只有从数据库查用户、校验密码这一步你只需要干两件事提供一个UserDetailsService实现声明一个PasswordEncoderBean。AuthenticationManager会自动装配好你不需要亲手创建它。只有当你的认证方式很特殊比如短信验证码、LDAP、OAuth 第三方登录才需要自定义AuthenticationProvider。对前后端分离项目来说绝大多数场景走的是用户名密码 数据库校验所以自定义UserDetailsService就够用了。等到写 JWT 过滤器的时候你会发现我们并不直接调用AuthenticationManager去认证而是在登录接口里手动用它登录取到 token 之后后续请求靠 token 本身来确认身份这就是无状态认证的思路后面具体讲。2.3 为什么前后端分离要抛弃 Session说句实在话Session 不是不能用但在前后端分离架构下它有几个不好处理的点。第一个是跨域问题。前端跑在 8080 端口后端跑在 9090 端口浏览器里这就是两个源Session 靠 Cookie 传递跨域请求要把withCredentials打开后端还得配合把allow-credentials配好。CORS 加 Cookie 的组合很容易踩到边界情况比如前端没配 axios 的withCredentials: true或者后端allowedOrigin写成了*这种情况浏览器会直接拒绝携带 Cookie。第二个问题是多端适配。同一个后端可能要给 Web 管理端、移动 App、小程序提供接口。App 和小程序的网络库对 Cookie 的支持参差不齐处理起来很别扭。而 token 放请求头里全端通用。第三个问题是服务端状态分散。Session 默认存内存里一旦部署多实例就得引入 Redis Session 共享之类的东西。JWT 本身是无状态的服务端不需要存会话天然适合水平扩容。基于这几点前后端分离 JWT 算是当前最主流的方案也是这篇教程采用的思路。3. JWT 认证流程设计与对比分析3.1 一次性讲透 JWT 的构成JWT 是一串形如eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.abc123的字符串由三部分拼起来中间用点隔开Header 和 Payload 只做 Base64 编码任何人都能解开看内容所以敏感信息不能往里放真正保证防篡改的是第三段——签名。签名是用 Header 里声明的算法把前两段内容加上一个服务端私有的密钥一起计算出来的。密钥只有服务端知道所以如果有人偷改 Payload 里的用户名权限签名就对不上服务端一验就发现 token 被动了手脚。这就是 JWT可信任的基础。结合实际场景我给出两个实操注意点。第一密钥不要写死在代码里放到application.yml环境配置或环境变量中越是多人协作的仓库越要注意。第二token 里不要放密码、手机号这类敏感字段因为 Payload 只是 Base64 编码不是加密抓到 token 的人把它拆开就能看到内容。3.2 无状态认证的完整时序用 JWT 做认证整个流程可以概括成一登二带三验。用户拿用户名密码请求/auth/login服务端校验成功后签发一个 JWT 返回给前端。前端拿到 token 之后存起来一般是localStorage或Pinia/Vuex之后每次发请求都在Authorization请求头里带Bearer ${token}。后端除了登录接口和少数白名单接口不放行其余接口都走JwtAuthenticationFilter这个过滤器把 token 解出来、验签、解析用户信息塞进SecurityContext里。后续的授权判断就从SecurityContext里拿当前用户身份去比对权限。这个模式的精髓在于服务端不存这个用户是否已登录这类状态每次请求自带身份证明服务端只验证证明的真伪所以叫无状态。3.3 Spring Security 6 中配置迁移的关键变化如果你之前只看过旧版教程这里给你列一个变化清单新项目直接按新版写即可旧版写法Security 5新版写法Security 6继承WebSecurityConfigurerAdapter定义SecurityFilterChainBeanhttp.csrf().disable()链式调用http.csrf(AbstractHttpConfigurer::disable)风格WebSecurityConfigurerAdapter里重写configure(AuthenticationManagerBuilder)直接注入AuthenticationManagerantMatchers(...)requestMatchers(...).and()连接配置去掉.and()全部用 lambda 表达风格新版这种写法适应了 Java 函数式编程的风格也让多SecurityFilterChain的组装更自然。刚开始觉得不习惯写两遍就好了。4. 核心配置与代码实现4.1 SecurityConfig 配置类配置类是整个安全体系的中枢。下面是我在实际项目中使用的配置注释标得比较全你可以直接对照着抄作业然后再按自己的需要调整细节。Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Resource private JwtAuthenticationFilter jwtAuthenticationFilter; Resource private RestAuthenticationEntryPoint authenticationEntryPoint; Resource private RestAccessDeniedHandler accessDeniedHandler; /** * 密码加密器BCrypt 是 Spring 推荐的标准实现 */ Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 前后端分离项目SSR 场景不需要 .csrf(AbstractHttpConfigurer::disable) // 使用无状态会话不在服务端保存 Session .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth - auth // 登录、注册、验证码等接口放行 .requestMatchers(/auth/login, /auth/register, /error).permitAll() // 需要 ADMIN 角色的接口 .requestMatchers(/admin/**).hasRole(ADMIN) // 其余所有请求都需要认证 .anyRequest().authenticated() ) // 自定义 JWT 过滤器在 UsernamePasswordAuthenticationFilter 之前 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // 认证异常和授权异常的自定义返回 .exceptionHandling(exception - exception .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) ); return http.build(); } }EnableMethodSecurity是新版替代EnableGlobalMethodSecurity的注解开启后就能在 Controller 上写PreAuthorize(hasRole(ADMIN))这类方法级权限控制。如果你的项目里只有接口级权限控制这个注解可以不写但绝大多数管理系统都会用到方法级权限细分建议保留。关于requestMatchers的匹配规则写的时候确实容易混/admin/**只匹配/admin开头的路径不匹配/administrator这种如果你用hasRole(ADMIN)那用户授权信息里必须带ROLE_ADMIN这个权限标识ROLE_前缀是框架自动拼接的开发的时候可以打印一下当前用户权限核对。我一开始写的时候数据库里直接存了ADMIN而不是ROLE_ADMIN结果hasRole一直不通过查了好一会儿才发现是前缀的问题。4.2 自定义 UserDetailsService 与用户模型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(用户不存在); } // 查询用户角色列表比如 [ADMIN, USER] ListString roles userMapper.selectRolesByUserId(user.getId()); // 权限标识可以直接用角色名也可以细粒度到具体权限点 ListSimpleGrantedAuthority authorities roles.stream() .map(role - new SimpleGrantedAuthority(ROLE_ role)) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getStatus() 1, true, true, true, authorities ); } }这里有几个细节要提醒。密码字段必须是从数据库取出来的加密后的密文不能是明文否则PasswordEncoder比对永远失败。账号是否启用的布尔值建议直接从用户状态字段映射而不是写死true这样封禁功能后面可以复用。返回的这个UserDetails对象最终会存进SecurityContext后面在业务代码里通过SecurityContextHolder.getContext().getAuthentication()拿到的就是它。注意如果用户同时有多个角色用ROLE_前缀统一格式化这样配置层的hasRole(ADMIN)和方法层的hasRole(ADMIN)才能统一识别。这点越早约定好后面越省心。4.3 JwtUtils 工具类JWT 的产生和验证我集中放在一个工具类里方便复用密钥和过期时间从配置读取。Component public class JwtUtils { Value(${jwt.secret}) private String secret; Value(${jwt.expire-time}) private Long expireTime; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } /** * 生成 tokensubject 存用户名额外存用户ID和角色 */ public String generateToken(UserDetails userDetails) { MapString, Object claims new HashMap(); claims.put(username, userDetails.getUsername()); claims.put(roles, userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expireTime)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } /** * 解析 token如果签名不对或过期会抛出 JwtException */ public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } /** * 从请求头提取 token */ public String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (bearer ! null bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }密钥长度要注意一下。HS256 算法要求密钥长度至少 256 位也就是 32 个字节。如果你在配置文件里写了一个很短的 key比如jwt.secretmysecret启动时可能不报错但实际生成或解析 token 时hmacShaKeyFor会直接抛WeakKeyException。最稳妥的办法是用openssl rand -base64 64这类工具生成一个随机长字符串放到配置里。如果要额外控制 token 的失效周期比如登录后 7 天有效把expire-time单位统一成毫秒即可例如86400000表示 24 小时。业务上还有需求要过期自动续期的话可以再写一个刷新 token 接口但会增加复杂度前期不建议一上来就做。4.4 JwtAuthenticationFilter 实现这个过滤器是整个链路的最后一环负责把有 token 的请求转化为已认证的请求。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Resource private JwtUtils jwtUtils; Resource private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token jwtUtils.resolveToken(request); // token 非空且当前 SecurityContext 尚无认证信息时做认证 if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { try { Claims claims jwtUtils.parseToken(token); String username claims.get(username, String.class); if (username ! null) { // 重新加载用户拿到最新的角色权限用户被删了或禁用这里会抛异常 UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); // 额外记录一下当前请求的详情方便审计日志之类的场景 authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (JwtException | IllegalArgumentException e) { // token 无效或过期不设置认证信息后续会被 EntryPoint 拦下返回 401 logger.debug(Invalid JWT token: e.getMessage()); } } filterChain.doFilter(request, response); } }这个过滤器里我要强调一个很关键的小细节解析完 token 里的用户名之后不要直接根据claims里的角色信息构造认证对象而是再调一次userDetailsService.loadUserByUsername(username)从数据库拉取最新用户信息。为什么因为用户可能在你签发 token 之后被删了、被禁用了、或者角色被调整了。如果你完全信任 token 里的旧数据这个用户在你的系统里就永远删不掉了——改了数据库权限也不生效他手上的 token 依然能访问。虽然每次请求多一次数据库查询但对后台管理系统来说安全优先级远高于这点性能开销。如果以后要优化可以引入短时缓存比如 5 分钟或用 Redis 做二级存储但不要去信任 token 里的静态权限。4.5 登录接口与前端的联调闭环登录接口本身不需要任何 Security 的复杂配置就是一个普通RestController核心是我们手动调用AuthenticationManager。RestController RequestMapping(/auth) public class AuthController { Resource private AuthenticationManager authenticationManager; Resource private JwtUtils jwtUtils; Resource private UserDetailsService userDetailsService; PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest loginRequest) { // 这里会触发 UserDetailsService.loadUserByUsername 和密码比对 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 认证通过 UserDetails userDetails (UserDetails) authentication.getPrincipal(); String token jwtUtils.generateToken(userDetails); return ResponseEntity.ok(new LoginResponse(token, userDetails.getUsername())); } GetMapping(/me) public ResponseEntity? me(Authentication authentication) { // 只要走到了这里说明 JWT 过滤器已经认证通过 UserDetails userDetails (UserDetails) authentication.getPrincipal(); return ResponseEntity.ok(userDetails.getUsername()); } PostMapping(/logout) public ResponseEntity? logout() { // 无状态模式下服务端无需处理前端丢弃 token 即可 return ResponseEntity.ok(logged out); } }目前这套设计里登录接口默认是放行的因为SecurityConfig里已经permitAll()了/auth/login。如果借鉴开源项目可以加一个登录失败次数的校验比如 5 次失败锁账号这块可以在AuthenticationManager前面加业务代码做。前端异步请求时需要带上Authorization请求头。以 axios 为例拦截器里统一设置即可axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) axios.interceptors.response.use( response response, error { if (error.response?.status 401) { // token 过期清空本地状态并跳转登录页 localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )提示有些同学会把 token 放在localStorage有些放在 cookie。放在localStorage的问题是 XSS 脚本可以读到它放在 cookie 里要处理 CSRF 和withCredentials。没有绝对安全的选择核心原则是项目里一定要做输入过滤和输出转义不然 token 存哪儿都不安全。5. 异常处理与权限控制细化5.1 认证失败与权限不足的统一 JSON 返回Spring Security 默认的异常处理对前后端分离项目极为不友好——未登录时它可能返回重定向到/login页面或者返回一小段纯文本。我们需要自己接管让它在接口场景下返回application/json格式的错误信息。关键是定义两个组件。AuthenticationEntryPoint处理的是未认证情况比如没带 token、token 过期失效AccessDeniedHandler处理的是已认证但没权限情况比如普通用户访问了管理员接口。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({\code\:401,\message\:\未认证或登录已过期\}); } }Component public class RestAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\没有权限访问该资源\}); } }这两个类不需要加RestControllerAdvice之类的全局异常注解因为它们是 Spring Security 过滤器链内部的处理器配置到exceptionHandling里才会被调用。这里顺手把 401 和 403 的语义区分讲清楚401 是身份问题——我不知道你是谁403 是权限问题——我知道你是谁但你没有资格。前端拦截器一般只对 401 做强制跳登录403 通常是提示无权限。5.2 方法级权限注解用法接口级权限用SecurityConfig里的requestMatchers能覆盖大部分场景。但真实项目里经常有这种需求同一份资源列表普通用户只能看自己创建的管理员能看全部同一个删除接口只有拥有删除任务权限点的用户才能调用。这种细粒度控制接口级配置写起来会很笨拙方法级注解就好使了。开启方式前面已经写过EnableMethodSecurity。之后在 Service 或 Controller 方法上直接加注解GetMapping(/admin/users) PreAuthorize(hasRole(ADMIN)) public ListUserVo listUsers() { ... } PostMapping(/tasks/{id}/delete) PreAuthorize(hasPermission(#id, TASK, delete)) public void deleteTask(PathVariable Long id) { ... }hasRole(ADMIN)要求当前用户拥有ROLE_ADMINhasAuthority(xxx)是精确匹配权限标识不自动加前缀hasAnyRole(ADMIN, MANAGER)是多角色其一即可hasPermission这类更高级的用法需要自定义PermissionEvaluator一般项目用不上知道有这回事就行。实际项目里我的建议是这样划分涉及整个资源模块的粗粒度控制放到SecurityConfig的requestMatchers里单个方法内的细粒度校验用PreAuthorize写在方法上。这样配置和代码都不至于太分散也好排查。5.3 获取当前登录用户信息的统一封装业务代码里经常要拿到当前登录的人是谁最直接的写法是Authentication authentication SecurityContextHolder.getContext().getAuthentication(); String username authentication.getName();但每个方法都这样写太啰嗦。我一般在代码里封装一个工具类统一从SecurityContext提取当前用户信息比如把它封装成当前项目的LoginUser对象Service 层直接调用public class SecurityUtils { public static LoginUser getLoginUser() { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication null || !(authentication.getPrincipal() instanceof LoginUser)) { // 走到这里说明 filter 没有正确设置认证信息需要排查 throw new RuntimeException(当前无登录用户); } return (LoginUser) authentication.getPrincipal(); } public static Long getUserId() { return getLoginUser().getId(); } }这里有个前提JWT 过滤器里设置Authentication时principal必须是你的自定义LoginUser对象而不是 default 的 SpringUser。建议把项目里的用户实体设计成实现UserDetails接口的LoginUser这样从数据库加载和从 SecurityContext 获取的是同一个类型省掉一层转换。6. 常见问题排查与避坑记录6.1 401/403 高频成因排查表我把前后端分离项目中遇到过的认证授权问题整理成了速查表之前带的新人照着这个排查命中率很高。现象大概率原因排查方向登录接口直接 403CSRF 没关闭确认配置了csrf(AbstractHttpConfigurer::disable)登录成功但后续请求全部 401前端没带Authorization头看浏览器 Network 请求头是否出现Bearer xxx请求头带了 token 还是 401JWT 解析失败或过期先在没有 Security 的接口里打印原始 token单独用JwtUtils.parseToken测试对照服务端时间是否同步hasRole一直不通过权限标识缺ROLE_前缀打印authentication.getAuthorities()检查实际值放行接口仍被拦截requestMatchers路径写错或顺序不对permitAll的路径要放在anyRequest之前匹配规则用/**结尾静态资源(图片/JS)无法访问没给静态资源路径放行或放行顺序有问题用requestMatchers(/static/**, /*.js, /*.css).permitAll()显式声明出现无休止重定向到/login过滤器链拦截了你的接口且 session 模式不是 STATELESS把sessionCreationPolicy设置为STATELESSFailed to process URI之类的跨域错误CORS 配置和请求头不一致后端allowedOriginPatterns不能是*且要前端显式声明 Origin启动报WeakKeyExceptionJWT 密钥太短检查jwt.secret长度是否超过 32 字节这里特别展开说下 CORS。前后端分离项目跨域问题几乎必现Spring Security 的 CORS 配置要在 Security 过滤器链里显式声明单独在 WebMvc 里配有时候不生效因为 Security 的过滤器优先级高于 Spring MVC。Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(Arrays.asList(http://localhost:*, https://your-domain.com)); config.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(Collections.singletonList(*)); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }然后在SecurityConfig的http链上加上.cors(cors - cors.configurationSource(corsConfigurationSource()))。allowedOriginPatterns和allowedOrigins的区别是前者支持通配符后者不支持。如果你用了allowCredentials(true)allowedOrigins就不能写*这是浏览器的硬性限制不是 Spring 的限制。OPTIONS预检请求也必须在允许的方法列表里否则前端预检直接失败实际业务请求发不出去。6.2 调试工具与断点技巧排查 Security 问题用好调试工具能省一半时间。第一个是日志级别调整。在application.yml里临时加一段配置logging: level: org.springframework.security: DEBUG然后启动项目访问一个受保护接口控制台会打印整个过滤器链的执行顺序以及是哪个过滤器拦截了请求返回了什么状态码。我看过最多的场景是日志里显示FilterInvocation denied because ...就跟着往下排查具体原因效率极高。第二个是直接观察SecurityContext的内容。在过滤器或 Controller 里加一个临时断点或者输出语句打印SecurityContextHolder.getContext().getAuthentication()及其authorities一眼就能发现问题出在根本没认证还是认证了但权限不对。第三个是写单元测试。Spring Security 提供了WithMockUser注解可以快速在测试方法里模拟一个用户不用真的请求登录接口。SpringBootTest AutoConfigureMockMvc public class SecurityTest { Test WithMockUser(username admin, roles {ADMIN}) public void adminEndpointShouldReturnOk() throws Exception { mockMvc.perform(get(/admin/users)) .andExpect(status().isOk()); } Test public void anonymousShouldBeRejected() throws Exception { mockMvc.perform(get(/admin/users)) .andExpect(status().isUnauthorized()); } }WithMockUser不经过真实认证流程适合测试授权规则如果要测试登录接口的完整链路还是要手动调用authenticationManager.authenticate或直接发 HTTP 请求。6.3 热搜问题选用Spring Boot 3 配置迁移细节师出同门这里多说一句 Spring Boot 3 中 Security 配置迁移的关键差异。如果你是从旧项目升级大概率会碰到spring.security.filter.order这个配置失效的问题因为在 Spring Boot 3 里 Security 过滤链的排序改由SecurityProperties.DEFAULT_FILTER_ORDER内部管理你原来的显式排序配置需要移除或改用SecurityFilterChainBean 的Order注解。另外一点是如果自定义过滤器里依赖了其他 Bean并且你把它注册成了普通FilterBean那它可能会被 Spring Boot 默认注册到全局过滤器链里导致同一个过滤器执行两遍。解决办法是不要给它加Component注解只通过addFilterBefore注册到 Security 的链上这样它只走 Security 那套流程避免在 Servlet 容器层面也执行一次。6.4 一个真实的踩坑案例最后讲一个实际遇到过的非常典型的坑。有一个接口需要导出 Excel 文件前端通过window.open(/export/user)直接发起 GET 请求——这个请求没法在window.open里手动加Authorization头于是后台一直 401。一开始我先放行了/export/**但这样等于把导出接口暴露为匿名可访问安全隐患很大。后来换了一种思路把 token 作为 query 参数传过去在JwtAuthenticationFilter里取不到Authorization头时再尝试从request.getParameter(token)里取一次。这种方案要在产品层面充分评估风险因为 query 参数会被记录到各种访问日志、反向代理日志里token 泄露风险明显高于请求头。实在要支持window.open场景可以对这种单次下载类的 token 做短时效处理比如 5 分钟有效用完即作废。从这个案例引出的经验是任何安全方案都是在便利性、风险、成本之间做权衡没有银弹。作为开发人员理解了过滤链和 token 机制之后再针对场景设计对应的折中方案比死记硬背必须怎么配要可靠得多。7. 个人经验总结与后续优化方向这一节写得短一些我想聊聊这套方案在实际项目落地后关于性能和安全的一些体会。先说性能。JWT 无状态确实带来了扩容的便利但也把用户状态的存储压力转移到了 token 本身。每次请求都要验签、解析、查数据库加载用户在高并发场景下数据库查询压力会逐渐显现。我当时处理的办法是给UserDetailsService加了 5 分钟的本地缓存只缓存有效用户的 ID、用户名、角色列表不缓存密码等敏感字段。注意这里不能缓存禁用状态因为账号封禁需要立即生效。再谈安全。网上很多帖子推荐 Access Token 有效期 2 小时 Refresh Token 7 天的双 token 方案这个设计确实能平衡安全和体验但实现复杂度不低。做之前先想清楚一个哲学问题你的项目到底服务谁。内部管理系统并发低、用户少Access Token 直接搞 12 小时甚至 24 小时都行出问题能忍面向 C 端的项目token 泄露带来的风险高双 token 的成本才值得付。我在多个项目里反复验证过的结论是JWT 这套方案真正的护城河不是 token 本身而是对 SecurityContext 和过滤器链的准确理解。很多网上复制粘贴的代码能跑但一旦出现定制需求比如多端登录互斥、刷新 token、强制下线理解不深就会无从下手。这份教程写到这基本上把 Spring Security 6 在前后端分离项目里的核心链路都过了一遍。从过滤器链、认证流程到 JWT 设计、SecurityConfig 配置、异常处理器、CORS 联调再到排查问题的方法论每一块都对应一个真实的开发阶段。如果你能跟着代码完整跑一遍再结合自己的业务改一改这套东西就算真正是你的了。后面有时间的话我再写一篇针对 Redis 做 token 黑名单和在线用户管理的进阶版算是这个方案的下一步演进。