上一篇说了要写这个系列,今天兑现承诺。
这篇要做的:从零搭建一个完整的 JWT 认证系统,能直接用在项目里的那种。代码全部开源,保证能跑。
先看整体流程
核心特性:
- 双 Token 机制:Access Token(15分钟) + Refresh Token(7天)
- 自动刷新:Token 过期自动换新,用户无感知
- 可撤销:Refresh Token 存数据库,随时可撤销
- 无状态:不依赖 Session,天然支持分布式
要解决的核心问题
做认证系统不只是登录登出,真正要考虑的是:
- Token 过期怎么办? 总让用户登录体验太差 → 用 Refresh Token 自动刷新
- Token 泄露怎么办? 不能让它一直有效 → Access Token 短期(15分钟),Refresh Token 可撤销
- 分布式部署怎么办? Session 那套不好使 → JWT 无状态,哪台服务器都能验证
- 前端怎么处理? 手动刷新太麻烦 → Axios 拦截器自动处理
- 密码怎么存? 明文肯定不行 → BCrypt 加密,防暴力破解
技术选型
后端: Java 17 + Spring Boot 3.3.2 + Spring Security 6.x + JWT + MyBatis + MySQL 8.0
前端: Vue 3 + Vite + Axios
为什么不用 Shiro?
两个原因:
- 后面要做微服务、OAuth2、网关鉴权,Spring Security 生态更好
- Spring Security 功能更强,方法级权限、SpEL 表达式都支持
是的,Spring Security 配置复杂点,但我会讲透为什么要这样配,不是照抄代码。
为什么要用双 Token
我刚开始做 JWT 的时候,就用一个 Token,设置 7 天有效期,简单省事。
但后来发现问题:
- Token 泄露了怎么办?7 天内都可以拿着这个 Token 访问,没办法让它失效
- 缩短有效期?那用户每天都要登录,体验太差
后来参考了若依的方案,用双 Token:
设计思路:
- Access Token 短命(15分钟): 即使泄露,损失窗口很小
- Refresh Token 长效(7天): 存数据库,可以随时撤销
- 自动刷新: 前端无感知,用户体验好
实际场景举例:
场景1: 用户改密码
- 把该用户所有 Refresh Token 状态改成 REVOKED
- 旧 Token 立即失效,必须重新登录
场景2: 检测到异常登录
- 撤销该设备的 Refresh Token
- 强制用户重新认证
场景3: 正常使用
- Access Token 过期自动刷新
- 用户一直在用,可以保持 7 天登录态
好处是:
- Access Token 泄露了,15 分钟后就失效,损失可控
- Refresh Token 可以单独撤销(存数据库),比如用户改密码后,把旧的 Refresh Token 全删掉
- 用户无感知,Access Token 过期自动刷新
这就是我说的,平衡安全和体验。
JWT 长什么样
JWT 就是一个很长的字符串,用 . 分成三段:
类型: JWT] C --> C1[用户名
角色
过期时间] D --> D1[HMAC签名
防篡改] style A fill:#E0E0E0 style B fill:#87CEEB style C fill:#90EE90 style D fill:#FFB6C1
举个例子:
erlang
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImp0aSI6IjEyMzQ1IiwiaWF0IjoxNzAxMjM0NTY3LCJleHAiOjE3MDEyMzU0NjcsImlzcyI6InN1cmZpbmctYXV0aCIsInR5cGUiOiJhY2Nlc3MifQ.abc123def456...
看着乱,其实就是 Base64 编码,解码后就是 JSON:
Header(头部):
json
{
"alg": "HS256",
"typ": "JWT"
}
说明用的是 HMAC SHA-256 签名算法。
Payload(载荷):
json
{
"sub": "admin",
"roles": ["admin"],
"jti": "12345",
"iat": 1701234567,
"exp": 1701235467,
"iss": "surfing-auth",
"type": "access"
}
这里面:
- sub: 用户名
- roles: 角色列表(直接存 Token 里,不用每次查数据库)
- jti: Token 的唯一 ID,用来追踪和撤销
- iat: 签发时间
- exp: 过期时间
- type: 标记是 access 还是 refresh
Signature(签名):
scss
HMACSHA256(
base64(header) + "." + base64(payload),
secret
)
签名的作用是防篡改。用户拿到 Token 后,可以自己解码看里面的内容(所以别放敏感信息),但改不了。一改签名就对不上,后端验证就会失败。
数据库怎么设计
认证系统涉及三张核心表:
用户表(sys_user):
sql
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(100) NOT NULL, -- BCrypt 加密后的密码
nickname VARCHAR(50),
status TINYINT DEFAULT 1, -- 1启用 0禁用
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
角色表(sys_role):
sql
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(50),
role_code VARCHAR(50) UNIQUE NOT NULL -- 如: admin, user
);
用户角色关联表(sys_user_role):
sql
CREATE TABLE sys_user_role (
user_id BIGINT,
role_id BIGINT,
PRIMARY KEY (user_id, role_id)
);
Refresh Token 表(sys_refresh_token):
这个是重点,用来管理 Refresh Token 的生命周期:
sql
CREATE TABLE sys_refresh_token (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
jti VARCHAR(36) UNIQUE NOT NULL, -- JWT ID
status VARCHAR(20) DEFAULT 'ACTIVE', -- ACTIVE, REVOKED
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME,
INDEX idx_user_id (user_id),
INDEX idx_jti (jti),
INDEX idx_user_status (user_id, status)
);
为什么要单独存 Refresh Token?
因为 JWT 一旦签发,没法主动让它失效。但我们可以通过数据库记录它的状态:
- 用户改密码?把他所有的 Refresh Token 状态改成 REVOKED
- 用户登出?撤销当前的 Refresh Token
- Refresh Token 被用来刷新?旧的标记 REVOKED,生成新的
这样就实现了 JWT 的可控撤销。
后端核心代码讲解
1. JWT 生成和验证
我封装了一个 JwtService,专门处理 Token 的生成和解析:
java
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-ttl-minutes}")
private long accessTtlMinutes;
@Value("${jwt.refresh-ttl-days}")
private long refreshTtlDays;
// 生成 Access Token
public String createAccessToken(String username, List<String> roleCodes) {
Date now = new Date();
Date expiry = new Date(now.getTime() + accessTtlMinutes * 60 * 1000);
return Jwts.builder()
.setSubject(username)
.claim("roles", roleCodes)
.claim("type", "access")
.setId(UUID.randomUUID().toString())
.setIssuedAt(now)
.setExpiration(expiry)
.setIssuer("surfing-auth")
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
// 生成 Refresh Token
public String createRefreshToken(Long userId, String username) {
Date now = new Date();
Date expiry = new Date(now.getTime() + refreshTtlDays * 24 * 3600 * 1000);
String jti = UUID.randomUUID().toString();
// 存数据库
SysRefreshToken refreshToken = new SysRefreshToken();
refreshToken.setUserId(userId);
refreshToken.setJti(jti);
refreshToken.setExpiresAt(expiry);
refreshTokenMapper.insert(refreshToken);
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.claim("type", "refresh")
.setId(jti)
.setIssuedAt(now)
.setExpiration(expiry)
.setIssuer("surfing-auth")
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
// 解析 Token
public Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secret)
.build()
.parseClaimsJws(token)
.getBody();
}
}
关键点:
- secret 必须够长,至少 256 位,否则不安全
- Refresh Token 的 jti 要存数据库,用来追踪状态
- 角色信息直接放 Access Token 里,避免每次请求都查库
2. Spring Security 配置
这是最关键的部分,告诉 Spring Security 怎么做认证。
先看下 Spring Security 的过滤器链是怎么工作的:
关键配置项:
- STATELESS: 完全无状态,不创建 Session
- permitAll: 登录接口必须放行
- 过滤器顺序: JWT 过滤器要在认证过滤器之前
代码实现:
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRF,因为是无状态 API
.csrf(csrf -> csrf.disable())
// 完全无状态,不创建 Session
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置哪些接口需要认证
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 登录接口放行
.anyRequest().authenticated() // 其他都需要认证
)
// 添加 JWT 过滤器
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
// 异常处理
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint) // 401
.accessDeniedHandler(accessDeniedHandler) // 403
);
return http.build();
}
// 密码加密器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
重点说几个:
STATELESS:这个配置很重要,告诉 Spring Security 不要创建 Session。传统的 Session 认证,每次请求都要查 Session,分布式部署还要做 Session 共享。用 JWT 就是为了无状态,所以必须设置成 STATELESS。
permitAll:登录接口必须放行,不然用户还没登录,怎么访问登录接口?这个我刚开始配置时忘了,结果陷入死循环,登录接口也要认证,认证又需要先登录。
过滤器顺序:JWT 过滤器必须放在 UsernamePasswordAuthenticationFilter 之前。因为我们要在 Spring Security 处理认证之前,先从 Token 里提取用户信息,设置到 SecurityContext。
3. JWT 过滤器
这个过滤器的作用是:拦截每个请求,从 Header 里提取 Token,验证后设置认证信息。
java
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
// 1. 从 Header 提取 Token
String bearerToken = request.getHeader("Authorization");
if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = bearerToken.substring(7);
try {
// 2. 解析 Token
Claims claims = jwtService.parseClaims(token);
// 3. 验证类型(必须是 access)
String type = claims.get("type", String.class);
if (!"access".equals(type)) {
chain.doFilter(request, response);
return;
}
// 4. 提取用户信息
String username = claims.getSubject();
List<String> roles = claims.get("roles", List.class);
// 5. 转换成 Spring Security 的权限对象
List<GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 6. 创建认证对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
// 7. 设置到 SecurityContext(线程本地变量)
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
// Token 无效或过期,不设置认证信息
// 后续会被 AuthenticationEntryPoint 处理,返回 401
}
// 8. 继续过滤链
chain.doFilter(request, response);
}
}
为什么要继承 OncePerRequestFilter?
因为 Spring 的过滤器可能会被调用多次(转发、包含等场景),OncePerRequestFilter 保证每个请求只执行一次。
SecurityContextHolder 是什么?
它是 Spring Security 的核心,用 ThreadLocal 存储当前请求的认证信息。后续的业务代码可以通过它获取当前用户:
java
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
4. 登录接口
java
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthService authService;
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginRequest request) {
LoginResponse response = authService.login(
request.getUsername(),
request.getPassword()
);
return AjaxResult.success(response);
}
@PostMapping("/refresh")
public AjaxResult refresh(@RequestBody RefreshRequest request) {
RefreshResponse response = authService.refresh(request.getRefreshToken());
return AjaxResult.success(response);
}
@PostMapping("/logout")
public AjaxResult logout(@RequestBody LogoutRequest request) {
authService.logout(request.getRefreshToken());
return AjaxResult.success();
}
}
AuthService 的核心逻辑:
java
@Service
public class AuthService {
@Autowired
private SysUserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtService jwtService;
@Autowired
private RefreshTokenMapper refreshTokenMapper;
public LoginResponse login(String username, String password) {
// 1. 查询用户
SysUser user = userMapper.findByUsername(username);
if (user == null) {
throw new RuntimeException("用户名或密码错误");
}
// 2. 验证密码(BCrypt 会自动处理盐值)
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new RuntimeException("用户名或密码错误");
}
// 3. 查询角色
List<String> roleCodes = userMapper.findRoleCodesByUsername(username);
// 4. 生成 Token
String accessToken = jwtService.createAccessToken(username, roleCodes);
String refreshToken = jwtService.createRefreshToken(user.getId(), username);
// 5. 返回
LoginResponse response = new LoginResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
return response;
}
public RefreshResponse refresh(String refreshToken) {
// 1. 解析 Token
Claims claims = jwtService.parseClaims(refreshToken);
// 2. 验证类型
String type = claims.get("type", String.class);
if (!"refresh".equals(type)) {
throw new RuntimeException("无效的 Refresh Token");
}
// 3. 验证数据库状态
String jti = claims.getId();
SysRefreshToken dbToken = refreshTokenMapper.findByJti(jti);
if (dbToken == null || !"ACTIVE".equals(dbToken.getStatus())) {
throw new RuntimeException("Refresh Token 已失效");
}
// 4. 撤销旧 Token(Token 旋转机制)
refreshTokenMapper.revokeByJti(jti);
// 5. 查询角色
String username = claims.getSubject();
Long userId = claims.get("userId", Long.class);
List<String> roleCodes = userMapper.findRoleCodesByUsername(username);
// 6. 生成新 Token
String newAccessToken = jwtService.createAccessToken(username, roleCodes);
String newRefreshToken = jwtService.createRefreshToken(userId, username);
// 7. 返回
RefreshResponse response = new RefreshResponse();
response.setAccessToken(newAccessToken);
response.setRefreshToken(newRefreshToken);
return response;
}
public void logout(String refreshToken) {
Claims claims = jwtService.parseClaims(refreshToken);
String jti = claims.getId();
refreshTokenMapper.revokeByJti(jti);
}
}
关键点:
BCrypt 密码加密:BCrypt 是专门用来加密密码的算法,慢速设计,防暴力破解。每次加密同一个密码,结果都不一样,因为内部有随机盐值。验证时用 matches 方法,它会自动提取盐值比对。
Token 旋转机制:每次刷新,旧的 Refresh Token 立即失效,生成新的。这样即使 Refresh Token 泄露,也只能用一次。如果攻击者用旧 Token 再次刷新,会被检测到,可以撤销该用户所有 Token。
前端怎么处理
前端的核心是 Axios 拦截器,自动处理 Token 刷新。
1. 存储 Token
javascript
// authStore.js
export const authStore = {
getAccessToken() {
return localStorage.getItem('access_token')
},
getRefreshToken() {
return localStorage.getItem('refresh_token')
},
setTokens(accessToken, refreshToken) {
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
},
clear() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
}
为什么用 localStorage 不用 Cookie?
- JWT 不依赖 Cookie,移动端友好
- 跨域时 Cookie 比较麻烦
- localStorage 前端控制更灵活
但要注意 XSS 攻击,所以前端要做好输入验证和输出转义。
2. 请求拦截器
javascript
// 请求拦截:自动带上 Token
http.interceptors.request.use(
config => {
const token = authStore.getAccessToken()
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
3. 响应拦截器(自动刷新 Token)
这是最复杂的部分,要处理并发请求时的刷新。
先看下并发场景的问题:
所以需要单飞控制,只发起一次刷新:
代码实现:
javascript
let isRefreshing = false // 是否正在刷新
let failedQueue = [] // 失败的请求队列
// 处理队列中的请求
function processQueue(error, token = null) {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error)
} else {
prom.resolve(token)
}
})
failedQueue = []
}
// 响应拦截:401 自动刷新
http.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config
// 如果是 401 且不是刷新接口本身
if (error.response?.status === 401 && !originalRequest._retry) {
// 如果正在刷新,把请求加入队列
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`
return http(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
const refreshToken = authStore.getRefreshToken()
if (!refreshToken) {
// 没有 Refresh Token,跳转登录
router.push('/login')
return Promise.reject(error)
}
try {
// 刷新 Token
const resp = await http.post('/api/auth/refresh', { refreshToken })
const { accessToken, refreshToken: newRefreshToken } = resp.data.data
// 更新存储
authStore.setTokens(accessToken, newRefreshToken)
// 处理队列
processQueue(null, accessToken)
// 重试原请求
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`
return http(originalRequest)
} catch (err) {
// 刷新失败,清空 Token,跳转登录
processQueue(err, null)
authStore.clear()
router.push('/login')
return Promise.reject(err)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
关键点:
单飞控制:isRefreshing 标志位保证同一时间只有一个刷新请求。如果多个接口同时返回 401,只发起一次刷新,其他请求等待。
请求队列:等待刷新的请求放到 failedQueue,刷新成功后统一重试。
_retry 标记:防止刷新失败后再次进入拦截器,死循环。
4. 登录组件
vue
<template>
<div class="login-page">
<form @submit.prevent="handleLogin">
<input v-model="username" placeholder="用户名" />
<input v-model="password" type="password" placeholder="密码" />
<button type="submit">登录</button>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import http from '@/utils/http'
import { authStore } from '@/stores/auth'
const router = useRouter()
const username = ref('')
const password = ref('')
async function handleLogin() {
try {
const resp = await http.post('/api/auth/login', {
username: username.value,
password: password.value
})
const { accessToken, refreshToken } = resp.data.data
authStore.setTokens(accessToken, refreshToken)
router.push('/dashboard')
} catch (err) {
alert('登录失败:' + err.message)
}
}
</script>
踩过的坑
1. CORS 跨域问题
前后端分离,肯定会遇到跨域。后端配置:
java
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOriginPattern("*"); // 开发环境
// config.addAllowedOrigin("https://yourdomain.com"); // 生产环境
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
生产环境记得把 * 改成具体域名,不然不安全。
2. JWT 密钥太短
我一开始用的密钥是 "mySecret",结果报错:
vbnet
The signing key's size is 64 bits which is not secure enough for the HS256 algorithm.
HS256 算法要求密钥至少 256 位,也就是 32 个字节。后来改成:
yaml
jwt:
secret: W3jv9u2yLq8Zr1Pd5Xs6Cg0NmBvQkRtUyHaJeKpLxMnOpQrStUvWxYz12345678
用随机字符串生成,够长够复杂。
3. 前端刷新 Token 时的并发问题
最开始没做单飞控制,多个接口同时 401,就发起多个刷新请求。结果:
- 第一个刷新成功,拿到新 Token
- 第二个刷新也成功,又拿到新 Token
- 第一个新 Token 还没用,就被第二个撤销了
- 用户又要重新登录
后来加了 isRefreshing 标志位和请求队列,解决了。
4. Refresh Token 没存数据库
刚开始我只把 Refresh Token 发给前端,没存数据库。结果用户改密码后,旧 Token 还能用,没法撤销。
后来加了 sys_refresh_token 表,每次刷新都检查数据库状态,解决了。
安全建议
做认证系统,安全最重要:
- 密码加密:必须用 BCrypt,别用 MD5,太容易被破解
- HTTPS:生产环境必须 HTTPS,否则 Token 明文传输
- JWT 密钥:够长够复杂,别泄露,定期更换
- 敏感信息别放 Payload:JWT 可以被解码,别放密码、身份证号这些
- XSS 防护:前端做好输入验证和输出转义
- CORS 限制 :生产环境别用
*,只允许信任的域名 - Token 有效期:Access Token 别太长,15 分钟合适
- Refresh Token 旋转:每次刷新生成新的,撤销旧的
性能优化
虽然 JWT 无状态,性能已经不错了,但还能优化:
- 数据库索引:username、jti 字段加索引,查询快
- 角色信息缓存:直接放 Access Token 里,不用每次查库
- 连接池配置:HikariCP 配置合理的最大连接数
- 前端缓存用户信息:不用每次都请求 /profile 接口
总结
这套认证系统,虽然代码不多,但把核心问题都解决了:
- JWT 双 Token 机制,平衡安全和体验
- Token 旋转,限制泄露风险
- Spring Security 无状态配置,适合分布式
- 前端自动刷新,用户无感知
- BCrypt 密码加密,防暴力破解
代码已经开源,地址在文章开头,保证能跑起来。
如果你在实现过程中遇到问题,欢迎在评论区留言,我会回复。
下一篇打算写:Spring Cloud Gateway 统一鉴权。微服务场景下,在网关统一验证 JWT,下游服务就不用重复验证了。
如果你对这个系列感兴趣,建议关注我,后续文章会第一时间推送。
评论区见。