SpringBoot 整合 JWT + Redis 实现登录鉴权

SpringBoot 整合 JWT + Redis 实现登录鉴权(含Token自动续期+账户防暴力破解锁定)完整实现方案

一、方案说明

本方案采用 JWT + Redis 组合实现登录鉴权,解决了纯JWT无法主动失效、无法续期的痛点:

  1. JWT 生成令牌,承载用户核心信息,客户端请求携带令牌实现无状态认证
  2. Redis 存储有效令牌,做双重校验(JWT签名有效性+Redis令牌存在性),支持令牌主动失效(如登出)
  3. 实现令牌自动续期:令牌剩余有效期不足1/3时,自动刷新Redis过期时间
  4. 登录接口做防暴力破解:连续5次登录失败,账户锁定15分钟,保障账户安全
  5. 基于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();
    }
}

七、核心亮点

  1. 高安全性:JWT签名防篡改 + Redis双重校验 + 账户防暴力破解锁定,三重保障
  2. 高可用性:令牌自动无感续期,用户无需重复登录,体验友好
  3. 高健壮性:完善的异常处理,避免token格式错误、篡改、过期导致的程序崩溃
  4. 高可维护性:代码结构清晰,常量统一管理,注释完整,逻辑精简

生产环境必改配置

  1. SECRET_KEY:必须改为32位以上的随机字符串,Base64编码后使用,防止密钥被破解
  2. 令牌有效期:可根据业务调整,建议后台管理系统30分钟,移动端2小时
  3. Redis部署:生产环境建议使用Redis集群,防止单点故障
  4. 密码加密:必须使用BCryptPasswordEncoder加密存储,禁止明文存储

核心设计思想

为什么用JWT+Redis,而不是纯JWT/纯Redis?

  • 纯JWT:令牌一旦生成无法主动失效,过期时间固定,续期困难
  • 纯Redis:需要存储大量用户信息,Redis压力大,且无状态认证优势丧失
  • JWT+Redis:扬长避短,JWT做无状态令牌,Redis做有效令牌存储+续期,完美解决痛点
相关推荐
小飞Coding2 小时前
Redis Cluster 实现多key事务操作
redis
壹米饭2 小时前
MYSQL进阶:删除视图时视图被lock解决方案
后端·mysql
CV工程师的自我修养2 小时前
还不知道线程池如何使用?看懂这篇就可以创建合理稳定的线程池
后端·架构
悦悦妍妍2 小时前
spring-ioc
java
优弧2 小时前
Claude 终于对普通人下手了!Cowork 发布,你的最强 AI 打工搭子来了!
前端·后端
萧曵 丶2 小时前
Redis 是单线程的吗?
数据库·redis
佛系打工仔2 小时前
绘制K线第一章:可见区间处理
java
wangkay883 小时前
【Java 转运营】Day02:抖音直播间流量底层逻辑全解析
java·新媒体运营
我是谁的程序员3 小时前
有没有在 iOS 直接抓包 的App?
后端