ARTICLE DETAIL

资讯详情

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

Spring Security核心机制与JWT实战:过滤器链、认证授权与权限模型

Spring Security核心机制与JWT实战:过滤器链、认证授权与权限模型 1. 内容整体设计与思路拆解1.1 为什么都要学Spring Security做Java后端的人迟早要过Spring Security这一关。不管你是在写多商户跨境商城、大学生就业推荐系统还是给第三方提供接口用户认证和权限控制永远是绕不开的课题。我最早接触Spring Security时也是一头雾水因为它的过滤器链设计、前后端分离下的Token方案、以及那套抽象到让人怀疑人生的Bean配置学习曲线确实不友好。但踩过坑之后再看它其实是Spring Boot生态里最值得投入时间去啃的框架之一。很多人会问我自己写拦截器加Session不行吗当然行做一个Demo绰绰有余。但一旦系统膨胀到多商户、多角色、细粒度权限控制的规模自己从头造轮子的代价会非常高。Spring Security的优势在于它把认证、授权、会话管理、密码加密、CSRF防护、跨域处理、OAuth2社交登录等安全能力都模块化了。你不需要全都懂但你可以按需组合并且它已经过大量生产环境的验证坑比你自己的少得多。1.2 学习路径要怎么规划我见过的学习路径有两种偏差一种是直接抄一段配置就跑只求接口能调通另一种是把官方文档从头啃到尾结果越学越迷。前者的问题是换一个场景就不会写了后者的问题是沉没在概念海洋里迟迟落不了地。这里我给出一条更适合实际开发的路径分四步走。第一步先理清Spring Security的过滤器链模型。你可以把它理解成一道多层的安检闸机请求进来之后层层过检只要有一层不放行或认证失败请求就到不了你的Controller。第二步搞懂Authentication对象在认证通过前后分别长什么样。认证前它只有principal用户标识和credentials密码凭证认证通过后会被填充完整的用户信息、权限列表Session或Token里存的就是这个对象。第三步做一套最小可用的表单登录配置理解认证管理器AuthenticationManager是如何协调ProviderManager和UserDetailsService一起工作的。最后一步再拿JWT这种无状态Token方案去替换默认的Session机制。这一整套走下来你对Spring Security的认知就会比较立体而不是只有一堆注解碎片。关于工具版本我建议新项目直接上Spring Boot 3.x Spring Security 6.x这已经是当前的主线版本。网上大量的旧教程基于Spring Security 5.x甚至4.x其中lambda配置风格和几个废弃API的差异不小照着抄容易报错看的时候要特别注意文章的版本标题。2. 核心细节解析与实操要点2.1 过滤器链机制到底是怎么一回事Spring Security的核心是一条过滤器链主线叫FilterChainProxy它内部按顺序注册了多个SecurityFilter。每个过滤器负责一件事有的处理CSRF校验有的处理登录请求有的生成或者校验Session有的检查当前用户是否已经认证。链式设计的妙处在于一个请求只要被一个过滤器拦住并抛出异常整个链路就中断了根本到不了后面的环节。实际操作中你不太需要关心链上所有过滤器的实现顺序但有一个关键点必须记住自定义过滤器必须知道自己在链上的相对位置。比如你要做Token认证过滤器通常要加在UsernamePasswordAuthenticationFilter之前因为你要先把Token解析成Authentication对象放进去后面的授权过滤器才能判断用户有没有权限访问某个接口。你可以把这条链想象成景区入园的几道闸机。第一道闸机检查你有没有带违禁品CSRF校验第二道闸机检查你的票是否有效认证第三道闸机检查你有没有预约某个特定场馆授权全部通过了才能进到园区内部也就是你的Controller。这样理解过滤器链就会形象很多。2.2 AuthenticationManager、ProviderManager与AuthenticationProvider的分工这三个类的关系经常把人搞晕。AuthenticationManager是整个认证流程的入口接口它的实现类是ProviderManager。ProviderManager内部维护了一个AuthenticationProvider列表通过迭代这些Provider来尝试认证。每个Provider负责不同类型的认证逻辑DaoAuthenticationProvider处理用户名密码登录JwtAuthenticationProvider处理Token登录OAuth2LoginAuthenticationProvider处理第三方授权码登录。一个Provider不行就换下一个成功则返回一个已认证的Authentication对象全部失败就抛出异常。这里有个实践建议如果你要对接多套用户来源比如商户端和买家端分属不同数据表不要在一个UserDetailsService里写一堆if-else。更好的方式是配置多个AuthenticationProvider每套Provider绑定独立的UserDetailsService和密码编码器这样扩展新用户源时只需要新增Provider不需要动原有代码。2.3 SecurityContext与SecurityContextHolder的关系SecurityContextHolder是Spring Security的“全局背包”默认基于ThreadLocal实现每个线程持有自己的SecurityContext。SecurityContext里装的就是当前请求的Authentication对象。认证通过后你就可以在任意地方通过SecurityContextHolder.getContext().getAuthentication()拿到当前的用户信息。需要注意两点。第一在异步处理的情况下ThreadLocal不会自动传递子线程里拿不到认证信息需要手动做上下文传播。第二在Web环境中Spring Security的过滤器会在每次请求结束时清空SecurityContextHolder避免线程池复用导致用户信息串到下一个请求中这是默认的MODE_THREADLOCAL策略。如果你用了虚拟线程或者某些响应式场景要特别留意上下文传播策略的调整。3. 实操过程与核心环节实现3.1 最小可用的依赖引入与基础配置我直接给出一版Spring Boot 3.2 Spring Security 6.2的最小依赖配置。先引入两个核心依赖我这里以Maven为例dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency引入依赖后只需要这样一段配置就能让所有接口进入认证保护状态Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated() ) .formLogin(form - form.disable()) .httpBasic(basic - basic.disable()); return http.build(); } Bean public UserDetailsService userDetailsService() { UserDetails user User.withDefaultPasswordEncoder() .username(admin) .password(admin123) .roles(ADMIN) .build(); return new InMemoryUserDetailsManager(user); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这段配置的含义是关闭CSRF放行/api/public/**下的所有请求其余请求全部需要认证。关闭表单登录和HTTP Basic认证因为我们走的是接口认证方案。这个配置本身不完整直接照搬到生产环境会有问题因为还未配置实际的无状态Token认证过滤器但作为学习第一步它用最少的代码让你看到了SecurityFilterChain长什么样。3.2 基于数据库的用户认证实现内存用户只适合Demo真实项目里用户信息必然在数据库。这里补充一套基于数据库的用户认证方案。首先定义用户表对应的实体类和Mapper这里以Spring Boot MyBatis组合为例这也是目前国内Java开源项目中非常常见的搭配。用户表结构设计上我建议至少包含id、username、password、enabled四个字段。enabled字段对应UserDetails接口的isEnabled方法用来实现账号禁用。角色和权限可以另外用user_role、role_permission关联表来维护但学习阶段可以先在用户表里加一个role字段用逗号分隔角色比如ROLE_ADMIN,ROLE_USER。然后实现UserDetailsService接口Service public class CustomUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public CustomUserDetailsService(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserDO userDO userMapper.findByUsername(username); if (userDO null) { throw new UsernameNotFoundException(用户不存在); } ListGrantedAuthority authorities new ArrayList(); authorities.add(new SimpleGrantedAuthority(userDO.getRole())); return new User(userDO.getUsername(), userDO.getPassword(), authorities); } }在过滤器中DaoAuthenticationProvider会调用这个UserDetailsService来加载用户信息然后用PasswordEncoder比对传入的密码和数据库中的密码哈希。这里最重要的是数据库存储的密码必须是用BCrypt这类算法加密过的绝不能存明文。BCryptPasswordEncoder的encode方法每次生成的哈希值不同因为内部会生成随机盐验证时用matches方法比对即可。3.3 前后端分离下的JWT无状态认证实现现在前后端分离已经是标配Session方案在跨域、移动端场景下越来越力不从心。JWT方案的基本思路是认证成功后服务端签发一个签名Token客户端后续请求在Header中携带这个Token服务端通过自定义过滤器解析并校验Token将用户信息放入SecurityContext中。先引入JWT库依赖我常用的是jjwtdependency 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然后写一个Token生成和校验的工具类。密钥我这里用HMAC-SHA256方式生成签名实际生产环境密钥要通过环境变量注入不能硬编码在代码中Component public class JwtTokenUtil { private final SecretKey key Keys.hmacShaKeyFor(your-256-bit-secret-key-change-it.getBytes(StandardCharsets.UTF_8)); private final long expiration 86400000L; public String generateToken(String username, ListString roles) { Date now new Date(); Date expiryDate new Date(now.getTime() expiration); return Jwts.builder() .setSubject(username) .claim(roles, roles) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String getUsernameFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody() .getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }接着是核心的Token认证过滤器。这个过滤器需要继承OncePerRequestFilter确保每个请求只执行一次Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenUtil jwtTokenUtil; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); if (jwtTokenUtil.validateToken(token)) { String username jwtTokenUtil.getUsernameFromToken(token); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } } filterChain.doFilter(request, response); } }最后把这个过滤器注册进SecurityFilterChain中http .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/public/**).permitAll() .anyRequest().authenticated() );注意addFilterBefore是将自定义过滤器插入到UsernamePasswordAuthenticationFilter之前执行。这个位置选择是有讲究的因为后续的授权过滤器要依赖当前SecurityContext中是否有Authentication对象所以解析Token的操作需要提前完成。如果放得太靠后授权过滤已经执行完你的认证信息就无法生效。3.4 登录接口与认证入口的具体实现有了过滤器之后还需要一个实际的登录接口来签发Token。这里我们直接注入AuthenticationManager来驱动认证流程RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenUtil jwtTokenUtil; PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest loginRequest) { try { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword()) ); SecurityContextHolder.getContext().setAuthentication(authentication); // 从认证对象中提取角色 ListString roles authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); String token jwtTokenUtil.generateToken(loginRequest.getUsername(), roles); return ResponseEntity.ok(new LoginResponse(token, loginRequest.getUsername(), roles)); } catch (BadCredentialsException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(用户名或密码错误); } } }为了让AuthenticationManager这个Bean可用需要在SecurityConfig中配置Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }AuthenticationConfiguration会读取你配置的UserDetailsService和PasswordEncoder自动组装好ProviderManager。如果你的用户来源是唯一的用这个默认逻辑就够了。3.5 基于注解的方法级权限控制在接口配置之外Spring Security还支持方法级权限控制。开启全局方法安全校验EnableGlobalMethodSecurity(prePostEnabled true)Spring Boot 3.x中这个注解的位置也可以放在SecurityConfig上但实际上Spring Security 6.x里更推荐使用EnableMethodSecurity。可以用以下注解做细粒度控制Service public class OrderServiceImpl implements OrderService { PreAuthorize(hasRole(ADMIN)) public void deleteOrder(Long orderId) { // 只有管理员才能删除订单 } PreAuthorize(hasAnyRole(ADMIN, MERCHANT)) public void updateOrder(Order order) { // 管理员和商户都能更新订单 } PostAuthorize(returnObject.ownerUsername authentication.name) public Order getOrder(Long orderId) { return orderMapper.findById(orderId); } }PreAuthorize在执行方法前校验权限适合删除、修改等操作。PostAuthorize在执行方法后校验适合数据归属类的权限控制比如普通用户只能看自己的订单方法是先查出数据再判断归属不满足就抛AccessDeniedException。实际项目中我一般优先使用PreAuthorize因为它的语义更清晰执行前就拦住了少一次无谓的数据库查询。PostAuthorize那种场景如果能在SQL层面加归属条件其实比代码层面二次校验效率更高。3.6 多商户跨境商城场景下的权限模型扩展结合热词中提到的多商户跨境商城这类系统的权限模型有其特殊性。普通单用户系统只需区分管理员和用户但多商户系统里还存在商户、店员、物流商等多个主体每个主体管理不同的资源。比较常见的方案是RBAC模型扩展一层“租户”维度。用户表增加tenant_id角色表也跟租户关联。Spring Security的authority只表现角色不表现数据归属。为了在接口层区分数据归属我会额外写一个自定义的权限表达式public class CustomSecurityExpressionRoot implements MethodSecurityExpressionOperations { // 自定义hasTenantAccess方法校验当前用户和当前请求资源是否属于同一租户 }在Controller里可以这样用PreAuthorize(tenantSecurity.check(#merchantId)) public ProductVO getProduct(Long merchantId) { // 当前登录用户必须属于这个商户才能查看商品 }这种方式比把所有商品查询都放在一个接口中再通过if-else判断要清晰得多。权限校验的前置化不仅让代码更安全也让接口语义更干净。4. 常见问题与排查技巧实录4.1 登录接口反复重定向或返回403这是新手遇到最多的问题。出现这个现象通常有两个原因一是CSRF防护没有关闭表单登录模式下Spring Security默认会校验CSRF Token你的Ajax请求没有携带Token就会被拦截。二是未认证的请求被默认重定向到了/login页面但你的前端并不存在这个页面。排查步骤先看浏览器Network面板的请求状态码。如果是302说明是认证入口重定向如果是403优先检查CSRF配置。前后端分离项目中我通常建议在无状态接口认证方案下直接关闭CSRF因为CSRF防护的核心机制依赖会话Cookie一旦改成Token方案CSRF的意义就大幅削弱了。API的安全不能只靠一个机制兜底但CSRF在无状态场景下的确不是主要防线更重要的还是Token的加密强度、过期策略和HTTPS传输。如果你的接口涉及Cookie认证又要跨站请求CSRF还是不能随便关的。4.2 明明数据库密码正确却提示用户名或密码错误这个坑十有八九出在密码编码器不匹配上。Spring Security 6.x默认使用DelegatingPasswordEncoder它要求数据库中的密码带有编码器前缀比如{bcrypt}明文哈希。如果你的数据库密码是直接存储的{bcrypt}前缀的BCrypt哈希而你没有显式配置PasswordEncoder或者你配置的编码器与存储格式不一致验证就会失败。解决方法是明确指定全局唯一的密码编码器Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }确保在UserDetailsService中返回的密码就是BCrypt哈希而注册用户时用passwordEncoder.encode(rawPassword)存储。我这里再强调一遍自己写代码注册用户的逻辑中一定不能直接存原始密码。我见过不止一个项目上线后数据库密码全明文的事故被脱库之后相当于把用户账号拱手送人。4.3 自定义过滤器不生效或者重复执行过滤器不生效最常见的原因是SecurityFilterChain中的配置没有生效。Spring Boot 3.x中如果你不小心定义了多个SecurityFilterChain Bean某些路径会被不同链路处理配置隔离起来很麻烦。确保你只需要一条链路的时候所有相关配置都放在同一个SecurityFilterChain中。重复执行的问题则多半出在Filter的注册方式上。如果你既在SecurityFilterChain里addFilterBefore又在WebMvcConfigurer或Servlet容器里注册了Filter Bean同一个请求可能会被过滤两次JWT解析逻辑也就执行两次。虽然幂等设计下没有大问题但浪费不必要的CPU和内存。我的做法是需要被Spring Security管理的过滤器只加在SecurityFilterChain中不加Component注解。不要两种方式混用。4.4 使用PreAuthorize但注解不生效注解不生效先在启动类或者配置类上确认是否加了解锁方法级安全的注解。Spring Security 6.x使用EnableMethodSecurity旧版是EnableGlobalMethodSecurity。如果你的项目还停留在旧版本依赖上同时写的是新注解自然没反应。升级Spring Boot到3.x之后注意检查所有旧的security配置类是否还能编译通过废弃API会直接暴露出来。另一种情况是方法调用来自类内部比如一个Service方法调用同类中的另一个被PreAuthorize标注的方法注解不会触发因为Spring AOP代理默认只拦截外部调用。解决办法是把需要注解保护的方法拆分到不同的Bean中或者通过AopContext.currentProxy()获取代理对象来调用。4.5 JWT过期后用户还在页面上“看似已登录”这是JWT方案容易忽略的体验问题。Token无状态意味着服务端无法主动注销它过期之前客户端一直能用。如果你希望用户登出后Token立刻失效单靠JWT办不到。实际项目中通常配合Redis做短期Token黑名单或者改用短Token加RefreshToken双Token方案。AccessToken设短过期时间比如15分钟RefreshToken设长过期时间比如7天AccessToken过期后用RefreshToken换新。这样既控制了Token泄露的风险窗口又不用太频繁地让用户重新登录。这个方案会增加一些实现复杂度但多商户商城这类生产级系统这点复杂度是值得承受的。4.6 常见问题速查表为了方便查阅我把上面的高频问题整理成了一张速查表问题现象可能原因排查/解决方向请求302到/login未认证请求触发了表单登录入口确认是否调用login接口无状态认证下关闭formLogin请求403无响应CSRF拦截或权限不足检查CSRF配置检查当前用户authorities是否满足要求密码正确但认证失败PasswordEncoder不匹配统一使用BCryptPasswordEncoder密码带编码器前缀检查自定义过滤器不生效过滤器没有加入链中确认在filterChain中addFilterBefore方法未遗漏PreAuthorize不生效缺少EnableMethodSecurity检查注解并确认方法是外部调用Token能解析但接口401过滤器未将认证信息写入SecurityContext检查SecurityContextHolder赋值逻辑是否被异常打断权限数据修改后不生效用户权限被缓存或存在ThreadLocal残留重启或确认清理逻辑避免在请求间复用SecurityContext5. 扩展Spring Boot Admin监控中的安全集成热词列表中出现了Spring Boot Admin这个工具也和安全配置强相关。很多人集成Spring Boot Admin时遇到一个典型问题监控页打不开或者打开后需要认证但不知道账号密码。Spring Boot Admin自身就是一个Spring Boot应用服务端可以接入Spring Security来保护监控端点。被监控的客户端需要暴露actuator端点同时设置管理端和客户端的认证信息。如果监控页面要显示完整的JVM、线程、日志等敏感信息这些端点绝不能裸奔。建议在客户端配置management.endpoints.web.exposure.include* management.endpoint.health.show-detailswhen-authorized spring.security.user.nameadmin spring.security.user.passwordadmin123同时服务端依赖spring-boot-admin-server-ui和spring-boot-admin-server并在配置中加入客户端的凭据信息。在实践中我习惯额外引入spring-boot-starter-actuator并配置好账号否则监控页能显示但接口数据加载不出来排查起来容易走弯路。如果你在Spring Security配置中给所有请求加上了认证保护需要放行Spring Boot Admin的监控端点否则监控服务连不上客户端。这里需要你单独定义一套匹配规则来放行相关路径。6. 实操中的避坑清单与学习方法建议6.1 我踩过的坑先说说密码加密的坑。有一个阶段我把DaoAuthenticationProvider默认使用的PasswordEncoder与数据库里存的密码哈希算法搞混了导致线上用户无法登录排查了整整半天。后来我养成了一个习惯注册用户时显式指定PasswordEncoder登录认证路径上确保全局只有一个编码器Bean。这样即使出错问题也就锁死在Bean配置上。Spring Security的DelegatingPasswordEncoder默认行为会检查密码哈希的前缀比如存储格式是{bcrypt}xxxx如果你删除前缀或换算法匹配逻辑就完全变了。再说一下跨域的坑。前端服务跑在8000端口后端跑在8080端口直接请求接口会报跨域错误。Spring Security的CORS配置和Spring MVC的CORS配置容易混淆。最佳实践是在SecurityFilterChain中配置corshttp.cors(cors - cors.configurationSource(corsConfigurationSource()))自定义CorsConfigurationSource来放行允许的域名、Header和方法。CORS配置不会浪费你太多时间但不配置的话前后端联调阶段会让你怀疑人生。6.2 给新手的三个学习建议第一个建议先实现一套完整的用户认证过程哪怕只是InMemoryUserDetailsManager也要把注册、登录、修改权限、退出登录这个流程跑通。只有跑通了完整闭环你才会真正理解Token是从哪里来的SecurityContext什么时候被写入什么时候被清理。第二个建议每次看别人项目的代码不要只盯着配置类看要看过滤器链中的每一个关键节点。比如别人加了一个JwtAuthenticationFilter你要追一下它是在哪个过滤器之前插入的为什么要放在那里。理顺这个逻辑之后你遇到需求新增过滤器也不会发怵。第三个建议学会读官方文档的迁移指南。Spring Security 5.x到6.x的变更版本里很多类名、方法签名都变了。你不会永远停留在一个版本上企业项目迟早要升级。平时养成看迁移指南的习惯升级的时候会少掉特别多头发。6.3 从学习到落地的路径如果你现在的目标是完成课程设计或者一个真实项目我建议从Spring Boot脚手架生成器创建一个最小项目然后按顺序做四件事先把用户表和角色表建好用Spring Boot MyBatis做一套UserDetailsService实现接着配置基于数据库的登录认证跑通Session模式然后升级到JWT无状态认证写一个自定义过滤器最后接入方法级权限控制和Swagger让接口文档也能识别Token。这套路径做下来你不仅掌握了Spring Security的基础核心还顺手掌握了一整套前后端分离项目的安全标配。以后不管写大学生就业推荐系统还是参与多商户跨境商城源码的开发都不需要再从零学起更多只是数据模型层面的调整。我自己在写完JWT方案后又把Session方案和Redis缓存方案各做了一遍对比测试体会很深。安全设计不是越复杂越好而是越合适越好。小系统用Session加简单拦截器就能用多租户、高并发系统则需要Token加细粒度权限模型。Spring Security给了你选择的空间关键在于你理解每条技术路线背后的本质。
返回列表