有些用户可能需要在多台设备中登录同一个账户,同时希望设置可以登录的设备数。举个例子:公司的账户只允许五个员工登录系统。
利用redis的Zset有序集合,使用登录的当前时间戳作为分数,后续达到最大设备数之后,删除分数最小的,也就是登录时间最早的设备,实现强制退出。
配置信息读取yaml文件
# 用户登陆配置
user-login:
# token 到期时间 单位秒
token-expire: 7200
# 是否允许多设备登录
allowMultiDevice: true
# 最大允许设备数
maxDevices: 5
@Data
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "user-login")
public class UserLoginConfig {
/**
* token过期时间:单位秒
*/
private Long tokenExpire;
/**
* 是否允许多设备登录
*/
private Boolean allowMultiDevice;
/**
* 最大允许设备数
*/
private Integer maxDevices;
}
@Resource
private RedissonClient redisson;
@Resource
private UserLoginConfig userLoginConfig;
/**
* 登录设备配置化:向 ZSet 中添加元素,只能添加设置的登录设备数量
*
* @param accessToken 元素的值
* @param jwtToken 最新的登录jwtToken
*/
public void addToZSetByLoginDevice(String accessToken, String accountCode, String jwtToken) {
// 1.将AccountCode与accessToken关联起来(用来管理一个账户的多个accessToken)
String key = RedisKeyConstants.USER_CURRENT_TOKEN + accountCode;
// 获取 RScoredSortedSet 实例
RScoredSortedSet zSet = redisson.getScoredSortedSet(key);
// 支持多设备登录,并判断设备数量
if (userLoginConfig.getAllowMultiDevice()) {
// 检查 ZSet 中的元素数量
int size = zSet.size();
if (size >= userLoginConfig.getMaxDevices()) {
// 计算需要删除的元素数量,+1因为后续要加入新的数据
int elementsToRemove = size - userLoginConfig.getMaxDevices() + 1;
// 循环删除分数最小的元素
for (int i = 0; i < elementsToRemove; i++) {
// 获取分数最小的元素,也就是登录最早的token
String oldAccessToken = zSet.first();
if (oldAccessToken != null) {
// 删除分数最小的元素
zSet.remove(oldAccessToken);
// 删除旧Token关联的Redis键
this.del(oldAccessToken);
log.info("账户{},设备{},已强制退出", accountCode, oldAccessToken);
}
}
}
// 刷新accessToken与JWT的映射(用于按Token验证),把之前的设备也刷新成最新的token
if (size > 0) {
for (String oldAccessToken : zSet) {
this.setex(oldAccessToken, userLoginConfig.getTokenExpire(), jwtToken);
}
}
} else {
// 如果不支持多设备登录,则直接清除
Collection oldAccessTokenSet = zSet.readAll();
if (oldAccessTokenSet != null && !oldAccessTokenSet.isEmpty()) {
for (String oldAccessToken : oldAccessTokenSet) {
// 删除旧Token关联的Redis键
this.del(oldAccessToken);
log.info("账户{},设备{},已强制退出", accountCode, oldAccessToken);
}
}
zSet.clear();
}
// 向 ZSet 中添加新的元素
// 获取当前时间戳作为分数
double score = new Date().getTime();
// 每次添加元素时都会自动使用当前时间戳作为分数,从而保证 ZSet 中始终保留最大设备数中最新的元素
zSet.add(score, accessToken);
log.info("账户{},设备{},成功登录,目前登录设备数{}", accountCode, accessToken, zSet.size());
// 2. 存储新Token到Redis,设置过期时间
// 存储accountCode与JWT的映射(用于按用户标识查询)
this.setex(RedisKeyConstants.ACCESS_TOKEN + accountCode, userLoginConfig.getTokenExpire(), jwtToken);
// 存储最新的accessToken与JWT的映射(用于按Token验证)
this.setex(accessToken, userLoginConfig.getTokenExpire(), jwtToken);
}
/**
* 退出系统,处理token
*
* @param accountCode 账户code
* @param authorization 请求头的访问token
* @author wujiada
* @since 2025/2/18 17:26
*/
public void logoutRemoveToken(String accountCode, String authorization) {
// 同一个账户有个多个accessToken
String key = RedisKeyConstants.USER_CURRENT_TOKEN + accountCode;
// 获取 RScoredSortedSet 实例
RScoredSortedSet zSet = redisson.getScoredSortedSet(key);
// 检查 ZSet 中的元素数量
int size = zSet.size();
// 如果开启了多设备登录,需要判断是否还有其他设备,如果有,则不允许清除accessToken和jwtToken的关联
if (userLoginConfig.getAllowMultiDevice()) {
if (size > 1) {
// 访问token
this.del(authorization);
// token和设备的关联
zSet.remove(authorization);
} else {
// 当前只有一个设备,则直接清除accessToken和jwtToken的关联
this.del(RedisKeyConstants.ACCESS_TOKEN + accountCode);
this.del(authorization);
// token和设备的关联
zSet.clear();
}
} else {
// 如果不支持多设备,则直接清除accessToken和jwtToken的关联
this.del(RedisKeyConstants.ACCESS_TOKEN + accountCode);
// 如果不支持多设备登录,则直接清除
Collection oldAccessTokenSet = zSet.readAll();
if (oldAccessTokenSet != null && !oldAccessTokenSet.isEmpty()) {
for (String oldAccessToken : oldAccessTokenSet) {
// 删除旧Token关联的Redis键
this.del(oldAccessToken);
log.info("账户{},设备{},已强制退出", accountCode, oldAccessToken);
}
}
// token和设备的关联
zSet.clear();
}
log.info("账户{},设备{},成功退出,目前登录设备数{}", accountCode, authorization, zSet.size());
}
/**
* 更新密码,处理token
*
* @param accountCode 账户code
* @author wujiada
* @since 2025/2/18 17:26
*/
public void updatePasswordRemoveToken(String accountCode) {
// 更新密码需要,需要删除全部的token数据
// 同一个账户有个多个accessToken
String key = RedisKeyConstants.USER_CURRENT_TOKEN + accountCode;
// 获取 RScoredSortedSet 实例
RScoredSortedSet zSet = redisson.getScoredSortedSet(key);
this.del(RedisKeyConstants.ACCESS_TOKEN + accountCode);
Collection oldAccessTokenSet = zSet.readAll();
if (oldAccessTokenSet != null && !oldAccessTokenSet.isEmpty()) {
for (String oldAccessToken : oldAccessTokenSet) {
// 删除该账户下所有登录设备的访问token
this.del(oldAccessToken);
log.info("账户{},设备{},已强制退出", accountCode, oldAccessToken);
}
}
// token和设备的关联
zSet.clear();
}
/**
* token续期
* @author wujiada
* @since 2025/1/21 11:51
*/
public void renewToken(String accessToken, String claims) {
RBucket rBucket = this.redisson.getBucket(accessToken);
// token续期
// 判断过期时间是否少于60分钟,如果小于,则续期
long remainTime = rBucket.remainTimeToLive();
// 60 分钟的毫秒数
long thirtyMinutesInMillis = TimeUnit.MINUTES.toMillis(60);
if (remainTime < thirtyMinutesInMillis) {
String accountCode = Base64Util.decode(claims);
// 续期成功之后,同一个账户关联多个accessToken,需要将续期成功的作为新增的加入到Zset集合中,避免达到最大设备数时将其删除
RScoredSortedSet zSet = redisson.getScoredSortedSet(RedisKeyConstants.USER_CURRENT_TOKEN + accountCode);
RBucket rBucket1 = this.redisson.getBucket(RedisKeyConstants.ACCESS_TOKEN + accountCode);
// 设置新的过期时间
Long tokenExpire = userLoginConfig.getTokenExpire();
rBucket.expire(tokenExpire, TimeUnit.SECONDS);
rBucket1.expire(tokenExpire, TimeUnit.SECONDS);
// 获取当前时间戳作为分数,时间越新,时间戳越大。
double score = new Date().getTime();
zSet.add(score, accessToken);
}
}
通过以上的方法可以实现同一个账户多设备登录、设备数量进行可配置化操作。