尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Spring Security整合JWT实战:从过滤器链到认证授权全解析

发布时间:2026/9/29 5:37:06

资讯中心
01
ARTICLE

Spring Security整合JWT实战:从过滤器链到认证授权全解析

Spring Security整合JWT实战:从过滤器链到认证授权全解析
Spring Security是一个绕不开的话题。做Java后端久了你会发现不管是用Spring Boot做接口服务还是维护老项目里的权限模块只要涉及登录和权限控制,最终都会落到它头上。尤其是现在前后端分离越来越普及无状态认证成了标配Spring Security整合JWT几乎是每个后端开发者都得亲手走一遍的流程。这几年面了不少候选人也帮团队带过新人我发现大部分人卡住的地方不在原理而在为什么这么设计和怎么集成才不出幺蛾子。网上的教程很多但不少要么停留在配置demo层面要么直接抄老版本的配置扔给你根本跑不通。今天这篇我按照自己的理解从过滤链机制讲到认证授权再上手把JWT整合进去最后聊聊实战里经常踩的坑。内容不追求大而全但求把关键路径讲顺让你看完能直接在自己的项目里动手。1. 先从整体架构说起Spring Security到底在干什么1.1 过滤器链机制是真正的核心思想很多人学Spring Security第一步就倒在过滤器链上其实这东西不难理解。你可以把整个安全框架想象成机场的安检通道每个过滤器就是一道安检口。请求从进来开始依次经过所有安检口每一道都做自己的检查全部通过了才放行进到业务代码里。// 请求访问流程 客户端请求 - 过滤器链 - 分发器Servlet - Controller处理 - 返回响应 | |--- 过滤器1: 安全检查 |--- 过滤器2: 认证处理 |--- 过滤器3: 授权校验Spring Security的核心就是一套有序的Filter链。从SecurityFilterChain注册的过滤器开始像UsernamePasswordAuthenticationFilter负责处理表单登录BasicAuthenticationFilter处理Basic认证ExceptionTranslationFilter统一处理认证授权异常AuthorizationFilter做最终的授权裁决。特别注意一点Spring Security 5.x和6.x的配置方式完全变了。5.x时代用WebSecurityConfigurerAdapter继承重写这玩意在6.x里已经被移除。你要是搜到老教程照着配置大概率会得到ClassNotFound或者Method not found一类错误。现在标准做法是直接声明SecurityFilterChain的Bean。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .anyRequest().authenticated() ); return http.build(); } }这套代码看起来简单背后却包含了Spring Security的核心设计思想通过过滤器组合完成认证与授权业务代码只需要关注自己的逻辑安全横切面全部交给过滤器链。1.2 认证与授权是两个完全不同的问题He is who he says he is -- 认证; He can do what he wants to do -- 授权。很多问题排查不清楚就是因为把这两个概念混在一起。认证Authentication确认你是谁。通常通过用户名密码、Token等方式完成。认证通过后系统把你的身份信息放进SecurityContext安全上下文。授权Authorization确认你能否做某件事。基于认证得到的身份信息系统判断你有没有某个角色或权限去访问某个资源。打个比方认证就像你进入小区的门禁刷卡刷完卡你才能进小区授权就像进入小区后你能不能刷开某一栋单元楼的门。能进小区不代表能进所有楼栋这是两层完全独立的检查。Spring Security里认证的信息放在SecurityContextHolder中。SecurityContextHolder默认使用ThreadLocal存储策略也就是说当前线程在处理请求时随时可以从上下文中拿到当前登录用户信息。这个设计让AuthenticationPrincipal注解、SecurityContextHolder.getContext()等方法变得极其方便。1.3 为什么偏偏是它而不是Shiro市面上做安全框架的不只Spring Security一个Shiro也是老牌选手。我记得早年间Shiro因为上手简单、文档直白在中小型项目里非常流行。但如果你问问自己项目在持续演进安全需求越来越复杂到底选哪个我个人的建议是新项目直接上Spring Security。理由有三Spring Security与Spring Boot生态深度集成配置中心化自动配置帮你省掉大量样板代码。功能覆盖全面从基础认证、方法级安全到OAuth2客户端、OIDC、LDAP几乎所有你能想到的安全场景都有现成支持且版本迭代一直在同步。社区活跃度决定了你搜解决方案时能搜到可靠的答案这一点在踩坑时感受尤其明显。Shiro的优势在于轻量、独立于Spring之外如果你的项目几乎没有Spring影子那考虑Shiro也合理。但既然标题是Spring Security知识点我们还是把重心放在这条主线上。2. 认证流程的核心组件把每个角色搞清楚2.1 AuthenticationManager是怎么把认证跑通的认证环节里AuthenticationManager是顶层接口它的默认实现是ProviderManager。ProviderManager内部维护着一个AuthenticationProvider列表每个Provider负责一种特定的认证方式。举个例子你的系统同时支持用户名密码登录和短信验证码登录那就至少有两个AuthenticationProvider。ProviderManager会依次尝试每个Provider哪个能处理当前的Authentication请求就用哪个来处理。AuthenticationManagerProviderManager ├── DaoAuthenticationProvider处理用户名密码 ├── JwtAuthenticationProvider处理JWT Token └── CustomSmsProvider处理短信验证码你可能已经发现了认证的本质就是凭借某样凭证构建一个Authentication对象然后让它通过校验。Authentication接口的authenticated字段标识是否认证成功一旦认证通过这个对象就会被放进SecurityContext。2.2 UserDetailsService和UserDetails一个负责取数据一个负责装数据UserDetailsService是连接数据库与安全框架的桥梁。它的唯一方法loadUserByUsername(String username)负责根据用户名加载用户信息返回一个UserDetails对象。Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userMapper.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } return new LoginUser(user); } }UserDetails接口规定了几个必须实现的方法getAuthorities()返回用户权限集合getPassword()返回密码isEnabled()等返回账号状态。实际开发中我习惯把UserDetails和一个业务实体分开做一个LoginUser包装类里面既保留数据库的用户ID又实现安全的接口方法。这样业务层访问用户信息不用反复查库。有个细节值得注意DaoAuthenticationProvider拿到UserDetails后会比较提交的密码和UserDetails里的密码。比较逻辑不是简单的字符串equals而是交给PasswordEncoder来比对这样就算密码被加密存储也能正确校验。2.3 PasswordEncoder与密码加密策略密码存储是个老生常谈的安全话题。MD5、SHA-1这类算法早就被证明不适合直接存密码因为彩虹表攻击可以轻松还原。现在主流做法是使用加盐的强哈希算法BCrypt就是其中最有代表性的。Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }BCryptPasswordEncoder的encode方法会自动生成随机盐并把盐一并嵌入到最终的结果字符串中。所以哪怕两个用户密码都是123456生成的密文也完全不同。验证的时候matches(原始密码, 加密后的密文)会从密文中提取盐重新计算比对整个流程对开发者完全透明。我记得刚开始做项目时图省事直接用MessageDigest手写了个MD5加盐逻辑还把盐拼接在密文后面。后来做安全评审别人一看到这种实现直接要求返工。别在密码加密上寻求独立创新用业界标准的BCryptPasswordEncoder稳且安全。Spring Security 6之后如果项目里配置了用户密码但没有指定PasswordEncoder启动时会直接报错提示PasswordEncoder is required。这个改动让很多新人措手不及但这是好事它强制你从一开始就采用安全的密码存储方案。3. 授权配置实战把规则理清楚3.1 SecurityFilterChain里这些配置都代表什么到了实际配置环节很多人面对HttpSecurity一大串链式方法时容易懵。我把常用的几个模块拆开讲。http .csrf(Customizer::disable) // 关闭CSRF防护 .cors(Customizer::disable) // 关闭跨域配置一般会自己配 .formLogin(Customizer::disable) // 关闭表单登录页面 .httpBasic(Customizer::disable) // 关闭Basic认证 .logout(LogoutConfigurer::permitAll) // 登出 .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态会话 .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**, /error).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() );逐个说csrf关闭前后端分离项目用Token做认证CSRF防护基本失去意义。带着JWT的请求天然不依赖CookieCSRF攻击的核心场景是浏览器自动附带Cookie所以关掉是合理选择。如果你做的是服务端渲染的传统Web项目千万别关关掉等于给攻击者开后门。formLogin和httpBasic关闭前后端分离模式下接口返回JSON就够了不需要Spring Security自动生成的登录页也不需要弹浏览器原生的Basic认证框。sessionManagement设为STATELESS让Spring Security不创建HttpSession。JWT方案下用户状态存在客户端服务端不需要保存会话。清掉这一点能在架构上天然支持横向扩展多实例部署时不用担心Session同步问题。authorizeHttpRequestspermitAll()表示完全放行不需要认证就能访问hasRole(ADMIN)表示需要拥有ADMIN角色authenticated()表示只要登录就可以访问。注意写法精确规则放前面模糊规则放后面anyRequest()必须写在整个规则链最后否则后面的规则永远不会被匹配。3.2 方法级安全注解颗粒度更细的操作URL级别的配置能搞定大部分需求但有些接口的权限判断不能只看路径还要依赖数据本身。比如用户只能修改自己的信息光靠URL规则没法表达。这时候就需要方法级安全。Configuration EnableMethodSecurity(prePostEnabled true) public class SecurityConfig { }开启之后你可以在Service方法上使用// 需要ROLE_ADMIN角色 PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long userId) { ... } // 需要满足自定义条件 PreAuthorize(#userId authentication.principal.userId) public void updateUser(Long userId, UserUpdateDTO dto) { ... }第一个例子简单。第二个例子使用了SpEL表达式它把方法参数#userId和当前认证对象authentication.principal.userId做对比只有一致才放行。这种写法极大提升了权限控制的灵活度也让你能把资源归属权校验下沉到业务方法本身。方法级安全本质上是Spring AOP在作怪。EnableMethodSecurity开启后框架会给这些方法创建代理对象方法执行前拦截检查权限。所以别在同一类内部通过this.method()调用代理失效权限检查也就跟着失效了。3.3 无状态会话与SecurityContext到底存在哪我接触过的项目中最容易出问题的就是SecurityContext的丢失。在传统Session方案中SecurityContext存放在Session里由SessionRepository统一管理每次请求都能恢复。但切换成无状态会话STATELESS之后Session里没有数据了每个请求进来都是空白上下文。JWT方案里的思路是每次请求到达时由过滤器解析Token把解析结果重新构建成Authentication并塞进SecurityContext。这样才能让后续的授权判断拿到用户信息。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) { this.jwtUtil jwtUtil; this.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); try { String username jwtUtil.extractUsername(token); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authToken new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } } catch (Exception e) { logger.error(JWT认证失败, e); } } filterChain.doFilter(request, response); } }这段代码是JWT整合最核心的部分把这个过滤器理解透彻后面整合就成功了一大半。4. Spring Security整合JWT把登录和鉴权串起来4.1 JWT的结构三段字符串分别存什么JWTJSON Web Token是一个开放标准RFC 7519它定义了Token的格式和生成校验方式。一个标准JWT长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTcwMDAwMDAwMH0.signatureHeader头部声明算法类型和Token类型比如{alg:HS256,typ:JWT}。Payload有效载荷存放实际声明数据比如用户ID、用户名、过期时间exp、签发时间iat等。注意Payload里的数据只是Base64编码不是加密敏感信息千万别往里放。Signature签名将Header和Payload拼接后用密钥HMACSHA256或私钥RS256计算出的签名。签名是防篡改的钥匙。JWT的最大优势是让服务端不存状态。服务端只要持有密钥收到Token后重新计算一遍签名一致就是合法Token。这样一来服务端没有Session天然无状态适合分布式部署、微服务架构。4.2 用JJWT库还是用java-jwt简单说下选型整合JWT时先去网上搜几乎都是jose4j、java-jwt、jjwt这些库。我比较常用的是jjwtio.jsonwebtoken它API设计干净版本更新也及时。dependency 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的方法写起来很直接public String generateToken(UserDetails userDetails) { MapString, Object claims new HashMap(); claims.put(userId, userDetails.getUsername()); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60)) .signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) .compact(); }注意SECRET_KEY不能用太短的字符串HS256要求至少256位也就是32个字节。你写个mysecret作为密钥生成过程不会报错但运行时校验密钥长度报错或者直接抛WeakKeyException。这点容易被忽略我在这里踩过一次。4.3 登录流程串起来一个完整链路把JWT和Spring Security整合好后完整的登录认证流程是这样的用户调用POST /api/auth/login发送用户名和密码。登录接口通过AuthenticationManager执行认证成功则生成JWT返回给客户端。客户端后续每次请求在Authorization: Bearer token中携带JWT。请求进入Spring Security过滤器链JwtAuthenticationFilter从Header提取Token解析校验将用户信息放入SecurityContext。AuthorizationFilter判断当前用户能否访问请求的URL或方法。PostMapping(/api/auth/login) public AuthResponse login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); UserDetails userDetails (UserDetails) authentication.getPrincipal(); String token jwtUtil.generateToken(userDetails); return new AuthResponse(token); }登录接口本身要放在permitAll()的放行规则下否则用户还没登录就连登录接口都进不去形成死循环。流程理顺后你会发现AuthenticationManager.authenticate()内部做的工作和JwtAuthenticationFilter里做的事有点像都是拿到凭证加载用户比对密码。区别在于前者做的是账号密码登录后者做的是Token校验。理解这一点整个框架就不再是黑盒了。4.4 刷新Token和退出登录怎么处理无状态认证下的退出登录是个经典话题。服务端没有Session可以销毁JWT本身在过期前始终有效怎么办有两种常见做法黑名单机制服务端维护一个Token黑名单用Redis存储Token的jti标识退出时把Token加入黑名单过期时间设为Token剩余有效时间。每次请求校验时先查黑名单。缩短Token有效期 主动刷新AccessToken有效期设短比如30分钟配合一个RefreshToken。AccessToken过期后客户端用RefreshToken换取新的AccessToken。RefreshToken库里存可以控制失效。对于一般业务系统方案一简单直接对高安全要求或者用户量特别大的系统方案二更加合理。但不管选哪种都要把用户主动退出后Token立即失效作为需求去落地别让退出按钮形同虚设。5. 常见问题与排查技巧实录5.1 401和403傻傻分不清这是后台开发里最基础的错误码区分但也是我见过最多人问的问题。401 Unauthorized代表未认证或认证失败意思是我不知道你是谁403 Forbidden代表已认证但无权限意思是我知道你是谁但你不许访问。用Spring Security的异常链来讲ExceptionTranslationFilter会捕获两类异常AuthenticationException抛出后转成401AccessDeniedException抛出后转成403。如果你遇到该返回401却返回403的情况排查思路大概是看请求有没有携带有效的Token再看Token解析出来的用户有没有对应权限。大部分问题是Token没传或者传错了位置少部分是权限配置不对。5.2 配置了放行规则为什么还是进不去这种问题最常见的一个坑放行规则写在authorizeHttpRequests里但请求到过滤器链时先被其他过滤器拦截了。比如自定义的JwtAuthenticationFilter在所有请求上都执行先解析Token解析失败抛异常导致后续规则根本没机会处理。http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);addFilterBefore指定了自定义过滤器的位置也可以直接http.addFilter(jwtAuthenticationFilter)。但无论哪种如果自定义过滤器里对放行路径也做了强校验就会覆盖掉URL层面的放行策略。我的习惯是自定义过滤器里先判断请求路径如果是permitAll()的路由比如登录、注册直接放行不进行Token解析。String path request.getRequestURI(); if (path.startsWith(/api/auth/) || path.equals(/error)) { filterChain.doFilter(request, response); return; }5.3 SecurityContextHolder上下文丢失很多人在Service层里通过SecurityContextHolder.getContext().getAuthentication()取用户信息发现有时候能取到有时候取不到而且越到后面越迷惑。排查重点之一是异步方法的使用。SecurityContextHolder默认的ThreadLocal策略意味着数据只存在于当前线程。当你用了Async注解或者调用了线程池新线程里是没有主线程的SecurityContext的。解决办法有三个方向在主线程将需要的信息作为参数传给异步方法避免依赖上下文。配置SecurityContextHolder的策略为MODE_INHERITABLETHREADLOCAL让子线程继承上下文。但注意这只能覆盖由当前线程创建的子线程对线程池复用场景不完全可靠。在异步任务开头手动传递SecurityContext例如SecurityContextHolder.setContext(context)。实践中最稳妥的还是不要在异步链路里依赖SecurityContext把用户ID作为方法参数传下去。5.4 Redis缓存用户信息时UserDetails序列化问题系统用户量大时通常会把用户信息缓存到Redis。这里极易踩坑直接缓存UserDetails实现类但实现类没实现Serializable或者字段为LocalDateTime等序列化不友好的类型报类型转换错误。我的方案是持久层实体用implements Serializable并且自定义权限集合类型保证能序列化缓存只存核心数据用户ID、用户名、权限编码列表需要完整用户信息时再查库。还有一点查询Redis时如果用到JSON.parseObject记得指定TypeReference不然泛型信息丢失会拿到一堆字符串而不是对象。5.5 Spring Security版本升级带来的坑如果你从5.x升到6.x至少有三个地方要改WebSecurityConfigurerAdapter类没了改为配置SecurityFilterChainBean。.antMatchers()方法没了改成.requestMatchers()。.and()方法语法调整转为Lambda风格DSL。这些变化本质上都是让配置更清晰、更模块化。升级时不要直接复制旧配置先看官方迁移指南再对照新API重写。升级不复杂但机械替换容易漏掉隐式行为变化。6. 实操心得与项目中的建议6.1 从简单的方案起步不要一开始就套复杂架构我在早期项目中试过把Spring Security JWT Redis黑名单 多Provider OAuth2客户端全都塞进一个小项目结果是代码复杂度爆炸出了问题都不好定位。如果你是在已有项目里引入安全框架建议分阶段落地第一阶段只做用户名密码认证 内存用户跑通过滤器链。第二阶段引入数据库用户完善UserDetailsService。第三阶段引入JWT实现无状态认证。第四阶段再加Redis缓存、黑名单、多维度权限控制。每阶段都能独立运行出问题时能快速定位到具体环节。6.2 一定要自己写一遍过滤器别只粘贴完事JWT过滤器这段代码网上随处可见但照着抄和真正理解是两码事。我建议至少亲手写三遍第一遍照抄第二遍先不看代码自己还原第三遍尝试优化比如增加Token刷新逻辑、增加日志审计。写完之后你一定会有几个收获OncePerRequestFilter和普通Filter的区别、UsernamePasswordAuthenticationToken这个类的构造方法参数意义、以及为什么authToken.setDetails()这段代码偶尔会被一些人省略。这些细节就是实际排查问题的经验底座。6.3 安全代码的测试不能只测正常流程安全框架最容易忽略的是异常路径。我在项目里专门整理过一份安全测试清单未携带Token访问受保护接口期望401。携带过期Token访问受保护接口期望401且无明显堆栈泄露。携带伪造签名Token期望401。携带普通用户Token访问管理员接口期望403。登录接口重复提交确认没有并发问题。账号被禁用后旧Token是否立即失效结合需求确认。顺着这份清单走一遍能发现很多埋在细枝末节里的bug。比如有些框架配置会让伪造签名报500而不是401这就需要自定义异常处理统一封装成标准响应。6.4 关于Spring Security后续扩展简单说两句如果你做的是微服务架构可以关注下Spring Security在OAuth2和OIDC方面的能力它能让统一认证授权中心比如基于Authorization Server的解决方案和网关鉴权配合得非常好。如果做的是多租户系统可以关注Tenant隔离与SecurityContext如何配合。这些方向都是建立在今天讲的过滤器链和认证授权体系之上的。把底层思路吃透遇到具体场景时换几个Provider、配几条授权规则就能搞定曾经觉得复杂的东西其实也就那么回事。我这几年做安全相关的功能最大的体会是安全框架不负责替你思考它只是把你写好的规则执行到位。你越理解它背后的执行逻辑就越能写出既安全又贴合业务的设计。希望这篇内容能帮你把链路理清少踩几个我踩过的坑。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。