diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java index d76af802..e916afb7 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/config/SaTokenConfig.java @@ -3,7 +3,7 @@ package cn.dev33.satoken.config; import java.io.Serializable; /** - * Sa-Token 配置类 Model + * Sa-Token 配置类 Model *

* 你可以通过yml、properties、java代码等形式配置本类参数,具体请查阅官方文档: http://sa-token.dev33.cn/ * @@ -32,6 +32,11 @@ public class SaTokenConfig implements Serializable { /** 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) */ private Boolean isShare = true; + /** + * 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置才有效) + */ + private int maxLoginCount = 10; + /** 是否尝试从请求体里读取token */ private Boolean isReadBody = true; @@ -176,6 +181,22 @@ public class SaTokenConfig implements Serializable { return this; } + /** + * @return 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置才有效) + */ + public int getMaxLoginCount() { + return maxLoginCount; + } + + /** + * @param maxLoginCount 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置才有效) + * @return 对象自身 + */ + public SaTokenConfig setMaxLoginCount(int maxLoginCount) { + this.maxLoginCount = maxLoginCount; + return this; + } + /** * @return 是否尝试从请求体里读取token */ @@ -458,6 +479,7 @@ public class SaTokenConfig implements Serializable { + ", activityTimeout=" + activityTimeout + ", isConcurrent=" + isConcurrent + ", isShare=" + isShare + + ", maxLoginCount=" + maxLoginCount + ", isReadBody=" + isReadBody + ", isReadHead=" + isReadHead + ", isReadCookie=" + isReadCookie diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/session/SaSession.java b/sa-token-core/src/main/java/cn/dev33/satoken/session/SaSession.java index b3503dcc..0812ec49 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/session/SaSession.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/session/SaSession.java @@ -1,6 +1,7 @@ package cn.dev33.satoken.session; import java.io.Serializable; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -110,12 +111,37 @@ public class SaSession implements Serializable { private final List tokenSignList = new Vector<>(); /** - * 返回token签名列表的拷贝副本 + * 此Session绑定的token签名列表 * * @return token签名列表 */ public List getTokenSignList() { - return new Vector<>(tokenSignList); + return tokenSignList; + } + + /** + * 返回token签名列表的拷贝副本 + * + * @return token签名列表 + */ + public List tokenSignListCopy() { + return new ArrayList<>(tokenSignList); + } + + /** + * 返回token签名列表的拷贝副本,根据 device 筛选 + * + * @param device 设备类型,填 null 代表不限设备类型 + * @return token签名列表 + */ + public List tokenSignListCopyByDevice(String device) { + List list = new ArrayList<>(); + for (TokenSign tokenSign : tokenSignListCopy()) { + if(device == null || tokenSign.getDevice().equals(device)) { + list.add(tokenSign); + } + } + return list; } /** @@ -125,7 +151,7 @@ public class SaSession implements Serializable { * @return 查找到的tokenSign */ public TokenSign getTokenSign(String tokenValue) { - for (TokenSign tokenSign : getTokenSignList()) { + for (TokenSign tokenSign : tokenSignListCopy()) { if (tokenSign.getValue().equals(tokenValue)) { return tokenSign; } @@ -140,7 +166,7 @@ public class SaSession implements Serializable { */ public void addTokenSign(TokenSign tokenSign) { // 如果已经存在于列表中,则无需再次添加 - for (TokenSign tokenSign2 : getTokenSignList()) { + for (TokenSign tokenSign2 : tokenSignListCopy()) { if (tokenSign2.getValue().equals(tokenSign.getValue())) { return; } diff --git a/sa-token-core/src/main/java/cn/dev33/satoken/stp/StpLogic.java b/sa-token-core/src/main/java/cn/dev33/satoken/stp/StpLogic.java index 5e8d697c..cfd80969 100644 --- a/sa-token-core/src/main/java/cn/dev33/satoken/stp/StpLogic.java +++ b/sa-token-core/src/main/java/cn/dev33/satoken/stp/StpLogic.java @@ -1,7 +1,10 @@ package cn.dev33.satoken.stp; -import java.util.*; -import java.util.function.Consumer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; import cn.dev33.satoken.SaManager; import cn.dev33.satoken.annotation.SaCheckLogin; @@ -316,6 +319,11 @@ public class StpLogic { // 如果配置为共享token, 则尝试从Session签名记录里取出token if(getConfigOfIsShare()) { tokenValue = getTokenValueByLoginId(id, loginModel.getDeviceOrDefault()); + } else { + // 如果配置为不共享token,需要检查会话是否超出 max-login-count + if(config.getMaxLoginCount() != -1) { + logoutByRetainCount(id, null, config.getMaxLoginCount() - 1); + } } } else { // --- 如果不允许并发登录,则将这个账号的历史登录标记为:被顶下线 @@ -383,7 +391,7 @@ public class StpLogic { public void logout(Object loginId) { logout(loginId, null); } - + /** * 会话注销,根据账号id 和 设备类型 * @@ -391,12 +399,38 @@ public class StpLogic { * @param device 设备类型 (填null代表注销所有设备类型) */ public void logout(Object loginId, String device) { - clearTokenCommonMethod(loginId, device, tokenValue -> { - // 删除Token-Id映射 & 清除Token-Session - deleteTokenToIdMapping(tokenValue); - deleteTokenSession(tokenValue); - SaManager.getSaTokenListener().doLogout(loginType, loginId, tokenValue); - }, true); + logoutByRetainCount(loginId, device, 0); + } + + /** + * 会话注销,根据账号id 和 设备类型 和 保留数量 + * + * @param loginId 账号id + * @param device 设备类型 (填null代表注销所有设备类型) + * @param retainCount 保留最近的n次登录 + */ + public void logoutByRetainCount(Object loginId, String device, int retainCount) { + SaSession session = getSessionByLoginId(loginId, false); + if(session != null) { + List list = session.tokenSignListCopyByDevice(device); + // 遍历操作 + for (int i = 0; i < list.size(); i++) { + // 只操作前n条 + if(i >= list.size() - retainCount) { + continue; + } + // 清理: token签名、token最后活跃时间 + String tokenValue = list.get(i).getValue(); + session.removeTokenSign(tokenValue); + clearLastActivity(tokenValue); + // 删除Token-Id映射 & 清除Token-Session + deleteTokenToIdMapping(tokenValue); + deleteTokenSession(tokenValue); + SaManager.getSaTokenListener().doLogout(loginType, loginId, tokenValue); + } + // 注销 Session + session.logoutByTokenSignCountToZero(); + } } /** @@ -451,11 +485,20 @@ public class StpLogic { * @param device 设备类型 (填null代表踢出所有设备类型) */ public void kickout(Object loginId, String device) { - clearTokenCommonMethod(loginId, device, tokenValue -> { - // 将此 token 标记为已被踢下线 - updateTokenToIdMapping(tokenValue, NotLoginException.KICK_OUT); - SaManager.getSaTokenListener().doKickout(loginType, loginId, tokenValue); - }, true); + SaSession session = getSessionByLoginId(loginId, false); + if(session != null) { + for (TokenSign tokenSign: session.tokenSignListCopyByDevice(device)) { + // 清理: token签名、token最后活跃时间 + String tokenValue = tokenSign.getValue(); + session.removeTokenSign(tokenValue); + clearLastActivity(tokenValue); + // 将此 token 标记为已被踢下线 + updateTokenToIdMapping(tokenValue, NotLoginException.KICK_OUT); + SaManager.getSaTokenListener().doKickout(loginType, loginId, tokenValue); + } + // 注销 Session + session.logoutByTokenSignCountToZero(); + } } /** @@ -498,44 +541,18 @@ public class StpLogic { * @param device 设备类型 (填null代表顶替所有设备类型) */ public void replaced(Object loginId, String device) { - clearTokenCommonMethod(loginId, device, tokenValue -> { - // 将此 token 标记为已被顶替 - updateTokenToIdMapping(tokenValue, NotLoginException.BE_REPLACED); - SaManager.getSaTokenListener().doReplaced(loginType, loginId, tokenValue); - }, false); - } - - /** - * 封装 注销、踢人、顶人 三个动作的相同代码(无API含义方法) - * @param loginId 账号id - * @param device 设备类型 - * @param appendFun 追加操作 - * @param isLogoutSession 是否注销 User-Session - */ - protected void clearTokenCommonMethod(Object loginId, String device, Consumer appendFun, boolean isLogoutSession) { - // 1. 如果此账号尚未登录,则不执行任何操作 SaSession session = getSessionByLoginId(loginId, false); - if(session == null) { - return; - } - // 2. 循环token签名列表,开始删除相关信息 - for (TokenSign tokenSign : session.getTokenSignList()) { - if(device == null || tokenSign.getDevice().equals(device)) { - // -------- 共有操作 - // s1. 获取token + if(session != null) { + for (TokenSign tokenSign: session.tokenSignListCopyByDevice(device)) { + // 清理: token签名、token最后活跃时间 String tokenValue = tokenSign.getValue(); - // s2. 清理掉[token-last-activity] + session.removeTokenSign(tokenValue); clearLastActivity(tokenValue); - // s3. 从token签名列表移除 - session.removeTokenSign(tokenValue); - // -------- 追加操作 - appendFun.accept(tokenValue); + // 将此 token 标记为已被顶替 + updateTokenToIdMapping(tokenValue, NotLoginException.BE_REPLACED); + SaManager.getSaTokenListener().doReplaced(loginType, loginId, tokenValue); } } - // 3. 尝试注销session - if(isLogoutSession) { - session.logoutByTokenSignCountToZero(); - } } // ---- 会话查询 @@ -1381,7 +1398,7 @@ public class StpLogic { return Collections.emptyList(); } // 遍历解析 - List tokenSignList = session.getTokenSignList(); + List tokenSignList = session.tokenSignListCopy(); List tokenValueList = new ArrayList<>(); for (TokenSign tokenSign : tokenSignList) { if(device == null || tokenSign.getDevice().equals(device)) { @@ -1411,7 +1428,7 @@ public class StpLogic { return null; } // 遍历解析 - List tokenSignList = session.getTokenSignList(); + List tokenSignList = session.tokenSignListCopy(); for (TokenSign tokenSign : tokenSignList) { if(tokenSign.getValue().equals(tokenValue)) { return tokenSign.getDevice(); diff --git a/sa-token-doc/doc/use/config.md b/sa-token-doc/doc/use/config.md index b3fbe53e..80330f16 100644 --- a/sa-token-doc/doc/use/config.md +++ b/sa-token-doc/doc/use/config.md @@ -79,6 +79,7 @@ PS:两者的区别在于:**`模式1会覆盖yml中的配置,模式2会与y | activityTimeout | long | -1 | token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒, 默认-1 代表不限制 (例如可以设置为1800代表30分钟内无操作就过期) [参考:token有效期详解](/fun/token-timeout) | | isConcurrent | Boolean | true | 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录) | | isShare | Boolean | true | 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token) | +| maxLoginCount | int | 10 | 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置才有效) | | isReadBody | Boolean | true | 是否尝试从 请求体 里读取 Token | | isReadHead | Boolean | true | 是否尝试从 header 里读取 Token | | isReadCookie | Boolean | true | 是否尝试从 cookie 里读取 Token | diff --git a/sa-token-plugin/sa-token-dao-redis-jackson/src/main/java/cn/dev33/satoken/dao/SaSessionForJacksonCustomized.java b/sa-token-plugin/sa-token-dao-redis-jackson/src/main/java/cn/dev33/satoken/dao/SaSessionForJacksonCustomized.java index e83471d6..4175f537 100644 --- a/sa-token-plugin/sa-token-dao-redis-jackson/src/main/java/cn/dev33/satoken/dao/SaSessionForJacksonCustomized.java +++ b/sa-token-plugin/sa-token-dao-redis-jackson/src/main/java/cn/dev33/satoken/dao/SaSessionForJacksonCustomized.java @@ -5,12 +5,12 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import cn.dev33.satoken.session.SaSession; /** - * Jackson定制版SaSession,忽略 timeout 属性的序列化 + * Jackson定制版SaSession,忽略 timeout 等属性的序列化 * * @author kong * */ -@JsonIgnoreProperties("timeout") +@JsonIgnoreProperties({"timeout"}) public class SaSessionForJacksonCustomized extends SaSession { /**