Spring Security + JWT 无状态认证实战——从理论到生产级实现完整方案
Spring Security + JWT 无状态认证实战——从理论到生产级实现完整方案
适读人群:需要在Spring Boot项目中实现JWT认证的Java后端开发者 | 阅读时长:约20分钟 | 核心价值:一套完整可运行的JWT认证方案,包含Token刷新、黑名单、安全最佳实践
第一次写JWT的我,不知道自己有多危险
2021年做第一个前后端分离项目,我用了JWT做认证。代码很简单,网上一搜就有,看起来没问题:
// 我当时用的"JWT工具类"
String token = Jwts.builder()
.setSubject(userId)
.setExpiration(new Date(System.currentTimeMillis() + 3600000))
.signWith(SignatureAlgorithm.HS256, "mySecret")
.compact();密钥是字符串"mySecret",硬编码在代码里。
没有Token刷新机制,Token过期用户就必须重新登录。没有Token黑名单,用户点了退出按钮,Token在过期前依然有效。密钥用的HS256,如果密钥泄漏,任何人都可以伪造Token。
好在项目只是内部工具,没出什么大事。但那之后我认真研究了JWT的安全细节,今天把一套生产级的方案完整写出来。
JWT 的结构与原理
JWT(JSON Web Token)由三部分组成,用.分隔:
Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiaWF0IjoxNjI2MDAwMDAwfQ.abc123...Header(头部): Base64编码,描述算法类型
{"alg": "HS256", "typ": "JWT"}Payload(载荷): Base64编码,存储声明(Claims)
{
"sub": "user123", // subject,通常是用户ID
"iat": 1626000000, // issued at,签发时间
"exp": 1626003600, // expiration,过期时间
"roles": ["USER"], // 自定义声明:用户角色
"tid": "tenant-001" // 自定义声明:租户ID
}Signature(签名): 用密钥对 Header+Payload 进行HMAC或RSA签名,防止内容被篡改
依赖与配置
<!-- pom.xml -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency># application.yml
app:
security:
jwt:
# 密钥:实际生产中用环境变量注入,绝对不能硬编码
secret: ${JWT_SECRET:请替换为至少256bit的强密钥不要用这个默认值}
access-token-expire: 1800 # access token 30分钟
refresh-token-expire: 604800 # refresh token 7天完整生产级实现
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* JWT 工具类 - 生产级实现
* 特性:双Token(access+refresh)、黑名单、密钥安全
*/
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenExpireMs;
private final long refreshTokenExpireMs;
// Token黑名单(退出登录时加入)
// 生产环境应用Redis实现,这里用内存演示
private final Set<String> blacklist = Collections.newSetFromMap(new ConcurrentHashMap<>());
public JwtTokenProvider(
@Value("${app.security.jwt.secret}") String secret,
@Value("${app.security.jwt.access-token-expire}") long accessExpireSeconds,
@Value("${app.security.jwt.refresh-token-expire}") long refreshExpireSeconds) {
// 密钥长度必须 >= 256bit(HMAC-SHA256要求)
if (secret.length() < 32) {
throw new IllegalArgumentException("JWT密钥长度必须至少32字符(256bit)");
}
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessTokenExpireMs = accessExpireSeconds * 1000;
this.refreshTokenExpireMs = refreshExpireSeconds * 1000;
}
/**
* 生成 Access Token(短期,用于接口认证)
*/
public String generateAccessToken(String userId, List<String> roles, String tenantId) {
return Jwts.builder()
.subject(userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenExpireMs))
.claim("roles", roles)
.claim("tid", tenantId)
.claim("type", "access") // Token类型标识
.id(UUID.randomUUID().toString()) // JWT唯一ID(jti),用于黑名单
.signWith(secretKey)
.compact();
}
/**
* 生成 Refresh Token(长期,用于刷新Access Token)
* Refresh Token只包含userId,不包含角色等信息
*/
public String generateRefreshToken(String userId) {
return Jwts.builder()
.subject(userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + refreshTokenExpireMs))
.claim("type", "refresh")
.id(UUID.randomUUID().toString())
.signWith(secretKey)
.compact();
}
/**
* 解析并验证Token
* @throws JwtException Token无效或已过期
*/
public Claims parseToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
// 检查黑名单
String jti = claims.getId();
if (jti != null && blacklist.contains(jti)) {
throw new JwtException("Token已失效(已退出登录)");
}
return claims;
} catch (ExpiredJwtException e) {
throw new TokenExpiredException("Token已过期", e);
} catch (JwtException e) {
throw new InvalidTokenException("Token无效: " + e.getMessage(), e);
}
}
/**
* 将Token加入黑名单(退出登录时调用)
*/
public void invalidateToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
String jti = claims.getId();
if (jti != null) {
blacklist.add(jti);
// 生产环境:blacklist.set("jwt:blacklist:" + jti, "1", ttl, TimeUnit.SECONDS)
}
} catch (JwtException ignored) {
// 无效Token不需要加入黑名单
}
}
public String getUserId(Claims claims) { return claims.getSubject(); }
@SuppressWarnings("unchecked")
public List<String> getRoles(Claims claims) {
return (List<String>) claims.get("roles", List.class);
}
public String getTokenType(Claims claims) {
return claims.get("type", String.class);
}
}
/**
* JWT认证过滤器
* 在每次请求中解析JWT,设置认证信息到SecurityContext
*/
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
private final SecurityContextRepository securityContextRepository =
new RequestAttributeSecurityContextRepository(); // Spring Security 6.x
// 不需要JWT认证的路径
private static final List<String> WHITELIST = List.of(
"/api/auth/login", "/api/auth/register", "/api/auth/refresh",
"/actuator/health", "/swagger-ui", "/v3/api-docs"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
String requestPath = request.getServletPath();
if (WHITELIST.stream().anyMatch(requestPath::startsWith)) {
filterChain.doFilter(request, response);
return;
}
String token = extractToken(request);
if (token != null) {
try {
Claims claims = tokenProvider.parseToken(token);
// 验证这是access token,不接受refresh token访问接口
if (!"access".equals(tokenProvider.getTokenType(claims))) {
sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "请使用Access Token");
return;
}
String userId = tokenProvider.getUserId(claims);
List<String> roles = tokenProvider.getRoles(claims);
// 构建认证信息(不再查数据库,从Token中直接取,减少DB查询)
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, authorities);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 设置SecurityContext(Spring Security 6.x显式保存)
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, request, response);
} catch (TokenExpiredException e) {
sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "Token已过期,请刷新");
return;
} catch (InvalidTokenException e) {
sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "Token无效");
return;
}
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private void sendError(HttpServletResponse response, int status, String message)
throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("""
{"code": %d, "message": "%s"}
""".formatted(status, message));
}
}
/**
* 认证Controller:登录、刷新Token、退出
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtTokenProvider tokenProvider;
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
@PostMapping("/login")
public TokenResponse login(@RequestBody @Valid LoginRequest request) {
// 委托给Spring Security进行认证
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
UserDetails user = (UserDetails) authentication.getPrincipal();
List<String> roles = user.getAuthorities().stream()
.map(a -> a.getAuthority().replace("ROLE_", ""))
.toList();
String userId = user.getUsername();
String accessToken = tokenProvider.generateAccessToken(userId, roles, "default");
String refreshToken = tokenProvider.generateRefreshToken(userId);
return new TokenResponse(accessToken, refreshToken, 1800L);
}
@PostMapping("/refresh")
public TokenResponse refreshToken(@RequestBody RefreshRequest request) {
Claims claims;
try {
claims = tokenProvider.parseToken(request.refreshToken());
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh Token无效或已过期");
}
if (!"refresh".equals(tokenProvider.getTokenType(claims))) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请提供Refresh Token");
}
String userId = tokenProvider.getUserId(claims);
// 从数据库获取最新的用户角色(防止角色变更后Token还有旧权限)
List<String> roles = userRepository.findRolesByUserId(userId);
String newAccessToken = tokenProvider.generateAccessToken(userId, roles, "default");
// 可选:同时生成新的Refresh Token(滚动刷新)
String newRefreshToken = tokenProvider.generateRefreshToken(userId);
tokenProvider.invalidateToken(request.refreshToken()); // 使旧Refresh Token失效
return new TokenResponse(newAccessToken, newRefreshToken, 1800L);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestHeader("Authorization") String bearerToken,
@RequestBody(required = false) LogoutRequest request) {
// 将access token加入黑名单
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
tokenProvider.invalidateToken(bearerToken.substring(7));
}
// 将refresh token也加入黑名单
if (request != null && request.refreshToken() != null) {
tokenProvider.invalidateToken(request.refreshToken());
}
return ResponseEntity.noContent().build();
}
record LoginRequest(String username, String password) {}
record RefreshRequest(String refreshToken) {}
record LogoutRequest(String refreshToken) {}
record TokenResponse(String accessToken, String refreshToken, long expiresIn) {}
}三个踩坑实录
坑一:JWT密钥硬编码,密钥泄漏后无法补救
现象: 代码push到公开仓库,被人拿到密钥,伪造任意用户的Token,在生产环境横行了两天才发现。
原因: signWith(SignatureAlgorithm.HS256, "hardcodedSecret"),密钥直接在代码里。
解法: 密钥必须从环境变量注入,绝对不能出现在代码或配置文件里:
export JWT_SECRET=$(openssl rand -base64 64) # 生成强密钥同时,HS256改用RS256(RSA非对称签名)会更安全:私钥签名,公钥验证,公钥可以分发给各服务,私钥只在认证服务里保存。
坑二:Refresh Token 未做单设备限制,被转移使用
现象: 用户A的账号被异常登录,分析发现其Refresh Token在另一个IP上被使用了。
原因: Refresh Token是长期有效的,一旦泄漏(比如XSS窃取),攻击者可以持续刷新Access Token而用户毫不知情。
解法:
- 实现Refresh Token Rotation:每次使用Refresh Token都换发新的,旧的立刻失效
- 检测Refresh Token重复使用:如果一个已失效的Refresh Token被使用,立刻吊销整个Token家族
- 将Refresh Token存入数据库,做设备绑定(与设备指纹/IP绑定)
坑三:没有Token过期处理,用户突然被踢出
现象: Access Token 30分钟后过期,前端收到401,跳到了登录页,用户的编辑内容全丢了,体验极差。
原因: 前端没有做Token过期前自动刷新的逻辑。
解法: 前端在Token过期前(比如提前5分钟)主动用Refresh Token换取新的Access Token。也可以用"无感刷新":请求失败收到401时,自动用Refresh Token重试,对用户透明。
// Axios 响应拦截器(前端参考)
axios.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
const newToken = await refreshAccessToken(); // 用refreshToken换新token
error.config.headers.Authorization = `Bearer ${newToken}`;
return axios(error.config); // 用新token重试原请求
}
return Promise.reject(error);
}
);JWT 安全清单
- 密钥长度 >= 256bit,从环境变量注入
- 优先使用RS256而不是HS256
- Access Token 有效期 <= 30分钟
- Refresh Token 实现Rotation机制
- 退出登录时将Token加入黑名单(Redis TTL = Token剩余有效期)
- Payload中不存储敏感信息(密码、身份证号等)
- 验证typ字段,拒绝用Refresh Token访问业务接口
- 启用HTTPS,防止Token在传输中被截获
小结
JWT的核心是"自包含"——Token里携带了用户信息,服务端不需要查数据库就能完成认证,这是无状态架构的基础。
但"无状态"带来的代价是:Token难以主动失效。这就需要黑名单机制(Redis)来补充。
双Token方案(Access + Refresh)是业界的标准做法,短期的Access Token降低密钥泄露风险,长期的Refresh Token提供续期能力。
