SpringBoot 实现 JWT 认证及高级特性(Token刷新+黑名单机制)
JWT(JSON Web Token)是实现无状态认证的理想方案,但需要解决Token过期和撤销的问题。本文将详细介绍如何在SpringBoot中实现JWT认证,并添加Token刷新机制和黑名单机制。
一、技术栈选择
- Spring Boot 3.x
- Redis(用于存储黑名单和刷新令牌)
- JJWT库(JWT实现)
- Spring Security(可选,用于安全配置)
二、项目依赖配置
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
三、配置文件
spring:
application:
name: springboot-jwt-refresh-demo
# Redis配置
redis:
host: localhost
port: 6379
database: 0
# JWT配置
jwt:
# 密钥
secret: your-secret-key-change-in-production
# 访问令牌过期时间(分钟)
access-token-expire: 30
# 刷新令牌过期时间(天)
refresh-token-expire: 7
# 请求头中Token的名称
header: Authorization
# Token前缀
token-prefix: Bearer
四、核心代码实现
1. JWT配置属性类
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "jwt")
@Data
public class JwtProperties {
private String secret;
private long accessTokenExpire;
private long refreshTokenExpire;
private String header;
private String tokenPrefix;
}
2. Token工具类
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Component
@Slf4j
public class JwtTokenUtil {
@Autowired
private JwtProperties jwtProperties;
/**
* 生成访问令牌
*/
public String generateAccessToken(String username, String deviceId) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("deviceId", deviceId);
claims.put("jti", UUID.randomUUID().toString()); // JWT ID,用于标识令牌
Date now = new Date();
Date expirationTime = new Date(now.getTime() + jwtProperties.getAccessTokenExpire() * 60 * 1000);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expirationTime)
.signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret())
.compact();
}
/**
* 生成刷新令牌
*/
public String generateRefreshToken(String username, String deviceId) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("deviceId", deviceId);
claims.put("type", "refresh"); // 标识为刷新令牌
Date now = new Date();
Date expirationTime = new Date(now.getTime() + jwtProperties.getRefreshTokenExpire() * 24 * 60 * 60 * 1000);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expirationTime)
.signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret())
.compact();
}
/**
* 解析Token
*/
public Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecret())
.parseClaimsJws(token)
.getBody();
}
/**
* 从Token中获取用户名
*/
public String getUsernameFromToken(String token) {
return parseToken(token).get("username", String.class);
}
/**
* 从Token中获取设备ID
*/
public String getDeviceIdFromToken(String token) {
return parseToken(token).get("deviceId", String.class);
}
/**
* 从Token中获取JWT ID
*/
public String getJtiFromToken(String token) {
return parseToken(token).get("jti", String.class);
}
/**
* 验证Token是否过期
*/
public boolean isTokenExpired(String token) {
Date expiration = parseToken(token).getExpiration();
return expiration.before(new Date());
}
/**
* 获取Token剩余有效期(毫秒)
*/
public long getTokenRemainingTime(String token) {
Date expiration = parseToken(token).getExpiration();
return expiration.getTime() - System.currentTimeMillis();
}
}
3. Redis Token服务(黑名单和刷新令牌管理)
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class RedisTokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private JwtProperties jwtProperties;
// 黑名单前缀
private static final String BLACKLIST_PREFIX = "blacklist:";
// 刷新令牌前缀
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
/**
* 将Token添加到黑名单
*/
public void addToBlacklist(String token) {
try {
String jti = jwtTokenUtil.getJtiFromToken(token);
long remainingTime = jwtTokenUtil.getTokenRemainingTime(token);
// 如果Token已经过期,不需要添加到黑名单
if (remainingTime > 0) {
String key = BLACKLIST_PREFIX + jti;
redisTemplate.opsForValue().set(key, "1", remainingTime, TimeUnit.MILLISECONDS);
log.info("Token已添加到黑名单: {}", jti);
}
} catch (Exception e) {
log.error("添加Token到黑名单失败: {}", e.getMessage());
}
}
/**
* 检查Token是否在黑名单中
*/
public boolean isInBlacklist(String token) {
try {
String jti = jwtTokenUtil.getJtiFromToken(token);
String key = BLACKLIST_PREFIX + jti;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception e) {
log.error("检查Token是否在黑名单失败: {}", e.getMessage());
return false;
}
}
/**
* 存储刷新令牌
*/
public void storeRefreshToken(String username, String deviceId, String refreshToken) {
String key = REFRESH_TOKEN_PREFIX + username + ":" + deviceId;
// 刷新令牌有效期与JWT中设置的一致
redisTemplate.opsForValue().set(key, refreshToken, jwtProperties.getRefreshTokenExpire(), TimeUnit.DAYS);
log.info("刷新令牌已存储: 用户={}, 设备={}", username, deviceId);
}
/**
* 获取存储的刷新令牌
*/
public String getRefreshToken(String username, String deviceId) {
String key = REFRESH_TOKEN_PREFIX + username + ":" + deviceId;
return redisTemplate.opsForValue().get(key);
}
/**
* 移除刷新令牌(用户登出时)
*/
public void removeRefreshToken(String username, String deviceId) {
String key = REFRESH_TOKEN_PREFIX + username + ":" + deviceId;
redisTemplate.delete(key);
log.info("刷新令牌已移除: 用户={}, 设备={}", username, deviceId);
}
/**
* 验证刷新令牌
*/
public boolean validateRefreshToken(String username, String deviceId, String refreshToken) {
String storedToken = getRefreshToken(username, deviceId);
return storedToken != null && storedToken.equals(refreshToken);
}
}
4. JWT拦截器
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
@Component
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private RedisTokenService redisTokenService;
@Autowired
private JwtProperties jwtProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader(jwtProperties.getHeader());
// 检查请求头中是否包含Token
if (token == null || "".equals(token.trim())) {
response401(response, "Token不存在", "MISSING_TOKEN");
return false;
}
// 移除Token前缀
if (token.startsWith(jwtProperties.getTokenPrefix())) {
token = token.replace(jwtProperties.getTokenPrefix(), "").trim();
} else {
response401(response, "Token格式错误", "INVALID_TOKEN_FORMAT");
return false;
}
try {
// 检查Token是否在黑名单中
if (redisTokenService.isInBlacklist(token)) {
response401(response, "Token已被撤销", "TOKEN_REVOKED");
return false;
}
// 验证Token
jwtTokenUtil.parseToken(token);
// 将用户信息存储到请求中,供后续使用
request.setAttribute("username", jwtTokenUtil.getUsernameFromToken(token));
request.setAttribute("deviceId", jwtTokenUtil.getDeviceIdFromToken(token));
log.info("Token验证成功");
return true;
} catch (ExpiredJwtException e) {
log.error("Token已过期: {}", e.getMessage());
response401(response, "Token已过期", "TOKEN_EXPIRED");
return false;
} catch (JwtException e) {
log.error("Token无效: {}", e.getMessage());
response401(response, "Token无效", "INVALID_TOKEN");
return false;
}
}
/**
* 返回401错误响应
*/
private void response401(HttpServletResponse response, String message, String errorCode) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
try (PrintWriter out = response.getWriter()) {
out.append(String.format("{\"code\": 401, \"message\": \"%s\", \"errorCode\": \"%s\"}", message, errorCode));
} catch (Exception e) {
log.error("响应错误: {}", e.getMessage());
}
}
}
5. 拦截器配置
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加拦截器,拦截需要认证的路径
registry.addInterceptor(jwtInterceptor)
// 需要拦截的路径
.addPathPatterns("/api/**")
// 排除不需要拦截的路径
.excludePathPatterns("/api/auth/login", "/api/auth/refresh", "/api/auth/logout");
}
}
6. 实体类
import lombok.Data;
// 登录请求
@Data
public class LoginRequest {
private String username;
private String password;
private String deviceId; // 设备ID,用于多端登录
}
// 登录响应
@Data
public class LoginResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private long expiresIn; // 访问令牌过期时间(秒)
private String username;
}
// Token刷新请求
@Data
public class TokenRefreshRequest {
private String refreshToken;
private String deviceId;
}
// Token刷新响应
@Data
public class TokenRefreshResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private long expiresIn;
}
// 通用响应
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
}
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}
7. 用户服务接口及实现
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
public interface UserService {
boolean login(String username, String password);
boolean register(String username, String password);
}
@Service
public class UserServiceImpl implements UserService {
// 模拟数据库,实际项目中应使用数据库存储
private final Map<String, String> userMap = new HashMap<>();
public UserServiceImpl() {
// 初始化测试用户
userMap.put("admin", "123456");
}
@Override
public boolean login(String username, String password) {
return userMap.containsKey(username) && userMap.get(username).equals(password);
}
@Override
public boolean register(String username, String password) {
if (userMap.containsKey(username)) {
return false; // 用户名已存在
}
userMap.put(username, password);
return true;
}
}
8. 认证控制器
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private UserService userService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private RedisTokenService redisTokenService;
@Autowired
private JwtProperties jwtProperties;
/**
* 用户登录
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody LoginRequest request) {
// 验证用户名密码
if (!userService.login(request.getUsername(), request.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(401, "用户名或密码错误"));
}
// 生成Access Token
String accessToken = jwtTokenUtil.generateAccessToken(request.getUsername(), request.getDeviceId());
// 生成Refresh Token
String refreshToken = jwtTokenUtil.generateRefreshToken(request.getUsername(), request.getDeviceId());
// 存储Refresh Token到Redis
redisTokenService.storeRefreshToken(request.getUsername(), request.getDeviceId(), refreshToken);
// 构建响应
LoginResponse response = new LoginResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
response.setTokenType(jwtProperties.getTokenPrefix().trim());
response.setExpiresIn(jwtProperties.getAccessTokenExpire() * 60); // 转换为秒
response.setUsername(request.getUsername());
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 刷新Token
*/
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenRefreshResponse>> refresh(@RequestBody TokenRefreshRequest request) {
try {
// 解析Refresh Token
String username = jwtTokenUtil.getUsernameFromToken(request.getRefreshToken());
String deviceId = request.getDeviceId();
// 验证Refresh Token
if (!redisTokenService.validateRefreshToken(username, deviceId, request.getRefreshToken())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(401, "无效的刷新令牌"));
}
// 生成新的Access Token
String newAccessToken = jwtTokenUtil.generateAccessToken(username, deviceId);
// 生成新的Refresh Token(可选:可以选择不生成新的刷新令牌)
String newRefreshToken = jwtTokenUtil.generateRefreshToken(username, deviceId);
// 更新Redis中的Refresh Token
redisTokenService.storeRefreshToken(username, deviceId, newRefreshToken);
// 构建响应
TokenRefreshResponse response = new TokenRefreshResponse();
response.setAccessToken(newAccessToken);
response.setRefreshToken(newRefreshToken);
response.setTokenType(jwtProperties.getTokenPrefix().trim());
response.setExpiresIn(jwtProperties.getAccessTokenExpire() * 60);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(401, "刷新令牌已过期或无效"));
}
}
/**
* 用户登出
*/
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(HttpServletRequest request) {
try {
// 获取请求头中的Token
String token = request.getHeader(jwtProperties.getHeader());
if (token != null && token.startsWith(jwtProperties.getTokenPrefix())) {
token = token.replace(jwtProperties.getTokenPrefix(), "").trim();
// 解析Token获取用户信息
String username = jwtTokenUtil.getUsernameFromToken(token);
String deviceId = jwtTokenUtil.getDeviceIdFromToken(token);
// 将Access Token添加到黑名单
redisTokenService.addToBlacklist(token);
// 移除Refresh Token
redisTokenService.removeRefreshToken(username, deviceId);
return ResponseEntity.ok(ApiResponse.success(null));
}
} catch (Exception e) {
// 即使Token解析失败,也返回登出成功
log.error("登出时处理Token失败: {}", e.getMessage());
}
return ResponseEntity.ok(ApiResponse.success(null));
}
}
9. 测试控制器
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/api")
public class TestController {
/**
* 测试需要认证的接口
*/
@GetMapping("/test")
public ResponseEntity<ApiResponse<String>> test(HttpServletRequest request) {
String username = (String) request.getAttribute("username");
String deviceId = (String) request.getAttribute("deviceId");
return ResponseEntity.ok(
ApiResponse.success("访问成功,Token有效。用户名: " + username + ",设备ID: " + deviceId)
);
}
}
五、前端实现建议
1. Token存储
// 存储Token
localStorage.setItem('accessToken', response.data.accessToken);
localStorage.setItem('refreshToken', response.data.refreshToken);
localStorage.setItem('tokenExpiresAt', Date.now() + response.data.expiresIn * 1000);
2. 请求拦截器(自动刷新Token)
// 创建axios实例
const axiosInstance = axios.create({
baseURL: '/api',
timeout: 10000
});
// 请求拦截器
axiosInstance.interceptors.request.use(
config => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 响应拦截器
axiosInstance.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 如果是401错误且不是刷新Token的请求
if (error.response && error.response.status === 401 &&
error.response.data.errorCode === 'TOKEN_EXPIRED' &&
!originalRequest._retry) {
originalRequest._retry = true;
try {
// 尝试刷新Token
const refreshToken = localStorage.getItem('refreshToken');
const deviceId = localStorage.getItem('deviceId');
const refreshResponse = await axios.post('/api/auth/refresh', {
refreshToken: refreshToken,
deviceId: deviceId
});
// 更新存储的Token
localStorage.setItem('accessToken', refreshResponse.data.data.accessToken);
localStorage.setItem('refreshToken', refreshResponse.data.data.refreshToken);
localStorage.setItem('tokenExpiresAt', Date.now() + refreshResponse.data.data.expiresIn * 1000);
// 更新当前请求的Token并重新发送
originalRequest.headers['Authorization'] = `Bearer ${refreshResponse.data.data.accessToken}`;
return axiosInstance(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转到登录页
localStorage.clear();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
六、最佳实践和安全性建议
-
密钥安全:
- 使用强密钥,至少32位
- 生产环境中从环境变量或配置中心获取密钥
- 考虑使用非对称加密(如RSA)增强安全性
-
Token过期时间:
- Access Token设置较短的过期时间(15-30分钟)
- Refresh Token可以设置较长的过期时间(7-30天)
- 根据业务需求调整过期策略
-
Redis优化:
- 使用Redis集群提高可用性
- 为Redis配置密码和访问控制
- 定期清理过期数据
-
多端登录管理:
- 为每个设备生成独立的Refresh Token
- 可以实现强制下线指定设备的功能
- 提供查看当前登录设备的接口
-
防重放攻击:
- 在Token中添加jti(JWT ID)字段
- 实现请求频率限制
- 考虑在Token中添加时间戳或随机数
-
HTTPS传输:
- 生产环境必须使用HTTPS
- 配置适当的安全头
-
日志记录:
- 记录重要操作的日志,如登录、登出、Token刷新
- 记录异常情况,便于问题排查
七、测试方法
- 启动SpringBoot应用和Redis服务
- 使用Postman测试登录接口:
POST http://localhost:8080/api/auth/login - 获取返回的Access Token和Refresh Token
- 使用Access Token访问受保护接口:
GET http://localhost:8080/api/test - 测试Token刷新接口:
POST http://localhost:8080/api/auth/refresh - 测试登出接口:
POST http://localhost:8080/api/auth/logout - 验证登出后的Token是否已被加入黑名单
这个实现提供了完整的JWT认证、Token刷新机制和黑名单机制,适用于生产环境。您可以根据实际项目需求进行调整和扩展。