☰
Spring Security核心原理与实战:认证授权、过滤器链及JWT落地解析
2026/10/6 17:10:53 网站建设 项目流程

写电商后台的时候,遇到过一件让我印象深刻的事:新同事在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()
方法安全开启@EnableGlobalMethodSecurity@EnableMethodSecurity
默认行为相对宽松默认不暴露过多内容,规则更严格

我见过很多“明明按教程配了,就是404/403”的情况,第一件事就是去看安全依赖的版本,再检查配置语法。你在网上搜解决方案时,也尽量搜对应版本的关键词,不然老配置会让你怀疑人生。

另外有一个事实能解决很多困惑:Spring Security的过滤器链注册在Servlet容器上,运行在Spring MVC的DispatcherServlet之前。也就是说,请求还没到你的Controller,就已经被安全过滤器拦截了。为什么你在Controller里看不到某些请求的日志?因为它们在更早的过滤层就被拒了。

2. 认证链路全拆解:一次登录背后那串过滤器的执行过程

2.1 过滤器链的骨架

以最经典的表单登录为例,我把一次登录的完整处理流程按顺序写在下面:

  1. 浏览器向/login发送POST请求,参数里带username和password;
  2. 请求先到达FilterChainProxy,它是整个过滤器链的调度中心;
  3. 链上的UsernamePasswordAuthenticationFilter从request里提取账号密码,组装成一个“未认证”的Authentication对象;
  4. 过滤器把这个对象交给AuthenticationManager尝试认证;
  5. 认证成功后,框架把新的Authentication对象放进SecurityContextHolder;
  6. 请求继续往后走,如果后面还有授权过滤器,就会拿着这个身份去做权限判断。

在这个流程里,有几个容易被误读的点,我展开说一下。

FilterChainProxy本身不提供任何安全能力,它像一个调度中心,负责选出匹配当前请求的SecurityFilterChain并执行。为什么叫“代理”?因为真正干活的是它托管的一大串过滤器,每个过滤器只关注一个问题:认证的做认证,CSRF的做CSRF,异常转发的做异常转发。

还有一点:同一过滤器链里,很多过滤器在某种特定认证方式下可能什么都不做,判断完直接放行。比如你用的是JWT,没有表单登录,UsernamePasswordAuthenticationFilter基本等于摆设。也正是因为这个原因,新手看经验贴把某个过滤器禁用掉,看起来好像没什么影响,但当你真要切换认证方式时,就会踩到一堆隐性依赖。

你可以把这条链路想象成接力赛:每一棒只负责自己的任务,检查、解密、放行、拒绝,各司其职。某个过滤器觉得当前请求不需要处理,就直接交给下一棒,绝不越权。

2.2 AuthenticationManager与它的协作对象们

AuthenticationManager是一个接口,默认实现叫ProviderManager。它持有多个AuthenticationProvider,每个Provider负责一种登录方式的认证操作。常见的有:

  • DaoAuthenticationProvider:从数据库读用户,校验密码,最常用;
  • 自定义Provider:处理第三方登录、验证码登录、无状态token校验等;
  • 各种安全协议对应的Provider,比如OAuth2登录时的相关组件。

一个很多人没搞清楚的细节:UserDetailsService只负责“加载用户”,不负责校验密码。密码校验发生在AuthenticationProvider里,由PasswordEncoder完成。这两个职责如果混在一起,很容易写出“查询用户时顺手把密码判了”的代码,后面想抽换加密算法就得大改。

一次典型的用户名密码认证,内部协作过程大概是这样:

  1. UsernamePasswordAuthenticationFilter构造UsernamePasswordAuthenticationToken,此时它是未认证状态;
  2. ProviderManager遍历所有AuthenticationProvider,找到能处理这个Token类型的那个;
  3. DaoAuthenticationProvider调用userDetailsService.loadUserByUsername(username)拿到用户信息;
  4. DaoAuthenticationProvider用passwordEncoder.matches(rawPassword, userDetails.getPassword())比对密码;
  5. 比对通过,返回一个带权限集合、已认证的Authentication对象;
  6. 过滤器把这个对象放入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); List<GrantedAuthority> 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、OPERATOR
sys_user_roleuser_id, role_id用户与角色多对多关联
sys_permissionid, perm_code权限码表,如order:delete
sys_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 AuthorizationManager<RequestAuthorizationContext> { @Override public AuthorizationDecision check(Supplier<Authentication> 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency>

这里注意:我选jjwt而不是老旧的java-jwt,看个人习惯。jjwt的API比较现代,支持HS256、RS256,而且对JWT标准处理得比较严谨。

如果项目里暂时没有自定义的UserDetailsService和PasswordEncoder,启动时Spring Boot会自动生成一个默认账号(用户名是user,密码是启动日志里那串随机字符)并打印到控制台。这个默认行为在开发环境很烦人,配置完整之后会自然消失。

4.2 第二步:SecurityFilterChain 配置模板

在Spring Security 6里,不再推荐继承WebSecurityConfigurerAdapter,而是声明一个SecurityFilterChainBean:

@Configuration @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 ResponseEntity<LoginResponse> 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存进Cookie,CSRF攻击面就又回来了,那时你需要配置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的AuthenticationEntryPoint:

@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(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,完整旅程是这样的:

  1. Tomcat收到请求,Servlet容器先按顺序执行已注册的Spring Security过滤器链;
  2. JwtAuthenticationFilter解析Authorization头,校验token,从用户服务加载用户,把Authentication放进SecurityContextHolder;
  3. 授权过滤器查看当前用户是否匹配/api/**的规则;
  4. 匹配通过后,请求进入DispatcherServlet,再路由到Controller;
  5. 如果Controller里的方法标了@PreAuthorize,方法级安全的AOP代理会再做一次细粒度权限判断;
  6. 业务处理完成后返回数据。

第3步和第5步是两道独立的关卡,路径规则是第一道,方法级权限是第二道。理解了这两道关卡的配合,你在面试里被问到“权限校验发生在哪一层”时,能够清楚地回答出过滤器链、AOP代理、SecurityContext三条线索是怎么串起来的。

我自己实际的体会是,Spring Security最值得花时间的核心不是某个注解怎么用,而是两条链路:认证链路怎么把用户身份放进上下文,授权链路怎么在多个关卡上做校验。把这两条链路想清楚,配置就从一个一个孤立的API变成了一张有逻辑的清单。后面如果继续深入,还可以聊聊OAuth2登录集成、多租户权限隔离,这些都是从这套基础骨架上长出来的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询