ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Security 6.x实战:从迁移到认证授权与JWT/OAuth2集成

Spring Security 6.x实战:从迁移到认证授权与JWT/OAuth2集成 1. 从5.x迁移到6.x先搞懂底层的“断代”变化如果你之前用的是Spring Security 5.7或者更早的5.6直接跳到6.x的时候第一反应大概率是“这玩意怎么全变了”。Spring Security 6.x是跟着Spring Boot 3.0一起出来的属于一次比较大的“断代式”升级不是说简单地换个依赖版本就能跑通。我最早在做项目升级的时候把spring-boot-starter-security从2.7调到3.x之后编译期直接报错一片最典型的就是WebSecurityConfigurerAdapter这个类彻底没了。这个变化背后的逻辑其实很清晰。WebSecurityConfigurerAdapter是一个抽象类过去我们通过继承它、重写configure(HttpSecurity http)方法来定义安全规则。这种方式本身没什么问题但它把“配置”和“实现”耦合在了一起而且一个项目里如果多个Adapter同时存在加载顺序和生效逻辑很容易出幺蛾子。Spring Security 6.x引入了组件化的SecurityFilterChain不再需要继承任何基类而是通过声明一个Bean方法在方法里用HttpSecurity去构建过滤链。Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults()); return http.build(); }这段代码是6.x的标志性写法注意几个细节authorizeHttpRequests取代了旧的authorizeRequestsrequestMatchers取代了antMatchers和mvcMatchers整个配置是lambda风格的DSL。如果你在网上搜到旧教程照着5.x的写法抄编译器会直接提示找不到方法这不是你环境的问题是版本变了。另一个容易踩的坑是Spring Boot 2.7和3.x之间的自动配置差异。Spring Boot 3.x强制要求Java 17及以上而Spring Security 6.x也是基于Jakarta EE 9的命名空间原来的javax.servlet全部变成了jakarta.servlet。如果你的老项目还在用javax那升级之后连启动都起不来必须先处理依赖层面的迁移再谈功能改造。从实际开发的角度讲6.x的这套组件化设计反而让项目更清爽。你可以在一个配置类里定义多个SecurityFilterChain不同路径前缀走不同的过滤链比如/api/**走无状态JWT校验/page/**走表单登录。这在旧版里实现起来很别扭6.x里就是多写几个Bean的事后面我会专门讲。2. 认证链路表单登录、用户加载与密码认证的完整闭环认证是Spring Security最核心的流程你要理解它先得在脑子里有一条链路请求进来之后被UsernamePasswordAuthenticationFilter拦截从请求里取出用户名和密码封装成一个UsernamePasswordAuthenticationToken然后交给AuthenticationManager去认证。AuthenticationManager本身只是接口真正干活的是它的实现类ProviderManager。ProviderManager维护了一组AuthenticationProvider其中DaoAuthenticationProvider专门负责“查数据库-比对密码-填充权限”这套逻辑。2.1 配置表单登录时的三个隐藏细节用6.x做表单登录最简单的写法就是http.formLogin(Customizer.withDefaults())。但默认配置只能保证“能跳转登录页”实际项目里你一定会改三样东西登录页路径、登录请求的URL、登录成功后的处理逻辑。http.formLogin(form - form .loginPage(/login) .loginProcessingUrl(/api/login) .successHandler(customAuthenticationSuccessHandler) .failureHandler(customAuthenticationFailureHandler) .permitAll() );这里有个非常容易踩的坑如果你指定了loginPage(/login)那么GET /login这个路径必须能被匿名访问否则登录页都进不去浏览器会一直重定向最后报“Too many redirects”。我见过好几个同事把这个重定向问题当成会话配置错误去排查浪费了大半天其实就是忘了在授权规则里放行登录页。另外loginProcessingUrl默认是POST /login如果你改了它前端表单的action必须同步改否则请求打到默认地址上返回405。这个属于前后端联调时的高频问题偏偏报错信息又很迷惑因为Spring Security的过滤器链不会把你重定向到默认登录页而是直接返回“Not Found”或者405状态码。2.2 UserDetailsService和PasswordEncoder认证的两个支柱DaoAuthenticationProvider要工作离不开两个BeanUserDetailsService和PasswordEncoder。UserDetailsService负责根据用户名加载用户信息返回一个UserDetails对象PasswordEncoder负责密码比对。有些新手会问为什么一定要PasswordEncoder直接存明文密码、用equals比较不行吗技术上当然可以但Spring Security把“密码比对”封装成一个独立策略的原因在于密码存储的加密算法会演进。今天你用的可能是BCrypt明天可能换成scrypt或者Argon2如果你把比对逻辑写死在业务代码里换算法就要动一大片代码。用PasswordEncoder抽象之后替换算法就是换一个Bean的事。自定义UserDetailsService的正确姿势长这样Service public class CustomUserDetailsService implements UserDetailsService { private final UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles(user.getRoles().split(,)) .build(); } }注意这里返回的是Spring Security自己的User对象不是你的业务实体。如果业务上需要在登录后获取用户ID、部门ID这些字段有两个做法一是自己实现一个类继承User并扩展字段二是在登录成功后从SecurityContextHolder里拿Authentication再强转。2.3 为什么示例代码里经常用内存用户很多教程一上来就让你定义InMemoryUserDetailsManager写死一组用户名密码。有人觉得这太玩具了实际项目根本不用。但我的看法是特别适合用来验证认证链路通不通。Bean public UserDetailsService userDetailsService() { UserDetails user User.withUsername(admin) .password(passwordEncoder().encode(admin123)) .roles(ADMIN) .build(); return new InMemoryUserDetailsManager(user); }这一段代码能把环境问题、依赖问题、过滤链配置问题一次性暴露出来。如果内存用户能登录但数据库用户不能那问题一定出在你的UserDetailsService实现或者数据库查询上排查范围一下就缩小了。相反如果连内存用户都登不进去那就要回头查Spring Security的版本兼容性和过滤链配置。2.4 登录成功后的权限怎么被塞进SecurityContext整个认证过程的最关键产物是Authentication对象它包含principal用户信息、credentials凭证一般是密码认证通过后会被清空、authorities权限列表。这个对象会被放进SecurityContextHolder中而SecurityContextHolder默认使用ThreadLocal意味着它只在当前线程内有效。这带来一个非常实际的坑如果你在业务代码里起了异步线程拿不到SecurityContextHolder里的用户信息。我遇到过一个案例用户反馈“登录后下载报表日志里审计字段死活是空的”排查到最后发现下载动作是异步执行的主线程的认证信息根本没传过去。解决方案要么是手动把Authentication对象传给子线程要么用SecurityContextHolder.getContextHolderStrategy()的虚拟线程相关策略Spring Security 6.3开始支持。3. 会话与无状态化从Session登录态到JWT Token的衔接策略Spring Security 6.x的会话管理在互联网项目中几乎绕不开一个选择到底用Session还是JWT我的建议很直接前后端不分离的老项目用Session省事前后端分离、尤其是移动端和Web端共用一套API的项目用JWT因为天然无状态。3.1 SessionManagement配置的两个必改项如果你坚持用Session至少要把会话创建策略和并发会话控制配置清楚。http.sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) .expiredUrl(/login?expired) );maximumSessions(1)的作用是同一个账号只能在一处登录。这里有个经典误区Spring Security 6.x默认不会踢掉旧的会话而是阻止新的会话创建需要你额外配置maxSessionsPreventsLogin(false)才能做到“新登录踢掉旧登录”也就是后登录者生效。具体用哪种策略要看业务场景内部管理系统一般禁止同时在线那就用默认的阻止策略面向C端的App用户换了台设备重新登录你总不能让人家卡住所以通常是允许后登录者顶掉前面的。3.2 JWT无状态认证过滤器往哪个位置塞无状态认证的核心思路是客户端每次请求把Token放在Header里服务端通过一个自定义过滤器解析Token、校验签名、构建Authentication对象并塞进SecurityContext。这个过滤器到底往SecurityFilterChain的哪个位置放很多人搞不清楚。我一般放在UsernamePasswordAuthenticationFilter之前因为JWT接口压根不需要表单登录。如果放在后面请求可能已经被其他过滤器拦截处理了逻辑上虽然可能也通但语义不对而且有安全隐患。http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)自定义过滤器的核心逻辑并不复杂Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null jwtTokenProvider.validateToken(token)) { String username jwtTokenProvider.getUsername(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } }为什么用OncePerRequestFilter而不是普通Filter因为Spring Security的过滤器链本身可能包含多次请求转发如果过滤器被重复执行Token就会被重复解析产生不必要的性能损耗。OncePerRequestFilter通过一个标志位保证每个请求只执行一次过滤逻辑这是官方推荐的做法。3.3 STATELESS模式下的CSRF问题用JWT无状态认证你还需要把SessionCreationPolicy设成STATELESS并且显式关闭CSRF保护http.csrf(csrf - csrf.disable())这两件事必须一起做。原因在于CSRF攻击的根源是浏览器自动携带Cookie而JWT一般存在前端存储localStorage里通过Authorization头手动携带不存在“浏览器自动带Token”的问题所以理论上CSRF这种攻击方式对JWT方案不构成威胁关闭是合理的。如果你用的是SessionCookie方案千万不要盲目关闭CSRF这是一个非常危险的操作。4. 授权体系URL级权限、方法级安全与自定义鉴权认证解决的是“你是谁”授权解决的是“你能干什么”。Spring Security 6.x的授权体系分成了URL级授权和方法级安全两层两套机制可以配合使用也可以只用其中一种。4.1 requestMatchers的匹配规则细节6.x的requestMatchers合并了旧的antMatchers和mvcMatchers写起来更简洁了但也有人因此搞不清楚匹配规则。http.authorizeHttpRequests(auth - auth .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(USER, ADMIN) .requestMatchers(/public/**, /static/**, /error).permitAll() .anyRequest().authenticated() );这里有一个顺序问题非常关键规则是从上到下匹配的只要命中就不再往下走。所以更具体的路径要写在更靠前的位置。比如/公开路径下的一个接口如果先写了任何请求都要认证后面再放开就没有意义了。另外要注意hasRole和hasAuthority的区别。hasRole(ADMIN)默认会加上ROLE_前缀去和用户权限比对也就是说用户的authorities里必须存在ROLE_ADMIN。而hasAuthority(ADMIN)是精确匹配要求用户的authorities里直接存在ADMIN。在使用User.withUsername().roles()构建用户时roles方法已经在内部帮你加了ROLE_前缀所以千万不要在数据库里把角色存成ROLE_ADMIN到了代码里又调用withRoles(ROLE_ADMIN)最后变成ROLE_ROLE_ADMIN这种尴尬的结果。4.2 EnableMethodSecurity方法级注解的打开方式方法级安全的核心是四个注解PreAuthorize、PostAuthorize、PreFilter、PostFilter。6.x里需要通过EnableMethodSecurity开启替代了旧版的EnableGlobalMethodSecurity。Configuration EnableMethodSecurity public class MethodSecurityConfig { }开启之后就能在Service层或Controller层直接写注解PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long userId) { // 业务逻辑 } PreAuthorize(hasRole(ADMIN) or #userId authentication.principal.id) public void updateUser(Long userId, UserUpdateRequest request) { // 业务逻辑 }PreAuthorize的表达式里可以操作参数变量和authentication对象这是最常用的。它还有缓存优化机制Spring Security 6.x在多次调用同一个方法时会缓存权限计算结果通过EnableMethodSecurity( cachingEnabled true )打开。如果你的业务权限在很多请求里都是静态的建议打开性能提升明显。4.3 自定义AuthorizationManager当默认规则不够用有时候业务权限特别复杂比如“员工只能查看自己部门的报表而且只能查看最近30天的数据”。这种规则没法单纯用角色表达你得自己实现AuthorizationManager。AuthorizationManager是6.x引入的新接口取代了旧的AccessDecisionManager和AccessDecisionVoter体系。它的核心方法只有一个Component public class DepartmentDataAuthorizationManager implements AuthorizationManagerRequestAuthorizationContext { Override public AuthorizationDecision check(SupplierAuthentication authentication, RequestAuthorizationContext context) { Authentication auth authentication.get(); if (auth null || !auth.isAuthenticated()) { return new AuthorizationDecision(false); } // 从URL里取部门ID String departmentId context.getVariables().get(departmentId); // 判断当前用户是否属于该部门 boolean granted checkUserInDepartment(auth.getName(), departmentId); return new AuthorizationDecision(granted); } }使用的时候把这些规则链式加到URL授权里http.authorizeHttpRequests(auth - auth .requestMatchers(/report/department/{departmentId}/**) .access(departmentDataAuthorizationManager) .anyRequest().authenticated() );这种设计比旧的投票器体系清晰多了。旧版AccessDecisionManager返回的是“通过/拒绝/弃权”三种状态多个投票器之间还要配置投票策略容易搞混。AuthorizationManager直接把结果收敛成布尔值业务上更好推断。5. 集成OAuth2.0资源服务器与客户端搭建的实操要点Spring Security 6.x自带OAuth2的客户端和资源服务器支持不要自己造轮子。很多项目需要对接第三方登录或者内部SSOSpring Security的OAuth2模块能让你的工作量减掉一大半。5.1 资源服务器只校验Token不参与登录资源服务器Resource Server做的事情是校验请求里的Access Token是否合法、是否过期、作用域够不够。它本身不负责登录只负责保护API。配置起来也直接spring: security: oauth2: resourceserver: jwt: issuer-uri: http://auth-server:9000对应的Java配置http.oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt .jwtAuthenticationConverter(customJwtAuthenticationConverter()) ) );如果使用issuer-uriSpring Security会自动从授权服务器的well-known配置里获取公钥来验签。如果你们用的是自定义的授权服务器不方便暴露issuer也可以换成jwk-set-uri直接指定公钥地址或者用public-key-location指向本地公钥文件。这里有个经常被问到的点JWT解码后怎么把Token里的scope、角色转成Spring Security的权限这就需要自定义JwtAuthenticationConverter。默认情况下它只会把scope转成权限。如果你的Token里是自定义的授权字段就得自己写converter从claims里提取字段并映射成SimpleGrantedAuthority列表。5.2 客户端对接授权码模式的完整路线如果你的系统需要接入第三方OAuth2登录比如企业微信、Gitee、GitHub那你是客户端Client的角色。配置上也不算复杂spring: security: oauth2: client: registration: gitee: client-id: xxx client-secret: xxx scope: - user_info redirect-uri: {baseUrl}/login/oauth2/code/gitee authorization-grant-type: authorization_code client-name: Gitee登录 provider: gitee: authorization-uri: https://gitee.com/oauth/authorize token-uri: https://gitee.com/oauth/token user-info-uri: https://gitee.com/api/v5/user user-name-attribute: id然后通过http.oauth2Login(...)把它挂到过滤器链上前端访问/login/oauth2/gitee就会跳到第三方授权页。回调之后Spring Security拿到了该用户在第三方平台的资料接下来你有两个选择一是直接把用户作为认证主体放行二是拿第三方返回的userInfo去查本地数据库找到映射的本地用户用本地用户身份继续后面的流程。企业项目里几乎都会用方案二因为本地系统有自己的角色、部门和权限体系。5.3 多认证源共存时的优先级问题一个项目里既想支持表单登录又想支持OAuth2登录还可能有JWT无状态接口三种认证方式能不能共存答案是能但前提是你要理清过滤器的执行顺序和认证入口的优先级。http .authorizeHttpRequests(...) .formLogin(form - form.loginPage(/login).permitAll()) .oauth2Login(oauth2 - oauth2.loginPage(/login).permitAll()) .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults()))默认情况下Spring Security会先判断请求路径匹配哪个认证机制。表单登录和OAuth2登录都需要在/login页面走一圈但处理方式不同。OAuth2LoginUrlAuthenticationEntryPoint会引导你跳转第三方授权服务器而LoginUrlAuthenticationEntryPoint只是返回登录页。要避免互相干扰建议给两种登录方式分配不同入口路径并在前端做分流。6. 六个高频故障与修复实录最后这部分我把这半年在项目里实际遇到过的、以及身边同事被卡了很久的经典报错整理出来。每一个都是真实解决过的不是网上抄的答案。6.1 - Failed to evaluate expression ‘ROLE_ADMIN’原因在角色前缀报错信息类似“Failed to evaluate expression ‘ROLE_ADMIN’”但逻辑和权限都没问题。这个问题十有八九是我们在数据库里存的是ADMINUserDetailsService构造User对象时调用了roles(ADMIN)而数据库查出来的字段没有加ROLE_前缀导致最终权限列表里出现的是ROLE_ADMIN但是你的表达式写的是hasRole(ADMIN)。理论上应该是对的问题出在某个地方你用了authorities方法而不是roles方法导致前缀没加进去。修复方法统一用User.withUsername().roles(...)构建因为roles内部会自动加前缀如果组件要求你存原始权限字段则在表达式里用hasAuthority(ROLE_ADMIN)自行补齐前缀。6.2 CSRF导致的403日志里总是看不到具体错误Spring Security 6.x默认开启了CSRF保护。前后端不分离的项目还好页面表单会自动带上_csrf隐藏字段。但前后端分离后POST请求没有CSRF TokenSpring Security直接返回403而且默认错误页不会显示具体原因。排查思路先去日志里找“Invalid CSRF token found for http://xxx”如果没有检查你是否在SecurityFilterChain里关闭了CSRF。如果你确认自己的场景不需要CSRF比如走JWT的API显式关闭即可如果不想全局关闭只想放行部分路径可以定义两个SecurityFilterChain一个带CSRF保护一个关闭。6.3 JWT过滤器解析了一次Token为什么还是401有一个情况我印象很深同事在JwtAuthenticationFilter里已经解析出了用户信息并放进SecurityContextHolder但请求打到Controller时还是返回401。排查后发现问题出在过滤器的移除逻辑上他在finally里调用了SecurityContextHolder.clearContext()把认证信息清掉了。这个写法本身没错但要注意清除的时机。OncePerRequestFilter的默认实现里finally清除Context的逻辑最好放在父类的处理逻辑里让它自动管理而不是自己手动清。另一个可能是Controller方法被Spring Security的PreAuthorize拦截了而不是被过滤器拦截。这两者返回的401/403来源不同需要在调试时区分如果日志只打印了ExceptionTranslationFilter相关的信息那问题大概率在过滤器链如果日志有MethodSecurityInterceptor那就是方法级安全没通过。6.4 配置文件里的密码是明文启动警告并不致命Spring Boot 3.x启动时会打印一句话提示UserDetailsService里没有配置PasswordEncoder或者配置了InMemoryUserDetailsManager但是密码用{noop}前缀。很多人看到警告就慌了实际上这只影响你用内存用户做测试不影响数据库用户认证。如果你是自定义UserDetailsService只要注入PasswordEncoder警告自然消失。6.5 Spring Security 6.3版本自动配置的Bearer Token队列6.3之后Spring Security支持了“Bearer Token自动持久化”的机制可以在OAuth2资源服务器之间传递Token。这个功能挺好用但如果你没用OAuth2只是自己写了个JWT过滤器可能会发现请求的Token被某个自动配置的BearerTokenAuthenticationFilter提前消费了导致你的过滤器拿不到Token。解决办法是不要同时开启oauth2ResourceServer和自定义JWT过滤器二选一或者把自定义过滤器放到BearerTokenAuthenticationFilter之前确保它先拿到Token。6.6 修改了SecurityFilterChain配置但改动不生效Spring Boot的自动配置有时会让你“感觉”自己定义的SecurityFilterChain没生效其实是因为你项目的包扫描范围里存在多个配置类或者你定义了SecurityFilterChain却忘了加Bean注解。我见过最典型的情况是配置类写好了也加了Configuration和EnableWebSecurity但SecurityFilterChain方法没有Bean导致Spring根本没用上你定义的过滤链而是走了默认配置。检查顺序是看启动日志有没有“DefaultSecurityFilterChain”再看自己的Bean有没有被加载最后看是否有多套配置互相覆盖。以上这些坑基本都是我在实操里踩过或者帮别人排查过的。Spring Security 6.x本身的设计逻辑其实不复杂核心就是一条过滤链上的层层协作。但它的演进速度不慢网上很多资料的版本已经过时了所以最好的学习方式就是你手里项目的这一个稳定版本把它吃透再去看更新的功能。能读到这里的人应该也都亲手配过一个能跑起来的demo了接下来就是把认证、授权、过滤链这些概念真正落到自己的项目里去多踩几个坑理解就会完全不一样。
返回列表