feat(iam): 会话管理支持并发计数范围与网关豁免

新增concurrentScope(GLOBAL全局共享/PER_DEVICE按终端独立); GATEWAY终端豁免并发限制; 显式setIsShare(false)使代码自洽; DB默认值合理化新系统开箱即用; 精简yml删与框架默认一致项并修正注释
This commit is contained in:
DaxPay Dev
2026-07-12 12:32:14 +08:00
parent 57be55f749
commit a53dae3d76
6 changed files with 85 additions and 32 deletions

View File

@@ -23,6 +23,7 @@ import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformSe
import cn.dev33.satoken.session.SaSession;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.dev33.satoken.stp.parameter.enums.SaLogoutMode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
@@ -197,25 +198,38 @@ public class TokenService {
/// 执行 Sa-Token 登录(建立会话)
private void doSaLogin(AuthInfoResult authInfoResult, String clientCode, String loginType) {
Object userId = authInfoResult.getId();
var saLoginModel = new SaLoginParameter()
.setDeviceType(clientCode)
.setIsLastingCookie(true);
// 应用会话管理配置(在线时长/活跃超时/并发策略)
this.applySessionConfig(saLoginModel, authInfoResult.getId());
.setIsLastingCookie(true)
// 逐设备隔离, 不依赖全局配置(代码自洽)
.setIsShare(false);
var config = iamSecurityConfigService.getSessionManagement();
// 应用在线时长/活跃超时(对所有终端生效)
this.applyTimeConfig(saLoginModel, config);
// 应用并发控制(GATEWAY豁免), 返回是否需要login后按终端踢人
boolean perDeviceKickOldest = this.applyConcurrentConfig(saLoginModel, config, userId, clientCode);
authInfoResult.setClient(clientCode)
.setLoginType(loginType);
StpUtil.login(authInfoResult.getId(), saLoginModel);
StpUtil.login(userId, saLoginModel);
// PER_DEVICE + KICK_OLDEST 必须在 login 后执行(login前本终端尚无token)
if (perDeviceKickOldest && config.getMaxConcurrentSessions() != null) {
StpUtil.stpLogic.logoutByMaxLoginCount(
userId, null, clientCode,
config.getMaxConcurrentSessions(), SaLogoutMode.KICKOUT);
}
SaSession session = StpUtil.getSession();
UserDetail userDetail = authInfoResult.getUserDetail();
session.set(CommonCode.USER, userDetail);
}
/// 应用会话管理配置到 Sa-Token 登录参数
/// 配置未启用时跳过, 继续使用 application.yml 中的静态默认值
private void applySessionConfig(SaLoginParameter model, Object userId) {
PlatformSessionManagementConfig config = iamSecurityConfigService.getSessionManagement();
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
/// 应用在线时长与活跃超时配置
private void applyTimeConfig(SaLoginParameter model, PlatformSessionManagementConfig config) {
if (config == null) {
return;
}
// 在线时长 -> token 固定有效期(秒)
@@ -226,26 +240,53 @@ public class TokenService {
if (config.getActiveTimeoutHours() != null && config.getActiveTimeoutHours() > 0) {
model.setActiveTimeout(config.getActiveTimeoutHours() * 3600L);
}
// 并发登录策略
}
/// 应用并发登录控制
/// 返回 true 表示需要在 login 后手动按终端踢最早会话(PER_DEVICE + KICK_OLDEST 场景)
private boolean applyConcurrentConfig(SaLoginParameter model, PlatformSessionManagementConfig config,
Object userId, String clientCode) {
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
return false;
}
// GATEWAY 终端豁免: 网关为机器API调用, 不受并发限制
if (ClientEnum.GATEWAY.getCode().equals(clientCode)) {
return false;
}
Integer max = config.getMaxConcurrentSessions();
String strategy = config.getConcurrentStrategy();
if ("KICK_OLDEST".equals(strategy) && max != null && max > 0) {
// 允许并发, 超出上限时 Sa-Token 自动注销最早的会话
model.setIsConcurrent(true).setMaxLoginCount(max);
boolean perDevice = "PER_DEVICE".equals(config.getConcurrentScope());
// NEW_SESSION 或未配并发数: 允许并发, 不限制
if (max == null || max <= 0 || "NEW_SESSION".equals(strategy)) {
model.setIsConcurrent(true);
return false;
}
else if ("DENY_NEW".equals(strategy) && max != null && max > 0) {
// 登录前预检: 已达上限则拒绝新登录
int current = StpUtil.getTokenValueListByLoginId(userId).size();
if (current >= max) {
if ("KICK_OLDEST".equals(strategy)) {
model.setIsConcurrent(true);
if (perDevice) {
// PER_DEVICE: setMaxLoginCount是全局的, 需login后手动按deviceType踢
return true;
}
// GLOBAL: Sa-Token login内部自动注销超额最早会话
model.setMaxLoginCount(max)
.setOverflowLogoutMode(SaLogoutMode.KICKOUT);
return false;
}
if ("DENY_NEW".equals(strategy)) {
// 登录前预检: PER_DEVICE按当前终端计数, GLOBAL按所有终端总和
List<String> tokens = perDevice
? StpUtil.getTokenValueListByLoginId(userId, clientCode)
: StpUtil.getTokenValueListByLoginId(userId);
if (tokens.size() >= max) {
throw new LoginFailureException("error.auth.concurrentLimitExceeded");
}
// 不设 maxLoginCount, 保持"拒绝"语义, 避免触发 Sa-Token 自动踢旧
model.setIsConcurrent(true);
}
else {
// NEW_SESSION 或未配置策略: 允许并发, 不限制数量
model.setIsConcurrent(true);
return false;
}
// 未知策略: 允许并发, 不限制
model.setIsConcurrent(true);
return false;
}
/// 退出

View File

@@ -19,4 +19,6 @@ public class PlatformSessionManagementConfig {
private Integer maxConcurrentSessions;
/// 并发会话策略
private String concurrentStrategy;
/// 并发计数范围(GLOBAL全局共享 / PER_DEVICE按终端独立), 默认GLOBAL
private String concurrentScope;
}

View File

@@ -25,4 +25,7 @@ public class PlatformSessionManagementConfigParam {
@Schema(description = "并发会话策略")
private String concurrentStrategy;
@Schema(description = "并发计数范围(GLOBAL全局共享 / PER_DEVICE按终端独立)")
private String concurrentScope;
}

View File

@@ -28,4 +28,7 @@ public class PlatformSessionManagementConfigResult extends BaseResult {
@Schema(description = "并发会话策略")
private String concurrentStrategy;
@Schema(description = "并发计数范围(GLOBAL全局共享 / PER_DEVICE按终端独立)")
private String concurrentScope;
}

View File

@@ -62,7 +62,18 @@ public class PlatformSecurityConfigService {
public PlatformSessionManagementConfig getSessionManagementConfig() {
return systemConfigService.getOrCreateConfig(PlatformConfigTypeEnum.SECURITY_SESSION,
PlatformSessionManagementConfig.class,
new PlatformSessionManagementConfig());
defaultSessionConfig());
}
/// 会话管理配置默认值: 新系统开箱即用
private PlatformSessionManagementConfig defaultSessionConfig() {
return new PlatformSessionManagementConfig()
.setEnabled(true)
.setMaxOnlineHours(72)
.setActiveTimeoutHours(24)
.setMaxConcurrentSessions(5)
.setConcurrentStrategy("KICK_OLDEST")
.setConcurrentScope("GLOBAL");
}
/// 获取会话管理配置

View File

@@ -41,16 +41,9 @@ mybatis-plus-join:
banner: false
# 安全框架
sa-token:
# token 名称同时是 cookie 名称)
# token 名称(同时是 cookie/请求头名), 前端据此读写, 不可变更
token-name: Accesstoken
# token 有效期(单位:秒) 默认72小时-1 代表永久有效
# token 有效期兜底(秒), 仅在会话管理未启用时生效; 启用后以数据库配置 maxOnlineHours 为准
timeout: 259200
# token 最低活跃频率(单位:秒) 默认24小时-1 代表永久有效
active-timeout: -1
# 是否允许同一账号多地同时登
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token
# false: 每次登录生成独立 token, 实现逐设备会话隔离(改密踢人时可精确保留当前 token)
is-share: false
is-log: false
is-print: false