SpringBoot 整合 JWT + Redis 實現(xiàn)登錄鑒權(quán)
一、方案說明
本方案采用 JWT + Redis 組合實現(xiàn)登錄鑒權(quán),解決了純JWT無法主動失效、無法續(xù)期的痛點:
- JWT 生成令牌,承載用戶核心信息,客戶端請求攜帶令牌實現(xiàn)無狀態(tài)認證
- Redis 存儲有效令牌,做雙重校驗(JWT簽名有效性+Redis令牌存在性),支持令牌主動失效(如登出)
- 實現(xiàn)令牌自動續(xù)期:令牌剩余有效期不足1/3時,自動刷新Redis過期時間
- 登錄接口 做防 暴 力 破解:連續(xù)5次登錄失敗,賬戶鎖定15分鐘,保障賬戶安全
- 基于SpringMVC的
HandlerInterceptor實現(xiàn)全局請求攔截,統(tǒng)一校驗令牌
二、核心依賴引入
<!-- 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 啟動器 - 存儲有效令牌/續(xù)期控制 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
三、全局請求攔截器 - 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. 獲取請求頭中的令牌
String authToken = request.getHeader("Authorization");
// 2. 令牌為空,直接返回未授權(quán)
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. 令牌有效,判斷是否需要自動續(xù)期(剩余時間不足1/3則續(xù)期)
if (jwtService.isAuthTokenExpiringSoon(token)) {
jwtService.expireToken(token);
}
// 9. 將用戶ID存入ThreadLocal,供后續(xù)業(yè)務邏輯獲取,無需重復解析
UserContext.set(Integer.parseInt(userId));
} catch (Exception e) {
// 捕獲所有令牌異常:解析失敗、簽名篡改、數(shù)據(jù)異常等
return writeErrorResponse(response, "Token無效或已過期");
}
// 全部校驗通過,放行請求
return true;
}
/**
* 抽離公共的錯誤響應方法,統(tǒng)一返回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核心業(yè)務實現(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)境請改為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);
}
/**
* 獲取令牌續(xù)期閾值:剩余有效期不足總時長的1/3時,觸發(fā)自動續(xù)期
*/
private long getAuthRefreshSeconds() {
return getAuthExpireSeconds() / 3;
}
// ======================== 公有對外方法 ========================
/**
* 計算令牌有效期,轉(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 主題,存儲用戶ID等唯一標識
* @param claimsMap 自定義載荷,可存儲用戶角色、權(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 簽名錯誤/令牌篡改
* @throws MalformedJwtException 令牌格式錯誤
*/
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) {
// 設置令牌用途和業(yè)務類型,便于后續(xù)擴展多場景令牌
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);
}
/**
* 判斷令牌是否即將過期,是否需要續(xù)期
*/
public boolean isAuthTokenExpiringSoon(String token) {
long remainExpireSeconds = getTokenRedisExpire(token);
long refreshThreshold = getAuthRefreshSeconds();
return remainExpireSeconds > 0 && remainExpireSeconds < refreshThreshold;
}
/**
* 令牌續(xù)期:刷新Redis中令牌的過期時間,實現(xiàn)無感續(xù)期
*/
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);
}
/**
* 構(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è)務實現(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;
/** 賬戶鎖定時長:15分鐘,單位毫秒 */
private static final Long LOCK_TIME = 15 * 60 * 1000L;
/**
* 用戶登錄核心方法
* @param userDto 登錄入?yún)ⅲㄓ脩裘?密碼)
* @return 登錄成功返回JWT令牌,失敗拋出業(yè)務異常
*/
public String login(UserDto userDto){
// 1. 根據(jù)用戶名查詢用戶,用戶不存在直接返回失敗
User user = userService.getByUsername(userDto.getUsername());
if (user == null) {
throw new BusinessException("用戶名或密碼錯誤");
}
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();
// 判斷是否超過鎖定時間
if (isTimeExceeded(lastFailTime, LOCK_TIME)) {
// 鎖定時間已過,重置失敗次數(shù)和狀態(tài)
needResetStatus = true;
} else {
// 賬戶仍在鎖定中
throw new BusinessException("連續(xù)5次登錄失敗,賬戶已鎖定15分鐘,請稍后再試");
}
}
// 3. 判斷用戶狀態(tài)是否正常
if (!UserEnum.NORMAL.getStatus().equals(userStatus) && !needResetStatus) {
throw new BusinessException("賬戶狀態(tài)異常,無法登錄");
}
// 4. 校驗密碼是否正確
boolean passwordValid = userService.verifyPassword(userDto.getPassword(), user.getPassword());
if (passwordValid) {
// 密碼正確:重置失敗次數(shù)+解鎖賬戶
if (needResetStatus) {
userService.updateUserStatus(user.getId());
}
// 生成并返回令牌
return jwtService.generateAuthToken(user.getId());
} else {
// 密碼錯誤:失敗次數(shù)+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 攔截器注冊配置類
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("/**")
// 放行登錄接口、靜態(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<>();
/** 設置當前線程的用戶ID */
public static void set(Integer userId) {
USER_ID_CONTEXT.set(userId);
}
/** 獲取當前線程的用戶ID */
public static Integer get() {
return USER_ID_CONTEXT.get();
}
/** 清除當前線程的用戶ID,防止內(nèi)存泄漏 */
public static void remove() {
USER_ID_CONTEXT.remove();
}
}
七、核心亮點
- 高安全性:JWT簽名防篡改 + Redis雙重校驗 + 賬戶防暴力破解鎖定,三重保障
- 高可用性:令牌自動無感續(xù)期,用戶無需重復登錄,體驗友好
- 高健壯性:完善的異常處理,避免token格式錯誤、篡改、過期導致的程序崩潰
- 高可維護性:代碼結(jié)構(gòu)清晰,常量統(tǒng)一管理,注釋完整,邏輯精簡
生產(chǎn)環(huán)境必改配置
SECRET_KEY:必須改為32位以上的隨機字符串,Base64編碼后使用,防止密鑰被破解- 令牌有效期:可根據(jù)業(yè)務調(diào)整,建議后臺管理系統(tǒng)30分鐘,移動端2小時
- Redis部署:生產(chǎn)環(huán)境建議使用Redis集群,防止單點故障
- 密碼加密:必須使用
BCryptPasswordEncoder加密存儲,禁止明文存儲
核心設計思想
為什么用JWT+Redis,而不是純JWT/純Redis?
- 純JWT:令牌一旦生成無法主動失效,過期時間固定,續(xù)期困難
- 純Redis:需要存儲大量用戶信息,Redis壓力大,且無狀態(tài)認證優(yōu)勢喪失
- JWT+Redis:揚長避短,JWT做無狀態(tài)令牌,Redis做有效令牌存儲+續(xù)期,完美解決痛點
到此這篇關(guān)于SpringBoot 整合 JWT + Redis 實現(xiàn)登錄鑒權(quán)的文章就介紹到這了,更多相關(guān)SpringBoot JWT Redis登錄鑒權(quán)內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Scala 操作Redis使用連接池工具類RedisUtil
這篇文章主要介紹了Scala 操作Redis使用連接池工具類RedisUtil,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2019-06-06
SpringBoot+vue+Axios實現(xiàn)Token令牌的詳細過程
Token是在服務端產(chǎn)生的,前端可以使用用戶名/密碼向服務端請求認證(登錄),服務端認證成功,服務端會返回?Token?給前端,Token可以使用自己的算法自定義,本文給大家介紹SpringBoot+vue+Axios實現(xiàn)Token令牌,感興趣的朋友一起看看吧2023-10-10
springboot項目關(guān)閉swagger如何防止漏洞掃描
這篇文章主要介紹了springboot項目關(guān)閉swagger如何防止漏洞掃描,本文通過示例代碼給大家介紹的非常詳細,感興趣的朋友跟隨小編一起看看吧2024-05-05
Spring?Cloud?Gateway?服務網(wǎng)關(guān)的部署與使用詳細講解
這篇文章主要介紹了Spring?Cloud?Gateway?服務網(wǎng)關(guān)的部署與使用詳細介紹,本文給大家講解的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-04-04

