一、方案说明
本方案采用 jwt + redis 组合实现登录鉴权,解决了纯jwt无法主动失效、无法续期的痛点:
- jwt 生成令牌,承载用户核心信息,客户端请求携带令牌实现无状态认证
- redis 存储有效令牌,做双重校验(jwt签名有效性+redis令牌存在性),支持令牌主动失效(如登出)
- 实现令牌自动续期:令牌剩余有效期不足1/3时,自动刷新redis过期时间
- 登录接口 做防 暴 力 破解:连续5次登录失败,账户锁定15分钟,保障账户安全
- 基于springmvc的
handlerinterceptor实现全局请求拦截,统一校验令牌
二、核心依赖引入
<!-- 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
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
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);
}
}
五、登录业务实现
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 拦截器注册配置类
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
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做有效令牌存储+续期,完美解决痛点
到此这篇关于springboot 整合 jwt + redis 实现登录鉴权的文章就介绍到这了,更多相关springboot jwt redis登录鉴权内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论