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 {
/**