一、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有效性需求的应用 |
密钥轮换 | 全局 | 极低 | 低 | 中 | 中 | 需要定期轮换密钥的系统 |
集中式令牌存储 | 完全 | 高 | 高 | 高 | 高 | 企业级应用,多设备管理 |
会话状态监控 | 条件性 | 中 | 中 | 高 | 中 | 平衡安全和性能的系统 |
九、总结
每种方案都有其优缺点和适用场景,选择合适的方案取决于应用的安全需求、性能要求和架构设计。
在实际应用中,常常需要组合使用多种策略,构建多层次的安全防护。
以上就是springboot实现jwt令牌失效的6种方案的详细内容,更多关于springboot jwt令牌失效的资料请关注代码网其它相关文章!
发表评论