SpringBoot 整合 JWT + Redis 实现登录鉴权(含Token自动续期+账户防暴力破解锁定)完整实现方案
一、方案说明
本方案采用 JWT + Redis 组合实现登录鉴权,解决了纯JWT无法主动失效、无法续期的痛点:
- JWT 生成令牌,承载用户核心信息,客户端请求携带令牌实现无状态认证
- Redis 存储有效令牌,做双重校验(JWT签名有效性+Redis令牌存在性),支持令牌主动失效(如登出)
- 实现令牌自动续期:令牌剩余有效期不足1/3时,自动刷新Redis过期时间
- 登录接口做防暴力破解:连续5次登录失败,账户锁定15分钟,保障账户安全
- 基于SpringMVC的
HandlerInterceptor实现全局请求拦截,统一校验令牌
二、核心依赖引入
xml
<!-- SpringSecurity 加密/核心工具 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>5.7.3</version>
</dependency>
<!-- JJWT 核心依赖 - 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>
<!-- SpringBoot Redis 启动器 - 存储有效令牌/续期控制 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
三、全局请求拦截器 - JwtInterceptor
java
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Resource
private JwtServiceImpl jwtService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取请求头中的令牌
String authToken = request.getHeader("Authorization");
// 2. 令牌为空,直接返回未授权
if (org.springframework.util.StringUtils.isBlank(authToken)) {
return writeErrorResponse(response, "Token不能为空");
}
// 3. 校验令牌格式是否包含Bearer前缀
if (!authToken.startsWith("Bearer ")) {
return writeErrorResponse(response, "Token格式错误,必须以Bearer 开头");
}
// 4. 截取纯token字符串
String token = authToken.substring("Bearer".length() + 1).trim();
try {
// 5. 第一步校验:Redis中是否存在该令牌,不存在=过期/已注销/非法令牌
if (!jwtService.redisHasToken(token)) {
return writeErrorResponse(response, "Token无效或已过期");
}
// 6. 第二步校验:解析JWT令牌,获取载荷信息
io.jsonwebtoken.Claims claims = jwtService.extractAllClaims(token);
String userId = claims.getSubject();
// 7. 第三步校验:令牌签名+用户信息有效性校验
boolean isTokenValid = jwtService.validAuthToken(token, userId);
if (!isTokenValid) {
return writeErrorResponse(response, "Token无效或已过期");
}
// 8. 令牌有效,判断是否需要自动续期(剩余时间不足1/3则续期)
if (jwtService.isAuthTokenExpiringSoon(token)) {
jwtService.expireToken(token);
}
// 9. 将用户ID存入ThreadLocal,供后续业务逻辑获取,无需重复解析
UserContext.set(Integer.parseInt(userId));
} catch (Exception e) {
// 捕获所有令牌异常:解析失败、签名篡改、数据异常等
return writeErrorResponse(response, "Token无效或已过期");
}
// 全部校验通过,放行请求
return true;
}
/**
* 抽离公共的错误响应方法,统一返回JSON格式错误信息
*/
private boolean writeErrorResponse(HttpServletResponse response, String msg) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
String errorJson = "{\"code\": 401, \"message\": \"" + msg + "\"}";
response.getWriter().print(errorJson);
return false;
}
}
四、JWT核心业务实现类 - JwtServiceImpl
java
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Base64;
@Service
public class JwtServiceImpl {
@Resource
private RedisService redisService;
// ======================== 常量配置区 ========================
/** redis中令牌的前缀 */
private static final String TOKEN_AUTH_PREFIX = "token:auth:";
/** JWT签名密钥【生产环境请改为32位以上随机字符串,Base64编码后的值】 */
private static final String SECRET_KEY = Base64.getEncoder().encodeToString("springboot-jwt-redis-auth-2026-key".getBytes());
/** JWT令牌默认有效期 单位:分钟 */
private static final Integer DEFAULT_AUTH_EXPIRE_MINUTE = 30;
/** JWT令牌有效期配置 单位:分钟 */
private static final Integer JWT_TOKEN_EXPIRE_MINUTE = 30;
// ======================== 私有工具方法 ========================
/**
* 获取JWT签名密钥对象,基于HS256算法
*/
private Key getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 获取Redis中令牌的剩余过期时间,单位:秒
*/
private long getTokenRedisExpire(String token) {
return redisService.getExpire(TOKEN_AUTH_PREFIX + token);
}
/**
* 获取令牌续期阈值:剩余有效期不足总时长的1/3时,触发自动续期
*/
private long getAuthRefreshSeconds() {
return getAuthExpireSeconds() / 3;
}
// ======================== 公有对外方法 ========================
/**
* 计算令牌有效期,转成秒数返回
*/
public int getAuthExpireSeconds() {
Integer expireMinutes = Optional.ofNullable(JWT_TOKEN_EXPIRE_MINUTE).orElse(DEFAULT_AUTH_EXPIRE_MINUTE);
return expireMinutes * 60;
}
/**
* 构建JWT令牌,自定义载荷信息
* @param identifier 主题,存储用户ID等唯一标识
* @param claimsMap 自定义载荷,可存储用户角色、权限等信息
* @param secondsValidity 令牌有效期,单位:秒
* @return 生成的JWT令牌字符串
*/
public String generateTokenWithClaims(String identifier, Map<String, Object> claimsMap, int secondsValidity) {
return Jwts.builder()
.setClaims(claimsMap)
.setSubject(identifier)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + secondsValidity * 1000))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析token,获取自定义载荷中的purpose字段
*/
public String extractPurpose(String token) {
try {
return extractAllClaims(token).get("purpose", String.class);
} catch (Exception e) {
return null;
}
}
/**
* 解析token,获取全部载荷信息
* @throws ExpiredJwtException token过期
* @throws SignatureException 签名错误/令牌篡改
* @throws MalformedJwtException 令牌格式错误
*/
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 生成登录鉴权专用令牌
* @param userId 用户ID
* @return JWT令牌
*/
public String generateAuthToken(Integer userId) {
// 设置令牌用途和业务类型,便于后续扩展多场景令牌
Map<String, Object> claimsMap = generateClaims("auth", "accessControl");
// 生成JWT令牌
String token = generateTokenWithClaims(String.valueOf(userId), claimsMap, getAuthExpireSeconds());
// 令牌存入Redis,Redis过期时间与JWT一致,双重保障
redisService.set(TOKEN_AUTH_PREFIX + token, String.valueOf(userId), getAuthExpireSeconds());
return token;
}
/**
* 校验令牌有效性:Redis存在性+用户ID一致性校验
*/
public boolean validAuthToken(String token, String userId) {
String storedUserId = getAuthData(token);
return StringUtils.isNotBlank(storedUserId) && userId.equals(storedUserId);
}
/**
* 判断令牌是否即将过期,是否需要续期
*/
public boolean isAuthTokenExpiringSoon(String token) {
long remainExpireSeconds = getTokenRedisExpire(token);
long refreshThreshold = getAuthRefreshSeconds();
return remainExpireSeconds > 0 && remainExpireSeconds < refreshThreshold;
}
/**
* 令牌续期:刷新Redis中令牌的过期时间,实现无感续期
*/
public void expireToken(String token){
redisService.expireKey(TOKEN_AUTH_PREFIX + token, getAuthExpireSeconds());
}
/**
* 获取Redis中存储的令牌绑定的用户ID
*/
public String getAuthData(String token){
return redisService.get(TOKEN_AUTH_PREFIX + token);
}
/**
* 构建JWT自定义载荷信息
*/
public Map<String, Object> generateClaims(String purpose, String busType) {
Map<String, Object> claims = new HashMap<>(2);
claims.put("purpose", purpose);
claims.put("busType", busType);
return claims;
}
/**
* 判断Redis中是否存在该令牌
*/
public boolean redisHasToken(String token){
return redisService.hasKey(TOKEN_AUTH_PREFIX + token);
}
}
五、登录业务实现
java
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class LoginServiceImpl {
@Resource
private UserService userService;
@Resource
private JwtServiceImpl jwtService;
/** 登录失败锁定阈值:连续失败5次 */
private static final Integer FAILED_LOCK_COUNT = 5;
/** 账户锁定时长:15分钟,单位毫秒 */
private static final Long LOCK_TIME = 15 * 60 * 1000L;
/**
* 用户登录核心方法
* @param userDto 登录入参(用户名+密码)
* @return 登录成功返回JWT令牌,失败抛出业务异常
*/
public String login(UserDto userDto){
// 1. 根据用户名查询用户,用户不存在直接返回失败
User user = userService.getByUsername(userDto.getUsername());
if (user == null) {
throw new BusinessException("用户名或密码错误");
}
boolean needResetStatus = false;
Integer failedAttempts = user.getFailedAttempts();
Short userStatus = user.getStatus();
// 2. 判断账户是否被锁定(连续5次失败)
if (FAILED_LOCK_COUNT.equals(failedAttempts)) {
long lastFailTime = user.getUpdatedDate().getTime();
// 判断是否超过锁定时间
if (isTimeExceeded(lastFailTime, LOCK_TIME)) {
// 锁定时间已过,重置失败次数和状态
needResetStatus = true;
} else {
// 账户仍在锁定中
throw new BusinessException("连续5次登录失败,账户已锁定15分钟,请稍后再试");
}
}
// 3. 判断用户状态是否正常
if (!UserEnum.NORMAL.getStatus().equals(userStatus) && !needResetStatus) {
throw new BusinessException("账户状态异常,无法登录");
}
// 4. 校验密码是否正确
boolean passwordValid = userService.verifyPassword(userDto.getPassword(), user.getPassword());
if (passwordValid) {
// 密码正确:重置失败次数+解锁账户
if (needResetStatus) {
userService.updateUserStatus(user.getId());
}
// 生成并返回令牌
return jwtService.generateAuthToken(user.getId());
} else {
// 密码错误:失败次数+1,达到阈值则锁定
userService.incrFailedAttempts(userDto.getUsername());
throw new BusinessException("用户名或密码错误");
}
}
/**
* 判断是否超过指定时长
* @param startTime 开始时间戳
* @param timeLimit 时长限制(毫秒)
*/
private boolean isTimeExceeded(long startTime, long timeLimit) {
return System.currentTimeMillis() - startTime > timeLimit;
}
}
六、拦截器和ThreadLocal
6.1 拦截器注册配置类
java
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
// 拦截所有请求
.addPathPatterns("/**")
// 放行登录接口、静态资源等无需鉴权的接口
.excludePathPatterns("/user/login", "/error");
}
}
6.2 ThreadLocal用户上下文 - UserContext
java
public class UserContext {
private static final ThreadLocal<Integer> USER_ID_CONTEXT = new ThreadLocal<>();
/** 设置当前线程的用户ID */
public static void set(Integer userId) {
USER_ID_CONTEXT.set(userId);
}
/** 获取当前线程的用户ID */
public static Integer get() {
return USER_ID_CONTEXT.get();
}
/** 清除当前线程的用户ID,防止内存泄漏 */
public static void remove() {
USER_ID_CONTEXT.remove();
}
}
七、核心亮点
- 高安全性:JWT签名防篡改 + Redis双重校验 + 账户防暴力破解锁定,三重保障
- 高可用性:令牌自动无感续期,用户无需重复登录,体验友好
- 高健壮性:完善的异常处理,避免token格式错误、篡改、过期导致的程序崩溃
- 高可维护性:代码结构清晰,常量统一管理,注释完整,逻辑精简
生产环境必改配置
SECRET_KEY:必须改为32位以上的随机字符串,Base64编码后使用,防止密钥被破解- 令牌有效期:可根据业务调整,建议后台管理系统30分钟,移动端2小时
- Redis部署:生产环境建议使用Redis集群,防止单点故障
- 密码加密:必须使用
BCryptPasswordEncoder加密存储,禁止明文存储
核心设计思想
为什么用JWT+Redis,而不是纯JWT/纯Redis?
- 纯JWT:令牌一旦生成无法主动失效,过期时间固定,续期困难
- 纯Redis:需要存储大量用户信息,Redis压力大,且无状态认证优势丧失
- JWT+Redis:扬长避短,JWT做无状态令牌,Redis做有效令牌存储+续期,完美解决痛点