
写电商后台的时候遇到过一件让我印象深刻的事新同事在pom里加了spring-boot-starter-security然后开心地告诉我“这下安全了”。结果上线当天线上所有接口都能匿名访问。排查后发现他自定义了SecurityFilterChain但把所有路径都放行了后面的规则等于没写。这种“加依赖安全”的误解在Spring Security新手里非常普遍。Spring Security之所以被很多人说成“玄学”是因为它不是普通工具库不是调用一个方法就完事而是一整套挂在Servlet容器上的过滤器链。你配置的不是单一功能而是一条条规则的组合配置顺序、放行范围、异常处理器、上下文管理任何一环出问题表现出来就是五花八门的401、403、302甚至404。这篇文章我会把Spring Security的几个核心问题拆开讲透认证和授权到底怎么分工、一次登录请求在过滤器链里经历了什么、Spring Boot 3里基于JWT的无状态API怎么落地以及实际项目中我踩过的高频坑。适合已经能用Spring Boot写接口、想系统掌握安全框架的开发者也适合面试前想把关键链路整理清楚的人。1. Spring Security到底在解决什么问题先分清认证与授权1.1 认证与授权一个门禁系统里的两件事很多人背过“认证和授权是两个不同概念”但一到系统设计还是会混着做。我用一个生活场景来解释想象一栋公司大楼进门刷工牌这个动作回答“你是谁”叫认证Authentication。刷完卡系统判断你有没有权限进入财务室这个动作回答“你能干什么”叫授权Authorization。同一张工牌可能通过了认证但进不了财务室。在Spring Security里认证对应的是AuthenticationManager这条链路授权对应的是AuthorizationManager以及你写在过滤器配置里的那些规则。很多配置出错本质上是把两条链路的职责搞混了有人在授权规则里做用户名校验有人在认证逻辑里塞角色判断。理清这两条线后面所有代码都好理解。认证链路的产物是一个“可信的用户身份”通常用Authentication对象表示里面包含用户名、凭证和权限集合。授权链路消费这个身份判断它是否有权限访问某个资源。身份信息的传递靠SecurityContextHolder我把这玩意儿理解成一个“当前请求线程专用的小抽屉”认证成功就把用户放进抽屉后续授权判断从抽屉里取。1.2 和手写拦截器相比Spring Security的“体系化”优势如果项目里只有两三个后台接口手写一个拦截器校验token确实够用。但一旦你认真做一套多角色、多权限的管理系统手写方案很快会陷入重复造轮子的局面登录方式变多账号密码、手机验证码、第三方OAuth2时拦截器里的if-else会越来越长最后变成一个巨型分支判断记住我、Session并发控制、密码加密策略、CSRF防护、防会话固定攻击这些老代码几乎不可能考虑全而且每套系统重写一遍安全逻辑和业务代码耦合在一起今天想从Session改成JWT可能要改Controller、拦截器、工具类一大片。Spring Security把这些问题抽成了过滤器链上的标准组件。你想换登录方式换一个AuthenticationProvider想加无状态认证插一个过滤器想精细控制权限在方法上加注解。框架最核心的价值是让你基于一整套成熟的安全模型去思考问题而不是每次从零开始发明轮子。你可能还听说过Apache Shiro。两者定位类似但Spring Security和Spring生态深度绑定尤其在Spring Boot 3之后自动配置、方法级安全、OAuth2集成这些能力都很成熟。如果你用的就是Spring Boot我基本不纠结直接选它。1.3 版本差异为什么网上很多配置你复制过来就是跑不起来这是个非常现实的坑。网上大量教程基于Spring Security 5.x而你现在项目很可能用的Spring Boot 3.0对应的是Security 6.x。两者配置方式差异明显我整理一张常用对照表维度Security 5.xSecurity 6.x配置类入口继承WebSecurityConfigurerAdapter声明SecurityFilterChain的Bean路径匹配方法antMatchers()requestMatchers()授权APIauthorizeRequests()authorizeHttpRequests()方法安全开启EnableGlobalMethodSecurityEnableMethodSecurity默认行为相对宽松默认不暴露过多内容规则更严格我见过很多“明明按教程配了就是404/403”的情况第一件事就是去看安全依赖的版本再检查配置语法。你在网上搜解决方案时也尽量搜对应版本的关键词不然老配置会让你怀疑人生。另外有一个事实能解决很多困惑Spring Security的过滤器链注册在Servlet容器上运行在Spring MVC的DispatcherServlet之前。也就是说请求还没到你的Controller就已经被安全过滤器拦截了。为什么你在Controller里看不到某些请求的日志因为它们在更早的过滤层就被拒了。2. 认证链路全拆解一次登录背后那串过滤器的执行过程2.1 过滤器链的骨架以最经典的表单登录为例我把一次登录的完整处理流程按顺序写在下面浏览器向/login发送POST请求参数里带username和password请求先到达FilterChainProxy它是整个过滤器链的调度中心链上的UsernamePasswordAuthenticationFilter从request里提取账号密码组装成一个“未认证”的Authentication对象过滤器把这个对象交给AuthenticationManager尝试认证认证成功后框架把新的Authentication对象放进SecurityContextHolder请求继续往后走如果后面还有授权过滤器就会拿着这个身份去做权限判断。在这个流程里有几个容易被误读的点我展开说一下。FilterChainProxy本身不提供任何安全能力它像一个调度中心负责选出匹配当前请求的SecurityFilterChain并执行。为什么叫“代理”因为真正干活的是它托管的一大串过滤器每个过滤器只关注一个问题认证的做认证CSRF的做CSRF异常转发的做异常转发。还有一点同一过滤器链里很多过滤器在某种特定认证方式下可能什么都不做判断完直接放行。比如你用的是JWT没有表单登录UsernamePasswordAuthenticationFilter基本等于摆设。也正是因为这个原因新手看经验贴把某个过滤器禁用掉看起来好像没什么影响但当你真要切换认证方式时就会踩到一堆隐性依赖。你可以把这条链路想象成接力赛每一棒只负责自己的任务检查、解密、放行、拒绝各司其职。某个过滤器觉得当前请求不需要处理就直接交给下一棒绝不越权。2.2 AuthenticationManager与它的协作对象们AuthenticationManager是一个接口默认实现叫ProviderManager。它持有多个AuthenticationProvider每个Provider负责一种登录方式的认证操作。常见的有DaoAuthenticationProvider从数据库读用户校验密码最常用自定义Provider处理第三方登录、验证码登录、无状态token校验等各种安全协议对应的Provider比如OAuth2登录时的相关组件。一个很多人没搞清楚的细节UserDetailsService只负责“加载用户”不负责校验密码。密码校验发生在AuthenticationProvider里由PasswordEncoder完成。这两个职责如果混在一起很容易写出“查询用户时顺手把密码判了”的代码后面想抽换加密算法就得大改。一次典型的用户名密码认证内部协作过程大概是这样UsernamePasswordAuthenticationFilter构造UsernamePasswordAuthenticationToken此时它是未认证状态ProviderManager遍历所有AuthenticationProvider找到能处理这个Token类型的那个DaoAuthenticationProvider调用userDetailsService.loadUserByUsername(username)拿到用户信息DaoAuthenticationProvider用passwordEncoder.matches(rawPassword, userDetails.getPassword())比对密码比对通过返回一个带权限集合、已认证的Authentication对象过滤器把这个对象放入SecurityContextHolder后续代码就能任意获取当前用户。有些初学者想不明白为什么我不直接调用PasswordEncoder.matches自己判断可以但那样就绕过了框架的异常处理和上下文管理认证失败信息也好、上下文清理也好全都要自己写。让框架按标准流程走你的代码只需要关注用户加载和业务逻辑。2.3 认证成功之后SecurityContext与记住我认证成功的状态存在哪默认情况下SecurityContextHolder使用ThreadLocal保存SecurityContext所以同一个线程里的后续代码都能拿到当前用户。过滤器链结束时框架会清理这个ThreadLocal避免线程池复用导致“串号”。这个机制你不需要手动干预但理解它能解释不少诡异现象比如在异步线程里拿不到当前登录用户本质就是ThreadLocal没有传递到子线程。再说“记住我”机制。默认表单登录里勾选“记住我”会在登录成功时生成一个Token放在Cookie里。下次请求时RememberMeAuthenticationFilter发现认证上下文里没有用户但Cookie里有合法Token就会自动恢复一个认证状态。注意它走的是一条独立的Provider链路和普通用户名密码认证不是一回事。在Spring Security较新的版本里默认情况下框架不再自动从Session恢复安全上下文而是更偏向无状态模式。如果你确实需要传统的Session方案得显式配置SecurityContextRepository。这个变化好多人没注意到导致升级Spring Boot版本后“登录状态存不住了”的诡异问题。2.4 自定义AuthenticationProvider的典型场景假设公司自研了统一认证中心密码校验不需要Spring Security来做我只想拿到用户对应的角色信息。这时实现一个AuthenticationProvider就行Component public class CustomAuthenticationProvider implements AuthenticationProvider { private final RemoteAuthClient remoteAuthClient; public CustomAuthenticationProvider(RemoteAuthClient remoteAuthClient) { this.remoteAuthClient remoteAuthClient; } Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username authentication.getName(); String password authentication.getCredentials().toString(); RemoteUser user remoteAuthClient.authenticate(username, password); ListGrantedAuthority authorities user.getRoles().stream() .map(SimpleGrantedAuthority::new) .toList(); return new UsernamePasswordAuthenticationToken(user, null, authorities); } Override public boolean supports(Class? authenticationType) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authenticationType); } }写完这个Provider之后你还需要把它暴露为Bean框架会自动把它配置到ProviderManager里。这里有个经验要提醒Provider只负责认证也就是确认身份并附上初始的角色信息至于这个角色能不能访问某个订单接口那是授权阶段的事。别把权限判断塞进Provider扩散到整个安全策略里之后维护成本会急剧上升。3. 授权机制实操从路径拦截到方法级权限RBAC怎么做3.1 路径级授权规则匹配的顺序问题授权规则在SecurityFilterChain里声明而且按顺序生效。我日常的一个基础模板长这样http.authorizeHttpRequests(auth - auth .requestMatchers(/public/**, /error).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/api/**).hasAnyRole(USER, ADMIN) .anyRequest().authenticated() );这个配置表达的是公开路径完全放行管理员路径只允许特定角色API路径登录用户都能访问其余没写到的路径一律要求先认证。两个非常常见的错误我说一下。第一个是规则顺序写反。比如先把.anyRequest().permitAll()放在前面后面的requestMatchers(/admin/**).hasRole(ADMIN)永远不会生效因为请求走到anyRequest这一行就被放行了。授权规则本质上是从上往下匹配命中第一条就不再往后看。第二个是路径匹配方式没搞清。在Security 6里用requestMatchers字符串参数可以做Ant风格的路径匹配。注意/api/**能匹配/api/orders也能匹配/api/orders/42/items而/api只匹配那个精确入口不会匹配子路径。很多“放行不生效”的问题就是把/**写成了*或者漏写了。这里还要区分hasRole和hasAuthority。hasRole(ADMIN)会自动拼上ROLE_前缀也就是说用户权限集合里必须有一条ROLE_ADMIN才能通过而hasAuthority(order:delete)不加前缀适合直接校验权限码。我在项目里通常这样约定角色统一用ROLE_前缀权限码用模块:操作的格式两者混在一起时配置规则一眼就能看出类型。3.2 方法级安全注解把权限判断下沉到业务边界接口粒度到操作之后光靠路径规则会非常吃力。比如“删除订单”只有管理员能做“查看订单”登录用户即可这种规则用路径很难优雅表达尤其当一个Controller里几十个接口路径都长得很像时。Spring Security的方法级安全正好解决这个问题。开启方式很简单Configuration EnableMethodSecurity public class MethodSecurityConfig { }开启之后在业务方法上直接加注解PreAuthorize(hasRole(ADMIN)) public void deleteOrder(Long orderId) { // 业务逻辑 } PreAuthorize(hasRole(ADMIN) or hasRole(OPERATOR)) public void refundOrder(Long orderId) { // 业务逻辑 }如果权限判断需要依赖参数里的业务数据还可以用SpEL表达式引用方法参数。比如限流一个用户只能修改自己创建的订单PreAuthorize(hasRole(ADMIN) or #order.ownerId authentication.principal.id) public void updateOrder(Order order) { // 业务逻辑 }这个特性把权限判断放到了真正的业务边界可读性比在Controller里写一堆if好太多。同时也要提示一点方法级安全基于AOP代理每次调用都会做安全检查有一定性能开销。如果是声量极高的读接口且权限规则相对固定更建议在接口层或路径规则层做拦截把细粒度判断留给低频、高风险的操作。3.3 RBAC模型落地用户-角色-权限的关系设计绝大多数Spring Security项目最终都要回到“角色和权限的数据库设计”。我实践下来比较稳固的一套表结构是这样的表名关键列说明sys_userid, username, password用户表sys_roleid, role_code角色表如ADMIN、OPERATORsys_user_roleuser_id, role_id用户与角色多对多关联sys_permissionid, perm_code权限码表如order:deletesys_role_permissionrole_id, permission_id角色与权限多对多关联在Spring Security的UserDetailsService实现里把该用户关联的角色转成ROLE_开头的GrantedAuthority把权限码直接作为另一个GrantedAuthority塞进返回的UserDetails。这样hasRole(ADMIN)能匹配角色hasAuthority(order:delete)能匹配权限码业务系统调整权限时只需要改sys_role_permission不需要改用户表。为什么不直接给用户挂权限中间夹一层角色是这套模型的核心价值。新员工入职分配角色就够了权限变更也只需要在角色维度操作一遍。如果上百个用户逐一挂权限码改一次配置要改到崩溃。3.4 自定义AuthorizationManager扩展Spring Security 6.x推荐用AuthorizationManager实现更灵活的授权判断。以前那种自定义AccessDecisionVoter的方式已经有点落伍了新写法更直观。举个例子非管理员用户在固定时间段之外不允许访问某些接口。public class TimeBasedAuthorizationManager implements AuthorizationManagerRequestAuthorizationContext { Override public AuthorizationDecision check(SupplierAuthentication authentication, RequestAuthorizationContext context) { boolean isAdmin authentication.get().getAuthorities().stream() .anyMatch(a - a.getAuthority().equals(ROLE_ADMIN)); if (isAdmin) { return new AuthorizationDecision(true); } LocalTime now LocalTime.now(); boolean inWindow now.isAfter(LocalTime.of(9, 0)) now.isBefore(LocalTime.of(18, 0)); return new AuthorizationDecision(inWindow); } }然后把这个Manager塞进请求规则里比如.requestMatchers(/api/transfer/**) .access(timeBasedAuthorizationManager)注意check方法返回的是AuthorizationDecision不是直接抛异常。这样好处是框架可以统一决定返回403还是401而且管理决策和资源访问之间是松耦合的。你不需要在业务代码里关心这个决策对象是谁创建的。4. Spring Boot 3 JWT 无状态化的完整落地配置4.1 第一步引入依赖和基础配置我以一个Spring Boot 3项目为例。Maven依赖这样加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.5/version scoperuntime/scope /dependency这里注意我选jjwt而不是老旧的java-jwt看个人习惯。jjwt的API比较现代支持HS256、RS256而且对JWT标准处理得比较严谨。如果项目里暂时没有自定义的UserDetailsService和PasswordEncoder启动时Spring Boot会自动生成一个默认账号用户名是user密码是启动日志里那串随机字符并打印到控制台。这个默认行为在开发环境很烦人配置完整之后会自然消失。4.2 第二步SecurityFilterChain 配置模板在Spring Security 6里不再推荐继承WebSecurityConfigurerAdapter而是声明一个SecurityFilterChainBeanConfiguration EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final AuthenticationEntryPoint authenticationEntryPoint; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, AuthenticationEntryPoint authenticationEntryPoint) { this.jwtAuthenticationFilter jwtAuthenticationFilter; this.authenticationEntryPoint authenticationEntryPoint; } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**, /actuator/health).permitAll() .anyRequest().authenticated()) .exceptionHandling(ex - ex.authenticationEntryPoint(authenticationEntryPoint)) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }逐行解释一下这些配置的意图。关闭CSRF纯JWT无状态接口token放在Authorization头里浏览器不会自动携带这个自定义头CSRF风险已经大幅降低。但你要注意如果你的token是放在Cookie里的那就另说——Cookie会被浏览器自动带上CSRF仍然需要考虑不能简单关掉。sessionManagement设置成无状态不创建Session、不读取Session。这是JWT模式的核心否则明明说了无状态框架还是偷偷为每个请求建立会话后端负载均衡之后登录状态就乱了。addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)把自定义的JWT过滤器插到用户名密码过滤器之前。这样带token的请求会在最早的环节被识别并认证而不是等表单登录过滤器先处理一遍。4.3 第三步JWT 认证过滤器的实现细节JWT过滤器是整条链路里最需要细心写的部分。我的一个可复用模板如下Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) { this.jwtTokenProvider jwtTokenProvider; this.userDetailsService userDetailsService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(HttpHeaders.AUTHORIZATION); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); if (jwtTokenProvider.validateToken(token)) { String username jwtTokenProvider.getUsername(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } }写这个过滤器时有几个细节值得专门拿出来说。不要在处理token解析失败时静默放行。有些新手的写法是 catch 掉所有异常然后继续调filterChain.doFilter这样带错误token的请求会被当成匿名用户继续向后走等授权过滤器发现没有身份时返回的可能是403而不是401排查起来非常费劲。正确的做法是让认证异常统一抛给框架由AuthenticationEntryPoint处理返回前端能识别的401 JSON。每次请求都从数据库拉一次用户在高并发下性能不太好。你可以引入缓存但要权衡权限变更的时效性。如果角色或权限在Redis里改了而JWT过滤器还在用老缓存就会造成“权限改了但用户短时间内还能访问”的尴尬。我一般做法是JWT里的用户身份只存用户名和tokenID用户权限信息每次通过UserDetailsService加载必要时加一个短TTL的缓存而不是永久让权限停留在token里。用OncePerRequestFilter而不是普通Filter保证一次请求只执行一次过滤逻辑。这是Security圈里的最佳实践别图省事直接实现Filter接口。4.4 第四步登录接口与 PasswordEncoder登录接口本身不复杂核心是调用AuthenticationManager完成认证然后把认证后的用户名生成JWT返回。RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; public AuthController(AuthenticationManager authenticationManager, JwtTokenProvider jwtTokenProvider) { this.authenticationManager authenticationManager; this.jwtTokenProvider jwtTokenProvider; } PostMapping(/login) public ResponseEntityLoginResponse login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.username(), request.password())); String token jwtTokenProvider.generateToken(authentication.getName()); return ResponseEntity.ok(new LoginResponse(token)); } }这里有一个很容易卡住的点Spring Boot并不会自动注入AuthenticationManager到你的Bean里。你需要先从AuthenticationConfiguration拿到它Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }这个Bean声明放在SecurityConfig里即可。如果你在启动时遇到“No qualifying bean of type AuthenticationManager”多半就是少了这一步。密码加密器我最推荐BCryptPasswordEncoder。不要用NoOpPasswordEncoder那是明文也不要自创“加盐哈希”大概率有安全隐患。BCrypt每次生成的哈希都不同自带盐而且计算成本可控已经经受住了很多年的实践检验。注册用户时的密码写入统一用passwordEncoder.encode(rawPassword)登录时的校验交给框架完成业务代码里不要自己去match避免逻辑散落。4.5 CORS 与 CSRF无状态API容易忽略的两个点前后端分离一定会遇到跨域。Spring Security的CORS配置要先定义CorsConfigurationSource再让http.cors()使用它。Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOrigins(List.of(https://admin.example.com)); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(Authorization, Content-Type)); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }这里有个浏览器规范级别的坑allowedOrigins和allowCredentials(true)同时生效时不能使用*通配符必须写具体域名。否则浏览器会直接拒绝响应看起来像是请求超时。再回到CSRF。无状态JWT方案关闭CSRF保护是常见做法但你要清楚前提token需要放在Authorization头或请求体里而不是靠Cookie自动携带。如果把token存进CookieCSRF攻击面就又回来了那时你需要配置CsrfTokenRepository或者干脆把token放Header。安全方案的取舍从来都是和存储位置强相关的。5. 线上踩坑实录这些安全配置翻车现场我都遇到过5.1 放行规则不生效一个路径导致所有接口放行这个坑的翻车案例太多了。典型代码长这样.authorizeHttpRequests(auth - auth .anyRequest().permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) )表面上看好像“所有请求放行”写在前面再写管理路径限制。但框架是顺序匹配的anyRequest()已经匹配了所有请求后面的规则永远执行不到。结果是整个权限体系崩溃。排查这类问题先看SecurityFilterChain里授权规则的书写顺序把合理的规则排列成“精确路径在前兜底规则在后”。我习惯的写法是把公共的permitAll路径尽量限定最小范围最后一行永远写.anyRequest().authenticated()。这样即使有规则写漏了结果也是“未认证就拒绝”而不是“什么都能放”。5.2 前后端分离下返回登录页而不是 JSON老版本Spring Security默认会在未认证时把请求重定向到登录页。前后端分离部署下前端不会跟随这个重定向现象就是“带token访问还是302最后变成首页”。修复方式就是我前面配置模板里那行exceptionHandling自定义一个返回JSON的AuthenticationEntryPointComponent public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(UTF-8); response.getWriter().write({\message\:\unauthenticated\}); } }类似的还有AccessDeniedHandler处理“已认证但没有足够权限”的403情况。很多项目只配了401入口没配403。真正遇到权限不足时前端拿到的响应体可能就是容器默认的错误页格式不统一联调时非常头痛。5.3 UserDetailsService 实现不完整导致的各种诡异新手最常写出的半成品是UserDetailsService里只查了用户表返回的UserDetails对象没有设置authorities。结果表现为登录成功但任何需要权限的接口全部403——因为角色信息根本没有被框架拿到。另一个坑是查不到用户时直接抛了一个自定义异常。这会绕开Spring Security的认证异常机制导致前端接到的错误信息不一致。正确做法是抛框架里的AuthenticationException子类比如BadCredentialsException或者UsernameNotFoundException——注意默认情况下Spring Security会隐藏用户是否存在的信息统一抛出BadCredentialsException来防止用户枚举攻击。如果你在UserDetailsService里直接暴露“用户不存在”就会把这个保护机制破坏掉。5.4 并发登录与踢人下线的正确姿势后台管理系统经常要求“同一账号只能在一个设备上登录”。如果你用Session方案配置起来很简单http.sessionManagement(session - session .maximumSessions(1) .maxSessionsPreventsLogin(false) );maxSessionsPreventsLogin(false)的含义是新登录会把旧登录踢下线如果设成true新登录会被阻止。前者适合你希望“顶号”的业务后者适合“账号被占住就无法登录”的业务。但如果JWT无状态模式这个方案完全失效因为服务端根本不存会话。你需要自己维护在线状态。我的一个折中方案是JWT里带一个jti唯一ID同时在Redis里存当前有效的jti与用户ID的映射。每次请求时JWT过滤器校验当前token的jti是否等于Redis中存储的jti不等就视为被踢下线。这样既保留了无状态认证的优点又保留了并发控制能力。5.5 放行清单静态资源、错误页与接口文档很多项目在集成Swagger之后发现接口文档被Security拦住了。排查思路第一条就是检查放行配置/swagger-ui/**和/v3/api-docs/**需要放行/error是Spring Boot错误页路径建议放行否则失败响应的渲染都会异常静态资源如/css/**、/js/**、/favicon.ico按项目实际路径精确放行。放行清单不要写太宽。我见过有人图省事写.requestMatchers(/api/**).permitAll()结果整个业务API全部裸奔。每一条放行规则你都得能回答“为什么放行”答不上来的建议先收紧。6. 性能与安全加固的经验之谈6.1 过滤器链不是越厚越好无状态场景的瘦身思路依赖引入得越多过滤器链就越长。每个请求可能要穿过十几个过滤器虽然大部分是轻量判断但积少成多高并发下的开销确实存在。在无状态JWT场景有些过滤器是不需要注册的比如RememberMeAuthenticationFilter、AnonymousAuthenticationFilter、LogoutFilter。不过这里要特别提醒初学者不要为了性能过度裁剪。匿名过滤器如果被移除未认证请求的Authentication可能直接是null而框架有些默认配置是依赖匿名用户做权限放行的。改之前先搞清楚每个过滤器在这个环境里承担什么角色否则性能没涨多少安全问题先冒出来。6.2 密码存储与校验的细节密码这块值得单独多说几句因为它和线上安全直接相关。数据库里存的是BCrypt哈希不是密文注册时统一用passwordEncoder.encode()不要在业务代码里手写拼接盐登录校验时尽量不返回“用户名不存在”和“密码错误”两种不同提示统一模糊成“账号或密码错误”能有效防止账号枚举密码强度校验、过期策略建议放在认证之后的额外校验逻辑里不要塞进DaoAuthenticationProvider否则会污染标准认证链路。现在有些项目还在用MD5加盐这种方式我只能说“能用”和“安全”是两回事。BCrypt的计算成本本身就让暴力破解变得不划算MD5太快GPU暴力破解下几乎等于裸奔。6.3 最小权限原则与审计日志我见过很多后台项目权限粗到“登录就能管理一切”。这种内网系统一旦账号泄露影响面会非常大。线上系统更应该让每个用户只拥有完成任务所需的最小权限。授权规则宁可先收紧再逐步放宽也不要一开始全放开尤其是删除、导出、转账这类高风险操作。审计日志也是一大块。不要把关键操作的审计只放在业务代码里安全框架层面也可以统一记录认证成功和失败事件。比如监听AuthenticationSuccessEvent和AuthenticationFailureBadCredentialsEvent把登录人、IP、时间、目标资源落到日志里。一旦出现异常操作你至少能还原出“谁在什么时间干了什么”。6.4 从头到尾梳理一次请求的完整旅程把上面的知识串成一个整体。假设前端带token请求GET /api/orders/42完整旅程是这样的Tomcat收到请求Servlet容器先按顺序执行已注册的Spring Security过滤器链JwtAuthenticationFilter解析Authorization头校验token从用户服务加载用户把Authentication放进SecurityContextHolder授权过滤器查看当前用户是否匹配/api/**的规则匹配通过后请求进入DispatcherServlet再路由到Controller如果Controller里的方法标了PreAuthorize方法级安全的AOP代理会再做一次细粒度权限判断业务处理完成后返回数据。第3步和第5步是两道独立的关卡路径规则是第一道方法级权限是第二道。理解了这两道关卡的配合你在面试里被问到“权限校验发生在哪一层”时能够清楚地回答出过滤器链、AOP代理、SecurityContext三条线索是怎么串起来的。我自己实际的体会是Spring Security最值得花时间的核心不是某个注解怎么用而是两条链路认证链路怎么把用户身份放进上下文授权链路怎么在多个关卡上做校验。把这两条链路想清楚配置就从一个一个孤立的API变成了一张有逻辑的清单。后面如果继续深入还可以聊聊OAuth2登录集成、多租户权限隔离这些都是从这套基础骨架上长出来的东西。