Spring Security认证与授权核心链路解析:从过滤器链到JWT实践

发布时间:2026/10/21 16:17:49
Spring Security认证与授权核心链路解析:从过滤器链到JWT实践
Spring Security 是我见过Java生态里“劝退率”最高的框架没有之一。很多同事一提到它就说“太重了”“看不懂”“配置好复杂”甚至有人宁愿自己写拦截器也不想碰它。但说实话如果你愿意花一个下午把它的执行链路跟一遍源码你会发现它其实就做了一件事在Servlet请求进入Controller之前用一条过滤器链把“你是谁”和“你能干什么”这两件事盘得明明白白。这篇文章我就基于自己用Spring Security做过的几个企业级项目把认证与授权这两条核心链路掰开揉碎讲清楚全程用大白话加代码示例适合正在学Spring Security的初级开发也适合被各种配置折磨得想骂人的中级工程师。开篇先亮明我的立场Spring Security的核心价值不是“安全”而是“结构化地表达安全策略”。它替你考虑了攻击面、会话、权限模型、密码编码、CSRF防护等几乎你能想到的所有安全问题代价是你必须理解它的抽象层次。不理解这些抽象你写出来的代码永远是被框架牵着走理解了之后你反而会感谢它的严谨。1. 从过滤器链理解认证的核心骨架1.1 Spring Security 到底在请求的哪个环节插手的先搞清楚一个最基本的问题Spring Security是在什么时候介入HTTP请求的我们在Web.xml时代会配置一个springSecurityFilterChain过滤器由DelegatingFilterProxy统一转发给实际的处理链。到了Spring Boot时代这个注册过程被自动化了你只需要引入依赖断点打在doFilterInternal()里就能看到每个请求都会经过一条长长的过滤器链。这条链的名字叫FilterChainProxy它内部管理了多个SecurityFilterChain。每个SecurityFilterChain按优先级Order匹配请求。比如你可以配置一个/api/**专用链、一个/admin/**专用链再给静态资源单独开一条permitAll的Chain。这就是为什么Spring Security能在同一个应用中用不同规则管理不同路径——本质上就是多个过滤器链的“路由”。过滤器链的成员非常多但你不必全部记住。核心是这几条SecurityContextPersistenceFilter负责从Session里捞身份信息UsernamePasswordAuthenticationFilter处理表单登录BasicAuthenticationFilter处理HTTP BasicExceptionTranslationFilter负责把异常翻译成401/403响应FilterSecurityInterceptor做最后一道授权关卡。这条链从头到尾顺序几乎不能乱因为前面过滤器产生的上下文是后面过滤器判断的依据。1.2 认证体系三个核心概念的关系讲认证之前必须建立一个三角关系AuthenticationManager、AuthenticationProvider、UserDetailsService。AuthenticationManager是一个门面接口核心实现是ProviderManager。ProviderManager维护了一组AuthenticationProvider每一个Provider负责“用某种方式验证身份”。比如DaoAuthenticationProvider会用用户名密码去数据库里比对JwtAuthenticationProvider会用JWT的签名信息还原一个身份。当你提交登录请求时流程是这样的前端传来username和password被UsernamePasswordAuthenticationFilter包装成一个UsernamePasswordAuthenticationToken——这个Token对象里包含“未认证的凭证”。然后Filter把它交给AuthenticationManagerProviderManager遍历所有Provider找到支持该Token类型的那个调用authenticate()方法。DaoAuthenticationProvider内部会主动调用UserDetailsService.loadUserByUsername()去查库拿到真实用户信息后再用PasswordEncoder比对密码。比对通过后它构造一个新的“已认证”的Authentication对象塞进SecurityContextHolder。从这一刻起当前线程就“登录”了。注意默认情况下同一个HTTP请求内的多个线程可能共用SecurityContextHolder原理是ThreadLocal。异步方法里拿不到当前用户就是因为子线程不在同一个ThreadLocal上下文里。想要跨线程传递需要手动设置SecurityContext或者用DelegatingSecurityContextExecutor包装线程池。2. 认证从登录到会话完整链路解析2.1 默认登录流程拆解Spring Security默认提供了一套表单登录页面和登录接口地址是/login。你启动项目以后访问任意受保护路径会被重定向到这个页面。它的逻辑非常简单UsernamePasswordAuthenticationFilter只匹配POST /login请求提取参数username和password然后走完上面说的认证链路。认证成功之后默认行为是跳回原来访问的地址同时向Session写入SPRING_SECURITY_CONTEXT。这个Session对象默认用的是Servlet容器的HttpSession也就是共享Session方案——多个节点部署时你需要解决Session共享问题或者干脆切换到无状态JWT方案。认证失败也有默认逻辑AuthenticationFailureHandler会把用户丢到/login?error页面并在Session或请求里记录异常信息比如BadCredentialsException表示密码不对DisabledException表示账号被禁用LockedException表示被锁。如果你做的是前后端分离项目上面这套默认流程基本用不上。更合理的做法是自定义登录接口返回JSON。关键代码如下PostMapping(/api/login) public Result login(RequestBody LoginRequest req) { UsernamePasswordAuthenticationToken token new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()); Authentication authenticated authenticationManager.authenticate(token); SecurityContextHolder.getContext().setAuthentication(authenticated); return Result.success(登录成功); }这里最重要的思考是很多人以为“登录成功 认证管理器返回了结果”但实际上authenticate()返回的那一刻框架只负责验证并不会自动保存Session。你需要自己决定“认证结果怎么存下来”——是放到SecurityContext里让本次请求生效还是生成一个JWT返回给前端下次携带。这是理解Spring Security设计思想的分水岭。2.2 UserDetailsService 和 PasswordEncoder自研系统必改的两处几乎每个真实项目都需要改两处配置UserDetailsService和PasswordEncoder。UserDetailsService接口只有一个方法loadUserByUsername(String username)Spring Security不关心你的用户存在哪——数据库、Redis、甚至调外部接口都行。你只需要把它查出来并封装成一个UserDetails对象返回。默认实现是查内存所以你如果不重写启动后用配置文件里的user和随机生成的密码登录。实际项目里我一般这么写Service public class CustomUserDetailsService implements UserDetailsService { Autowired private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserPO user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .authorities(user.getRoles().stream() .map(role - new SimpleGrantedAuthority(ROLE_ role)) .toList()) .disabled(!user.getEnabled()) .build(); } }这段代码里有几个细节值得展开第一返回的UserDetails里封装的是“权限集合”每个权限是一个GrantedAuthority字符串。它不管你的角色表长什么样只关心最终每个用户拥有哪些以ROLE_开头的授权标识。这种设计其实是把“权限模型”的建模压力交给了业务层——你可以把角色塞进去可以把菜单权限塞进去也可以把相关按钮级别的权限码全部塞进去。第二disabled()控制的是用户能否登录。如果用户被封禁直接在这里标记框架就会在你登录时自动拦截抛出DisabledException。第三password()字段必须是从数据库查出来的“密文”绝对不能是明文。如果你老板逼你往UserDetails里塞明文密码一旦泄露就是安全事故请果断拒绝。PasswordEncoder接口更简单只有两个核心方法encode(CharSequence rawPassword)用于对原始密码加密matches(CharSequence rawPassword, String encodedPassword)用于登录时比对。为什么Spring Security坚持让你用BCrypt而不允许直接比对字符串因为比对逻辑是“把原文再加密一次用问号替代明文”你永远不能从存储的密文反推出用户的真实密码。这也是为什么你改了密码格式以后旧密码必须重新录入一次。实际开发中最常见的坑是在数据库里存了明文密码然后登录时一直报BadCredentialsException。排查思路是先看PasswordEncoder.matches()里的密文是什么如果它长得像$2a$10$...说明是BCrypt格式那前端传过来的明文必须能通过matches()验证才行。如果你确定密码是明文就把Encoder换成NoOpPasswordEncoder——但我强烈不推荐这只适合做Demo。2.3 无状态JWT方案的正确集成姿势会话模式和无状态模式是两种截然不同的方案。企业级项目越来越多地选择JWT不是因为JWT比Session更安全而是因为它在前后端分离、移动端适配、横向扩展上更省事。Session方案需要服务端维护状态JWT把状态签在令牌里服务端无状态天然适合水平扩容。在Spring Security里集成JWT核心思路是自定义一个OncePerRequestFilter在过滤器链最前端解析JWT把解析出的用户信息构造成Authentication放进SecurityContext。具体实践时有几点经验你最好记下来第一过滤器上加OncePerRequestFilter是必须的否则同一个请求经过代理、转发时过滤器会被执行多次导致JWT解析重复、抛异常或者在SecurityContext里重复塞值。第二JWT的密钥必须环境隔离。测试环境、生产环境如果共用一个密钥那任何能进测试环境的人都能伪造生产环境的高权限令牌。密钥至少256bit用随机生成不要自己敲一串“abc12345”。第三不要把过多数据塞进JWT。JWT每多一个Claim带宽就多一段体积。我见过有人把几十个角色的权限明细塞进Token里结果每个请求的Header都跟个小包似的。正确做法Token里只放userId和username权限等敏感信息登录后按需查库。一个典型的JWT认证过滤器结构如下public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token resolveToken(request); if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { Claims claims jwtUtil.parseToken(token); if (claims ! null) { UserDetails userDetails userDetailsService.loadUserByUsername(claims.getSubject()); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } chain.doFilter(request, response); } }等一下这里有一个必须强调的细节getContext().getAuthentication() null这个判断不能省。如果你的过滤器写得不严谨同一请求里前置路由和后置路由各执行一次过滤器就会导致Authentication被重复赋值从而绕过某些权限检查。这个坑非常隐蔽我曾经在分布式网关场景里排查了一整天才定位到。3. 授权谁能干什么的精准控制3.1 URL级授权与 requestMatchers 的写法演进认证解决的是“你是谁”授权解决的是“你能干什么”。在两个维度上有两层控制URL级和方法级。URL级授权写起来最直观就在SecurityFilterChain配置里。早期版本用的是antMatchers新版本必须用requestMatchers两者的主要区别是antMatchers支持Ant风格匹配比如/api/**而requestMatchers在新版本里已经统一合并了Ant风格和MVC风格避免了两套写法带来的混乱。一个典型的配置是这样的Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**, /login, /register).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/user/**).hasAnyRole(USER, ADMIN) .requestMatchers(/api/order/**).hasAuthority(ORDER_READ) .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()) .logout(Customizer.withDefaults()); return http.build(); }顺序非常关键。Spring Security是从上到下匹配的一旦命中规则就停止继续匹配最后用anyRequest()兜底。所以尽量把permitAll的最先写越具体的规则越靠前避免/api/public/**被后面的authenticated()拦截。另外特别注意一点hasRole(ADMIN)的写法意味着权限标识里必须存在ROLE_ADMIN而hasAuthority(ORDER_READ)直接比对原始权限字符串。同样一个权限集合在两个方法里写法不同容易踩坑。我的建议是选一种风格全项目统一。3.2 方法级授权PreAuthorize 的实战姿势URL授权适合粗粒度控制但业务上经常需要细到“某个方法只允许创建人看”这时就得靠方法级授权。开启方法级授权的第一步是加注解Configuration EnableMethodSecurity public class SecurityConfig { }这个注解替代了老版本的EnableGlobalMethodSecurity(prePostEnabled true)。然后你就可以在Service方法上写PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long id) { ... } PreAuthorize(hasRole(ADMIN) or hasRole(MANAGER)) public void exportData() { ... } PreAuthorize(#order.userId authentication.principal.userId) public Order getOrder(Order order) { ... }第三种写法就是SpEL表达式Spring Security支持用#号引用方法参数。这里最常用的是authentication、principal这两个隐含对象分别代表当前认证信息和UserDetails。我实测过很多次在市级项目里最常见的用法就是“判断当前登录用户是否是这条数据的创建人”。这个功能的实现非常简洁在表达式里比较一下#order.userId和authentication.principal.userId即可。SpEL表达式的能力远不止于此。你还可以用BeanName.method()的语法调用任意的Spring Bean方法做自定义校验例如PreAuthorize(permissionService.checkManager(authentication.name)) public void approve() { ... }这种写法的好处是把复杂逻辑从表达式中抽离出来方便单元测试。我发现团队里写权限表达式越来越长的往往就是因为没有受委托给一个专门的校验Service。3.3 自定义权限校验的几种“姿势”对比URL级和方法级授权都无法满足所有场景。比如“订单必须由当前登录用户本人操作”这种需求用上面第3.2节最后一个表达式虽然能实现但每个方法都写一遍维护成本高。更优雅的方案是自定义PermissionEvaluator在表达式里调用hasPermission()。实现思路是实现PermissionEvaluator接口重写hasPermission()方法在方法里自己处理业务逻辑。再把它的Bean注册到MethodSecurityExpressionHandler里。这样代码里就可以写PreAuthorize(hasPermission(#orderId, order, read)) public OrderDetailVO queryOrder(Long orderId) { ... }另一个常见姿势是自定义注解。用一个RequireRoleAdmin之类的注解标注在Controller方法上再配合AOP或者Spring Security的注解解析器去读取注解参数做校验。这种方案适合那些权限规则跨度极大的项目因为它把权限模型化成了规约而不是散落在表达式里。不过说实话我踩坑最多的恰恰是自定义方案引入的复杂度。一旦你绕开了Spring Security自带机制就得自己承担上下文传播、异常处理、测试覆盖等等问题。如果业务没有强诉求我建议优先用PreAuthorizeSpEL组合。4. 常见问题与排查技巧实录4.1 401 和 403 分不清一句话讲明白很多前后端联调时把401和403混在一起用导致前端无法正确跳转。其实这两个状态码的含义完全不同401是“你没登录”要求你提供身份凭证403是“你已登录但没权限访问这个资源”。Spring Security默认对未认证请求返回403这跟很多前端预期不一致。想要更规范可以在ExceptionTranslationFilter里自定义AuthenticationEntryPointhttp.exceptionHandling(e - e .authenticationEntryPoint((req, resp, ex) - { resp.setStatus(401); resp.setContentType(application/json); resp.getWriter().write({\msg\:\未登录或登录已过期\}); }) .accessDeniedHandler((req, resp, ex) - { resp.setStatus(403); resp.setContentType(application/json); resp.getWriter().write({\msg\:\无权限访问\}); }) );这里有两个隐蔽坑要提醒第一未认证的请求如果带了一个无效Token默认也会命中401逻辑但如果你把JWT过滤器放在ExceptionTranslationFilter之前并且过滤器里直接响应了JSON那EntryPoint就压根不会执行——链路顺序又成了决定性因素。第二accessDeniedHandler只管已经认证通过但没有权限的请求如果用户压根没认证是先走401分支的。4.2 CSRF 在前后端分离里到底该不该关CSRF防护机制默认开启。它的工作原理是对于非GET请求要求请求携带一个TokenCookie或者表单隐藏字段里否则拒绝执行。这套机制在传统服务端渲染页面里非常重要因为伪造表单提交很容易。但在前后端分离项目里CSRF防护跟Token方案经常“打架”你不是用JWT放到Header里吗这本身就算一种防护因为跨站请求无法伪造Header。所以CSRF防护在这个场景下几乎没有收益却会带来配置复杂度。我现在的前后端分离项目一律关闭CSRF代码如下http.csrf(csrf - csrf.disable());关掉之后有个风险要心里有数如果哪天你要保留Cookie会话方案但把CSRF关利了配合前端没做任何防护那就是纯裸奔了。总之核心原则是关了CSRF就必须确保前端在非GET请求里带上自定义Header相当于用自己的机制补上CSRF那层防线。4.3 登录成功后拿不到当前用户信息这个问题出现频率极高。现象是登录成功后在Controller里用SecurityContextHolder.getContext().getAuthentication()取到的Authentication是null或者principal是字符串而不是UserDetails。最常见的原因是你在自定义过滤器里把Authentication放进了某个子线程或异步上下文。SecurityContextHolder默认策略是MODE_THREADLOCAL每个线程各一个副本。如果你在子线程里调用SecurityContextHolder.getContext()自然拿到null。解决办法要么用Runnable包装器DelegatingSecurityContextRunnable要么把认证信息作为方法参数传进线程。第二个原因是过滤器顺序问题。如果你的自定义过滤器赶在SecurityContextPersistenceFilter之前执行并且没有手动把认证信息放到SecurityContext里那么到Controller层的时候上下文已经被后续过滤器覆盖了。排查方法是在Controller里打一个断点查看SecurityContextHolder.getContext()的值再对比你过滤器里到底塞没塞进去。如果你确认塞了还是拿不到大概率是被后面的过滤器或拦截器重置了。4.4 自定义过滤器乱插顺序导致的死循环我曾经遇到过一个项目开发者为了让“校验Token的同时刷新Token过期时间”在过滤器里加入了重新登录逻辑——如果Token快过期了就调用authenticationManager.authenticate()重新认证并刷新Token。听起来挺合理实际运行却出现了一个经典问题认证过的请求进了一次老过滤器后新过滤器带着新Token又走了一遍导致同一请求经历了两次安全上下文最后把原本通过授权校验的用户给踢了出去。这个问题的本质是自定义过滤器必须放在正确的位置。正常情况下JWT过滤器应该放在UsernamePasswordAuthenticationFilter之前而不是跟它并列或者混进去。我倾向于把JWT过滤器放在SecurityContextPersistenceFilter之后、UsernamePasswordAuthenticationFilter之前这样既能利用Session上下文又能在JWT解析失败时回到Session认证兜底。5. 一点实战经验分享我在一个电商后端项目里把Spring Security的认证和授权全部重构过一遍最大的体会是这个框架的学习曲线陡峭不在于API多而在于“链路感”特别强。你只有把过滤器链上的每一个节点想明白才能灵活应对各种业务需求。如果让我给刚开始接触Spring Security的读者一个建议那就是拿到一个现成项目先别急着改配置花一天时间把springSecurityFilterChain的断点走一遍。看它从请求进来到FilterSecurityInterceptor之前经历了哪些过滤器登录时UsernamePasswordAuthenticationToken是如何流转和转换的授权时AuthorizationManager又是如何做最终裁决的。走完一遍之后你会发现以前所有看不懂的配置项都自动有了坐标。另外一个我个人的习惯是把“认证”和“授权”的配置尽量拆开维护。认证部分我习惯写成一个独立的AuthenticationEntryPointUserDetailsServicePasswordEncoder组合授权部分则单独抽出一个MethodSecurityConfig。这样团队里不同人维护不同模块时不会在同一个HttpSecurity配置上产生大量的合并冲突。最后分享一个排查问题时的骚操作给ExceptionTranslationFilter和FilterSecurityInterceptor各加一条日志把请求路径、认证状态、异常类型全部打出来。几乎所有“为什么明明登录了还是报403”“为什么我配了permitAll还是跳登录”这类问题都能在这两条日志里找到答案。工程化的安全感往往就来自对这些关键节点的掌控力。