1. 项目背景与需求拆解
先说结论:Spring Boot 3.x + Spring Security 6.x + JWT 这套组合,是当前 Java 后端做无状态鉴权的主流方案中门槛最低、坑最多、但收益也最直接的一套。 我之所以这么说,是因为这套东西表面上是“导几个依赖、写两个过滤器”的事,但真正到了生产环境,你会发现令牌过期、权限模型混乱、安全漏洞、刷新策略失效这些问题,基本都会在后端排查链路里以各种姿势出现。
在我最近接的一个项目里,需求是给一套前后端分离的 SPA 管理后台提供完整鉴权能力。前端是 Vue3 + Vite,后端需要提供登录接口、鉴权拦截、接口级权限控制。领导给的要求就两条:一是不能用老的 session 方案,因为后期会拆分多个子应用,session 共享太麻烦;二是安全等级别太低,不能被扫出来明显漏洞。于是方案就锁定在了 JWT + Spring Security 上。
1.1 核心需求解析
我把这个需求的本质拆成了三层:
第一层是认证问题——你是谁。传统方式是 session 往服务器内存里存一份会话数据,用户再来的时候靠 cookie 里的 sessionId 去服务器里查。JWT 方案则相反,用户登录验证通过后,签发一个携带身份信息的加密令牌,客户端保存这个令牌,每次请求带着它,服务器验签通过就认你是你。整个过程服务端不存会话,实现了天然的无状态。
第二层是授权问题——你能干什么。Spring Security 底层是基于过滤器链的,每一个请求都会经过一组过滤器之后才到 Controller。我们可以在过滤器链中嵌入 JWT 校验过滤器,把令牌里的角色、权限信息解析出来,设置到 SecurityContext 中,后续的 @PreAuthorize 注解、hasRole() 表达式才能生效。这一步如果做不好,就会出现“接口能登录但权限控制形同虚设”的局面。
第三层是安全性问题——怎么防止被绕过。网上很多人说 JWT 安全,但 JWT 本身只是一套令牌格式规范,并不等于安全。真正决定安全级别的是算法选择、密钥管理、过期策略和逆向校验逻辑。如果你用了 HS256 对称算法但密钥是硬编码的简单字符串,或者校验时没有校验 alg 头,就会出现经典的算法混淆攻击。
1.2 技术选型背后的理由
先说为什么用 Spring Boot 3.x 而不是继续停留在 2.x。
Spring Boot 3.x 是基于 Spring Framework 6.x 的,底层依赖 Jakarta EE 9,也就是原来的 javax 包全部迁移为 jakarta 包。这意味着老的 javax.servlet、javax.validation 这些坐标都要换,而且 JDK 版本最低要求是 17。很多人觉得这是麻烦事,但其实从工程角度这是好事——JDK17 的虚拟线程、密封类、模式匹配这些特性,对并发处理和代码健壮性都有实打实的提升。更重要的是,Spring Security 6.x 只支持 Spring Boot 3.x,而 Security 6 对 OAuth2、方法级安全、CORS 配置的支持都有优化,属于“早升早省心”的类型。
再来说 Spring Security 6.x 和 5.x 最大的差别。
在 5.x 时代,大部分教程都会引导你写一个配置类继承 WebSecurityConfigurerAdapter,然后重写 configure 方法。到了 6.x,这个类直接被废弃了,官方推荐的方式是声明一个 SecurityFilterChain Bean,采用组件化配置。刚开始升级的时候我确实不习惯,但用顺手之后发现,这种方式反而解耦度更高,配置链肉眼可见,排查问题也方便得多。
至于 JWT 库,我选的是 java-jwt(也就是 auth0 出品的那个),而不是业界也常用的 jjwt。原因很简单:java-jwt 的 API 设计更简洁,对 token 过期异常的抛出更明确,而且在 Spring Boot 3.x 下不需要额外适配模块。实际上两者都可以,我这里没有故意贬低 jjwt 的意思,只是多年的使用习惯让我对 java-jwt 的稳定性更有把握。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计:无状态鉴权方案怎么搭才不散
这套方案的整体架构,我给它起了个名字叫**“一链、两滤、三存储”**。一链指 Spring Security 的过滤器链;两滤指我们自定义的 JwtAuthenticationTokenFilter 和 Spring Security 内置的 UsernamePasswordAuthenticationFilter;三存储指令牌存储(客户端)、用户信息存储(数据库)、密钥存储(配置中心或环境变量)。这个设计框架适用于绝大多数中大型单体项目和微服务初期项目。
2.1 双令牌机制:Access Token + Refresh Token
单令牌方案最大的问题在于:令牌一旦签发,在过期之前几乎是无法作废的。如果把过期时间设短(比如 30 分钟),用户的体验会很糟糕,每隔半小时就掉线一次;如果设长(比如 7 天),令牌泄露后风险敞口又太大。
所以生产级方案几乎都会采用双令牌机制:
- Access Token(访问令牌):有效期短,一般 30 分钟到 2 小时。用于每次请求的身份认证。每次请求都携带,服务端只验签、不查库,性能最好。
- Refresh Token(刷新令牌):有效期长,一般 7 天到 30 天。只能用于调用刷新接口换取新的 Access Token,不能访问业务接口。相当于“你掉了登录态之后重新登录凭证”。
这个机制的逻辑闭环是这样的:用户登录成功后,服务端同时签发 accessToken 和 refreshToken。前端每次请求带 accessToken,如果接口返回 401 且 token 已过期,则前端调用刷新接口传入 refreshToken,服务端校验 refreshToken 有效后签发一组新令牌。refreshToken 过期则强制重新登录。
这样的好处很明显:就算 accessToken 被窃取了,攻击者最多只能在 30 分钟内利用它,refreshToken 不在业务请求链路上出现,泄露面小。而 refreshToken 本身平时不用,只有刷新时才传输一次,虽然不能说绝对安全,但比单令牌方案安全一个层级。
2.2 安全存储方案:为什么我坚决不用 localStorage
很多前端同学习惯把 token 存在 localStorage 里,因为取用方便。但这里有个很严重的风险:localStorage 的访问权限是同源下的所有 JavaScript 脚本共享的。如果前端代码里被注入了恶意脚本(比如供应链攻击、XSS 漏洞),攻击者可以直接把 localStorage 里的 token 偷走,而且这种情况很难被察觉。
所以我的推荐方案是:accessToken 存内存,refreshToken 存 HttpOnly Cookie。
accessToken 存内存的意思就是,前端拿到之后放在一个变量里,页面刷新就没了,靠 refreshToken 调接口续回来。这样即使 XSS 攻击发生,攻击者拿不到当前生效的 accessToken。refreshToken 存到 HttpOnly Cookie 里,JavaScript 代码无法读取这个 Cookie,只有浏览器在符合域和路径规则的情况下自动携带,从根源上杜绝了脚本窃取的可能。
注意:HttpOnly Cookie 有 CSRF 风险,因为浏览器会自动携带 Cookie。解决方案是设置
SameSite=Lax或SameSite=Strict,同时关键接口校验Referer头。如果你做的系统对安全要求很高,还可以在前端加入自定义请求头用于 CSRF 防护。
2.3 过滤器链的整体编排
在 Spring Security 6.x 中,过滤器链的执行顺序非常关键。合理顺序是这样的:
code复制1. CsrfFilter(跨站请求伪造防护)
2. CorsFilter(跨域处理)
3. JwtAuthenticationTokenFilter(我们自定义的 JWT 校验)
4. UsernamePasswordAuthenticationFilter(登录表单处理——如果我们不用表单登录,这个可以禁用或绕过)
5. ExceptionTranslationFilter(异常翻译,把认证异常转为 HTTP 状态码)
6. AuthorizationFilter(授权判断)
我们自定义的 JWT 过滤器,要放在 UsernamePasswordAuthenticationFilter 之前,这样在 Spring Security 决定请求是否需要认证之前,我们已经将身份信息填充到 SecurityContext 中了。如果 JWT 校验失败,ExceptionTranslationFilter 就会接管并返回 401,不会进入后续的授权过滤器和 Controller。
3. 核心实现:从零构建 JWT 鉴权链路
好,理论部分讲完了,现在直接进入代码实操环节。我下面贴的代码都是我在项目里实际用过的版本,基于 Spring Boot 3.2.x + Spring Security 6.2.x + JDK17 + Maven。
3.1 依赖配置与工程结构
先看 pom.xml 的核心依赖。
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
这里有两个值得强调的点:
一是我引入了 Redis。为什么?因为纯 JWT 是无状态的,服务端无法主动让某个 token 失效。但实际业务中经常有“管理员把某个用户踢下线”、“用户改密码后所有 token 失效”这种需求。所以我用一个 Redis 存储 accessToken 的“会话版本号”或“黑名单”,在 JWT 校验过滤器里多查一次 Redis。这样在保持无状态令牌优势的同时,补上了“主动失效”的能力。
二是Spring Boot 3.x 的依赖坐标注意点:如果你之前用惯了 Spring Boot 2.x,会发现 spring-boot-starter-web 里原来传递的 javax.servlet-api 已经不存在了,全部换成了 jakarta.servlet-api。所以在写代码时,import javax.servlet.* 这个老写法会直接编译报错,必须改成 import jakarta.servlet.*。这是 Spring Boot 3.x 升级最容易踩的坑,没有之一。
工程结构上,我习惯按功能分包而不是按技术层分包:
code复制com.example.auth
├── AuthApplication.java
├── config
│ ├── SecurityConfig.java
│ └── CorsConfig.java
├── controller
│ └── AuthController.java
├── filter
│ └── JwtAuthenticationTokenFilter.java
├── model
│ ├── LoginRequest.java
│ ├── LoginResponse.java
│ └── Result.java
├── security
│ ├── JwtTokenProvider.java
│ ├── LoginUser.java
│ └── RestAuthenticationEntryPoint.java
├── service
│ └── AuthService.java
└── util
└── JwtUtil.java
按功能分包的好处是,当你排查“登录流程”时,只需要看 controller 和 service 两个包;排查“请求鉴权流程”时,只需要看 filter、security、config 三个包。比起传统的 controller/service/mapper 三层大包结构,这种组织方式的认知负担小很多。
3.2 JWT 工具类:签发与解析的核心
接下来是 JwtUtil.java,这是整个鉴权链路的核心工具类。我直接贴代码,然后逐段解释。
java复制package com.example.auth.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Slf4j
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expire-ms}")
private long accessTokenExpireMs;
@Value("${jwt.refresh-token-expire-ms}")
private long refreshTokenExpireMs;
@Value("${jwt.issuer}")
private String issuer;
/**
* 生成访问令牌
*/
public String generateAccessToken(String username, Long userId, String role) {
return generateToken(username, userId, role, accessTokenExpireMs);
}
/**
* 生成刷新令牌
*/
public String generateRefreshToken(String username, Long userId) {
return generateToken(username, userId, "REFRESH", refreshTokenExpireMs);
}
private String generateToken(String username, Long userId, String role, long expireMs) {
JWTCreator.Builder builder = JWT.create()
.withIssuer(issuer)
.withSubject(username)
.withClaim("userId", userId)
.withClaim("role", role)
.withIssuedAt(new Date())
.withExpiresAt(new Date(System.currentTimeMillis() + expireMs));
return builder.sign(Algorithm.HMAC256(secret));
}
/**
* 解析令牌,返回 DecodedJWT,验证失败抛出异常
*/
public DecodedJWT verifyToken(String token) {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(issuer)
.build();
return verifier.verify(token);
}
/**
* 判断令牌是否过期(仅用于需要预先判断的场景)
*/
public boolean isTokenExpired(String token) {
try {
DecodedJWT jwt = verifyToken(token);
Date expiresAt = jwt.getExpiresAt();
return expiresAt.before(new Date());
} catch (JWTVerificationException e) {
return true;
}
}
}
有几点细节我必须单独拿出来讲:
第一,密钥长度问题。 Algorithm.HMAC256(secret) 对密钥长度是有要求的,HS256 算法要求密钥长度至少 256 位,也就是 32 字节。如果你的 secret 配置的是一个很短的字符串,比如 "my-secret",auth0 库会直接抛异常。所以我在 application.yml 里配的是一个 64 位的十六进制字符串,实际使用中建议用 openssl rand -hex 32 生成。
第二,.withClaim("role", role) 的使用。 有些教程会把整个用户对象序列化后塞进 claim 里,这是非常不推荐的做法。JWT 的 payload 虽然可以携带任意 JSON 数据,但一来会增加令牌体积(每次请求都带着,浪费带宽),二来如果塞了敏感信息(手机号、邮箱、密码哈希),等于把敏感信息暴露给了持有令牌的人。正确的姿势是只放最小必要信息——用户名、用户 ID、角色。其他需要的数据,在请求处理中根据 userId 去查库或查缓存。
第三,刷新令牌的 role 设置。 我上面用了一个取巧的设计:刷新令牌的 role 被固定为 "REFRESH"。这样在刷新接口里,只需要判断这个字段,就能确认这个令牌是刷新令牌还是访问令牌,防止有人拿着 accessToken 去刷新接口换新令牌(虽然 accessToken 的过期时间短,但这种边界情况还是要封死)。
3.3 Spring Security 配置:过滤器链与放行规则
下面是 SecurityConfig.java。这是整个项目中最容易出问题的文件,我提醒你看仔细了。
java复制package com.example.auth.config;
import com.example.auth.filter.JwtAuthenticationTokenFilter;
import com.example.auth.security.RestAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final CorsConfigurationSource corsConfigurationSource;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 关闭 CSRF:无状态 API 不需要
.csrf(csrf -> csrf.disable())
// 开启 CORS,使用自定义配置源
.cors(cors -> cors.configurationSource(corsConfigurationSource))
// 无状态会话:不创建、不使用 HttpSession
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 认证失败的返回结构
.exceptionHandling(ex -> ex.authenticationEntryPoint(restAuthenticationEntryPoint))
// 请求授权规则
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// 将自定义 JWT 过滤器添加到 UsernamePasswordAuthenticationFilter 之前
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
这里我逐个拆解关键配置。
csrf(csrf -> csrf.disable())。CSRF 防护在基于表单会话的 Web 应用中非常重要,但在我们这种纯 API + JWT 无状态场景下,用户的身份凭证不是自动携带的 Cookie 而是 Header 中的 Bearer Token,CSRF 攻击的基本前提(浏览器自动携带凭证)不成立,所以关闭是合理选择。但注意——如果你采用了前面提到的“refreshToken 放 HttpOnly Cookie”方案,那么刷新接口就有 CSRF 风险,此时不能全局关闭,需要单独给 /api/auth/refresh 开 CSRF 防护,甚至校验请求头的自定义字段。
sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))。这一行必须写上,否则 Spring Security 默认会创建 session,虽然 JWT 方案用不到,但多一层无用的 session 生命周期管理就多一分安全隐患。
authorizeHttpRequests 的规则匹配顺序是从上到下,先声明先匹配,匹配成功就不再继续。所以 /api/auth/login 和 /api/auth/refresh 要放在最前面。/api/admin/** 的 hasRole("ADMIN") 是角色控制,底层会校验 ROLE_ADMIN 这个权限标识。
@EnableMethodSecurity 这个注解也值得说一句。它对应老版本的 @EnableGlobalMethodSecurity,是 Spring Security 6 的新名字。加上之后,你才可以在 Controller 方法上使用 @PreAuthorize("hasAuthority('sys:user:list')") 这种细粒度权限控制。比如中后台系统里常见的情景:同一个接口,管理员能访问,运营人员不能访问,用方法级安全实现比在过滤链规则里写死要灵活得多。
3.4 JWT 校验过滤器:每个请求必经之路
JwtAuthenticationTokenFilter 是整个鉴权链路的咽喉。它的职责简单明确:从请求头中提取 token,验签,解析身份信息,注入 SecurityContext。
java复制package com.example.auth.filter;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.auth.security.LoginUser;
import com.example.auth.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (StringUtils.hasText(token)) {
try {
DecodedJWT decodedJWT = jwtUtil.verifyToken(token);
LoginUser loginUser = LoginUser.builder()
.userId(decodedJWT.getClaim("userId").asLong())
.username(decodedJWT.getSubject())
.role(decodedJWT.getClaim("role").asString())
.build();
SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + loginUser.getRole());
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, Collections.singletonList(authority));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (TokenExpiredException e) {
// 令牌过期,不设置认证信息,由后续的 ExceptionTranslationFilter 返回 401
SecurityContextHolder.clearContext();
} catch (JWTVerificationException e) {
// 令牌无效或签名不匹配,同上
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
这个过滤器有几个设计细节值得关注:
第一,继承了 OncePerRequestFilter 而不是 Filter。 这个类保证过滤器在同一个请求生命周期中只会执行一次,避免因为转发(forward)导致重复执行。虽然我们的场景中不太会用到转发,但这属于基本功,写对不亏。
第二,token 解析失败不直接返回错误,而是清空 SecurityContext 继续往后走。 很多新手会把异常在这里 catch 然后手动写 response 返回 JSON。这种做法的坏处是绕过了 Spring Security 的异常处理链路,导致响应格式和全局异常格式不一致。正确姿势是让过滤器链继续执行,最后被 ExceptionTranslationFilter 接管,由其调用配置好的 AuthenticationEntryPoint 统一返回 JSON。
第三,SimpleGrantedAuthority 的 ROLE_ 前缀。 Spring Security 的 hasRole("ADMIN") 在底层会检查 ROLE_ADMIN 这个权限项。我在构造权限时加了 "ROLE_" + loginUser.getRole(),这样后面用 hasRole("ADMIN") 或 @PreAuthorize("hasRole('ADMIN')") 才能匹配上。如果你构造权限时忘了加这个前缀,你会发现接口莫名返回 403,而且多半排查半天找不到原因——这是我踩过的实实在在的坑。
3.5 登录与刷新接口:双令牌链路落地
现在到了把前面所有零件组装起来的一步。登录流程是这样的:接收用户名密码 -> 校验 -> 签发 accessToken + refreshToken -> 返回给前端;刷新流程是:接收 refreshToken -> 校验 -> 签发新的一组令牌。
java复制package com.example.auth.controller;
import com.example.auth.model.LoginRequest;
import com.example.auth.model.LoginResponse;
import com.example.auth.service.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest loginRequest) {
LoginResponse response = authService.login(loginRequest.getUsername(), loginRequest.getPassword());
return ResponseEntity.ok(response);
}
@PostMapping("/refresh")
public ResponseEntity<LoginResponse> refresh(@RequestBody RefreshTokenRequest request) {
LoginResponse response = authService.refresh(request.getRefreshToken());
return ResponseEntity.ok(response);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestHeader(value = "Authorization", required = false) String authHeader) {
authService.logout(authHeader);
return ResponseEntity.ok().build();
}
}
java复制package com.example.auth.service;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.auth.model.LoginResponse;
import com.example.auth.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
public LoginResponse login(String username, String password) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password));
User principal = (User) authentication.getPrincipal();
String accessToken = jwtUtil.generateAccessToken(
principal.getUsername(), getUserIdByUsername(principal.getUsername()), "USER");
String refreshToken = jwtUtil.generateRefreshToken(principal.getUsername());
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(30 * 60)
.build();
}
public LoginResponse refresh(String refreshToken) {
DecodedJWT jwt = jwtUtil.verifyToken(refreshToken);
// 检查 role 是否为 REFRESH
String role = jwt.getClaim("role").asString();
if (!"REFRESH".equals(role)) {
throw new BusinessException("非法的刷新令牌");
}
String username = jwt.getSubject();
String accessToken = jwtUtil.generateAccessToken(username, getUserIdByUsername(username), "USER");
String newRefreshToken = jwtUtil.generateRefreshToken(username);
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(newRefreshToken)
.expiresIn(30 * 60)
.build();
}
private Long getUserIdByUsername(String username) {
// 这里从数据库查询 userId,简化处理省略 DAO 层
return 10001L;
}
}
看到这里你可能有一个问题:AuthenticationManager 的 authenticate() 方法会去调用 UserDetailsService 加载用户信息并比对密码,那我们的 UserDetailsService 在哪?
这里我用的做法是:在 Spring Security 配置中注册一个 UserDetailsService Bean,从数据库查用户并构建 UserDetails 对象。简化代码如下:
java复制@Bean
public UserDetailsService userDetailsService() {
return username -> {
// 从数据库根据 username 查询用户
SysUser user = userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
return User.withUsername(user.getUsername())
.password(user.getPasswordHash())
.roles("USER")
.build();
};
}
User.withUsername(...).roles(...) 这种方式底层也会自动加 ROLE_ 前缀,所以到这里你会发现上下文中的权限是 ROLE_USER,和过滤器里手动构造的完全一致,不会出现逻辑矛盾。
至于 logout 接口——在纯 JWT 方案下,让令牌“立即失效”的唯一可靠方法就是把 token 加进黑名单或删除对应的 Redis 会话键。我们简单实现为:解析 token 拿到 jti(JWT ID),然后写入 Redis 黑名单,有效期设置为 token 剩余有效期。这样 JWT 过滤器再加一道 Redis 检查,命中黑名单就拒绝。
4. Token 续签策略与安全加固
4.1 续签策略的对比与选择
Token 续签是 JWT 方案里的一个“老生常谈但又必须自己拍板”的设计点。我总结下来,业界主流做法有三种:
方案一:固定过期时间 + 前端拦截。 前端在收到 401 且 code 为 TOKEN_EXPIRED 时,自动调用刷新接口,拿到新 token 后重放原请求。这是实现最简单的方案,但用户体验有个致命伤——如果用户正在填写一个很长很复杂的表单,accessToken 在填写过程中过期,重放请求会导致数据丢失或提交失败。
方案二:滑动过期。 只要用户一直在操作,就不断刷新 accessToken 的有效期。这个方案的实现思路是:每次请求通过 JWT 过滤器时,判断 token 还剩多少时间过期,如果少于阈值(比如 10 分钟),就自动签发新的 accessToken 并通过响应头(比如 X-New-Access-Token)返回给前端。前端拦截响应后更新本地 token。这个方案用户体验最好,实现也不复杂。
方案三:定时刷新。 前端设置一个定时器,比如在 accessToken 过期前 5 分钟自动调用刷新接口。这是目前 SPA 项目最常用的方案,代码逻辑最简单。
我的建议是:形态上用方案一或方案三,底层支持方案二的“临近过期自动换发”逻辑。 换句话说,既要让令牌在正常情况下短生命周期滚动,也要保证极端情况下(比如长时间未操作后提交)不会因为过期导致请求失败。
4.2 双令牌续签的安全边界
很多人在实现续签时只想着“怎么让用户不掉线”,忽略了续签接口本身就是个高危入口。
第一个要防的是 refreshToken 重放。想象一个攻击者拿到了你的 refreshToken,他可以在你不知情的情况下不断调用刷新接口,窃取新的 accessToken。要防这种攻击,必须在 Redis 里记录 refreshToken 的“单次性”——每次使用后立即作废,签发新的 refreshToken。也就是轮换策略。如果检测到同一个 refreshToken 被重复使用,那么基本可以断定令牌泄露,此时要终止该用户的所有会话,强制重新登录。
第二个要防的是 刷新接口鉴权缺失。有些开发者把刷新接口当作公开接口直接放行,然后只靠验证 refreshToken 自身的签名。这里有个隐患:如果你签发 refreshToken 和 accessToken 用的是同一个密钥、同一套 claim 结构,攻击者完全可以把过期的 accessToken 稍加改造(如果算法实现不够严谨)或者拿一个合法签发的 accessToken 去调刷新接口。虽然我们的 "REFRESH" role 拦截了一部分,但更稳妥的做法还是,在数据库或 Redis 中为每个用户维护一个 tokenVersion,只有在版本号匹配时才允许刷新。
4.3 密钥管理与敏感信息保护
密钥管理这块,说实话是国内很多团队最不重视但又最容易出事的环节。我看到过太多项目把密钥写在 application.yml 里,然后整个项目打包后扔到代码仓库,等于所有能访问仓库的同事都拿到了签名密钥。
推荐的本地开发配置方式是环境变量注入:
yaml复制jwt:
secret: ${JWT_SECRET}
access-token-expire-ms: 1800000
refresh-token-expire-ms: 604800000
issuer: your-system-name
生产环境则用配置中心(如 Nacos、Apollo)或云厂商的密钥管理服务(如 KMS)管理。至少要保证密钥不进代码仓库、定期轮换、不同环境使用不同密钥。
在代码层面,我还会对敏感信息做一层脱敏。比如日志里禁止输出 token 信息,异常信息里不允许把完整的 JWT 打出来。JWT 的 payload 是 base64 编码的,任何人都可以解码查看,只是无法篡改。所以 payload 里不要放手机号、身份证号这类真正的敏感数据。
提示:JWT 的“签”只保证完整性(没有被篡改),不保证保密性(内容可以被任何人读)。如果你需要在令牌中携带敏感数据,请用 JWE(JSON Web Encryption)格式而不是 JWS(JSON Web Signature)。但常规场景下建议直接不放敏感数据,查询时再获取。
5. 常见问题与安全漏洞排查实录
最后这部分是我的重头戏。我把做这个项目期间遇到的实际问题以及行业里常见的 JWT 漏洞整理成了排查清单。这些内容,常规文档里不会写在明面上,但实际生产环境里几乎都会遇到。
5.1 问题一:AuthenticationManager 无法注入或 UserDetailsService 循环依赖
现象: 启动报错 BeanCurrentlyInCreationException 或 NoSuchBeanDefinitionException。
原因: 新版 Spring Security 在自动配置 AuthenticationManager 时需要 UserDetailsService。如果你同时手动声明了 UserDetailsService Bean 和 AuthenticationManager Bean,且配置类之间存在互相引用,就容易触发循环依赖。
解决: AuthenticationManager 的获取统一走 AuthenticationConfiguration.getAuthenticationManager(),不要在配置类里手动 new。我在前面的示例代码里用的就是这种方式,这也是官方推荐姿势。
5.2 问题二:JWT 算法混淆攻击(alg=none 与 HS256/RS256 切换)
现象: 安全扫描发现系统存在 JWT 算法混淆漏洞。攻击者把 token 的 alg 头改成 none,或者把原本用 RS256 签发的 token 改成 HS256,然后使用服务器公钥作为 HMAC 密钥来签名,导致伪造 token 通过校验。
原因: 有些 JWT 库在验签时会读取 token 头部的 alg 字段来决定验签算法,而不是由服务端代码强硬指定。如果服务端做了什么“自适应算法”的判断,就可能中招。
解决: 在 JwtUtil.verifyToken() 中必须强制指定算法。auth0 的 Algorithm.HMAC256(secret) 本身就是固定的,所以只要你别在验签前动态读取 alg,这个问题基本就堵死了。另外,在生成 token 时也建议给 header 加一层固定声明,校验时核对。
5.3 问题三:JWT 过期后返回 401,但前端拿不到统一错误结构
现象: 前端请求接口发现 401 响应体是 Spring Security 默认的英文 HTML 或文本,而不是项目统一的 JSON 格式。
原因: 认证失败的异常在到达 ControllerAdvice 之前就被 ExceptionTranslationFilter 处理了,然后调用了 AuthenticationEntryPoint。默认的实现是返回 403 或 401 空响应。
解决: 自定义一个 RestAuthenticationEntryPoint,实现 AuthenticationEntryPoint 接口,在 commence 方法里手写 JSON 返回:
java复制@Component
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;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}");
}
}
然后在 SecurityConfig 中配置 .exceptionHandling(ex -> ex.authenticationEntryPoint(restAuthenticationEntryPoint))。
5.4 问题四:同一个 token 被多次使用,如何实现“单点登录或踢人下线”
现象: 需求要求“同一账号只能在一个地方登录”,或者管理员可以强制把某用户踢下线。
原因与方案: 前面说过,JWT 本身无法被服务端主动作废,所以必须引入 Redis 做状态管理。我的做法是:登录成功后,以 userId 为 key 生成一个会话 ID 写入 Redis,JWT 的 claim 中携带这个会话 ID。每次请求校验时,对比 JWT 中的会话 ID 和 Redis 中的当前会话 ID,不一致则拒绝。
这样实现“踢人下线”就特别简单:删除 Redis 中对应的会话记录即可。而“单点登录”只需要在登录时覆盖会话 ID,旧 token 自然失效。
5.5 问题五:CORS 配置导致预检请求 401
现象: 前端跨域请求时报错,浏览器发送的 OPTIONS 预检请求返回 401 而不是 200。
原因: 在 Spring Security 过滤链接管请求时,如果 CORS 配置不当,预检请求也会被 JWT 过滤器拦下。由于预检请求不带 Authorization 头,自然无法通过认证。
解决: 正确配置 CorsConfigurationSource,并确保在 SecurityConfig 中通过 .cors(cors -> cors.configurationSource(corsConfigurationSource)) 启用。Spring Security 会在过滤器链的最前面处理 CORS 预检请求,并直接返回,不会进入后面的认证环节。关键代码如下:
java复制@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://admin.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
注意:setAllowCredentials(true) 和 setAllowedOrigins("*") 不能同时使用。浏览器规范禁止在携带凭证(Cookie 或 Authorization 头)的跨域请求中使用通配符来源,所以必须显式列出允许的域名。
5.6 问题六:刷新令牌被使用多次怎么办
现象: 日志显示同一个 refreshToken 被多次使用,怀疑是攻击者在重放。
处理逻辑: 我在 Redis 中以 refreshToken 的哈希值作为 key,存储 userId。每次刷新接口被调用时:
- JWT 验签通过后,查 Redis 中是否存在该 refreshToken 的 key。
- 如果存在,说明第一次使用,删除旧 key,签发新的令牌,同时写入新的 refreshToken key。
- 如果不存在,说明是重放或已过期,立即阻断,并将该
userId下的所有会话标记为可疑,强制下线。
这样处理之后,就算 refreshToken 泄露,攻击者也只能用一次,而且第二次使用会直接触发安全机制,把用户的所有会话清理掉。
5.7 问题七:Spring Boot 3.x 中 spring.factories 失效
现象: 把一个老项目从 2.x 升级到 3.x 后,自定义的自动配置文件失效,Bean 没有被加载。
原因: Spring Boot 3.x 用了新的自动配置加载机制 AutoConfiguration.imports,不再是 spring.factories。
解决: 在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件中列出自动配置类全限定名。这一条和 JWT 鉴权没有直接关系,但如果你在升级过程中遇到“配置死活不生效”的问题,十有八九就是这个原因。
6. 实测心路:这套方案的性能表现与扩容弹性
这个项目的 JWT 鉴权部分上线后,我自己做了压测:登录接口 QPS 受限于数据库密码比对,大概 1200 左右;而业务接口在 JWT 验签 + Redis 会话校验的双重开销下,单实例 QPS 能稳定在 4000 以上(4c8g 的容器,业务逻辑本身很轻)。相比 session 方案需要查 Redis 或内存会话,JWT 的验签计算(HMAC-SHA256)耗时是微秒级的,这部分开销完全可以忽略。
真正影响性能的往往是 Redis 那一次网络 IO。所以我在过滤器里做了一个本地缓存优化:把“会话 ID 是否有效”的结果短时间缓存到 Caffeine 中,5 秒内相同 userId 的请求不再打 Redis。压测数据直接提升了 18% 左右,逻辑很简单,但收益挺可观。
扩容弹性上,这套方案的好处就体现出来了:应用实例无状态,随便加节点,网关把请求分发到哪台机器都能验签通过。只要保证 Redis 和数据库共享,理论上水平扩展没有上限。这一点在 session 时代是要专门做 session 共享组件才能实现的,现在则是默认能力。
最后分享一点实战体会
项目收尾后复盘,我最深的感受是:JWT 不是银弹,它只是把“会话数据存服务端”换成了“会话数据交客户端”,既解决了分布式会话共享的问题,也引入了主动失效难、令牌泄露风险等新问题。 所以做这个项目时,我始终带着一个核心原则——每一个安全决策都要有明确理由。
比如 refreshToken 存 HttpOnly Cookie 而不存 localStorage、JWT 里不放敏感字段、强制算法绑定、Redis 做会话状态兜底,这些设计如果单独拆开看都是“小事”,但组合在一起,就是一个能扛住常规安全扫描、也能应对实际攻击面的方案。
另外给准备照着做的人两个建议:第一,如果你是生产项目,一定要上线前做一次完整的安全自查,把常见 JWT 漏洞清单拿出来逐条过;第二,不要为了“纯净的无状态”硬扛所有需求,有些业务场景(比如强制下线、登录设备管理、密码修改后的会话失效)该用 Redis 就用 Redis,技术洁癖在业务需求面前不值钱。
如果你也在用这套技术栈做鉴权,遇到什么坑或者有更好的方案,欢迎在评论区聊一聊。这几年的经验让我越来越确信——安全设计这东西,多一个人讨论,就少一个踩坑的人。
