ARTICLE DETAIL

资讯详情

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

SpringSecurity整合JWT实现前后端分离Token认证完整指南

SpringSecurity整合JWT实现前后端分离Token认证完整指南 前后端分离做久了大家大概率都会遇到同一个问题登录状态到底怎么维持Cookie Session 方案不是不能用但跨域、集群、App 端适配、单点登录扩展每一个都够你折腾半天。所以我现在做新项目基本直接上 SpringSecurity token 认证token 载体优先选 JWT。这篇文章就结合我最近整理的一套 SpringSecurity 模板聊聊如何实现 token 认证从过滤器链路、登录接口到 token 失效、续签以及那些真正坑过我的细节。不管你是刚接触 SpringSecurity 的初学者还是想搭建统一认证基础的中级开发下面的内容应该都能让你少走不少弯路。1. 为什么前后端分离项目都改用 token 认证1.1 Session 方案的局限在哪里传统的 Web 项目大多是服务端渲染前端页面跟后端接口跑在同一个域名下Session 方案很自然用户登录后服务端把用户信息存进 HttpSession再通过 Set-Cookie 把 JSESSIONID 发给浏览器后续请求浏览器自动带上这个 Cookie服务端一比对就恢复身份。这套机制在单体时代是没问题的但是前后端分离之后麻烦就来了。第一是跨域问题。前端跑在 8080后端跑在 9090接口调用天然跨域那么 Cookie 携带、CORS 配置里关于 credentials 的设置处理起来总是容易出问题。第二是集群扩展。服务端 Session 默认存在单机内存里一旦部署多台服务器负载均衡把请求分到另一台机器Session 就丢了你得上 Session 共享、引入 Redis 或者 Spring Session又增加一套运维复杂度。第三是客户端适配。App 端根本没有原生 Cookie 管理机制移动端和 Web 端共用一套后端时Session 方案往往要额外封装。1.2 Token 认证把状态交给客户端Token 认证的思路是服务端登录成功后生成一段凭证返回给客户端客户端每次请求时把它放在请求头里带回来服务端校验通过就放行。因为服务端不再保存会话状态所有身份信息都被编码在 token 里所以在集群下、跨域下、多端适配下都比 Session 更舒服。无状态、扩展性强、前后端解耦这是它被大规模采用的核心原因。而 JWTJSON Web Token是目前最常用的 token 载体。它由 Header、Payload、Signature 三部分组成Base64Url 编码后用密钥签名。服务端只需要用同一个密钥验证签名就能确认 token 没有被篡改再读取 Payload 里的用户信息即可。这个设计特别适合分布式系统任何服务节点只要拿到密钥就能独立完成认证不需要共享 Session 存储。当然无状态也不是没有缺点token 签发之后在过期之前是没法主动作废的这就是后面我们要解决的失效问题。2. 项目初始化与依赖选型2.1 版本组合怎么选我这套示例使用的是 Spring Boot 2.7.18对应的 Spring Security 是 5.8Java 版本用 8 或 11 都可以。为什么不直接上 Spring Boot 3因为 3.x 把 javax 命名空间迁移到了 jakarta很多老项目迁移成本不小而且网上大量资料还是基于 javax 写的新手看着容易混乱。这里我先用 2.7 讲清楚核心机制你以后迁 3.x主要就是改 import整体思路完全一样。JWT 库我建议用 jjwt 0.11.5它是目前 Java 生态里使用最广泛的 JWT 实现库。它的 API 在 0.10 之后变化比较大如果你搜到旧博客用的是Jwts.parser().parseClaimsJws现在要换成Jwts.parserBuilder().build()这个细节后面代码里能看到。2.2 引入核心依赖新建一个 Spring Boot Web 项目后pom.xml 里至少需要这几样东西dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency 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 /dependencyjjwt-api是编译时需要调用的 APIjjwt-impl和jjwt-jackson运行时才需要所以后两个 scope 设为 runtime。如果缺了jjwt-jackson解析 JWT 时可能报 Jacksons 相关 ClassNotFound这算是一个小坑。数据库和 ORM 根据你的情况选这里用 Spring Data JPA MySQL 就可以了。然后配置 application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root jpa: hibernate: ddl-auto: update show-sql: true jwt: secret: a-very-long-secret-key-that-must-be-at-least-256-bits-long expire-hours: 24 header: Authorization prefix: Bearer 注意jwt.secretjjwt 的Keys.hmacShaKeyFor要求密钥长度至少 32 字节256 bit。如果你写个my-secret这种短字符串启动一解析就会抛WeakKeyException。这个配置本身不是安全配置的最高标准但作为模板没问题。3. SpringSecurity 核心配置无状态 自定义过滤器3.1 SecurityFilterChain 配置要点SpringSecurity 从 5.7 版本开始推荐使用SecurityFilterChain组件式配置。旧版的WebSecurityConfigurerAdapter已经废弃不要再用。我的核心配置类长这样Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final RestAuthenticationEntryPoint authenticationEntryPoint; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, RestAuthenticationEntryPoint authenticationEntryPoint) { this.jwtAuthenticationFilter jwtAuthenticationFilter; this.authenticationEntryPoint authenticationEntryPoint; } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .cors(cors - {}) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .antMatchers(/api/auth/login, /error).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .exceptionHandling(ex - ex.authenticationEntryPoint(authenticationEntryPoint)) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } }三个关键点关闭 CSRF。因为 token 认证的防御思路不再是 Cookie 自动携带CSRF 攻击面大幅下降关闭是为了让 POST 接口不被默认拦截。会话策略设为STATELESS。SpringSecurity 不再创建 HttpSession这是无状态认证的关键。自定义JwtAuthenticationFilter必须加在UsernamePasswordAuthenticationFilter之前。这个执行顺序很重要否则请求刚从过滤器链进入还没到鉴权阶段身份就已经丢了。3.2 自定义 Token 过滤器到底做了什么过滤器是整个 token 认证流程的核心。它本质上做一件事尝试从请求头里拿到 token校验通过后把用户信息放进SecurityContextHolder。这样后续的鉴权机制才能识别出“你已登录”。代码实现如下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 chain) throws ServletException, IOException { String token jwtTokenProvider.resolveToken(request); if (StringUtils.hasText(token) jwtTokenProvider.validateToken(token) SecurityContextHolder.getContext().getAuthentication() null) { 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); } chain.doFilter(request, response); } }用OncePerRequestFilter而不是普通 Filter是因为一次请求经过内部转发时普通 Filter 可能被执行多次而OncePerRequestFilter保证一个请求只执行一次避免身份被重复设置。我加了一个SecurityContextHolder.getContext().getAuthentication() null的判断。这个判断主要是防止过滤器链中已经存在认证信息时被覆盖比如在同一个请求里先后经过了其他认证过滤器。很多初学者抄代码会漏掉这个判断但漏掉的后果在 SSO 或复杂过滤器链场景里会更明显。validateToken返回 false 时怎么办我这里选择了继续放行而不是直接抛出异常。因为 SpringSecurity 的原则是过滤器不负责决定“该不该拒绝请求”它只负责把能认证的认证好后续authorizeHttpRequests发现没有认证信息自然会触发AuthenticationEntryPoint返回 401。这样职责更清晰。3.3 异常处理与未认证响应默认的 SpringSecurity 认证失败响应是一段 HTML前端根本没法解析。所以我实现了AuthenticationEntryPoint统一返回 JSONComponent public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\login expired or unauthorized\}); } }AuthenticationEntryPoint只处理“未认证”的情况。如果用户已经登录但权限不够那会走AccessDeniedHandler返回 403。这两者不要搞混。新手排查时看到 403一般就是角色权限配置不对而不是 token 失效。4. 登录接口与令牌签发实现4.1 用户信息加载与密码校验SpringSecurity 的AuthenticationManager需要一个UserDetailsService来加载用户信息。我的实现大致是这样的Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserRepository userRepository; public UserDetailsServiceImpl(UserRepository userRepository) { this.userRepository userRepository; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userRepository.findByUsername(username) .orElseThrow(() - new UsernameNotFoundException(user not found)); return new SysUserDetails(user); } }SysUserDetails实现UserDetails把数据库里的角色转换成GrantedAuthoritypublic class SysUserDetails implements UserDetails { private final User user; public SysUserDetails(User user) { this.user user; } Override public Collection? extends GrantedAuthority getAuthorities() { return user.getRoles().stream() .map(role - new SimpleGrantedAuthority(ROLE_ role.getName())) .collect(Collectors.toList()); } Override public String getPassword() { return user.getPassword(); } Override public String getUsername() { return user.getUsername(); } Override public boolean isAccountNonExpired() { return true; } Override public boolean isAccountNonLocked() { return true; } Override public boolean isCredentialsNonExpired() { return true; } Override public boolean isEnabled() { return true; } }这里默认数据库里的密码是 BCrypt 加密后的。SpringSecurity 在认证时会调用PasswordEncoder.matches比较明文密码和密文如果数据库里存的是明文认证永远失败。4.2 登录接口代码与执行流程登录接口本身很简单核心是把 SpringSecurity 的认证流程串起来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? login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); String token jwtTokenProvider.generateToken(authentication); return ResponseEntity.ok(new LoginResponse(token, Bearer)); } }流程是前端提交用户名和密码。AuthenticationManager调用UserDetailsService加载用户再用PasswordEncoder校验密码。校验失败抛异常由全局异常处理器转成业务提示。校验通过后authentication对象里就包含了用户信息和权限集合。用JwtTokenProvider生成 token 返回前端。注意我这里没有用UsernamePasswordAuthenticationFilter因为它默认拦截/login并依赖表单请求参数。我们手动调用AuthenticationManager更灵活也适合前后端分离。4.3 Token 工具类生成与解析的细节JwtTokenProvider是封装 JWT 所有操作的公共类。初始化时构建密钥生成 token 时写入用户名和角色列表Component public class JwtTokenProvider { Value(${jwt.secret}) private String secret; Value(${jwt.expire-hours}) private int expireHours; private SecretKey key; PostConstruct public void init() { this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Authentication authentication) { String username authentication.getName(); ListString roles authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); Date now new Date(); Date expiration new Date(now.getTime() expireHours * 3600L * 1000L); return Jwts.builder() .setSubject(username) .claim(roles, roles) .setIssuedAt(now) .setExpiration(expiration) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } public String getUsername(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody() .getSubject(); } }有几个细节要特别注意。setSubject只放用户名是刻意为之不要把敏感信息、大对象放进 JWT。因为 JWT 的 Payload 只是 Base64Url 编码不是加密任何人拿到 token 都能解码看到内容。你如果往里面塞手机号、身份证号等于把敏感信息裸奔给了客户端。resolveToken只认标准的Authorization: Bearer xxx结构。header 名是Authorization前缀是Bearer注意后面有一个空格。前端如果自定义 header 名不要放到Authorization里会跟浏览器的某些机制冲突。validateToken里捕获了JwtException和IllegalArgumentException。如果密钥不对、token 被篡改、token 过期都会抛出对应异常这里统一当作无效 token 处理。5. 从请求到放行一次完整认证流程拆解5.1 认证流程中的角色与时机很多初学者把“登录认证”和“接口鉴权”混为一谈实际上一次带 token 的请求在 SpringSecurity 里经历的顺序是固定的浏览器或客户端携带Authorization: Bearer token访问接口。请求进入 SpringSecurity 过滤器链各个过滤器按顺序执行。JwtAuthenticationFilter最先解析 token校验通过后把用户信息放进SecurityContextHolder。AuthorizationFilter根据SecurityConfig里的规则判断当前请求是否需要登录、需要什么角色。如果未认证AuthenticationEntryPoint返回 401。如果已认证但权限不足AccessDeniedHandler返回 403。认证通过请求到达目标 Controller业务代码正常执行。SpringSecurity 的核心思路是“过滤链叠加”。你不需要在每个 Controller 里判断用户是否登录过滤链已经帮你兜底了。这就是框架最大的价值安全横切面被统一管理。5.2 方法级权限如何与 token 中的角色配合除了 URL 级别的拦截我还会在接口方法上用注解做细粒度控制。EnableGlobalMethodSecurity(prePostEnabled true)已经开启了对PreAuthorize的支持RestController RequestMapping(/api/order) public class OrderController { GetMapping(/list) PreAuthorize(hasAnyRole(USER, ADMIN)) public ListOrder list() { return orderService.list(); } DeleteMapping(/{id}) PreAuthorize(hasRole(ADMIN)) public void delete(PathVariable Long id) { orderService.delete(id); } }这里的角色判断依据就是 token 里rolesclaim 转成的GrantedAuthority。所以生成 token 时把角色写进 JWT 是必要的。如果用户角色在 token 有效期内被调整了旧 token 依然持有旧角色这是无状态认证的一个天然局限很多权限变更严格的项目会因此增加一层服务端校验比如每次请求读 Redis 确认角色版本号。5.3 过滤器执行顺序的坑如果你把JwtAuthenticationFilter加错位置可能出现请求到了 Controller 时SecurityContextHolder里依然是空的或者权限不起作用。我的建议是永远放在UsernamePasswordAuthenticationFilter之前。还有一个容易被忽略的点SecurityContextHolder默认的ThreadLocal策略在异步线程里会失效。例如在 Controller 里通过Async开启新线程那个子线程读不到登录用户信息因为 ThreadLocal 不跨线程传递。解决方案要么不在异步线程里读取当前用户要么显式传递用户参数不要依赖SecurityContext。6. Token 失效、续签与常见问题排查6.1 Token 过期与主动失效方案JWT 过期只依赖exp字段所以最简单的失效机制就是设置合理的过期时间。我通常会把 access token 的过期时间设为 1 到 24 小时具体看业务对安全性的要求。如果要做到“修改密码后旧 token 立即失效”“管理员封号后 token 立即失效”那就必须引入黑名单机制。黑名单的常见实现是 Redis把需要作废的 token 的唯一标识jti存进 Redis并设置过期时间等于剩余有效期。每次请求先检查该 token 是否在黑名单里如果在就拒绝。这样做的好处是保留 JWT 无状态特性的同时把“主动失效”的能力补了回来。代价是每增加一次 Redis 查询本质又变回了“有状态”。另一种更简单的方案是加用户密码版本号修改密码时把版本号加一生成 token 时把版本号放进 claim校验时对比数据库里的版本号。这个方案不用 Redis但每次请求要查一次数据库。6.2 刷新 token 与滑动续签业务上常常需要 token 续签。我推荐双 token 方案一个短时效的 access token用于正常请求有效期 15 分钟到 2 小时一个长时效的 refresh token只用于调刷新接口有效期 7 天到 30 天。客户端在 access token 失效后拿 refresh token 请求刷新接口换一个新的 access token。刷新接口的实现很简单PostMapping(/refresh) public ResponseEntity? refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); if (jwtTokenProvider.validateToken(refreshToken)) { String username jwtTokenProvider.getUsername(refreshToken); UserDetails userDetails userDetailsService.loadUserByUsername(username); Authentication authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); String newToken jwtTokenProvider.generateToken(authentication); return ResponseEntity.ok(new LoginResponse(newToken, Bearer)); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); }注意 refresh token 不要跟 access token 使用同样的密钥和 claim 规则否则一个 access token 丢了别人也能用它刷新。更安全的做法是为 refresh token 单独使用不同的audienceclaim 和密钥甚至专门部署刷新服务。对于大多数中小项目至少要在 token 里加一个tokenType字段做区分。另一种滑动续签方案是每次请求都检查剩余有效期如果少于某个阈值就生成一个新 token 放在响应头里返回。这个方式能减少刷新接口调用但会导致大量 token 派发不好追踪我一般在小规模内部系统才用。6.3 常见问题速查表现象可能原因解决方法登录接口直接 403CSRF 未关闭或 SpringSecurity 拦截了/login在 SecurityConfig 中csrf.disable()并放行登录路径登录成功后接口仍然 401请求头没带 token、token 过期、过滤器没解析到检查前端 Header 名称与前缀检查 token 有效期密钥太短抛 WeakKeyExceptionJWT secret 不足 32 字节将 secret 换成长度大于 32 的随机字符串解析 token 报 JWT signature does not match本地产生的 token 被篡改或密钥不一致确认 encrypt/decrypt 用的密钥一致明明有 token 但拿不到用户信息过滤器没执行 或 SecurityContext 被清理检查过滤器是否已成功注册是否放入了UsernamePasswordAuthenticationToken返回 403 而不是 401已登录但无对应角色检查 URL 权限配置和PreAuthorize角色前缀改动密码后旧 token 仍有效JWT 无状态导致无法主动失效引入 Redis 黑名单或用户版本号机制跨域请求带 token 还是被拦截CORS 配置未处理 Authorization 头在 CORS 配置中exposedHeaders(Authorization)并允许对应请求头使用 redis 后刷新 token 报空 token前端刷新接口没带新 token统一管理 access token 和 refresh token 的存储位置每一条我都实际踩过或帮同事排查过最容易被忽视的是 CSRF 和前缀拼写。CSRF 不关POST 请求默认全部 403前缀大小写不对过滤器startsWith(Bearer )匹配失败身份自然就丢了。6.4 一点个人体会把 SpringSecurity JWT 的模板沉淀下来之后我最大体会是不要一上来就抄配置先弄懂过滤器链的执行顺序和SecurityContextHolder的工作原理。SpringSecurity 的源码看起来复杂但它把认证、授权、过滤、异常处理分得非常清楚。你只要把“自定义过滤器负责认证SecurityConfig 负责规则EntryPoint 负责未认证结果”这三件事理清楚后面的功能扩展比如多因素认证、OAuth2 客户端、单点登录基本都是往过滤器链里加东西而已。最后再分享一个小技巧生产环境下不要用 JWT 的 HS256 对称密钥尽量用 RS256 非对称密钥私钥在认证服务公钥在业务服务。这样多个业务服务不用共享同一个对称密钥安全性也更好。不过那是另一个话题了希望这篇文章能帮你把 SpringSecurity 的 token 认证这条路顺利走通。
返回列表