纲要
- 令牌设计模式: 为什么需要区分
Access Token与Refresh Token - 前端令牌存储策略: 内存、
Cookie(HTTPOnly)、sessionStorage、localStorage安全性对比 - Spring Security 自定义 JWT 认证过滤器核心思路: 直接构造
Authentication对象并置入SecurityContext - JWT 工具类增强: 支持签名密钥分离、创建访问/刷新令牌的 Helper 方法
- 外部配置实现: 使用
@ConfigurationProperties将过期时间等参数迁移至application.yml - 完整项目结构: 展示包结构和关键类
- 配置示例与单元测试修复
访问令牌与刷新令牌的设计哲学
在分布式认证体系中,令牌一旦签发即视为“已暴露”。没有任何客户端存储方案能百分之百阻止令牌被窃取,因此安全策略的核心是降低令牌泄露后的影响范围与时间窗口。由此引入两种令牌:
- 访问令牌 (
Access Token):直接携带权限访问资源,生命周期极短(分钟级),即使泄露也会快速失效。 - 刷新令牌 (
Refresh Token):不能直接访问资源,唯一用途是换取新的访问令牌,有效期较长(小时、天或月),且签发时使用不同密钥。
这种职责分离的设计,配合 IP 风控、异常行为检测、多因子认证等手段,可以构建纵深防御体系。
令牌在前端的存储选择
| 存储方式 | 安全性 | 特点 |
|---|---|---|
| 内存 (Vuex/Redux) | 最高 | 页面刷新即丢失,需重新登录 |
Cookie (HTTPOnly, Secure, SameSite) | 较高 | 只能由服务端设置,无法通过 JS 读取,可限制域名 |
sessionStorage | 中等 | 关闭浏览器标签页后清除,略优于 localStorage |
localStorage | 较低 | 永久存储,所有同源脚本均可访问,不推荐 |
实际项目中建议采用 Cookie (HTTPOnly) 存储刷新令牌,访问令牌可存于内存并配合静默刷新。无论采用哪种方案,都不应只依赖令牌本身的安全性,还需要在服务端结合限流、设备指纹、异地登录检测等多重措施。
Spring Security 集成 JWT 的核心步骤
Spring Security 没有内置适用于前后端分离的 JwtAuthenticationFilter,但实现一个自定义过滤器非常简单。整个认证流程的“心脏”就是将填充完整的 Authentication 对象放入 SecurityContextHolder:
- 从请求头中提取 JWT 字符串
- 解析并校验令牌
- 构造
UsernamePasswordAuthenticationToken,设置权限 - 调用
SecurityContextHolder.getContext().setAuthentication(...)
认证失败时清空上下文并交由后续过滤器链处理。
下面我们就从改造 JWT 工具类开始,逐步完成一个可外部配置的生产级 JWT 过滤器。
JWT 工具类增强:支持双密钥与外部配置
新增 AppProperties 配置类
将令牌过期时间与签名密钥等抽离到配置文件中,避免硬编码。
package com.example.demo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private Jwt jwt = new Jwt();
public Jwt getJwt() {
return jwt;
}
public void setJwt(Jwt jwt) {
this.jwt = jwt;
}
public static class Jwt {
/** 访问令牌过期时间(毫秒),默认5分钟 */
private long accessTokenExpireTime = 5 * 60 * 1000L;
/** 刷新令牌过期时间(毫秒),默认30天 */
private long refreshTokenExpireTime = 30 * 24 * 60 * 60 * 1000L;
/** 访问令牌签名密钥 */
private String accessTokenSecret = "default-access-secret";
/** 刷新令牌签名密钥 */
private String refreshTokenSecret = "default-refresh-secret";
// getters and setters
public long getAccessTokenExpireTime() {
return accessTokenExpireTime;
}
public void setAccessTokenExpireTime(long accessTokenExpireTime) {
this.accessTokenExpireTime = accessTokenExpireTime;
}
public long getRefreshTokenExpireTime() {
return refreshTokenExpireTime;
}
public void setRefreshTokenExpireTime(long refreshTokenExpireTime) {
this.refreshTokenExpireTime = refreshTokenExpireTime;
}
public String getAccessTokenSecret() {
return accessTokenSecret;
}
public void setAccessTokenSecret(String accessTokenSecret) {
this.accessTokenSecret = accessTokenSecret;
}
public String getRefreshTokenSecret() {
return refreshTokenSecret;
}
public void setRefreshTokenSecret(String refreshTokenSecret) {
this.refreshTokenSecret = refreshTokenSecret;
}
}
}
改进后的 JwtUtil 工具类
工具类提供生成访问令牌、刷新令牌以及解析验证的核心逻辑,密钥和过期时间均从 AppProperties 注入。
package com.example.demo.util;
import com.example.demo.config.AppProperties;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
@Component
public class JwtUtil {
private final AppProperties appProperties;
public JwtUtil(AppProperties appProperties) {
this.appProperties = appProperties;
}
/**
* 生成访问令牌
*/
public String createAccessToken(String subject) {
AppProperties.Jwt jwtConfig = appProperties.getJwt();
return generateToken(subject, jwtConfig.getAccessTokenSecret(),
jwtConfig.getAccessTokenExpireTime());
}
/**
* 生成刷新令牌
*/
public String createRefreshToken(String subject) {
AppProperties.Jwt jwtConfig = appProperties.getJwt();
return generateToken(subject, jwtConfig.getRefreshTokenSecret(),
jwtConfig.getRefreshTokenExpireTime());
}
/**
* 通用令牌生成方法
*/
private String generateToken(String subject, String secret, long expirationMs) {
Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Date now = new Date();
Date expiration = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析令牌,返回主体(用户名)
*/
public String parseToken(String token, String secret) {
Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return claimsJws.getBody().getSubject();
}
/**
* 校验令牌是否有效
*/
public boolean validateToken(String token, String secret) {
try {
parseToken(token, secret);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 获取访问令牌密钥(供过滤器使用)
*/
public String getAccessTokenSecret() {
return appProperties.getJwt().getAccessTokenSecret();
}
}
配置文件 application.yml
通过 app.jwt 前缀即可自定义密钥和过期时间,IDE 会自动提示。
app:
jwt:
access-token-expire-time: 300000 # 5分钟
refresh-token-expire-time: 2592000000 # 30天
access-token-secret: my-access-secret-key-must-be-long
refresh-token-secret: my-refresh-secret-key-must-be-long
自定义 JWT 认证过滤器
基于上述工具类,我们可以轻松编写一个 JwtAuthenticationFilter,它只做三件事:提取、验证、写入上下文。
package com.example.demo.security;
import com.example.demo.util.JwtUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@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);
String secret = jwtUtil.getAccessTokenSecret();
if (jwtUtil.validateToken(token, secret)) {
String username = jwtUtil.parseToken(token, secret);
// 实际项目应从数据库加载用户权限,此处仅做示例
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
// 令牌无效,清空上下文交给后续过滤器
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}
最后,在 Spring Security 配置类中将自定义过滤器添加至 UsernamePasswordAuthenticationFilter 之前即可生效。
项目代码结构
src/main/java/com/example/demo/
├── config
│ └── AppProperties.java
├── security
│ ├── JwtAuthenticationFilter.java
│ └── SecurityConfig.java
├── util
│ └── JwtUtil.java
└── DemoApplication.java
src/main/resources/
└── application.yml
单元测试修复
原有的测试用例需要传入 AppProperties 才能正常使用 JwtUtil。可以通过手动构造默认配置或使用 @SpringBootTest 加载上下文来解决:
// 简单构造示例
AppProperties props = new AppProperties();
// 内部 Jwt 对象已有默认值,无需额外设置
JwtUtil jwtUtil = new JwtUtil(props);
String accessToken = jwtUtil.createAccessToken("testuser");
assertNotNull(accessToken);
assertEquals("testuser", jwtUtil.parseToken(accessToken, props.getJwt().getAccessTokenSecret()));
总结
本文从令牌类型设计、安全存储策略开始,详细讲解了如何在 Spring Security 体系中集成 JWT,并通过 @ConfigurationProperties 将重要参数外部化,最终实现了一个可配置、易扩展的 JWT 认证过滤器。
所附代码包含完整的 package 和 import,可直接复制运行,只需确保项目已引入 spring-boot-starter-security 和 jjwt 依赖。

807

被折叠的 条评论
为什么被折叠?



