jwt(json web token)作为一种轻量级的认证方式,被广泛应用于现代web应用和微服务架构中。
然而,jwt的无状态特性虽然带来了扩展性优势,却也带来了令牌管理的挑战,特别是当需要使令牌提前失效时。
本文将介绍在springboot应用中实现jwt令牌失效的6种方案。
一、jwt基础与失效挑战
1.1 jwt的基本结构
jwt由三部分组成,以点(.)分隔:
- header(头部) :包含令牌类型和使用的签名算法
- payload(负载) :包含声明(claims),如用户信息和权限
- signature(签名) :用于验证令牌的完整性和真实性
一个典型的jwt看起来像这样:
eyjhbgcioijiuzi1niisinr5cci6ikpxvcj9.eyjzdwiioiixmjm0nty3odkwiiwibmftzsi6ikpvag4grg9liiwiawf0ijoxnte2mjm5mdiyfq.sflkxwrjsmekkf2qt4fwpmejf36pok6yjv_adqssw5c
1.2 jwt的特点与失效挑战
jwt的主要特点是无状态性,服务器不需要存储会话信息。
这带来了以下挑战:
- jwt一旦签发,在其有效期内始终有效
- 无法直接撤销或使令牌失效
- 服务器默认无法跟踪已发行的令牌
这些特性使得实现jwt的提前失效变得困难,特别是在以下场景:
- 用户登出系统
- 用户权限变更
- 账户被盗,需要使所有令牌失效
- 密码更改后使旧令牌失效
二、短期令牌+刷新令牌方案
2.1 基本原理
该方案使用两种令牌:
- 短期访问令牌(access token) :有效期短(如15分钟),用于api访问
- 长期刷新令牌(refresh token) :有效期长(如7天),用于获取新的访问令牌
当用户需要登出时,只需使刷新令牌失效,短期访问令牌会自然过期。
2.2 springboot实现
首先,添加必要的依赖:
<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>创建jwt工具类:
@component
public class jwttokenprovider {
@value("${jwt.secret}")
private string jwtsecret;
@value("${jwt.accesstokenexpiration}")
private long accesstokenexpiration;
@value("${jwt.refreshtokenexpiration}")
private long refreshtokenexpiration;
public string generateaccesstoken(userdetails userdetails) {
return generatetoken(userdetails, accesstokenexpiration);
}
public string generaterefreshtoken(userdetails userdetails) {
return generatetoken(userdetails, refreshtokenexpiration);
}
private string generatetoken(userdetails userdetails, long expiration) {
date now = new date();
date expirydate = new date(now.gettime() + expiration);
return jwts.builder()
.setsubject(userdetails.getusername())
.setissuedat(now)
.setexpiration(expirydate)
.signwith(keys.hmacshakeyfor(jwtsecret.getbytes()), signaturealgorithm.hs512)
.compact();
}
public string getusernamefromtoken(string token) {
claims claims = jwts.parserbuilder()
.setsigningkey(keys.hmacshakeyfor(jwtsecret.getbytes()))
.build()
.parseclaimsjws(token)
.getbody();
return claims.getsubject();
}
public boolean validatetoken(string token) {
try {
jwts.parserbuilder()
.setsigningkey(keys.hmacshakeyfor(jwtsecret.getbytes()))
.build()
.parseclaimsjws(token);
return true;
} catch (exception e) {
return false;
}
}
}实现刷新令牌服务:
@service
@requiredargsconstructor
public class refreshtokenservice {
private final refreshtokenrepository refreshtokenrepository;
private final jwttokenprovider jwttokenprovider;
@transactional
public refreshtoken createrefreshtoken(string username) {
refreshtoken refreshtoken = new refreshtoken();
refreshtoken.setusername(username);
refreshtoken.settoken(uuid.randomuuid().tostring());
refreshtoken.setexpirydate(instant.now().plusmillis(
jwttokenprovider.getrefreshtokenexpiration()));
return refreshtokenrepository.save(refreshtoken);
}
@transactional
public void deletebyusername(string username) {
refreshtokenrepository.deletebyusername(username);
}
public optional<refreshtoken> findbytoken(string token) {
return refreshtokenrepository.findbytoken(token);
}
public refreshtoken verifyexpiration(refreshtoken token) {
if (token.getexpirydate().compareto(instant.now()) < 0) {
refreshtokenrepository.delete(token);
throw new tokenrefreshexception(token.gettoken(),
"refresh token was expired. please make a new signin request");
}
return token;
}
}实现认证控制器:
@restcontroller
@requestmapping("/api/auth")
@requiredargsconstructor
public class authcontroller {
private final authenticationmanager authenticationmanager;
private final userdetailsservice userdetailsservice;
private final jwttokenprovider jwttokenprovider;
private final refreshtokenservice refreshtokenservice;
@postmapping("/login")
public responseentity<?> authenticateuser(@valid @requestbody loginrequest loginrequest) {
authentication authentication = authenticationmanager.authenticate(
new usernamepasswordauthenticationtoken(
loginrequest.getusername(),
loginrequest.getpassword()
)
);
securitycontextholder.getcontext().setauthentication(authentication);
userdetails userdetails = (userdetails) authentication.getprincipal();
string accesstoken = jwttokenprovider.generateaccesstoken(userdetails);
refreshtoken refreshtoken = refreshtokenservice.createrefreshtoken(userdetails.getusername());
return responseentity.ok(new jwtresponse(accesstoken, refreshtoken.gettoken()));
}
@postmapping("/refresh")
public responseentity<?> refreshtoken(@valid @requestbody tokenrefreshrequest request) {
string requestrefreshtoken = request.getrefreshtoken();
return refreshtokenservice.findbytoken(requestrefreshtoken)
.map(refreshtokenservice::verifyexpiration)
.map(refreshtoken::getusername)
.map(username -> {
userdetails userdetails = userdetailsservice.loaduserbyusername(username);
string accesstoken = jwttokenprovider.generateaccesstoken(userdetails);
return responseentity.ok(new tokenrefreshresponse(accesstoken, requestrefreshtoken));
})
.orelsethrow(() -> new tokenrefreshexception(requestrefreshtoken,
"refresh token is not in database!"));
}
@postmapping("/logout")
public responseentity<?> logoutuser(@valid @requestbody logoutrequest logoutrequest) {
refreshtokenservice.deletebyusername(logoutrequest.getusername());
return responseentity.ok(new messageresponse("log out successful!"));
}
}application.properties配置:
jwt.secret=yourverylongandsecuresecretkeyherepleasemakeitatleast256bits jwt.accesstokenexpiration=900000 # 15分钟 jwt.refreshtokenexpiration=604800000 # 7天
2.3 优缺点分析
优点:
- 无需维护黑名单,降低服务器负担
- 访问令牌有效期短,安全性较高
- 用户体验良好,透明刷新令牌
- 实现简单,容易理解
缺点:
- 无法即时使访问令牌失效,最多等待其自然过期
- 需要额外存储刷新令牌,增加了状态性
- 增加了客户端复杂度,需要处理令牌刷新逻辑
- 如果刷新令牌泄露,可能导致长期安全风险
2.4 适用场景
- 一般的web应用和移动应用
- 对令牌即时失效要求不严格的场景
- 希望减轻服务器负担的系统
- 用户会话时间较长的应用
三、redis黑名单机制
3.1 基本原理
黑名单机制将已注销或失效的令牌存储在redis等高性能缓存中,每次验证令牌时都会检查它是否在黑名单中。
这种方法允许即时使令牌失效,同时保持良好的性能。
3.2 springboot实现
首先,添加redis依赖:
<dependency>
<groupid>org.springframework.boot</groupid>
<artifactid>spring-boot-starter-data-redis</artifactid>
</dependency>创建redis配置类:
@configuration
public class redisconfig {
@bean
public redistemplate<string, string> redistemplate(redisconnectionfactory factory) {
redistemplate<string, string> template = new redistemplate<>();
template.setconnectionfactory(factory);
template.setkeyserializer(new stringredisserializer());
template.setvalueserializer(new stringredisserializer());
return template;
}
}实现jwt黑名单服务:
@service
@requiredargsconstructor
public class jwtblacklistservice {
private final redistemplate<string, string> redistemplate;
private final jwttokenprovider jwttokenprovider;
private static final string blacklist_prefix = "jwt:blacklist:";
public void blacklisttoken(string token) {
try {
// 获取令牌过期时间
claims claims = jwttokenprovider.getclaimsfromtoken(token);
date expiration = claims.getexpiration();
long ttl = (expiration.gettime() - system.currenttimemillis()) / 1000;
// 仅当令牌未过期时添加到黑名单
if (ttl > 0) {
string key = blacklist_prefix + token;
redistemplate.opsforvalue().set(key, "blacklisted", ttl, timeunit.seconds);
}
} catch (exception e) {
// 令牌已无效,无需加入黑名单
}
}
public boolean isblacklisted(string token) {
string key = blacklist_prefix + token;
return boolean.true.equals(redistemplate.haskey(key));
}
}更新jwt工具类:
@component
public class jwttokenprovider {
// ... 之前的代码 ...
public claims getclaimsfromtoken(string token) {
return jwts.parserbuilder()
.setsigningkey(keys.hmacshakeyfor(jwtsecret.getbytes()))
.build()
.parseclaimsjws(token)
.getbody();
}
}添加jwt过滤器,检查黑名单:
@component
@requiredargsconstructor
public class jwttokenfilter extends onceperrequestfilter {
private final jwttokenprovider jwttokenprovider;
private final jwtblacklistservice blacklistservice;
private final userdetailsservice userdetailsservice;
@override
protected void dofilterinternal(httpservletrequest request, httpservletresponse response,
filterchain filterchain) throws servletexception, ioexception {
try {
string jwt = getjwtfromrequest(request);
if (stringutils.hastext(jwt) && jwttokenprovider.validatetoken(jwt)) {
// 检查令牌是否在黑名单中
if (blacklistservice.isblacklisted(jwt)) {
response.setstatus(httpservletresponse.sc_unauthorized);
response.getwriter().write("token has been revoked");
return;
}
string username = jwttokenprovider.getusernamefromtoken(jwt);
userdetails userdetails = userdetailsservice.loaduserbyusername(username);
usernamepasswordauthenticationtoken authentication =
new usernamepasswordauthenticationtoken(userdetails, null, userdetails.getauthorities());
authentication.setdetails(new webauthenticationdetailssource().builddetails(request));
securitycontextholder.getcontext().setauthentication(authentication);
}
} catch (exception ex) {
logger.error("could not set user authentication in security context", ex);
}
filterchain.dofilter(request, response);
}
private string getjwtfromrequest(httpservletrequest request) {
string bearertoken = request.getheader("authorization");
if (stringutils.hastext(bearertoken) && bearertoken.startswith("bearer ")) {
return bearertoken.substring(7);
}
return null;
}
}实现登出端点:
@restcontroller
@requestmapping("/api/auth")
@requiredargsconstructor
public class authcontroller {
// ... 之前的代码 ...
private final jwtblacklistservice blacklistservice;
@postmapping("/logout")
public responseentity<?> logoutuser(httpservletrequest request) {
string jwt = getjwtfromrequest(request);
if (stringutils.hastext(jwt)) {
blacklistservice.blacklisttoken(jwt);
}
return responseentity.ok(new messageresponse("log out successful!"));
}
private string getjwtfromrequest(httpservletrequest request) {
string bearertoken = request.getheader("authorization");
if (stringutils.hastext(bearertoken) && bearertoken.startswith("bearer ")) {
return bearertoken.substring(7);
}
return null;
}
}3.3 优缺点分析
优点:
- 可以即时使令牌失效
- 不影响令牌原有的有效期管理
- 无需修改客户端逻辑
- redis高性能,对系统影响小
缺点:
- 引入了状态存储,部分牺牲jwt的无状态特性
- redis需要存储所有已注销但未过期的令牌,增加存储开销
- 每次api请求都需要检查黑名单,增加了延迟
3.4 适用场景
- 对安全性要求较高的应用
- 需要即时令牌失效功能的系统
四、令牌版本/计数器机制
4.1 基本原理
该方案为每个用户维护一个令牌版本号或计数器。当用户登出或需要使令牌失效时,增加用户的令牌版本号。
令牌中包含发行时的版本号,验证时比较令牌中的版本号与用户当前的版本号,如果不匹配则拒绝访问。
4.2 springboot实现
首先,创建用户令牌版本实体:
@entity
@table(name = "user_token_versions")
@data
public class usertokenversion {
@id
private string username;
private int tokenversion;
public void incrementversion() {
this.tokenversion++;
}
}创建令牌版本仓库:
@repository
public interface usertokenversionrepository extends jparepository<usertokenversion, string> {
}实现令牌版本服务:
@service
@requiredargsconstructor
public class tokenversionservice {
private final usertokenversionrepository repository;
@transactional
public int getcurrentversion(string username) {
return repository.findbyid(username)
.orelseget(() -> {
usertokenversion newversion = new usertokenversion();
newversion.setusername(username);
newversion.settokenversion(0);
return repository.save(newversion);
})
.gettokenversion();
}
@transactional
public void incrementversion(string username) {
usertokenversion version = repository.findbyid(username)
.orelseget(() -> {
usertokenversion newversion = new usertokenversion();
newversion.setusername(username);
newversion.settokenversion(0);
return newversion;
});
version.incrementversion();
repository.save(version);
}
}修改jwt工具类,在令牌中包含版本信息:
@component
@requiredargsconstructor
public class jwttokenprovider {
@value("${jwt.secret}")
private string jwtsecret;
@value("${jwt.expiration}")
private long jwtexpiration;
private final tokenversionservice tokenversionservice;
public string generatetoken(userdetails userdetails) {
date now = new date();
date expirydate = new date(now.gettime() + jwtexpiration);
// 获取当前令牌版本
int tokenversion = tokenversionservice.getcurrentversion(userdetails.getusername());
return jwts.builder()
.setsubject(userdetails.getusername())
.claim("tokenversion", tokenversion) // 添加版本信息
.setissuedat(now)
.setexpiration(expirydate)
.signwith(keys.hmacshakeyfor(jwtsecret.getbytes()), signaturealgorithm.hs512)
.compact();
}
public boolean validatetoken(string token, userdetails userdetails) {
try {
claims claims = getclaimsfromtoken(token);
// 验证用户名
boolean usernamematches = claims.getsubject().equals(userdetails.getusername());
// 验证令牌未过期
boolean isnotexpired = claims.getexpiration().after(new date());
// 验证令牌版本
int tokenversion = claims.get("tokenversion", integer.class);
int currentversion = tokenversionservice.getcurrentversion(userdetails.getusername());
boolean versionmatches = tokenversion == currentversion;
return usernamematches && isnotexpired && versionmatches;
} catch (exception e) {
return false;
}
}
// ... 其他方法 ...
}更新jwt过滤器:
@component
@requiredargsconstructor
public class jwttokenfilter extends onceperrequestfilter {
private final jwttokenprovider jwttokenprovider;
private final userdetailsservice userdetailsservice;
@override
protected void dofilterinternal(httpservletrequest request, httpservletresponse response,
filterchain filterchain) throws servletexception, ioexception {
try {
string jwt = getjwtfromrequest(request);
if (stringutils.hastext(jwt)) {
string username = jwttokenprovider.getusernamefromtoken(jwt);
userdetails userdetails = userdetailsservice.loaduserbyusername(username);
// 使用版本验证令牌
if (jwttokenprovider.validatetoken(jwt, userdetails)) {
usernamepasswordauthenticationtoken authentication =
new usernamepasswordauthenticationtoken(userdetails, null, userdetails.getauthorities());
authentication.setdetails(new webauthenticationdetailssource().builddetails(request));
securitycontextholder.getcontext().setauthentication(authentication);
}
}
} catch (exception ex) {
logger.error("could not set user authentication in security context", ex);
}
filterchain.dofilter(request, response);
}
// ... getjwtfromrequest方法 ...
}实现登出端点:
@restcontroller
@requestmapping("/api/auth")
@requiredargsconstructor
public class authcontroller {
// ... 其他代码 ...
private final tokenversionservice tokenversionservice;
@postmapping("/logout")
public responseentity<?> logoutuser(authentication authentication) {
string username = authentication.getname();
// 增加令牌版本号,使所有现有令牌失效
tokenversionservice.incrementversion(username);
return responseentity.ok(new messageresponse("log out successful!"));
}
}4.3 优缺点分析
优点:
- 存储开销小,只需记录用户的当前版本号
- 无需维护黑名单,降低了内存需求
- 可以选择性地使部分令牌失效
缺点:
- 需要存储用户令牌版本
- 每次验证令牌都需要查询数据库或缓存
- 可能影响系统性能,特别是在用户量大的情况下
4.4 适用场景
- 需要用户主动登出功能的系统
- 用户量适中的系统
- 需要在特定操作后使令牌失效的场景
五、密钥轮换策略
5.1 基本原理
密钥轮换策略通过定期更换用于签名jwt的密钥来实现令牌失效。
当系统需要使所有令牌失效时,立即轮换密钥,所有使用旧密钥签名的令牌将无法通过验证。
为了支持平滑过渡,系统通常保留多个最近的密钥版本。
5.2 springboot实现
创建密钥管理服务:
@service
@slf4j
public class keyrotationservice {
private final map<string, key> keystore = new concurrenthashmap<>();
private string currentkeyid;
@postconstruct
public void init() {
// 初始化第一个密钥
rotatekey();
}
@scheduled(cron = "${jwt.key-rotation-cron:0 0 0 * * ?}") // 默认每天零点
public void scheduledrotation() {
log.info("performing scheduled key rotation");
rotatekey();
}
public synchronized void rotatekey() {
string keyid = uuid.randomuuid().tostring();
key key = generatekey();
keystore.put(keyid, key);
// 只保留最近3个密钥
if (keystore.size() > 3) {
list<string> keyids = new arraylist<>(keystore.keyset());
keyids.sort(null); // 自然排序
for (int i = 0; i < keyids.size() - 3; i++) {
keystore.remove(keyids.get(i));
}
}
currentkeyid = keyid;
log.info("key rotated, new key id: {}", keyid);
}
public string getcurrentkeyid() {
return currentkeyid;
}
public key getkey(string keyid) {
return keystore.get(keyid);
}
public key getcurrentkey() {
return keystore.get(currentkeyid);
}
private key generatekey() {
return keys.secretkeyfor(signaturealgorithm.hs512);
}
public void forcerotation() {
log.info("forcing key rotation to invalidate all tokens");
rotatekey();
}
}更新jwt工具类以支持密钥轮换:
@component
@requiredargsconstructor
public class jwttokenprovider {
@value("${jwt.expiration}")
private long jwtexpiration;
private final keyrotationservice keyrotationservice;
public string generatetoken(userdetails userdetails) {
date now = new date();
date expirydate = new date(now.gettime() + jwtexpiration);
string keyid = keyrotationservice.getcurrentkeyid();
key key = keyrotationservice.getcurrentkey();
return jwts.builder()
.setsubject(userdetails.getusername())
.setissuedat(now)
.setexpiration(expirydate)
.setheaderparam("kid", keyid) // 设置密钥id
.signwith(key, signaturealgorithm.hs512)
.compact();
}
public claims getclaimsfromtoken(string token) {
// 从令牌头部提取密钥id
string kid = extractkeyid(token);
if (kid == null) {
throw new jwtexception("invalid jwt: missing key id");
}
// 获取对应的密钥
key key = keyrotationservice.getkey(kid);
if (key == null) {
throw new jwtexception("invalid jwt: unknown key id");
}
return jwts.parserbuilder()
.setsigningkey(key)
.build()
.parseclaimsjws(token)
.getbody();
}
private string extractkeyid(string token) {
try {
string header = token.split("\.")[0];
string decodedheader = new string(base64.getdecoder().decode(header));
jsonnode headernode = new objectmapper().readtree(decodedheader);
return headernode.get("kid").astext();
} catch (exception e) {
return null;
}
}
public boolean validatetoken(string token) {
try {
getclaimsfromtoken(token);
return true;
} catch (exception e) {
return false;
}
}
// ... 其他方法 ...
}创建管理员控制器,提供强制失效所有令牌的功能:
@restcontroller
@requestmapping("/api/admin")
@requiredargsconstructor
@preauthorize("hasrole('admin')")
public class admincontroller {
private final keyrotationservice keyrotationservice;
@postmapping("/invalidate-all-tokens")
public responseentity<?> invalidatealltokens() {
keyrotationservice.forcerotation();
return responseentity.ok(new messageresponse("all tokens have been invalidated"));
}
}5.3 优缺点分析
优点:
- 可以立即使所有令牌失效
- 可以实现平滑过渡,支持旧密钥一段时间
- 符合安全最佳实践,定期轮换密钥
缺点:
- 无法选择性使单个用户的令牌失效
- 可能导致所有用户被迫重新登录
- 需要妥善管理密钥
5.4 适用场景
- 安全要求高,需要定期轮换密钥的系统
- 发生安全事件时,需要紧急使所有令牌失效
- 偏好无状态设计的应用
- 系统重大升级或维护时
六、集中式令牌存储
6.1 基本原理
这种方法将jwt作为访问标识符,但在服务器端维护一个集中式的令牌存储,存储介质可以使用数据库或者缓存。
每次验证时,不仅检查jwt的签名和有效期,还查询存储库确认令牌是否仍然有效。
这种方式结合了jwt的便利性和会话管理的灵活性。
6.2 springboot实现
创建令牌实体:
@entity
@table(name = "active_tokens")
@data
public class activetoken {
@id
private string tokenid;
private string username;
private date expirydate;
private boolean revoked;
@creationtimestamp
private date createdat;
public boolean isexpired() {
return expirydate.before(new date());
}
}创建令牌仓库:
@repository
public interface activetokenrepository extends jparepository<activetoken, string> {
list<activetoken> findbyusername(string username);
@modifying
@query("update activetoken t set t.revoked = true where t.username = :username")
void revokeallusertokens(@param("username") string username);
@modifying
@query("delete from activetoken t where t.expirydate < :now")
void deleteexpiredtokens(@param("now") date now);
}实现令牌服务:
@service
@requiredargsconstructor
public class tokenstorageservice {
private final activetokenrepository tokenrepository;
@transactional
public void savetoken(string tokenid, string username, date expirydate) {
activetoken token = new activetoken();
token.settokenid(tokenid);
token.setusername(username);
token.setexpirydate(expirydate);
token.setrevoked(false);
tokenrepository.save(token);
}
@transactional(readonly = true)
public boolean istokenvalid(string tokenid) {
return tokenrepository.findbyid(tokenid)
.map(token -> !token.isrevoked() && !token.isexpired())
.orelse(false);
}
@transactional
public void revoketoken(string tokenid) {
tokenrepository.findbyid(tokenid).ifpresent(token -> {
token.setrevoked(true);
tokenrepository.save(token);
});
}
@transactional
public void revokeallusertokens(string username) {
tokenrepository.revokeallusertokens(username);
}
@scheduled(fixedrate = 86400000) // 每天清理一次
@transactional
public void cleanexpiredtokens() {
tokenrepository.deleteexpiredtokens(new date());
}
}更新jwt工具类:
@component
@requiredargsconstructor
public class jwttokenprovider {
@value("${jwt.secret}")
private string jwtsecret;
@value("${jwt.expiration}")
private long jwtexpiration;
private final tokenstorageservice tokenstorageservice;
public string generatetoken(userdetails userdetails) {
date now = new date();
date expirydate = new date(now.gettime() + jwtexpiration);
// 生成唯一的令牌id
string tokenid = uuid.randomuuid().tostring();
string token = jwts.builder()
.setsubject(userdetails.getusername())
.setissuedat(now)
.setexpiration(expirydate)
.setid(tokenid) // 设置jwt id (jti)
.signwith(keys.hmacshakeyfor(jwtsecret.getbytes()), signaturealgorithm.hs512)
.compact();
// 将令牌保存到存储中
tokenstorageservice.savetoken(tokenid, userdetails.getusername(), expirydate);
return token;
}
public string gettokenid(string token) {
return getclaimsfromtoken(token).getid();
}
public boolean validatetoken(string token) {
try {
claims claims = getclaimsfromtoken(token);
// 验证jwt基本属性
boolean isnotexpired = claims.getexpiration().after(new date());
// 验证令牌是否在存储中有效
string tokenid = claims.getid();
boolean isvalidinstorage = tokenstorageservice.istokenvalid(tokenid);
return isnotexpired && isvalidinstorage;
} catch (exception e) {
return false;
}
}
// ... 其他方法 ...
}实现登出功能:
@restcontroller
@requestmapping("/api/auth")
@requiredargsconstructor
public class authcontroller {
// ... 其他代码 ...
private final jwttokenprovider jwttokenprovider;
private final tokenstorageservice tokenstorageservice;
@postmapping("/logout")
public responseentity<?> logoutuser(httpservletrequest request) {
string jwt = getjwtfromrequest(request);
if (stringutils.hastext(jwt)) {
string tokenid = jwttokenprovider.gettokenid(jwt);
tokenstorageservice.revoketoken(tokenid);
}
return responseentity.ok(new messageresponse("log out successful!"));
}
@postmapping("/logout-all")
public responseentity<?> logoutalldevices(authentication authentication) {
string username = authentication.getname();
tokenstorageservice.revokeallusertokens(username);
return responseentity.ok(new messageresponse("logged out from all devices"));
}
// ... 其他方法 ...
}6.3 优缺点分析
优点:
能够即时使单个令牌或所有令牌失效
提供精细的令牌管理,如查看活跃会话
可以实现"记住我"等高级功能
便于审计和监控
缺点:
完全放弃了jwt的无状态优势
每次请求都需要查询存储库
系统复杂度提高
6.4 适用场景
对安全性要求极高的系统
需要精细令牌管理的应用
已有会话管理需求的项目
多设备登录管理
企业级应用,需要详细的审计日志
七、会话状态监控机制
7.1 基本原理
会话状态监控机制在保持jwt无状态特性的同时,通过跟踪用户会话状态来间接控制令牌有效性。
系统维护用户登录状态(如最后活动时间、登录设备等),当状态变更(如密码修改、异常登录)时,可以拒绝特定令牌的访问。
7.2 springboot实现
创建用户会话状态实体:
@entity
@table(name = "user_sessions")
@data
public class usersessionstatus {
@id
private string username;
private date passwordlastchanged;
private date lastforcedlogout;
private string securitycontext;
@version
private long version;
public boolean haschangedafter(date tokenissuedat) {
return (passwordlastchanged != null && passwordlastchanged.after(tokenissuedat)) ||
(lastforcedlogout != null && lastforcedlogout.after(tokenissuedat));
}
}创建会话状态仓库:
@repository
public interface usersessionstatusrepository extends jparepository<usersessionstatus, string> {
}实现会话状态服务:
@service
@requiredargsconstructor
public class usersessionservice {
private final usersessionstatusrepository repository;
@transactional(readonly = true)
public usersessionstatus getsessionstatus(string username) {
return repository.findbyid(username)
.orelseget(() -> {
usersessionstatus status = new usersessionstatus();
status.setusername(username);
return status;
});
}
@transactional
public void updatepasswordchanged(string username) {
usersessionstatus status = getsessionstatus(username);
status.setpasswordlastchanged(new date());
repository.save(status);
}
@transactional
public void forcelogout(string username) {
usersessionstatus status = getsessionstatus(username);
status.setlastforcedlogout(new date());
repository.save(status);
}
@transactional
public void updatesecuritycontext(string username, string securitycontext) {
usersessionstatus status = getsessionstatus(username);
status.setsecuritycontext(securitycontext);
repository.save(status);
}
public boolean istokenvalid(string username, date tokenissuedat, string tokensecuritycontext) {
usersessionstatus status = getsessionstatus(username);
// 检查令牌是否在密码更改或强制登出之前签发
if (status.haschangedafter(tokenissuedat)) {
return false;
}
// 检查安全上下文是否匹配(可选)
if (status.getsecuritycontext() != null && tokensecuritycontext != null) {
return status.getsecuritycontext().equals(tokensecuritycontext);
}
return true;
}
}更新jwt工具类:
@component
@requiredargsconstructor
public class jwttokenprovider {
@value("${jwt.secret}")
private string jwtsecret;
@value("${jwt.expiration}")
private long jwtexpiration;
private final usersessionservice sessionservice;
public string generatetoken(userdetails userdetails, string securitycontext) {
date now = new date();
date expirydate = new date(now.gettime() + jwtexpiration);
return jwts.builder()
.setsubject(userdetails.getusername())
.setissuedat(now)
.setexpiration(expirydate)
.claim("securitycontext", securitycontext)
.signwith(keys.hmacshakeyfor(jwtsecret.getbytes()), signaturealgorithm.hs512)
.compact();
}
public boolean validatetoken(string token) {
try {
claims claims = getclaimsfromtoken(token);
// 基本验证
boolean isnotexpired = claims.getexpiration().after(new date());
if (!isnotexpired) {
return false;
}
// 验证会话状态
string username = claims.getsubject();
date issuedat = claims.getissuedat();
string securitycontext = claims.get("securitycontext", string.class);
return sessionservice.istokenvalid(username, issuedat, securitycontext);
} catch (exception e) {
return false;
}
}
// ... 其他方法 ...
}实现认证和密码更改接口:
@restcontroller
@requiredargsconstructor
public class authcontroller {
// ... 其他依赖 ...
private final usersessionservice sessionservice;
private final userservice userservice;
@postmapping("/login")
public responseentity<?> login(@requestbody loginrequest loginrequest) {
// ... 认证逻辑 ...
// 生成安全上下文(例如,设备信息、ip地址等)
string securitycontext = generatesecuritycontext(request);
// 更新用户会话状态
sessionservice.updatesecuritycontext(userdetails.getusername(), securitycontext);
// 生成令牌,包含安全上下文
string token = jwttokenprovider.generatetoken(userdetails, securitycontext);
// ... 返回令牌 ...
}
@postmapping("/change-password")
public responseentity<?> changepassword(@requestbody passwordchangerequest request,
authentication authentication) {
string username = authentication.getname();
// 更改密码
userservice.changepassword(username, request.getoldpassword(), request.getnewpassword());
// 更新密码更改时间,使旧令牌失效
sessionservice.updatepasswordchanged(username);
return responseentity.ok(new messageresponse("password changed successfully"));
}
@postmapping("/logout-all-devices")
public responseentity<?> logoutalldevices(authentication authentication) {
string username = authentication.getname();
// 强制所有设备登出
sessionservice.forcelogout(username);
return responseentity.ok(new messageresponse("logged out from all devices"));
}
private string generatesecuritycontext(httpservletrequest request) {
// 生成包含设备信息、ip地址等的安全上下文
string ipaddress = request.getremoteaddr();
string useragent = request.getheader("user-agent");
return digestutils.md5digestashex((ipaddress + ":" + useragent).getbytes());
}
}7.3 优缺点分析
优点:
保持了jwt的大部分无状态特性
可以基于用户状态变更使令牌失效
可以实现细粒度的会话控制
安全上下文可以防止令牌被盗用
缺点:
每次请求需要检查用户会话状态
状态管理增加了系统复杂性
安全上下文验证可能导致合法用户被拒绝(如ip变化)
7.4 适用场景
需要账户安全功能(如密码更改后使令牌失效)的系统
对可疑活动监控有需求的应用
需要防止令牌盗用的场景
平衡无状态性和安全性的应用
八、六种方案对比与选择指南
方案 | 即时失效 | 存储需求 | 性能影响 | 实现复杂度 | 维护成本 | 适用场景 |
短期令牌+刷新令牌 | 部分(仅刷新令牌) | 低 | 低 | 低 | 低 | 一般web/移动应用 |
redis黑名单 | 完全 | 中 | 中 | 中 | 中 | 安全性要求高的应用 |
令牌版本/计数器 | 完全 | 低 | 中 | 中 | 低 | 特定操作下需要控制token有效性需求的应用 |
密钥轮换 | 全局 | 极低 | 低 | 中 | 中 | 需要定期轮换密钥的系统 |
集中式令牌存储 | 完全 | 高 | 高 | 高 | 高 | 企业级应用,多设备管理 |
会话状态监控 | 条件性 | 中 | 中 | 高 | 中 | 平衡安全和性能的系统 |
九、总结
每种方案都有其优缺点和适用场景,选择合适的方案取决于应用的安全需求、性能要求和架构设计。
在实际应用中,常常需要组合使用多种策略,构建多层次的安全防护。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论