SpringBoot 整合 JWT + Redis 實(shí)現(xiàn)登錄鑒權(quán)
一、方案說明
本方案采用 JWT + Redis 組合實(shí)現(xiàn)登錄鑒權(quán),解決了純JWT無法主動(dòng)失效、無法續(xù)期的痛點(diǎn):
- JWT 生成令牌,承載用戶核心信息,客戶端請(qǐng)求攜帶令牌實(shí)現(xiàn)無狀態(tài)認(rèn)證
- Redis 存儲(chǔ)有效令牌,做雙重校驗(yàn)(JWT簽名有效性+Redis令牌存在性),支持令牌主動(dòng)失效(如登出)
- 實(shí)現(xiàn)令牌自動(dòng)續(xù)期:令牌剩余有效期不足1/3時(shí),自動(dòng)刷新Redis過期時(shí)間
- 登錄接口 做防 暴 力 破解:連續(xù)5次登錄失敗,賬戶鎖定15分鐘,保障賬戶安全
- 基于SpringMVC的
HandlerInterceptor實(shí)現(xiàn)全局請(qǐng)求攔截,統(tǒng)一校驗(yàn)令牌
二、核心依賴引入
<!-- 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 啟動(dòng)器 - 存儲(chǔ)有效令牌/續(xù)期控制 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
三、全局請(qǐng)求攔截器 - JwtInterceptor
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. 獲取請(qǐng)求頭中的令牌
String authToken = request.getHeader("Authorization");
// 2. 令牌為空,直接返回未授權(quán)
if (org.springframework.util.StringUtils.isBlank(authToken)) {
return writeErrorResponse(response, "Token不能為空");
}
// 3. 校驗(yàn)令牌格式是否包含Bearer前綴
if (!authToken.startsWith("Bearer ")) {
return writeErrorResponse(response, "Token格式錯(cuò)誤,必須以Bearer 開頭");
}
// 4. 截取純token字符串
String token = authToken.substring("Bearer".length() + 1).trim();
try {
// 5. 第一步校驗(yàn):Redis中是否存在該令牌,不存在=過期/已注銷/非法令牌
if (!jwtService.redisHasToken(token)) {
return writeErrorResponse(response, "Token無效或已過期");
}
// 6. 第二步校驗(yàn):解析JWT令牌,獲取載荷信息
io.jsonwebtoken.Claims claims = jwtService.extractAllClaims(token);
String userId = claims.getSubject();
// 7. 第三步校驗(yàn):令牌簽名+用戶信息有效性校驗(yàn)
boolean isTokenValid = jwtService.validAuthToken(token, userId);
if (!isTokenValid) {
return writeErrorResponse(response, "Token無效或已過期");
}
// 8. 令牌有效,判斷是否需要自動(dòng)續(xù)期(剩余時(shí)間不足1/3則續(xù)期)
if (jwtService.isAuthTokenExpiringSoon(token)) {
jwtService.expireToken(token);
}
// 9. 將用戶ID存入ThreadLocal,供后續(xù)業(yè)務(wù)邏輯獲取,無需重復(fù)解析
UserContext.set(Integer.parseInt(userId));
} catch (Exception e) {
// 捕獲所有令牌異常:解析失敗、簽名篡改、數(shù)據(jù)異常等
return writeErrorResponse(response, "Token無效或已過期");
}
// 全部校驗(yàn)通過,放行請(qǐng)求
return true;
}
/**
* 抽離公共的錯(cuò)誤響應(yīng)方法,統(tǒng)一返回JSON格式錯(cuò)誤信息
*/
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核心業(yè)務(wù)實(shí)現(xiàn)類 - JwtServiceImpl
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;
// ======================== 常量配置區(qū) ========================
/** redis中令牌的前綴 */
private static final String TOKEN_AUTH_PREFIX = "token:auth:";
/** JWT簽名密鑰【生產(chǎn)環(huán)境請(qǐng)改為32位以上隨機(jī)字符串,Base64編碼后的值】 */
private static final String SECRET_KEY = Base64.getEncoder().encodeToString("springboot-jwt-redis-auth-2026-key".getBytes());
/** JWT令牌默認(rèn)有效期 單位:分鐘 */
private static final Integer DEFAULT_AUTH_EXPIRE_MINUTE = 30;
/** JWT令牌有效期配置 單位:分鐘 */
private static final Integer JWT_TOKEN_EXPIRE_MINUTE = 30;
// ======================== 私有工具方法 ========================
/**
* 獲取JWT簽名密鑰對(duì)象,基于HS256算法
*/
private Key getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 獲取Redis中令牌的剩余過期時(shí)間,單位:秒
*/
private long getTokenRedisExpire(String token) {
return redisService.getExpire(TOKEN_AUTH_PREFIX + token);
}
/**
* 獲取令牌續(xù)期閾值:剩余有效期不足總時(shí)長(zhǎng)的1/3時(shí),觸發(fā)自動(dòng)續(xù)期
*/
private long getAuthRefreshSeconds() {
return getAuthExpireSeconds() / 3;
}
// ======================== 公有對(duì)外方法 ========================
/**
* 計(jì)算令牌有效期,轉(zhuǎn)成秒數(shù)返回
*/
public int getAuthExpireSeconds() {
Integer expireMinutes = Optional.ofNullable(JWT_TOKEN_EXPIRE_MINUTE).orElse(DEFAULT_AUTH_EXPIRE_MINUTE);
return expireMinutes * 60;
}
/**
* 構(gòu)建JWT令牌,自定義載荷信息
* @param identifier 主題,存儲(chǔ)用戶ID等唯一標(biāo)識(shí)
* @param claimsMap 自定義載荷,可存儲(chǔ)用戶角色、權(quán)限等信息
* @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 簽名錯(cuò)誤/令牌篡改
* @throws MalformedJwtException 令牌格式錯(cuò)誤
*/
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 生成登錄鑒權(quán)專用令牌
* @param userId 用戶ID
* @return JWT令牌
*/
public String generateAuthToken(Integer userId) {
// 設(shè)置令牌用途和業(yè)務(wù)類型,便于后續(xù)擴(kuò)展多場(chǎng)景令牌
Map<String, Object> claimsMap = generateClaims("auth", "accessControl");
// 生成JWT令牌
String token = generateTokenWithClaims(String.valueOf(userId), claimsMap, getAuthExpireSeconds());
// 令牌存入Redis,Redis過期時(shí)間與JWT一致,雙重保障
redisService.set(TOKEN_AUTH_PREFIX + token, String.valueOf(userId), getAuthExpireSeconds());
return token;
}
/**
* 校驗(yàn)令牌有效性:Redis存在性+用戶ID一致性校驗(yàn)
*/
public boolean validAuthToken(String token, String userId) {
String storedUserId = getAuthData(token);
return StringUtils.isNotBlank(storedUserId) && userId.equals(storedUserId);
}
/**
* 判斷令牌是否即將過期,是否需要續(xù)期
*/
public boolean isAuthTokenExpiringSoon(String token) {
long remainExpireSeconds = getTokenRedisExpire(token);
long refreshThreshold = getAuthRefreshSeconds();
return remainExpireSeconds > 0 && remainExpireSeconds < refreshThreshold;
}
/**
* 令牌續(xù)期:刷新Redis中令牌的過期時(shí)間,實(shí)現(xiàn)無感續(xù)期
*/
public void expireToken(String token){
redisService.expireKey(TOKEN_AUTH_PREFIX + token, getAuthExpireSeconds());
}
/**
* 獲取Redis中存儲(chǔ)的令牌綁定的用戶ID
*/
public String getAuthData(String token){
return redisService.get(TOKEN_AUTH_PREFIX + token);
}
/**
* 構(gòu)建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);
}
}
五、登錄業(yè)務(wù)實(shí)現(xiàn)
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class LoginServiceImpl {
@Resource
private UserService userService;
@Resource
private JwtServiceImpl jwtService;
/** 登錄失敗鎖定閾值:連續(xù)失敗5次 */
private static final Integer FAILED_LOCK_COUNT = 5;
/** 賬戶鎖定時(shí)長(zhǎng):15分鐘,單位毫秒 */
private static final Long LOCK_TIME = 15 * 60 * 1000L;
/**
* 用戶登錄核心方法
* @param userDto 登錄入?yún)ⅲㄓ脩裘?密碼)
* @return 登錄成功返回JWT令牌,失敗拋出業(yè)務(wù)異常
*/
public String login(UserDto userDto){
// 1. 根據(jù)用戶名查詢用戶,用戶不存在直接返回失敗
User user = userService.getByUsername(userDto.getUsername());
if (user == null) {
throw new BusinessException("用戶名或密碼錯(cuò)誤");
}
boolean needResetStatus = false;
Integer failedAttempts = user.getFailedAttempts();
Short userStatus = user.getStatus();
// 2. 判斷賬戶是否被鎖定(連續(xù)5次失?。?
if (FAILED_LOCK_COUNT.equals(failedAttempts)) {
long lastFailTime = user.getUpdatedDate().getTime();
// 判斷是否超過鎖定時(shí)間
if (isTimeExceeded(lastFailTime, LOCK_TIME)) {
// 鎖定時(shí)間已過,重置失敗次數(shù)和狀態(tài)
needResetStatus = true;
} else {
// 賬戶仍在鎖定中
throw new BusinessException("連續(xù)5次登錄失敗,賬戶已鎖定15分鐘,請(qǐng)稍后再試");
}
}
// 3. 判斷用戶狀態(tài)是否正常
if (!UserEnum.NORMAL.getStatus().equals(userStatus) && !needResetStatus) {
throw new BusinessException("賬戶狀態(tài)異常,無法登錄");
}
// 4. 校驗(yàn)密碼是否正確
boolean passwordValid = userService.verifyPassword(userDto.getPassword(), user.getPassword());
if (passwordValid) {
// 密碼正確:重置失敗次數(shù)+解鎖賬戶
if (needResetStatus) {
userService.updateUserStatus(user.getId());
}
// 生成并返回令牌
return jwtService.generateAuthToken(user.getId());
} else {
// 密碼錯(cuò)誤:失敗次數(shù)+1,達(dá)到閾值則鎖定
userService.incrFailedAttempts(userDto.getUsername());
throw new BusinessException("用戶名或密碼錯(cuò)誤");
}
}
/**
* 判斷是否超過指定時(shí)長(zhǎng)
* @param startTime 開始時(shí)間戳
* @param timeLimit 時(shí)長(zhǎng)限制(毫秒)
*/
private boolean isTimeExceeded(long startTime, long timeLimit) {
return System.currentTimeMillis() - startTime > timeLimit;
}
}
六、攔截器和ThreadLocal
6.1 攔截器注冊(cè)配置類
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)
// 攔截所有請(qǐng)求
.addPathPatterns("/**")
// 放行登錄接口、靜態(tài)資源等無需鑒權(quán)的接口
.excludePathPatterns("/user/login", "/error");
}
}
6.2 ThreadLocal用戶上下文 - UserContext
public class UserContext {
private static final ThreadLocal<Integer> USER_ID_CONTEXT = new ThreadLocal<>();
/** 設(shè)置當(dāng)前線程的用戶ID */
public static void set(Integer userId) {
USER_ID_CONTEXT.set(userId);
}
/** 獲取當(dāng)前線程的用戶ID */
public static Integer get() {
return USER_ID_CONTEXT.get();
}
/** 清除當(dāng)前線程的用戶ID,防止內(nèi)存泄漏 */
public static void remove() {
USER_ID_CONTEXT.remove();
}
}
七、核心亮點(diǎn)
- 高安全性:JWT簽名防篡改 + Redis雙重校驗(yàn) + 賬戶防暴力破解鎖定,三重保障
- 高可用性:令牌自動(dòng)無感續(xù)期,用戶無需重復(fù)登錄,體驗(yàn)友好
- 高健壯性:完善的異常處理,避免token格式錯(cuò)誤、篡改、過期導(dǎo)致的程序崩潰
- 高可維護(hù)性:代碼結(jié)構(gòu)清晰,常量統(tǒng)一管理,注釋完整,邏輯精簡(jiǎn)
生產(chǎn)環(huán)境必改配置
SECRET_KEY:必須改為32位以上的隨機(jī)字符串,Base64編碼后使用,防止密鑰被破解- 令牌有效期:可根據(jù)業(yè)務(wù)調(diào)整,建議后臺(tái)管理系統(tǒng)30分鐘,移動(dòng)端2小時(shí)
- Redis部署:生產(chǎn)環(huán)境建議使用Redis集群,防止單點(diǎn)故障
- 密碼加密:必須使用
BCryptPasswordEncoder加密存儲(chǔ),禁止明文存儲(chǔ)
核心設(shè)計(jì)思想
為什么用JWT+Redis,而不是純JWT/純Redis?
- 純JWT:令牌一旦生成無法主動(dòng)失效,過期時(shí)間固定,續(xù)期困難
- 純Redis:需要存儲(chǔ)大量用戶信息,Redis壓力大,且無狀態(tài)認(rèn)證優(yōu)勢(shì)喪失
- JWT+Redis:揚(yáng)長(zhǎng)避短,JWT做無狀態(tài)令牌,Redis做有效令牌存儲(chǔ)+續(xù)期,完美解決痛點(diǎn)
到此這篇關(guān)于SpringBoot 整合 JWT + Redis 實(shí)現(xiàn)登錄鑒權(quán)的文章就介紹到這了,更多相關(guān)SpringBoot JWT Redis登錄鑒權(quán)內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java基于ProcessBuilder+FFmpeg實(shí)現(xiàn)MP4轉(zhuǎn)WAV音頻轉(zhuǎn)碼方案
在音視頻處理場(chǎng)景中,將MP4視頻文件中的音頻流提取為WAV格式是一個(gè)常見需求,本文介紹一種基于Java ProcessBuilder + FFmpeg的高效解決方案,通過調(diào)用命令行工具FFmpeg完成轉(zhuǎn)換,實(shí)現(xiàn)跨平臺(tái)、高質(zhì)量的音頻提取,需要的朋友可以參考下2026-03-03
Java使用Hutool+自定義注解實(shí)現(xiàn)數(shù)據(jù)脫敏
我們?cè)谑褂檬謾C(jī)銀行的時(shí)候經(jīng)常能看到APP上會(huì)將銀行卡的卡號(hào)中間部分給隱藏掉使用 ***** 來代替,在某些網(wǎng)站上查看一些業(yè)務(wù)密碼時(shí)(例如簽到密碼等)也會(huì)使用 ***** 來隱藏掉真正的密碼,那么這種方式是如何實(shí)現(xiàn)的呢,本文將給大家介紹使用Hutool+自定義注解實(shí)現(xiàn)數(shù)據(jù)脫敏2023-09-09
mybatis-plus之自動(dòng)映射字段(typeHandler)的注意點(diǎn)及說明
這篇文章主要介紹了mybatis-plus之自動(dòng)映射字段(typeHandler)的注意點(diǎn)及說明,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-04-04
java的Builder原理和實(shí)現(xiàn)詳解
大家好,本篇文章主要講的是java的Builder原理和實(shí)現(xiàn)詳解,感興趣的同學(xué)趕快來看一看吧,對(duì)你有幫助的話記得收藏一下,方便下次瀏覽2021-12-12
Java正則表達(dá)式如何匹配特定html標(biāo)簽內(nèi)的內(nèi)容
這篇文章主要給大家介紹了關(guān)于Java正則表達(dá)式如何匹配特定html標(biāo)簽內(nèi)的內(nèi)容的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-09-09
Java樹形結(jié)構(gòu)數(shù)據(jù)生成導(dǎo)出excel文件方法記錄
最近好像得罪了poi,遇到的都是導(dǎo)出word、Excel、pdf的問題,下面這篇文章主要給大家介紹了關(guān)于Java樹形結(jié)構(gòu)數(shù)據(jù)生成導(dǎo)出excel文件的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),需要的朋友可以參考下2021-10-10
學(xué)習(xí)不同 Java.net 語(yǔ)言中類似的函數(shù)結(jié)構(gòu)
這篇文章主要介紹了學(xué)習(xí)不同 Java.net 語(yǔ)言中類似的函數(shù)結(jié)構(gòu),函數(shù)式編程語(yǔ)言包含多個(gè)系列的常見函數(shù)。但開發(fā)人員有時(shí)很難在語(yǔ)言之間進(jìn)行切換,因?yàn)槭煜さ暮瘮?shù)具有不熟悉的名稱。函數(shù)式語(yǔ)言傾向于基于函數(shù)范例來命名這些常見函數(shù)。,需要的朋友可以參考下2019-06-06
windows 32位eclipse遠(yuǎn)程hadoop開發(fā)環(huán)境搭建
這篇文章主要介紹了windows 32位eclipse遠(yuǎn)程hadoop開發(fā)環(huán)境搭建的相關(guān)資料,需要的朋友可以參考下2016-07-07

