diff --git a/continew-auth-refresh/pom.xml b/continew-auth-refresh/pom.xml
new file mode 100644
index 0000000000..24b7b44da7
--- /dev/null
+++ b/continew-auth-refresh/pom.xml
@@ -0,0 +1,46 @@
+
+
+ 4.0.0
+
+ top.continew.admin
+ continew-admin
+ ${revision}
+
+
+ continew-auth-refresh
+ jar
+
+ ${project.artifactId}
+ 认证会话模块(Refresh Token、会话轮换和 Access Token 绑定)
+
+
+
+ ${project.groupId}
+ continew-common
+
+
+ org.springframework.boot
+ spring-boot-starter-aop
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ false
+
+
+
+
+
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AccessSessionValidator.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AccessSessionValidator.java
new file mode 100644
index 0000000000..80e36b6204
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AccessSessionValidator.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.api;
+
+/** 校验 Access Token 是否仍绑定有效认证会话。 */
+public interface AccessSessionValidator {
+
+ /**
+ * 会话失效原因提示。
+ *
+ * @param accessToken Access Token
+ * @return 失效提示;null 表示会话仍然有效
+ */
+ String getInvalidReason(String accessToken);
+
+ /**
+ * 判断 Access Token 绑定的认证会话是否已经失效。
+ *
+ * @param accessToken Access Token
+ * @return 已失效返回 true
+ */
+ default boolean isInvalid(String accessToken) {
+ return this.getInvalidReason(accessToken) != null;
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyLockTarget.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyLockTarget.java
new file mode 100644
index 0000000000..fa0afbd6b5
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyLockTarget.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.api;
+
+/**
+ * 认证会话安全策略锁的目标。
+ *
+ * @author luoqiz
+ */
+public record AuthPolicyLockTarget(Type type, String key) {
+
+ public static AuthPolicyLockTarget client(String clientId) {
+ return new AuthPolicyLockTarget(Type.CLIENT, clientId);
+ }
+
+ public static AuthPolicyLockTarget tenant(Long tenantId) {
+ return new AuthPolicyLockTarget(Type.TENANT, String.valueOf(tenantId));
+ }
+
+ public static AuthPolicyLockTarget user(Long userId) {
+ return new AuthPolicyLockTarget(Type.USER, String.valueOf(userId));
+ }
+
+ public enum Type {
+ USER,
+ TENANT,
+ CLIENT
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyLockTargetResolver.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyLockTargetResolver.java
new file mode 100644
index 0000000000..7b97f0e511
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyLockTargetResolver.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.api;
+
+import java.util.Collection;
+
+/**
+ * 将业务方法参数解析为认证会话策略锁目标。
+ *
+ *
实现由 system 或 tenant 插件提供,认证会话模块不引用任何业务 Mapper。
+ *
+ * @author luoqiz
+ */
+public interface AuthPolicyLockTargetResolver {
+
+ Collection resolve(Object[] args);
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyWriteLocked.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyWriteLocked.java
new file mode 100644
index 0000000000..fd38112bd4
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthPolicyWriteLocked.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.api;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 在数据库事务开始前获取认证策略写锁。
+ *
+ * 标记会改变登录资格或会话策略的项目业务方法,统一约束分布式锁与数据库事务的
+ * 顺序,避免登录/刷新路径的“Redis 锁→数据库”与管理路径的“数据库→Redis 锁”互锁。
+ *
+ * @author luoqiz
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AuthPolicyWriteLocked {
+
+ /** 由业务模块实现的锁目标解析器。 */
+ Class extends AuthPolicyLockTargetResolver> value();
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthSessionConstants.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthSessionConstants.java
new file mode 100644
index 0000000000..b825478eb3
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthSessionConstants.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.api;
+
+/**
+ * 认证会话常量。
+ *
+ * @author luoqiz
+ */
+public final class AuthSessionConstants {
+
+ /** Access Token 中绑定 Refresh Session ID 的声明名称。 */
+ public static final String SESSION_ID_CLAIM = "sid";
+
+ /** 会话失效广播 Topic 前缀,完整 Topic 默认追加应用名:{prefix}:{spring.application.name} */
+ public static final String ACCESS_SESSION_INVALID_TOPIC_PREFIX = "auth:access-session-invalid";
+
+ private AuthSessionConstants() {
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthSessionRevocationNotifier.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthSessionRevocationNotifier.java
new file mode 100644
index 0000000000..545a02375f
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/api/AuthSessionRevocationNotifier.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.api;
+
+/**
+ * 认证会话撤销通知器。
+ *
+ * @author luoqiz
+ */
+public interface AuthSessionRevocationNotifier {
+
+ /**
+ * 通知所有实时连接撤销指定登录会话。
+ *
+ * @param sessionId Refresh Session ID
+ */
+ void notifyRevoked(String sessionId);
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/config/RefreshTokenProperties.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/config/RefreshTokenProperties.java
new file mode 100644
index 0000000000..a30e78fde5
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/config/RefreshTokenProperties.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.config;
+
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Refresh Token 配置。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Data
+@Component
+@Validated
+@ConfigurationProperties(prefix = "auth.refresh-token")
+public class RefreshTokenProperties {
+
+ private static final String DNS_LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
+ private static final java.util.regex.Pattern WILDCARD_COOKIE_ORIGIN =
+ java.util.regex.Pattern.compile(
+ "^https?://\\*\\.(" + DNS_LABEL + "(?:\\." + DNS_LABEL + ")+)$",
+ java.util.regex.Pattern.CASE_INSENSITIVE);
+
+ /**
+ * Refresh Token 服务端密钥。
+ *
+ * 用于派生 Token 指纹密钥和轮换快照加密密钥。生产环境必须通过
+ * {@code REFRESH_TOKEN_SECRET} 单独配置高熵随机值。
+ */
+ @NotBlank(message = "Refresh Token 服务端密钥不能为空")
+ @Size(min = 32, message = "Refresh Token 服务端密钥长度不能少于 32 个字符")
+ private String secret;
+
+ /** 浏览器 Refresh Token Cookie 名称 */
+ @NotBlank(message = "Refresh Token Cookie 名称不能为空")
+ private String cookieName = "refresh_token";
+
+ /**
+ * Cookie 作用路径。
+ *
+ * 前端开发环境通常通过 /api 或 /dev-api 代理访问后端,浏览器判断 Cookie
+ * Path 时使用的是代理后的前端 URL,因此不能设置为 /auth,否则刷新请求不会携带
+ * Cookie。生产环境如使用固定网关前缀,可通过配置覆盖该值。
+ */
+ @NotBlank(message = "Refresh Token Cookie Path 不能为空")
+ private String cookiePath = "/";
+
+ /** 生产环境必须开启 Secure;开发环境可关闭以支持 HTTP 本地调试 */
+ private boolean cookieSecure;
+
+ /** Cookie SameSite 属性 */
+ @Pattern(regexp = "(?i)Strict|Lax|None", message = "Cookie SameSite 只能是 Strict、Lax 或 None")
+ private String cookieSameSite = "Lax";
+
+ /**
+ * 允许携带 Refresh Token Cookie 的跨源前端 Origin。
+ *
+ * 空列表表示只允许请求自身同源。每一项可以是无路径、查询和片段的明确 HTTP(S)
+ * Origin,或格式为 {@code http[s]://*.example.com} 的子域通配符。通配符仅匹配最左侧
+ * 的一个 DNS 标签,例如 {@code http://*.luoqiz.top} 可匹配
+ * {@code http://admin.luoqiz.top}。
+ */
+ private List cookieAllowedOrigins = new ArrayList<>();
+
+ /** SameSite=None 只有在 HTTPS 下配合 Secure 才能被浏览器接受。 */
+ @AssertTrue(message = "Cookie SameSite=None 时必须同时开启 Secure")
+ public boolean isCookieSecurityValid() {
+ return !"None".equalsIgnoreCase(cookieSameSite) || cookieSecure;
+ }
+
+ /** __Host- Cookie 必须满足浏览器规定的 Secure + Path=/ 约束。 */
+ @AssertTrue(message = "__Host- Refresh Token Cookie 必须开启 Secure 且 Path=/")
+ public boolean isHostCookieValid() {
+ return !cookieName.startsWith("__Host-") || cookieSecure && "/".equals(cookiePath);
+ }
+
+ /** Cookie 来源白名单必须是严格 HTTP(S) Origin 或受限的子域通配符。 */
+ @AssertTrue(
+ message = "Refresh Token Cookie 允许来源必须是明确的 HTTP(S) Origin 或 http[s]://*.example.com 格式的子域通配符")
+ public boolean isCookieAllowedOriginsValid() {
+ return cookieAllowedOrigins != null && cookieAllowedOrigins.stream()
+ .allMatch(this::isValidAllowedOrigin);
+ }
+
+ /** 同一个旧 Token 的并发请求可重放第一次轮换结果的时间(秒)。 */
+ @Min(value = 1, message = "Refresh Token 轮换宽限期不能少于 1 秒")
+ @Max(value = 30, message = "Refresh Token 轮换宽限期不能超过 30 秒")
+ private int rotationGracePeriod = 5;
+
+ /** 刷新接口单个 IP 在限流窗口内允许的最大请求数,0 表示交由网关限流。 */
+ @Min(value = 0, message = "Refresh Token IP 限流次数不能小于 0")
+ @Max(value = 10000, message = "Refresh Token IP 限流次数不能超过 10000")
+ private int ipRateLimit = 60;
+
+ /** Refresh Token IP 限流窗口(秒)。 */
+ @Min(value = 1, message = "Refresh Token IP 限流窗口不能少于 1 秒")
+ @Max(value = 3600, message = "Refresh Token IP 限流窗口不能超过 3600 秒")
+ private int ipRateLimitPeriod = 60;
+
+ /** 单个登录会话在限流窗口内允许的最大刷新次数。 */
+ @Min(value = 1, message = "Refresh Token 会话限流次数不能少于 1")
+ @Max(value = 1000, message = "Refresh Token 会话限流次数不能超过 1000")
+ private int sessionRateLimit = 10;
+
+ /** 单个登录会话刷新限流窗口(秒)。 */
+ @Min(value = 1, message = "Refresh Token 会话限流窗口不能少于 1 秒")
+ @Max(value = 3600, message = "Refresh Token 会话限流窗口不能超过 3600 秒")
+ private int sessionRateLimitPeriod = 60;
+
+ /** 允许解析转发地址的反向代理地址列表;空列表时始终使用连接对端地址。 */
+ private List trustedProxyAddresses = new ArrayList<>();
+
+ /** 可信代理追加到 X-Forwarded-For 的跳数;0 表示不解析转发地址。 */
+ @Min(value = 0, message = "可信代理跳数不能小于 0")
+ @Max(value = 10, message = "可信代理跳数不能超过 10")
+ private int trustedProxyHops;
+
+ /**
+ * 热路径会话校验本地缓存开关。
+ *
+ * 开启后,每个已登录请求的会话校验命中本地缓存为 0 次 Redis 往返;会话撤销经
+ * 广播子秒级失效,最坏情况由本地缓存 TTL(2 秒)兜底。关闭则恢复逐请求实时校验
+ * (每次 2 次 Redis 往返,撤销立即生效)。
+ */
+ private boolean accessSessionCacheEnabled = true;
+
+ /**
+ * 会话失效广播 Topic 名称(显式覆盖)。
+ *
+ * 留空(默认)时使用 {@code auth:access-session-invalid:{spring.application.name}}:
+ * 同一服务多副本共用 Topic、不同服务天然隔离。仅当多个服务有意共享同一认证会话域
+ * (例如网关与资源服务拆分、共用同一套 Refresh Session)时才显式配置为公共 Topic。
+ * 必须与 jetcache 的 broadcastChannel 取值不同,二者消息载荷不兼容。
+ */
+ private String accessSessionInvalidTopic = "";
+
+ /**
+ * 本实例 WebSocket 连接凭证周期校验间隔(毫秒)。
+ *
+ * 兜底 Redis Pub/Sub 撤销消息丢失和 Access Token 自然过期;默认 60 秒,
+ * 与撤销广播配合时,未能及时收到通知的连接最迟在该间隔内被关闭。
+ */
+ @Min(value = 1000, message = "WebSocket 连接校验间隔不能少于 1000 毫秒")
+ @Max(value = 3600000, message = "WebSocket 连接校验间隔不能超过 3600000 毫秒")
+ private long websocketValidationInterval = 60000;
+
+ private boolean isValidAllowedOrigin(String value) {
+ if (value == null || value.isBlank() || !value.equals(value.trim())
+ || "*".equals(value)) {
+ return false;
+ }
+ return this.isValidOrigin(value) || WILDCARD_COOKIE_ORIGIN.matcher(value).matches();
+ }
+
+ /**
+ * 校验字符串是否为严格的无路径/查询/片段的 HTTP(S) Origin。
+ *
+ * 请求守卫的 {@code parseOrigin} 与本方法共用同一份语义,避免两份校验漂移:
+ * 端口只允许省略(-1)或 1-65535(拒绝 0),并拒绝 {@code https://host:} 这类
+ * 以冒号结尾的空端口写法。
+ *
+ * @param value 待校验的 Origin
+ * @return true:合法 HTTP(S) Origin;false:非法
+ */
+ public static boolean isValidOrigin(String value) {
+ try {
+ URI uri = URI.create(value);
+ String scheme = uri.getScheme();
+ int port = uri.getPort();
+ return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
+ && uri.getHost() != null
+ && uri.getUserInfo() == null
+ && (uri.getRawPath() == null || uri.getRawPath().isEmpty())
+ && uri.getRawQuery() == null
+ && uri.getRawFragment() == null
+ && (port == -1 || port > 0 && port <= 65535)
+ && !uri.getRawAuthority().endsWith(":");
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/controller/SessionController.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/controller/SessionController.java
new file mode 100644
index 0000000000..e5ed8c53f3
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/controller/SessionController.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.controller;
+
+import cn.dev33.satoken.annotation.SaIgnore;
+import cn.dev33.satoken.stp.StpUtil;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import top.continew.admin.auth.model.req.RefreshTokenReq;
+import top.continew.admin.auth.model.resp.LoginResp;
+import top.continew.admin.auth.service.RefreshAccessTokenIssuer;
+import top.continew.admin.auth.service.RefreshTokenService;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.starter.extension.tenant.annotation.TenantIgnore;
+import top.continew.starter.log.annotation.Log;
+import top.continew.starter.log.enums.Include;
+
+/** Refresh Token 会话 API。 */
+@Tag(name = "认证会话 API")
+@Log(module = "认证会话")
+@Validated
+@RestController
+@RequiredArgsConstructor
+@Slf4j
+@RequestMapping("/auth")
+public class SessionController {
+
+ private final RefreshAccessTokenIssuer refreshAccessTokenIssuer;
+ private final RefreshTokenService refreshTokenService;
+
+ /** 使用 Refresh Token 轮换 Access Token。 */
+ @SaIgnore
+ @TenantIgnore
+ @Operation(summary = "刷新令牌", description = "使用 Refresh Token 轮换 Access Token")
+ @Log(excludes = {Include.REQUEST_HEADERS, Include.REQUEST_BODY, Include.RESPONSE_HEADERS,
+ Include.RESPONSE_BODY})
+ @PostMapping("/refresh")
+ public LoginResp refresh(@RequestBody(required = false) @Valid RefreshTokenReq req,
+ HttpServletRequest request, HttpServletResponse response) {
+ String rawRefreshToken = refreshTokenService.resolve(req == null ? null : req
+ .getRefreshToken(), request);
+ refreshTokenService.checkRequestRateLimit(request);
+ refreshTokenService.validateRequest(rawRefreshToken, request);
+ return refreshTokenService.rotate(rawRefreshToken, response,
+ session -> refreshAccessTokenIssuer.issue(session, request, response));
+ }
+
+ /** 注销当前认证会话。 */
+ @SaIgnore
+ @TenantIgnore
+ @Operation(summary = "登出", description = "注销用户的当前登录")
+ @Log(excludes = {Include.REQUEST_HEADERS, Include.REQUEST_BODY, Include.RESPONSE_HEADERS})
+ @Parameter(name = "Authorization", description = "令牌", required = true,
+ example = "Bearer xxxx-xxxx-xxxx-xxxx", in = ParameterIn.HEADER)
+ @PostMapping("/logout")
+ public Object logout(@RequestBody(required = false) RefreshTokenReq req,
+ HttpServletRequest request, HttpServletResponse response) {
+ Object loginId = StpUtil.getLoginId(-1L);
+ String accessToken = StpUtil.getTokenValue();
+ refreshTokenService.validateCookieOrigin(request);
+ String refreshToken;
+ try {
+ refreshToken = refreshTokenService.resolve(req == null ? null : req.getRefreshToken(),
+ request);
+ refreshTokenService.validateRequest(refreshToken, request);
+ } catch (RefreshTokenException e) {
+ refreshTokenService.clearCookie(response);
+ throw e;
+ }
+ refreshTokenService.revokeCurrent(accessToken, refreshToken);
+ try {
+ if (accessToken != null) {
+ StpUtil.logoutByTokenValue(accessToken);
+ }
+ } catch (Exception e) {
+ log.debug("当前 Access Token 已无需注销", e);
+ }
+ refreshTokenService.clearCookie(response);
+ return loginId;
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/LogoutReasonEnum.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/LogoutReasonEnum.java
new file mode 100644
index 0000000000..b3dcc7eb3c
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/LogoutReasonEnum.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.enums;
+
+/**
+ * 登录会话失效原因。
+ *
+ * Sa-Token 不再管理并发登录与顶人下线,客户端配置的注销模式只能由 Refresh Session
+ * 在撤销会话时落到这里,才能把“被踢下线”“被顶下线”“已注销”区分给客户端。原因只用于
+ * 改善提示文案:标记过期后请求会回落到 {@link #LOGOUT} 的默认提示。
+ *
+ * @author luoqiz
+ */
+public enum LogoutReasonEnum {
+
+ /** 主动注销或会话自然失效。 */
+ LOGOUT("登录状态已失效,请重新登录"),
+
+ /** 被管理员强退,或因用户、租户、客户端变更被强制下线。 */
+ KICKOUT("您已被管理员强制下线,请重新登录"),
+
+ /** 登录数量超限或不允许多地登录,被新登录顶下线。 */
+ REPLACED("您的账号已在其他设备登录,请重新登录");
+
+ private final String message;
+
+ LogoutReasonEnum(String message) {
+ this.message = message;
+ }
+
+ /** 返回给客户端的失效提示。 */
+ public String getMessage() {
+ return this.message;
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/RefreshTokenModeEnum.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/RefreshTokenModeEnum.java
new file mode 100644
index 0000000000..dbe3b160a6
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/RefreshTokenModeEnum.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import top.continew.starter.core.enums.BaseEnum;
+
+/**
+ * Refresh Token 传输模式。
+ *
+ * COOKIE 适用于浏览器:长期凭证由 HttpOnly Cookie 承载,前端 JavaScript 无法读取。
+ * BODY 适用于 App、小程序,长期凭证由客户端安全存储后通过请求体提交。
+ *
+ * @author luoqiz
+ */
+@Getter
+@RequiredArgsConstructor
+public enum RefreshTokenModeEnum implements BaseEnum {
+
+ /** 浏览器 Cookie 模式 */
+ COOKIE("COOKIE", "Cookie"),
+
+ /** 原生 App / 小程序请求体模式 */
+ BODY("BODY", "请求体");
+
+ private final String value;
+ private final String description;
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/SessionReplacementScope.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/SessionReplacementScope.java
new file mode 100644
index 0000000000..7cdcfcb587
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/enums/SessionReplacementScope.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.enums;
+
+/**
+ * 非并发登录时替换既有登录会话的范围。
+ *
+ * @author luoqiz
+ */
+public enum SessionReplacementScope {
+
+ /** 仅替换相同客户端类型的会话。 */
+ CURRENT_CLIENT_TYPE,
+
+ /** 替换该用户的所有客户端类型会话。 */
+ ALL_CLIENT_TYPES
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/exception/RefreshTokenException.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/exception/RefreshTokenException.java
new file mode 100644
index 0000000000..847965efdc
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/exception/RefreshTokenException.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.exception;
+
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+import top.continew.starter.core.exception.BusinessException;
+
+import java.io.Serial;
+
+/**
+ * Refresh Token 协议异常。
+ *
+ * 认证终止、来源拒绝和限流必须使用稳定且可区分的业务状态码,客户端才能只在
+ * Refresh Token 确定失效时清理登录态。
+ *
+ * @author luoqiz
+ */
+@Getter
+public class RefreshTokenException extends BusinessException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final HttpStatus status;
+
+ private RefreshTokenException(HttpStatus status, String message) {
+ super(message);
+ this.status = status;
+ }
+
+ public static RefreshTokenException unauthorized(String message) {
+ return new RefreshTokenException(HttpStatus.UNAUTHORIZED, message);
+ }
+
+ public static RefreshTokenException forbidden(String message) {
+ return new RefreshTokenException(HttpStatus.FORBIDDEN, message);
+ }
+
+ public static RefreshTokenException tooManyRequests(String message) {
+ return new RefreshTokenException(HttpStatus.TOO_MANY_REQUESTS, message);
+ }
+
+ public static RefreshTokenException internalServerError(String message) {
+ return new RefreshTokenException(HttpStatus.INTERNAL_SERVER_ERROR, message);
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/AuthSecurityVersion.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/AuthSecurityVersion.java
new file mode 100644
index 0000000000..d7ef22d840
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/AuthSecurityVersion.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.model;
+
+/**
+ * 登录安全状态版本快照。
+ *
+ * 快照在最终状态复查前读取,并随 Refresh Session 保存。用户、客户端或租户发生
+ * 强制失效操作时递增对应版本,能够使并发创建但未被索引扫描命中的会话立即失效。
+ *
+ * @param userVersion 用户安全版本
+ * @param clientVersion 客户端安全版本
+ * @param tenantVersion 租户安全版本
+ * @author luoqiz
+ * @since 4.2.0
+ */
+public record AuthSecurityVersion(long userVersion, long clientVersion, long tenantVersion) {
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshClientPolicy.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshClientPolicy.java
new file mode 100644
index 0000000000..722bd32fe1
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshClientPolicy.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.model;
+
+import top.continew.admin.auth.enums.LogoutReasonEnum;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
+import top.continew.admin.auth.enums.SessionReplacementScope;
+
+/**
+ * 认证会话模块所需的客户端令牌策略。
+ *
+ * 该模型刻意不复用系统管理模块的 {@code ClientResp},避免认证会话模块反向依赖
+ * 用户、客户端等业务实体。
+ *
+ * @author luoqiz
+ */
+public record RefreshClientPolicy(String clientId, String clientType, long refreshTokenTimeout,
+ RefreshTokenModeEnum refreshTokenMode, boolean concurrent,
+ SessionReplacementScope replacementScope, int maxLoginCount,
+ LogoutReasonEnum overflowLogoutMode) {
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshRotationResult.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshRotationResult.java
new file mode 100644
index 0000000000..8ff54cea5f
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshRotationResult.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.model;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * Refresh Token 短时轮换结果。
+ *
+ * Redis 中的 Access Token 和 Refresh Token 均为 AES-GCM 密文,记录只在并发宽限期内
+ * 存活,用于让浏览器多标签页、App 和小程序弱网重试得到完全相同的结果。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Data
+@NoArgsConstructor
+public class RefreshRotationResult implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private String encryptedAccessToken;
+ private String encryptedRefreshToken;
+ private Long expiresIn;
+ private Long refreshExpiresIn;
+ private Long tenantId;
+
+ /** 是否已经完成 Access Token 签发,可以直接幂等返回。 */
+ public boolean isComplete() {
+ return encryptedAccessToken != null;
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshSession.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshSession.java
new file mode 100644
index 0000000000..b1ccd9d18b
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/RefreshSession.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.model;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * Redis 中保存的 Refresh Token 会话。
+ *
+ * 一条记录对应一次登录会话。Refresh Token 使用 {@code sessionId.secret} 格式,
+ * Redis 只保存 secret 的指纹,轮换只需要原子更新本对象。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Data
+@NoArgsConstructor
+public class RefreshSession implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /** 登录会话 ID,同时是 Refresh Token 的公开定位部分。 */
+ private String sessionId;
+
+ /** 用户 ID */
+ private Long userId;
+
+ /** 登录时的用户名快照,用于登录会话管理。 */
+ private String username;
+
+ /** 登录时的用户昵称快照,用于登录会话管理。 */
+ private String nickname;
+
+ /** 客户端 ID */
+ private String clientId;
+
+ /** 客户端类型,用于执行同类型设备互斥登录策略。 */
+ private String clientType;
+
+ /** 登录时确定的租户 ID,避免刷新时跨租户使用 */
+ private Long tenantId;
+
+ /** 当前客户端使用的 Refresh Token 传输模式 */
+ private RefreshTokenModeEnum mode;
+
+ /** 登录会话创建时间(毫秒时间戳),用于最大登录数量的稳定淘汰顺序。 */
+ private long createdAt;
+
+ /** 最近一次成功发起令牌轮换的时间(毫秒时间戳)。 */
+ private long lastRefreshAt;
+
+ /** 初次登录 IP。 */
+ private String ip;
+
+ /** 初次登录 IP 归属地。 */
+ private String address;
+
+ /** 初次登录浏览器或客户端。 */
+ private String browser;
+
+ /** 初次登录操作系统。 */
+ private String os;
+
+ /** 整个登录会话的绝对过期时间(毫秒时间戳),轮换不会无限延长会话寿命 */
+ private long expiresAt;
+
+ /** 创建会话时的用户安全版本。 */
+ private long userSecurityVersion;
+
+ /** 创建会话时的客户端安全版本。 */
+ private long clientSecurityVersion;
+
+ /** 创建会话时的租户安全版本。 */
+ private long tenantSecurityVersion;
+
+ /** 当前 Refresh Token secret 的 HMAC-SHA256 指纹。 */
+ private String currentTokenFingerprint;
+
+ /** 上一个 Refresh Token secret 的指纹,仅用于识别最近一次重放。 */
+ private String previousTokenFingerprint;
+
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/SessionView.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/SessionView.java
new file mode 100644
index 0000000000..482c4fc8f8
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/SessionView.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.model;
+
+import lombok.Data;
+
+/**
+ * 可供系统管理模块使用的认证会话安全视图。
+ *
+ * 不包含 Refresh Token、指纹、安全版本和轮换快照。
+ *
+ * @author luoqiz
+ */
+@Data
+public class SessionView {
+
+ private String sessionId;
+ private Long userId;
+ private String username;
+ private String nickname;
+ private String clientId;
+ private String clientType;
+ private Long tenantId;
+ private long createdAt;
+ private long lastRefreshAt;
+ private String ip;
+ private String address;
+ private String browser;
+ private String os;
+ private long expiresAt;
+}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/model/resp/LoginResp.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/req/RefreshTokenReq.java
similarity index 59%
rename from continew-system/src/main/java/top/continew/admin/auth/model/resp/LoginResp.java
rename to continew-auth-refresh/src/main/java/top/continew/admin/auth/model/req/RefreshTokenReq.java
index c92e557ca3..d974661ef7 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/model/resp/LoginResp.java
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/req/RefreshTokenReq.java
@@ -14,39 +14,30 @@
* limitations under the License.
*/
-package top.continew.admin.auth.model.resp;
+package top.continew.admin.auth.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
-import lombok.Builder;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
- * 登录响应参数
+ * Refresh Token 请求参数。
*
- * @author Charles7c
- * @since 2022/12/21 20:42
+ * Web 客户端不填写该字段,后端从 HttpOnly Cookie 读取;App、微信小程序通过该字段
+ * 提交安全存储的 Refresh Token。
+ *
+ * @author luoqiz
*/
@Data
-@Builder
-@Schema(description = "登录响应参数")
-public class LoginResp implements Serializable {
+@Schema(description = "Refresh Token 请求参数")
+public class RefreshTokenReq implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
- /**
- * 令牌
- */
- @Schema(description = "令牌",
- example = "eyJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjF9.KUPOYm-2wfuLUSfEEAbpGE527fzmkAJG7sMNcQ0pUZ8")
- private String token;
-
- /**
- * 租户 ID
- */
- @Schema(description = "租户 ID", example = "0")
- private Long tenantId;
+ /** App / 小程序提交的 Refresh Token,浏览器模式为空 */
+ @Schema(description = "Refresh Token(浏览器 Cookie 模式不填写)")
+ private String refreshToken;
}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/resp/LoginResp.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/resp/LoginResp.java
new file mode 100644
index 0000000000..d8d37c2a81
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/model/resp/LoginResp.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.model.resp;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 登录响应参数
+ *
+ * @author Charles7c
+ * @since 2022/12/21 20:42
+ */
+@Data
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Schema(description = "登录响应参数")
+public class LoginResp implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /** 短期访问令牌。Web 端仅保存在内存中,过期后调用 /auth/refresh 获取新令牌。 */
+ @Schema(description = "Access Token",
+ example = "eyJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjF9.KUPOYm-2wfuLUSfEEAbpGE527fzmkAJG7sMNcQ0pUZ8")
+ private String accessToken;
+
+ /** 访问令牌类型,当前固定为 Bearer。 */
+ @Schema(description = "Access Token 类型", example = "Bearer")
+ private String tokenType;
+
+ /** Access Token 有效期(秒),不会超过所属 Refresh Session 的剩余寿命。 */
+ @Schema(description = "Access Token 有效期(秒)", example = "900")
+ private Long expiresIn;
+
+ /** Refresh Token 的剩余有效期(秒),用于客户端展示或提前续期提示。 */
+ @Schema(description = "Refresh Token 有效期(秒)", example = "2592000")
+ private Long refreshExpiresIn;
+
+ /** BODY 模式的 Refresh Token,供 App / 微信小程序使用;浏览器 Cookie 模式为空。 */
+ @Schema(description = "Refresh Token(浏览器 Cookie 模式不返回)", example = "rft_xxx")
+ private String refreshToken;
+
+ /**
+ * 租户 ID
+ */
+ @Schema(description = "租户 ID", example = "0")
+ private Long tenantId;
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/RefreshAccessTokenIssuer.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/RefreshAccessTokenIssuer.java
new file mode 100644
index 0000000000..d8aecc0764
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/RefreshAccessTokenIssuer.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.resp.LoginResp;
+
+/**
+ * 刷新时重新装载主体状态并签发 Access Token 的业务适配点。
+ *
+ * 认证会话模块不依赖用户、客户端和租户实体;system 模块实现该接口,确保刷新时
+ * 使用最新权限和业务状态。
+ *
+ * @author luoqiz
+ */
+public interface RefreshAccessTokenIssuer {
+
+ LoginResp issue(RefreshSession session, HttpServletRequest request,
+ HttpServletResponse response);
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/RefreshTokenService.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/RefreshTokenService.java
new file mode 100644
index 0000000000..206884ae20
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/RefreshTokenService.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import top.continew.admin.auth.model.AuthSecurityVersion;
+import top.continew.admin.auth.model.RefreshClientPolicy;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.resp.LoginResp;
+import top.continew.admin.common.context.UserContext;
+import top.continew.admin.common.context.UserExtraContext;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.List;
+
+/**
+ * Refresh Token 会话服务。
+ *
+ * @author luoqiz
+ */
+public interface RefreshTokenService {
+
+ /** 获取客户端 Refresh Token 的绝对有效期(秒)。 */
+ long getRefreshTimeout(RefreshClientPolicy clientPolicy);
+
+ /** 获取客户端 Refresh Token 的传输模式。 */
+ RefreshTokenModeEnum getMode(RefreshClientPolicy clientPolicy);
+
+ /** 为一次新登录生成 Session ID,供 Access Token 和 Refresh Token 共同绑定。 */
+ String newSessionId();
+
+ /**
+ * 在固定作用域锁内复查登录状态、执行 Session 数量策略并创建会话。
+ *
+ * 并发登录和最大登录数以 Refresh Session 为唯一事实源,不能依赖生命周期更短的
+ * Access Token。当前浏览器中将被新 Cookie 覆盖的旧会话也在同一临界区内撤销。
+ * 锁顺序固定为用户、租户、客户端,与安全配置失效共用同一组锁;状态复查函数也在
+ * 锁内执行,调用方无法绕过临界区单独调用登录策略。
+ *
+ * @param userId 初步认证得到的用户 ID,仅用于确定锁范围
+ * @param clientId 初步认证得到的客户端 ID,仅用于确定锁范围
+ * @param tenantId 租户 ID
+ * @param attemptFactory 锁内最终状态复查函数;接收需要固化到新 Session 的安全版本
+ * @return 签发结果
+ */
+ T executeLoginPolicy(Long userId, String clientId, Long tenantId,
+ Function> attemptFactory);
+
+ /**
+ * 创建登录会话的 Refresh Token。
+ *
+ * @return BODY 模式需要返回给客户端的明文 Token;COOKIE 模式返回值仅供内部使用
+ */
+ String issue(String sessionId, UserContext userContext, RefreshClientPolicy clientPolicy,
+ UserExtraContext extraContext, AuthSecurityVersion securityVersion,
+ HttpServletResponse response, String accessToken, long accessTokenTimeout);
+
+ /**
+ * 原子轮换 Refresh Token。
+ *
+ * @param rawRefreshToken 客户端提交的明文 Refresh Token
+ * @param response 当前响应
+ * @param accessTokenIssuer 根据旧会话重新签发 Access Token 的函数
+ * @return 登录响应
+ */
+ LoginResp rotate(String rawRefreshToken, HttpServletResponse response,
+ Function accessTokenIssuer);
+
+ /** 从 Cookie 或 BODY 中读取 Refresh Token;同时出现两种来源时拒绝请求。 */
+ String resolve(String bodyRefreshToken, HttpServletRequest request);
+
+ /** 在解析不可信 Token 之前按可信客户端地址执行刷新限流。 */
+ void checkRequestRateLimit(HttpServletRequest request);
+
+ /** 校验 Cookie 模式请求来源,防止跨站请求伪造刷新或退出当前登录。 */
+ void validateRequest(String rawRefreshToken, HttpServletRequest request);
+
+ /** 请求携带 Refresh Token Cookie 时,在解析 Cookie 前校验请求来源。 */
+ void validateCookieOrigin(HttpServletRequest request);
+
+ /** 撤销当前 Access Token 对应的 Refresh Session */
+ void revokeCurrent(String accessToken, String refreshToken);
+
+ /** 撤销用户的全部 Refresh Session */
+ void revokeByUser(Long userId);
+
+ /** 撤销租户的全部 Refresh Session */
+ void revokeByTenant(Long tenantId);
+
+ /** 撤销客户端的全部 Refresh Session */
+ void revokeByClient(String clientId);
+
+ /** 查询有效登录会话;tenantId 为空时查询全部租户。 */
+ List listSessions(Long tenantId);
+
+ /** 查询指定有效登录会话。 */
+ RefreshSession getSession(String sessionId);
+
+ /** 撤销指定 Refresh Session。 */
+ void revokeBySessionId(String sessionId);
+
+ /** 清理浏览器 Refresh Token Cookie */
+ void clearCookie(HttpServletResponse response);
+
+ /**
+ * 已在策略锁内完成最终状态复查的一次登录尝试。
+ *
+ * @param userId 最终确认的用户 ID
+ * @param client 最终确认的客户端配置
+ * @param currentAccessToken 当前请求携带的 Access Token
+ * @param currentRefreshToken 当前请求携带的 Refresh Token
+ * @param issuer 新令牌签发函数
+ */
+ record LoginAttempt(Long userId, RefreshClientPolicy clientPolicy, String currentAccessToken,
+ String currentRefreshToken, Supplier issuer) {
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/SessionInvalidationService.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/SessionInvalidationService.java
new file mode 100644
index 0000000000..6f2d471266
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/SessionInvalidationService.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service;
+
+/** 认证会话失效入口。 */
+public interface SessionInvalidationService {
+
+ void invalidateClient(String clientId);
+
+ void invalidateTenant(Long tenantId);
+
+ void invalidateUser(Long userId);
+
+ void revokeSession(String sessionId);
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/SessionQueryService.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/SessionQueryService.java
new file mode 100644
index 0000000000..9843739b01
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/SessionQueryService.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service;
+
+import top.continew.admin.auth.model.SessionView;
+
+import java.util.List;
+
+/** 认证会话安全查询入口。 */
+public interface SessionQueryService {
+
+ SessionView getSession(String sessionId);
+
+ List listSessions(Long tenantId);
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/RefreshTokenServiceImpl.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/RefreshTokenServiceImpl.java
new file mode 100644
index 0000000000..bc1314c2e1
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/RefreshTokenServiceImpl.java
@@ -0,0 +1,808 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service.impl;
+
+import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.util.StrUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+import top.continew.admin.auth.enums.LogoutReasonEnum;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
+import top.continew.admin.auth.model.AuthSecurityVersion;
+import top.continew.admin.auth.model.RefreshClientPolicy;
+import top.continew.admin.auth.model.RefreshRotationResult;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.resp.LoginResp;
+import top.continew.admin.auth.service.RefreshTokenService;
+import top.continew.admin.auth.service.RefreshTokenService.LoginAttempt;
+import top.continew.admin.auth.support.AccessSessionCache;
+import top.continew.admin.auth.support.RefreshSessionStore;
+import top.continew.admin.auth.support.RefreshTokenCodec;
+import top.continew.admin.auth.support.RefreshTokenRequestGuard;
+import top.continew.admin.auth.support.AuthPolicyLock;
+import top.continew.admin.auth.support.RefreshTokenCodec.IssuedToken;
+import top.continew.admin.auth.support.RefreshTokenCodec.ParsedToken;
+import top.continew.admin.auth.api.AuthSessionConstants;
+import top.continew.admin.auth.api.AccessSessionValidator;
+import top.continew.admin.auth.api.AuthSessionRevocationNotifier;
+import top.continew.admin.common.context.UserContext;
+import top.continew.admin.common.context.UserExtraContext;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.admin.auth.enums.SessionReplacementScope;
+import top.continew.starter.cache.redisson.util.RedisLockUtils;
+import top.continew.starter.core.exception.BusinessException;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * 基于单 Session 状态的 Refresh Token 实现。
+ *
+ * Refresh Token 使用 {@code sessionId.secret} 格式。轮换时只更新 Session 记录中的
+ * current/previous 指纹,并用短时加密快照保证并发请求得到同一组 Token。未知 secret
+ * 只会认证失败,不会撤销 Session,避免攻击者利用公开 sessionId 强制用户下线。
+ *
+ * @author luoqiz
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RefreshTokenServiceImpl implements RefreshTokenService, AccessSessionValidator {
+
+ private final RefreshTokenProperties properties;
+ private final RefreshTokenCodec tokenCodec;
+ private final RefreshSessionStore sessionStore;
+ private final RefreshTokenRequestGuard requestGuard;
+ private final AuthSessionRevocationNotifier sessionRevocationNotifier;
+ private final AccessSessionCache accessSessionCache;
+
+ @Override
+ public long getRefreshTimeout(RefreshClientPolicy clientPolicy) {
+ return clientPolicy.refreshTokenTimeout();
+ }
+
+ @Override
+ public RefreshTokenModeEnum getMode(RefreshClientPolicy clientPolicy) {
+ return clientPolicy.refreshTokenMode();
+ }
+
+ @Override
+ public String newSessionId() {
+ return tokenCodec.newSessionId();
+ }
+
+ @Override
+ public T executeLoginPolicy(Long userId, String clientId, Long tenantId,
+ Function> attemptFactory) {
+ List> lockSuppliers = new ArrayList<>(3);
+ lockSuppliers.add(() -> sessionStore.lockUserPolicy(userId));
+ if (tenantId != null) {
+ lockSuppliers.add(() -> sessionStore.lockTenantPolicyRead(tenantId));
+ }
+ lockSuppliers.add(() -> sessionStore.lockClientPolicyRead(clientId));
+ return this.executeWithLocks(lockSuppliers, () -> {
+ AuthSecurityVersion securityVersion = sessionStore.getSecurityVersion(userId,
+ clientId, tenantId);
+ LoginAttempt attempt = attemptFactory.apply(securityVersion);
+ this.requireValidLoginAttempt(userId, clientId, attempt);
+ this.applyLoginPolicy(attempt);
+ return attempt.issuer().get();
+ });
+ }
+
+ private void applyLoginPolicy(LoginAttempt attempt) {
+ Long userId = attempt.userId();
+ RefreshClientPolicy client = attempt.clientPolicy();
+ // 浏览器新登录会覆盖现有 Cookie。无论客户端是否允许并发,都必须先撤销被
+ // 覆盖的会话,避免服务端遗留一个客户端再也无法主动退出的长期凭证。
+ Set replacedSessionIds = new LinkedHashSet<>();
+ String accessSessionId = this.findAccessSessionId(attempt.currentAccessToken());
+ if (accessSessionId != null) {
+ replacedSessionIds.add(accessSessionId);
+ }
+ String refreshSessionId = this.findAuthenticatedSessionId(attempt.currentRefreshToken());
+ if (refreshSessionId != null) {
+ replacedSessionIds.add(refreshSessionId);
+ }
+ this.revokeSessions(replacedSessionIds, LogoutReasonEnum.REPLACED);
+
+ List activeSessions = this.listActiveSessions(userId);
+ if (!client.concurrent()) {
+ SessionReplacementScope replacedRange = client.replacementScope();
+ if (replacedRange == null) {
+ throw new BusinessException("客户端顶人下线范围配置无效");
+ }
+ Set sessionIds = new LinkedHashSet<>();
+ for (RefreshSession session : activeSessions) {
+ if (SessionReplacementScope.ALL_CLIENT_TYPES.equals(replacedRange)
+ || Objects.equals(client.clientType(), session.getClientType())) {
+ sessionIds.add(session.getSessionId());
+ }
+ }
+ this.revokeSessions(sessionIds, LogoutReasonEnum.REPLACED);
+ } else {
+ // Web、App 和小程序分别控制并发数量,避免某一端的登录挤掉其他端。
+ List sameClientTypeSessions = activeSessions.stream()
+ .filter(session -> Objects.equals(client.clientType(), session.getClientType()))
+ .toList();
+ this.evictOverflowSessions(sameClientTypeSessions, client.maxLoginCount(),
+ client.overflowLogoutMode());
+ }
+ }
+
+ private void requireValidLoginAttempt(Long userId, String clientId,
+ LoginAttempt attempt) {
+ if (attempt == null || attempt.clientPolicy() == null || attempt.issuer() == null
+ || !Objects.equals(userId, attempt.userId())
+ || !Objects.equals(clientId, attempt.clientPolicy().clientId())) {
+ throw new IllegalStateException("登录状态复查结果与认证锁范围不一致");
+ }
+ }
+
+ @Override
+ public String issue(String sessionId, UserContext userContext, RefreshClientPolicy client,
+ UserExtraContext extraContext, AuthSecurityVersion securityVersion,
+ HttpServletResponse response, String accessToken, long accessTokenTimeout) {
+ long now = System.currentTimeMillis();
+ long refreshTimeout = this.getRefreshTimeout(client);
+ long expiresAt = this.calculateExpiresAt(now, refreshTimeout);
+ IssuedToken token = tokenCodec.issue(sessionId);
+ RefreshSession session = this.newSession(token, userContext, client, extraContext,
+ securityVersion, expiresAt);
+ try (RedisLockUtils ignored = sessionStore.lockSession(sessionId)) {
+ if (sessionStore.get(sessionId) != null) {
+ throw new BusinessException("登录会话创建失败,请重新登录");
+ }
+ sessionStore.save(session);
+ sessionStore.index(session);
+ if (!sessionStore.isSecurityVersionCurrent(session)) {
+ this.revokeSessionLocked(session, LogoutReasonEnum.KICKOUT);
+ throw this.invalidToken();
+ }
+ } catch (Exception e) {
+ this.revokeAfterFailure(sessionId);
+ throw e;
+ }
+ requestGuard.disableCaching(response);
+ if (RefreshTokenModeEnum.COOKIE.equals(session.getMode())) {
+ requestGuard.writeCookie(response, token.rawToken(), refreshTimeout);
+ }
+ return token.rawToken();
+ }
+
+ @Override
+ public LoginResp rotate(String rawRefreshToken, HttpServletResponse response,
+ Function accessTokenIssuer) {
+ ParsedToken presentedToken = tokenCodec.parse(rawRefreshToken);
+ // 在进入 Session 串行化区之前先验证凭证是否可能有效。否则只知道公开 sid 的
+ // 攻击者可用随机 secret 长时间争抢该 Session 的锁,影响合法用户刷新。
+ RefreshSession candidateSession = sessionStore.get(presentedToken.sessionId());
+ if (!this.isKnownToken(candidateSession, presentedToken.fingerprint())
+ && sessionStore.getRotation(presentedToken.fingerprint()) == null) {
+ throw this.invalidToken();
+ }
+ // 会话已撤销或过期时,旧代指纹的轮换快照可能仍在宽限期内残留(R0→R1→R2 后
+ // 撤销会话只清理 current/previous 两代快照)。此时凭证已无对应会话,必须按
+ // 无效令牌处理,否则下方 candidateSession.getUserId() 会触发空指针。
+ if (candidateSession == null) {
+ throw this.invalidToken();
+ }
+ List> lockSuppliers = new ArrayList<>(3);
+ lockSuppliers.add(() -> sessionStore.lockUserPolicy(candidateSession.getUserId()));
+ if (candidateSession.getTenantId() != null) {
+ lockSuppliers
+ .add(() -> sessionStore.lockTenantPolicyRead(candidateSession.getTenantId()));
+ }
+ lockSuppliers.add(() -> sessionStore.lockClientPolicyRead(candidateSession.getClientId()));
+ return this.executeWithLocks(lockSuppliers,
+ () -> this.rotateLocked(presentedToken, response, accessTokenIssuer));
+ }
+
+ private LoginResp rotateLocked(ParsedToken presentedToken, HttpServletResponse response,
+ Function accessTokenIssuer) {
+ try (RedisLockUtils ignored = sessionStore.lockSession(presentedToken.sessionId())) {
+ RefreshSession session = this.requireActiveSession(presentedToken.sessionId());
+ RefreshRotationResult cached = sessionStore.getRotation(
+ presentedToken.fingerprint());
+ boolean current = tokenCodec.matches(presentedToken.fingerprint(),
+ session.getCurrentTokenFingerprint());
+ boolean previous = tokenCodec.matches(presentedToken.fingerprint(),
+ session.getPreviousTokenFingerprint());
+ // 只有能够证明持有当前/上一代 Token,或命中服务端短时加密快照的请求,才消耗
+ // Session 级配额,避免攻击者仅凭公开 sid 耗尽合法会话的刷新次数。
+ if (!current && !previous && cached == null) {
+ throw this.invalidToken();
+ }
+ // 宽限期内整个 Session 只允许前进一代。后续请求统一返回最新结果,避免
+ // R0→R1→R2 后迟到的 R0 响应把客户端 Cookie 回退到已经失效的 R1。
+ RefreshRotationResult latest = sessionStore.getLatestRotation(session.getSessionId());
+ if (latest != null && latest.isComplete()
+ && this.isCurrentRotationResult(session, latest)) {
+ return this.replayRotation(latest, session.getMode(), response);
+ }
+ if (cached != null && cached.isComplete()) {
+ if (this.isCurrentRotationResult(session, cached)) {
+ sessionStore.saveLatestRotation(session.getSessionId(), cached,
+ properties.getRotationGracePeriod());
+ return this.replayRotation(cached, session.getMode(), response);
+ }
+ // 旧快照的输出已经不是当前代,不能再把陈旧凭证返回给客户端。
+ throw this.invalidToken();
+ }
+ if (previous && cached == null) {
+ // 上一代合法 Token 的宽限期已经结束,属于明确重放。撤销不能被会话
+ // 限流挡住,否则攻击者可先耗尽配额,再让被盗旧 Token 延迟触发失效。
+ this.revokeSessionLocked(session, LogoutReasonEnum.KICKOUT);
+ throw this.invalidToken();
+ }
+ // 完整幂等快照已经优先返回,只有真正产生新 Access Token 的轮换才消耗配额。
+ requestGuard.checkSessionRateLimit(session.getSessionId());
+
+ String newRefreshToken;
+ if (current) {
+ IssuedToken newToken = tokenCodec.issue(session.getSessionId());
+ newRefreshToken = newToken.rawToken();
+ // 先保存短时密文,再原子切换 Session。进程在任意一步退出,旧 Token 都能
+ // 从快照恢复出同一个新 Token,不会产生客户端永远无法获得的会话状态。
+ sessionStore.saveRotation(presentedToken.fingerprint(),
+ this.pendingRotation(newRefreshToken),
+ properties.getRotationGracePeriod());
+ session.setPreviousTokenFingerprint(session.getCurrentTokenFingerprint());
+ session.setCurrentTokenFingerprint(newToken.fingerprint());
+ session.setLastRefreshAt(System.currentTimeMillis());
+ sessionStore.save(session);
+ } else if (previous && cached != null) {
+ // 上次进程可能在 Session 已切换、Access Token 尚未签发时退出;从加密快照
+ // 恢复完全相同的新 Refresh Token,并继续完成这一轮签发。
+ newRefreshToken = tokenCodec.decrypt(cached.getEncryptedRefreshToken());
+ this.requireCurrentToken(session, newRefreshToken);
+ } else {
+ // current/previous/cached 已在锁内完整分类,此分支仅保护未来改动不变量。
+ throw this.invalidToken();
+ }
+
+ LoginResp loginResp = null;
+ try {
+ loginResp = accessTokenIssuer.apply(session);
+ this.requireCurrentSecurityVersion(session);
+ return this.completeRotationLocked(presentedToken, session, newRefreshToken,
+ loginResp, response);
+ } catch (RefreshTokenException e) {
+ if (loginResp != null && loginResp.getAccessToken() != null) {
+ this.kickoutQuietly(loginResp.getAccessToken());
+ }
+ if (HttpStatus.UNAUTHORIZED.equals(e.getStatus())) {
+ this.revokeSessionLocked(session, LogoutReasonEnum.KICKOUT);
+ }
+ throw e;
+ } catch (Exception e) {
+ if (loginResp != null && loginResp.getAccessToken() != null) {
+ this.kickoutQuietly(loginResp.getAccessToken());
+ }
+ // 基础设施临时故障不撤销仍然有效的 Session。保留待完成的新 Refresh
+ // Token,客户端用旧 Token 重试时可继续同一轮换,而不是被迫重新登录。
+ this.preservePendingRotation(presentedToken, session, newRefreshToken);
+ throw e;
+ }
+ }
+ }
+
+ private LoginResp completeRotationLocked(ParsedToken presentedToken, RefreshSession session,
+ String newRefreshToken, LoginResp loginResp, HttpServletResponse response) {
+ long now = System.currentTimeMillis();
+ this.requireCurrentToken(session, newRefreshToken);
+ loginResp.setRefreshExpiresIn(this.remainingSeconds(session.getExpiresAt(), now));
+ RefreshRotationResult completed = this.completedRotation(loginResp, newRefreshToken);
+ sessionStore.saveLatestRotation(session.getSessionId(), completed,
+ properties.getRotationGracePeriod());
+ sessionStore.saveRotation(presentedToken.fingerprint(), completed,
+ properties.getRotationGracePeriod());
+ if (RefreshTokenModeEnum.BODY.equals(session.getMode())) {
+ loginResp.setRefreshToken(newRefreshToken);
+ } else {
+ requestGuard.writeCookie(response, newRefreshToken, loginResp.getRefreshExpiresIn());
+ }
+ requestGuard.disableCaching(response);
+ return loginResp;
+ }
+
+ @Override
+ public String resolve(String bodyRefreshToken, HttpServletRequest request) {
+ return requestGuard.resolve(bodyRefreshToken, request);
+ }
+
+ @Override
+ public void checkRequestRateLimit(HttpServletRequest request) {
+ requestGuard.checkRequestRateLimit(request);
+ }
+
+ @Override
+ public void validateRequest(String rawRefreshToken, HttpServletRequest request) {
+ requestGuard.validateRequest(rawRefreshToken, request);
+ }
+
+ @Override
+ public void validateCookieOrigin(HttpServletRequest request) {
+ requestGuard.validateCookieOrigin(request);
+ }
+
+ @Override
+ public void revokeCurrent(String accessToken, String refreshToken) {
+ Set sessionIds = new LinkedHashSet<>();
+ String accessSessionId = this.findAccessSessionId(accessToken);
+ if (accessSessionId != null) {
+ sessionIds.add(accessSessionId);
+ }
+ String refreshSessionId = this.findAuthenticatedSessionId(refreshToken);
+ if (refreshSessionId != null) {
+ sessionIds.add(refreshSessionId);
+ }
+ sessionIds.forEach(sessionId -> this.revokeSession(sessionId,
+ LogoutReasonEnum.LOGOUT));
+ }
+
+ @Override
+ public void revokeByUser(Long userId) {
+ if (userId != null) {
+ this.invalidateSessions(() -> sessionStore.lockUserPolicy(userId),
+ () -> sessionStore.incrementUserSecurityVersion(userId),
+ () -> sessionStore.findByUser(userId));
+ }
+ }
+
+ @Override
+ public void revokeByTenant(Long tenantId) {
+ if (tenantId != null) {
+ this.invalidateSessions(() -> sessionStore.lockTenantPolicyWrite(tenantId),
+ () -> sessionStore.incrementTenantSecurityVersion(tenantId),
+ () -> sessionStore.findByTenant(tenantId));
+ }
+ }
+
+ @Override
+ public void revokeByClient(String clientId) {
+ if (clientId != null) {
+ this.invalidateSessions(() -> sessionStore.lockClientPolicyWrite(clientId),
+ () -> sessionStore.incrementClientSecurityVersion(clientId),
+ () -> sessionStore.findByClient(clientId));
+ }
+ }
+
+ @Override
+ public List listSessions(Long tenantId) {
+ Set sessionIds = tenantId == null ? sessionStore.findAll()
+ : sessionStore.findByTenant(tenantId);
+ List sessions = new ArrayList<>(sessionIds.size());
+ for (String sessionId : sessionIds) {
+ RefreshSession session = this.getSession(sessionId);
+ if (session != null
+ && (tenantId == null || Objects.equals(tenantId, session.getTenantId()))) {
+ sessions.add(session);
+ }
+ }
+ sessions.sort(Comparator.comparingLong(RefreshSession::getCreatedAt).reversed()
+ .thenComparing(RefreshSession::getSessionId));
+ return sessions;
+ }
+
+ @Override
+ public RefreshSession getSession(String sessionId) {
+ if (StrUtil.isBlank(sessionId)) {
+ return null;
+ }
+ RefreshSession session = sessionStore.get(sessionId);
+ if (session == null) {
+ return null;
+ }
+ if (session.getExpiresAt() <= System.currentTimeMillis()
+ || !sessionStore.isSecurityVersionCurrent(session)) {
+ this.revokeSession(sessionId, null);
+ return null;
+ }
+ return session;
+ }
+
+ @Override
+ public void revokeBySessionId(String sessionId) {
+ if (StrUtil.isNotBlank(sessionId)) {
+ this.revokeSession(sessionId, LogoutReasonEnum.KICKOUT);
+ }
+ }
+
+ @Override
+ public String getInvalidReason(String accessToken) {
+ if (StrUtil.isBlank(accessToken)) {
+ return null;
+ }
+ String claimedSessionId = Convert.toStr(StpUtil.getExtra(accessToken,
+ AuthSessionConstants.SESSION_ID_CLAIM));
+ if (StrUtil.isBlank(claimedSessionId)) {
+ return LogoutReasonEnum.LOGOUT.getMessage();
+ }
+ // 快路径:本地缓存命中「会话有效」,跳过 2 次 Redis 往返;撤销经广播失效并由
+ // 本地缓存 TTL 兜底,未命中一律回源全量校验,正确性不依赖缓存。
+ if (accessSessionCache.isValid(claimedSessionId)) {
+ return null;
+ }
+ RefreshSession session = sessionStore.get(claimedSessionId);
+ Long loginId = Convert.toLong(StpUtil.getLoginIdByToken(accessToken));
+ boolean invalid = session == null
+ || session.getExpiresAt() <= System.currentTimeMillis()
+ || !sessionStore.isSecurityVersionCurrent(session)
+ || !Objects.equals(session.getUserId(), loginId);
+ if (!invalid) {
+ accessSessionCache.markValid(claimedSessionId);
+ return null;
+ }
+ // 只有会话已经失效时才读取撤销原因,健康请求不会因此多一次 Redis 往返。
+ LogoutReasonEnum reason = sessionStore.getLogoutReason(claimedSessionId);
+ return reason == null ? LogoutReasonEnum.LOGOUT.getMessage() : reason.getMessage();
+ }
+
+ @Override
+ public void clearCookie(HttpServletResponse response) {
+ requestGuard.clearCookie(response);
+ }
+
+ private RefreshSession newSession(IssuedToken token, UserContext userContext,
+ RefreshClientPolicy client, UserExtraContext extraContext,
+ AuthSecurityVersion securityVersion,
+ long expiresAt) {
+ long now = System.currentTimeMillis();
+ RefreshSession session = new RefreshSession();
+ session.setSessionId(token.sessionId());
+ session.setUserId(userContext.getId());
+ session.setUsername(userContext.getUsername());
+ session.setNickname(userContext.getNickname());
+ session.setClientId(client.clientId());
+ session.setClientType(client.clientType());
+ session.setTenantId(userContext.getTenantId());
+ session.setMode(this.getMode(client));
+ session.setCreatedAt(now);
+ session.setLastRefreshAt(now);
+ session.setIp(extraContext.getIp());
+ session.setAddress(extraContext.getAddress());
+ session.setBrowser(extraContext.getBrowser());
+ session.setOs(extraContext.getOs());
+ session.setExpiresAt(expiresAt);
+ session.setUserSecurityVersion(securityVersion.userVersion());
+ session.setClientSecurityVersion(securityVersion.clientVersion());
+ session.setTenantSecurityVersion(securityVersion.tenantVersion());
+ session.setCurrentTokenFingerprint(token.fingerprint());
+ return session;
+ }
+
+ private RefreshSession requireActiveSession(String sessionId) {
+ RefreshSession session = sessionStore.get(sessionId);
+ if (session == null || session.getExpiresAt() <= System.currentTimeMillis()) {
+ throw this.invalidToken();
+ }
+ if (!sessionStore.isSecurityVersionCurrent(session)) {
+ this.revokeSessionLocked(session, LogoutReasonEnum.KICKOUT);
+ throw this.invalidToken();
+ }
+ return session;
+ }
+
+ private void requireCurrentSecurityVersion(RefreshSession session) {
+ if (session.getExpiresAt() <= System.currentTimeMillis()
+ || !sessionStore.isSecurityVersionCurrent(session)) {
+ throw this.invalidToken();
+ }
+ }
+
+ private void requireCurrentToken(RefreshSession session, String rawToken) {
+ ParsedToken token = tokenCodec.parse(rawToken);
+ if (!Objects.equals(session.getSessionId(), token.sessionId())
+ || !tokenCodec.matches(token.fingerprint(), session.getCurrentTokenFingerprint())) {
+ throw this.invalidToken();
+ }
+ }
+
+ private boolean isKnownToken(RefreshSession session, String fingerprint) {
+ return session != null && (tokenCodec.matches(fingerprint,
+ session.getCurrentTokenFingerprint())
+ || tokenCodec.matches(fingerprint,
+ session.getPreviousTokenFingerprint()));
+ }
+
+ private String findAuthenticatedSessionId(String refreshToken) {
+ if (StrUtil.isBlank(refreshToken)) {
+ return null;
+ }
+ ParsedToken token;
+ try {
+ token = tokenCodec.parse(refreshToken);
+ } catch (BusinessException e) {
+ return null;
+ }
+ RefreshSession session = sessionStore.get(token.sessionId());
+ return (this.isKnownToken(session, token.fingerprint())
+ || session != null && sessionStore.getRotation(token.fingerprint()) != null)
+ ? token.sessionId()
+ : null;
+ }
+
+ private String findAccessSessionId(String accessToken) {
+ if (StrUtil.isBlank(accessToken)) {
+ return null;
+ }
+ return Convert.toStr(StpUtil.getExtra(accessToken, AuthSessionConstants.SESSION_ID_CLAIM));
+ }
+
+ private void invalidateSessions(Supplier policyLock,
+ Runnable incrementVersion,
+ Supplier> sessionIds) {
+ this.executeWithLocks(List.of(policyLock), () -> {
+ incrementVersion.run();
+ this.revokeSessions(sessionIds.get(), LogoutReasonEnum.KICKOUT);
+ return null;
+ });
+ }
+
+ /**
+ * 获取一组固定顺序的分布式锁。若处于数据库事务中,锁延迟到事务完成后释放,保证
+ * 其他登录只能看到事务提交前的旧状态或提交后的新状态,不能落入中间窗口。
+ */
+ private T executeWithLocks(List> lockSuppliers,
+ Supplier action) {
+ List locks = new ArrayList<>(lockSuppliers.size());
+ boolean transactionManaged = false;
+ try {
+ for (Supplier lockSupplier : lockSuppliers) {
+ locks.add(lockSupplier.get());
+ }
+ if (TransactionSynchronizationManager.isActualTransactionActive()) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ throw new IllegalStateException("当前事务不支持认证策略锁同步");
+ }
+ TransactionSynchronizationManager.registerSynchronization(
+ new TransactionSynchronization() {
+
+ @Override
+ public void afterCompletion(int status) {
+ releaseLocks(locks);
+ }
+ });
+ transactionManaged = true;
+ }
+ return action.get();
+ } finally {
+ if (!transactionManaged) {
+ this.releaseLocks(locks);
+ }
+ }
+ }
+
+ private void releaseLocks(List locks) {
+ for (int i = locks.size() - 1; i >= 0; i--) {
+ try {
+ locks.get(i).close();
+ } catch (RuntimeException e) {
+ log.warn("释放认证策略锁失败", e);
+ }
+ }
+ }
+
+ /**
+ * 批量撤销会话并记录失效原因。
+ *
+ * @param sessionIds 待撤销的 Refresh Session ID
+ * @param reason 客户端可见的失效原因,null 表示不记录
+ */
+ private void revokeSessions(Set sessionIds, LogoutReasonEnum reason) {
+ RuntimeException firstFailure = null;
+ for (String sessionId : sessionIds) {
+ try {
+ this.revokeSession(sessionId, reason);
+ } catch (RuntimeException e) {
+ log.warn("撤销 Refresh Session [{}] 失败", sessionId, e);
+ if (firstFailure == null) {
+ firstFailure = e;
+ }
+ }
+ }
+ if (firstFailure != null) {
+ throw firstFailure;
+ }
+ }
+
+ /** 加载安全版本仍有效的用户会话,并清理失效记录。 */
+ private List listActiveSessions(Long userId) {
+ List sessions = new ArrayList<>();
+ for (String sessionId : sessionStore.findByUser(userId)) {
+ RefreshSession session = sessionStore.get(sessionId);
+ if (session == null || !Objects.equals(userId, session.getUserId())) {
+ continue;
+ }
+ if (session.getExpiresAt() <= System.currentTimeMillis()
+ || !sessionStore.isSecurityVersionCurrent(session)) {
+ this.revokeSession(sessionId, null);
+ continue;
+ }
+ sessions.add(session);
+ }
+ sessions.sort(Comparator.comparingLong(RefreshSession::getCreatedAt)
+ .thenComparing(RefreshSession::getSessionId));
+ return sessions;
+ }
+
+ /**
+ * 为本次新登录预留一个名额,稳定淘汰创建时间最早的 Refresh Session。
+ *
+ * 被淘汰会话看到的提示由客户端配置的注销模式决定:Sa-Token 不再参与并发控制,
+ * 这里必须自行把 {@code overflowLogoutMode} 落成失效原因。
+ */
+ private void evictOverflowSessions(List activeSessions,
+ Integer maxLoginCount, LogoutReasonEnum overflowLogoutMode) {
+ if (maxLoginCount == null || maxLoginCount == -1) {
+ return;
+ }
+ if (maxLoginCount < 1) {
+ throw new BusinessException("客户端最大登录数量必须为 -1 或正整数");
+ }
+ int overflowCount = activeSessions.size() - maxLoginCount + 1;
+ if (overflowCount <= 0) {
+ return;
+ }
+ Set sessionIds = new LinkedHashSet<>();
+ activeSessions.stream()
+ .limit(overflowCount)
+ .map(RefreshSession::getSessionId)
+ .forEach(sessionIds::add);
+ this.revokeSessions(sessionIds,
+ overflowLogoutMode == null ? LogoutReasonEnum.REPLACED : overflowLogoutMode);
+ }
+
+ private void revokeSession(String sessionId, LogoutReasonEnum reason) {
+ try (RedisLockUtils ignored = sessionStore.lockSession(sessionId)) {
+ RefreshSession session = sessionStore.get(sessionId);
+ if (session != null) {
+ this.revokeSessionLocked(session, reason);
+ }
+ }
+ }
+
+ private void revokeSessionLocked(RefreshSession session, LogoutReasonEnum reason) {
+ String currentFingerprint = session.getCurrentTokenFingerprint();
+ String previousFingerprint = session.getPreviousTokenFingerprint();
+ // 先写失效原因再删会话,保证被撤销的客户端下一次请求能读到成立的原因。
+ sessionStore.saveLogoutReason(session.getSessionId(), reason);
+ sessionStore.delete(session.getSessionId());
+ sessionStore.deleteRotation(currentFingerprint);
+ sessionStore.deleteRotation(previousFingerprint);
+ sessionStore.deleteLatestRotation(session.getSessionId());
+ try {
+ sessionStore.removeIndexes(session);
+ } catch (RuntimeException e) {
+ // Session 已删除;索引清理失败只会产生会自动过期的脏数据,不会让 Token 重新有效。
+ log.warn("清理 Refresh Session [{}] 管理索引失败", session.getSessionId(), e);
+ }
+ sessionRevocationNotifier.notifyRevoked(session.getSessionId());
+ // 清除热路径本地缓存并广播通知其他节点,撤销即时性由广播与本地 TTL 共同保证。
+ accessSessionCache.invalidate(session.getSessionId());
+ }
+
+ private void revokeAfterFailure(String sessionId) {
+ try {
+ // 登录签发失败时客户端还没拿到任何凭证,不需要记录失效原因。
+ this.revokeSession(sessionId, null);
+ } catch (RuntimeException revokeException) {
+ log.error("失败补偿时撤销 Refresh Session [{}] 失败", sessionId, revokeException);
+ }
+ }
+
+ private void kickoutQuietly(String accessToken) {
+ try {
+ StpUtil.kickoutByTokenValue(accessToken);
+ } catch (RuntimeException e) {
+ log.warn("刷新失败后清理 Access Token 失败", e);
+ }
+ }
+
+ private RefreshRotationResult pendingRotation(String newRefreshToken) {
+ RefreshRotationResult result = new RefreshRotationResult();
+ result.setEncryptedRefreshToken(tokenCodec.encrypt(newRefreshToken));
+ return result;
+ }
+
+ private RefreshRotationResult completedRotation(LoginResp loginResp, String refreshToken) {
+ RefreshRotationResult result = this.pendingRotation(refreshToken);
+ result.setEncryptedAccessToken(tokenCodec.encrypt(loginResp.getAccessToken()));
+ result.setExpiresIn(loginResp.getExpiresIn());
+ result.setRefreshExpiresIn(loginResp.getRefreshExpiresIn());
+ result.setTenantId(loginResp.getTenantId());
+ return result;
+ }
+
+ private boolean isCurrentRotationResult(RefreshSession session,
+ RefreshRotationResult result) {
+ String refreshToken = tokenCodec.decrypt(result.getEncryptedRefreshToken());
+ ParsedToken parsedToken = tokenCodec.parse(refreshToken);
+ return Objects.equals(session.getSessionId(), parsedToken.sessionId())
+ && tokenCodec.matches(parsedToken.fingerprint(),
+ session.getCurrentTokenFingerprint());
+ }
+
+ private void preservePendingRotation(ParsedToken presentedToken, RefreshSession session,
+ String newRefreshToken) {
+ RefreshRotationResult pending = this.pendingRotation(newRefreshToken);
+ try {
+ sessionStore.saveLatestRotation(session.getSessionId(), pending,
+ properties.getRotationGracePeriod());
+ sessionStore.saveRotation(presentedToken.fingerprint(), pending,
+ properties.getRotationGracePeriod());
+ } catch (RuntimeException persistenceException) {
+ log.warn("保存 Refresh Session [{}] 待恢复轮换状态失败", session.getSessionId(),
+ persistenceException);
+ }
+ }
+
+ private LoginResp replayRotation(RefreshRotationResult result, RefreshTokenModeEnum mode,
+ HttpServletResponse response) {
+ String accessToken = tokenCodec.decrypt(result.getEncryptedAccessToken());
+ String refreshToken = tokenCodec.decrypt(result.getEncryptedRefreshToken());
+ LoginResp loginResp = LoginResp.builder()
+ .accessToken(accessToken)
+ .tokenType("Bearer")
+ .expiresIn(result.getExpiresIn())
+ .refreshExpiresIn(result.getRefreshExpiresIn())
+ .tenantId(result.getTenantId())
+ .build();
+ if (RefreshTokenModeEnum.BODY.equals(mode)) {
+ loginResp.setRefreshToken(refreshToken);
+ } else {
+ requestGuard.writeCookie(response, refreshToken, result.getRefreshExpiresIn());
+ }
+ requestGuard.disableCaching(response);
+ return loginResp;
+ }
+
+ private long calculateExpiresAt(long now, long timeoutSeconds) {
+ if (timeoutSeconds > (Long.MAX_VALUE - now) / 1000) {
+ throw new BusinessException("令牌有效期超出系统支持范围");
+ }
+ return now + timeoutSeconds * 1000;
+ }
+
+ private long remainingSeconds(long expiresAt, long now) {
+ long remainingMillis = expiresAt - now;
+ if (remainingMillis <= 0) {
+ return 1;
+ }
+ return remainingMillis / 1000 + (remainingMillis % 1000 == 0 ? 0 : 1);
+ }
+
+ private RefreshTokenException invalidToken() {
+ return RefreshTokenException.unauthorized("登录状态已失效,请重新登录");
+ }
+
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/SessionInvalidationServiceImpl.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/SessionInvalidationServiceImpl.java
new file mode 100644
index 0000000000..d569bdc01f
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/SessionInvalidationServiceImpl.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service.impl;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import top.continew.admin.auth.service.RefreshTokenService;
+import top.continew.admin.auth.service.SessionInvalidationService;
+
+/** 认证会话失效服务实现。 */
+@Service
+@RequiredArgsConstructor
+public class SessionInvalidationServiceImpl implements SessionInvalidationService {
+
+ private final RefreshTokenService refreshTokenService;
+
+ @Override
+ public void invalidateClient(String clientId) {
+ this.afterCommit(() -> refreshTokenService.revokeByClient(clientId));
+ }
+
+ @Override
+ public void invalidateTenant(Long tenantId) {
+ this.afterCommit(() -> refreshTokenService.revokeByTenant(tenantId));
+ }
+
+ @Override
+ public void invalidateUser(Long userId) {
+ this.afterCommit(() -> refreshTokenService.revokeByUser(userId));
+ }
+
+ @Override
+ public void revokeSession(String sessionId) {
+ this.afterCommit(() -> refreshTokenService.revokeBySessionId(sessionId));
+ }
+
+ /**
+ * 业务数据提交后才撤销登录态。认证策略写锁由外层切面持有到事务 afterCompletion
+ * 之后才释放;这里的 afterCommit(先于 afterCompletion 执行)会重新获取同一个
+ * lockTenantPolicyWrite / lockUserPolicy / lockClientPolicyWrite,依赖 Redisson
+ * 同线程写锁可重入。该不变量保证“数据库已提交而新登录会话穿过旧策略”的窗口不会
+ * 出现;改动本方法时不得把撤销时机移出写锁持有区间。
+ */
+ private void afterCommit(Runnable action) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ action.run();
+ return;
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+
+ @Override
+ public void afterCommit() {
+ action.run();
+ }
+ });
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/SessionQueryServiceImpl.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/SessionQueryServiceImpl.java
new file mode 100644
index 0000000000..67c35e8fb5
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/service/impl/SessionQueryServiceImpl.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service.impl;
+
+import cn.hutool.core.bean.BeanUtil;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.SessionView;
+import top.continew.admin.auth.service.RefreshTokenService;
+import top.continew.admin.auth.service.SessionQueryService;
+
+import java.util.List;
+
+/** 认证会话查询服务实现。 */
+@Service
+@RequiredArgsConstructor
+public class SessionQueryServiceImpl implements SessionQueryService {
+
+ private final RefreshTokenService refreshTokenService;
+
+ @Override
+ public SessionView getSession(String sessionId) {
+ RefreshSession session = refreshTokenService.getSession(sessionId);
+ return session == null ? null : BeanUtil.copyProperties(session, SessionView.class);
+ }
+
+ @Override
+ public List listSessions(Long tenantId) {
+ return BeanUtil.copyToList(refreshTokenService.listSessions(tenantId), SessionView.class);
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AccessSessionCache.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AccessSessionCache.java
new file mode 100644
index 0000000000..53f6f58c40
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AccessSessionCache.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import cn.hutool.core.util.StrUtil;
+import com.alicp.jetcache.Cache;
+import com.alicp.jetcache.embedded.CaffeineCacheBuilder;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RTopic;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.api.AuthSessionConstants;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 会话有效结论的本地缓存:热路径 0 Redis、撤销广播失效、TTL 兜底。
+ *
+ * 只缓存「会话有效」结果;未命中一律回源全量校验,正确性不依赖本缓存。撤销通过
+ * {@link #invalidate(String)} 同时清除本节点缓存并广播,其余节点收到后清除各自缓存;
+ * 广播失败时由本地 TTL(2 秒)兜底,撤销延迟最坏 2 秒。
+ *
+ * 广播 Topic 默认按应用名隔离({@code auth:access-session-invalid:{spring.application.name}}):
+ * 同一服务的多副本共用 Topic(必须互相失效),不同服务天然隔离(互不串扰);多个服务
+ * 有意共享同一会话域时可通过 {@code auth.refresh-token.access-session-invalid-topic}
+ * 显式覆盖为公共 Topic。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Slf4j
+@Component
+public class AccessSessionCache {
+
+ /** 本地缓存 TTL:同时是撤销延迟的最坏兜底上限 */
+ private static final long CACHE_TTL_SECONDS = 2;
+ private static final int LOCAL_LIMIT = 100_000;
+
+ private final RefreshTokenProperties properties;
+ private final RedissonClient redissonClient;
+ private final String topicName;
+ private Cache validCache;
+ private RTopic invalidTopic;
+
+ public AccessSessionCache(RefreshTokenProperties properties, RedissonClient redissonClient,
+ @Value("${spring.application.name:unknown}") String applicationName) {
+ this.properties = properties;
+ this.redissonClient = redissonClient;
+ // 未显式配置时按应用名隔离;应用名缺失时退化为 unknown,多服务场景应显式配置
+ // topic 或保证 spring.application.name 唯一。
+ this.topicName = StrUtil.blankToDefault(properties.getAccessSessionInvalidTopic(),
+ AuthSessionConstants.ACCESS_SESSION_INVALID_TOPIC_PREFIX + ":" + StrUtil
+ .blankToDefault(applicationName, "unknown"));
+ }
+
+ @PostConstruct
+ void init() {
+ this.validCache = CaffeineCacheBuilder.createCaffeineCacheBuilder()
+ .expireAfterWrite(CACHE_TTL_SECONDS, TimeUnit.SECONDS)
+ .limit(LOCAL_LIMIT)
+ .buildCache();
+ if (properties.isAccessSessionCacheEnabled()) {
+ this.invalidTopic = redissonClient.getTopic(this.topicName);
+ this.invalidTopic.addListener(String.class, (channel, sessionId) -> {
+ // 防御性校验:topic 被错误共享时忽略空消息/非法载荷,只处理形如会话 ID 的消息
+ if (StrUtil.isBlank(sessionId)) {
+ return;
+ }
+ validCache.remove(sessionId);
+ log.debug("收到会话 [{}] 失效广播,已清除本地缓存", sessionId);
+ });
+ }
+ }
+
+ /** 热路径:会话是否缓存为「有效」。未命中或未启用返回 false,走回源校验。 */
+ public boolean isValid(String sessionId) {
+ return properties.isAccessSessionCacheEnabled() && StrUtil.isNotBlank(sessionId)
+ && Boolean.TRUE.equals(validCache.get(sessionId));
+ }
+
+ /** 回源校验通过后写入本地缓存。 */
+ public void markValid(String sessionId) {
+ if (properties.isAccessSessionCacheEnabled() && StrUtil.isNotBlank(sessionId)) {
+ validCache.put(sessionId, Boolean.TRUE);
+ }
+ }
+
+ /** 会话失效:清除本节点缓存并广播通知其他节点。 */
+ public void invalidate(String sessionId) {
+ if (!properties.isAccessSessionCacheEnabled() || StrUtil.isBlank(sessionId)) {
+ return;
+ }
+ validCache.remove(sessionId);
+ try {
+ invalidTopic.publish(sessionId);
+ } catch (RuntimeException e) {
+ // 广播失败时本地缓存已清除,其他节点由 2s TTL 兜底,不会无限期放行。
+ log.warn("广播会话 [{}] 失效失败,将由本地缓存 TTL 兜底", sessionId, e);
+ }
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AuthPolicyLock.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AuthPolicyLock.java
new file mode 100644
index 0000000000..d582fef1ce
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AuthPolicyLock.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import org.redisson.api.RLock;
+import top.continew.admin.auth.exception.RefreshTokenException;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 项目内认证策略锁。
+ *
+ * 支持 Redisson 普通锁和读写锁,避免为认证并发策略修改 ContiNew Starter。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+public final class AuthPolicyLock implements AutoCloseable {
+
+ private final RLock lock;
+ private final boolean acquired;
+
+ private AuthPolicyLock(RLock lock, boolean acquired) {
+ this.lock = lock;
+ this.acquired = acquired;
+ }
+
+ /** 在指定时间内获取锁,并使用 Redisson watchdog 自动续期。 */
+ public static AuthPolicyLock acquire(RLock lock, long waitMillis) {
+ boolean acquired;
+ try {
+ acquired = lock.tryLock(waitMillis, -1, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw RefreshTokenException.internalServerError("认证请求已中断");
+ }
+ if (!acquired) {
+ throw RefreshTokenException.tooManyRequests("认证请求正在处理中,请稍后重试");
+ }
+ return new AuthPolicyLock(lock, true);
+ }
+
+ @Override
+ public void close() {
+ if (acquired && lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AuthPolicyWriteLockAspect.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AuthPolicyWriteLockAspect.java
new file mode 100644
index 0000000000..1a35461d82
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/AuthPolicyWriteLockAspect.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.context.ApplicationContext;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import top.continew.admin.auth.api.AuthPolicyLockTarget;
+import top.continew.admin.auth.api.AuthPolicyLockTargetResolver;
+import top.continew.admin.auth.api.AuthPolicyWriteLocked;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 认证策略写锁协调器。
+ *
+ * 切面只理解通用策略锁目标。用户、客户端、部门、租户和套餐到目标的映射由各
+ * 业务模块的 {@link AuthPolicyLockTargetResolver} 提供,因此认证会话模块不会反向
+ * 依赖业务 Mapper。锁在事务代理外层获得,顺序固定为“策略锁 → 数据库事务 → 会话
+ * 失效”。
+ *
+ * @author luoqiz
+ */
+@Aspect
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+@RequiredArgsConstructor
+@Slf4j
+public class AuthPolicyWriteLockAspect {
+
+ private final ApplicationContext applicationContext;
+ private final RefreshSessionStore sessionStore;
+
+ /**
+ * 在事务开始前获取策略写锁,并在事务完成后释放。
+ *
+ * @param joinPoint 当前业务方法
+ * @return 业务方法返回值
+ * @throws Throwable 业务方法异常
+ */
+ @Around("@annotation(top.continew.admin.auth.api.AuthPolicyWriteLocked)")
+ public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
+ if (TransactionSynchronizationManager.isActualTransactionActive()) {
+ throw new IllegalStateException("认证策略变更必须在数据库事务外发起");
+ }
+ AuthPolicyWriteLocked locked = this.getPolicyWriteLocked(joinPoint);
+ AuthPolicyLockTargetResolver resolver = applicationContext.getBean(locked.value());
+ List targets = resolver.resolve(joinPoint.getArgs()).stream()
+ .filter(Objects::nonNull)
+ .filter(target -> target.key() != null && !target.key().isBlank())
+ .distinct()
+ .sorted(Comparator.comparing(AuthPolicyLockTarget::type)
+ .thenComparing(AuthPolicyLockTarget::key))
+ .toList();
+ List locks = new ArrayList<>(targets.size());
+ try {
+ for (AuthPolicyLockTarget target : targets) {
+ locks.add(this.lock(target));
+ }
+ return joinPoint.proceed();
+ } finally {
+ for (int i = locks.size() - 1; i >= 0; i--) {
+ try {
+ locks.get(i).close();
+ } catch (RuntimeException e) {
+ log.warn("释放认证策略写锁失败", e);
+ }
+ }
+ }
+ }
+
+ private AuthPolicyLock lock(AuthPolicyLockTarget target) {
+ return switch (target.type()) {
+ case USER -> sessionStore.lockUserPolicy(Long.parseLong(target.key()));
+ case TENANT -> sessionStore.lockTenantPolicyWrite(Long.parseLong(target.key()));
+ case CLIENT -> sessionStore.lockClientPolicyWrite(target.key());
+ };
+ }
+
+ private AuthPolicyWriteLocked getPolicyWriteLocked(ProceedingJoinPoint joinPoint) {
+ Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
+ Method targetMethod =
+ AopUtils.getMostSpecificMethod(method, joinPoint.getTarget().getClass());
+ AuthPolicyWriteLocked locked = AnnotationUtils.findAnnotation(targetMethod,
+ AuthPolicyWriteLocked.class);
+ if (locked == null) {
+ throw new IllegalStateException("认证策略写锁声明缺失");
+ }
+ return locked;
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshSessionStore.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshSessionStore.java
new file mode 100644
index 0000000000..a8a07e7251
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshSessionStore.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import cn.hutool.json.JSONUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RScoredSortedSet;
+import org.redisson.api.RScript;
+import org.redisson.api.RBatch;
+import org.redisson.api.RFuture;
+import org.redisson.api.RedissonClient;
+import org.redisson.client.codec.StringCodec;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.enums.LogoutReasonEnum;
+import top.continew.admin.auth.model.AuthSecurityVersion;
+import top.continew.admin.auth.model.RefreshRotationResult;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.starter.cache.redisson.util.RedisLockUtils;
+import top.continew.starter.cache.redisson.util.RedisUtils;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Refresh Session Redis 仓储。
+ *
+ * 轮换状态全部集中在 {@code SESSION:{sid}} 单条记录中。业务层持有 Session 锁时,
+ * 一次 Redis SET 即完成当前/上一个 Token 的原子切换,不再维护 Token 链和 family 指针。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RefreshSessionStore {
+
+ /** 新部署会话存储命名空间;不读取任何历史 Refresh Token 状态。 */
+ private static final String KEY_PREFIX = "AUTH:REFRESH:V1:";
+ private static final String SESSION_PREFIX = KEY_PREFIX + "DATA:";
+ private static final String ALL_INDEX_KEY = KEY_PREFIX + "ALL";
+ private static final String USER_INDEX_PREFIX = KEY_PREFIX + "USER:";
+ private static final String TENANT_INDEX_PREFIX = KEY_PREFIX + "TENANT:";
+ private static final String CLIENT_INDEX_PREFIX = KEY_PREFIX + "CLIENT:";
+ private static final String ROTATION_PREFIX = KEY_PREFIX + "ROTATION:";
+ private static final String LOGOUT_REASON_PREFIX = KEY_PREFIX + "REASON:";
+ private static final String LATEST_ROTATION_PREFIX = KEY_PREFIX + "ROTATION:LATEST:";
+ private static final String SESSION_LOCK_PREFIX = KEY_PREFIX + "LOCK:SESSION:";
+ private static final String USER_POLICY_LOCK_PREFIX = KEY_PREFIX + "LOCK:USER:";
+ private static final String TENANT_POLICY_LOCK_PREFIX = KEY_PREFIX + "LOCK:TENANT:";
+ private static final String CLIENT_POLICY_LOCK_PREFIX = KEY_PREFIX + "LOCK:CLIENT:";
+ private static final String USER_VERSION_PREFIX = KEY_PREFIX + "VERSION:USER:";
+ private static final String TENANT_VERSION_PREFIX = KEY_PREFIX + "VERSION:TENANT:";
+ private static final String CLIENT_VERSION_PREFIX = KEY_PREFIX + "VERSION:CLIENT:";
+ private static final long LOCK_WAIT_MILLIS = 5000L;
+ /**
+ * 失效原因标记保留时长(秒)。
+ *
+ * 标记只用于在会话被撤销后向客户端返回更有针对性的提示,不影响任何鉴权判定;
+ * 过期后请求回落到默认提示,因此不需要跟随 Refresh Token 的生命周期。
+ */
+ private static final long LOGOUT_REASON_TTL_SECONDS = 300L;
+ private static final String RATE_LIMIT_SCRIPT = "local current = redis.call('INCR', KEYS[1]); "
+ + "if current == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]); end; "
+ + "return current;";
+
+ private final RedissonClient redissonClient;
+
+ /** 按 Session ID 读取会话。 */
+ public RefreshSession get(String sessionId) {
+ Object value = RedisUtils.get(SESSION_PREFIX + sessionId);
+ if (value == null) {
+ return null;
+ }
+ try {
+ return JSONUtil.toBean(value.toString(), RefreshSession.class);
+ } catch (RuntimeException e) {
+ // 记录损坏(手工改库、滚动发布跨版本写入等)时按「会话不存在」处理,
+ // 配合 getSession/revokeSession 的懒清理删除脏记录,避免热路径 500。
+ log.warn("反序列化 Refresh Session [{}] 失败,按失效会话处理", sessionId, e);
+ return null;
+ }
+ }
+
+ /** 保存完整 Session;Redis SET 本身是单 Key 原子操作。 */
+ public void save(RefreshSession session) {
+ String key = SESSION_PREFIX + session.getSessionId();
+ String value = JSONUtil.toJsonStr(session);
+ RedisUtils.set(key, value,
+ Duration.ofSeconds(this.remainingSeconds(session.getExpiresAt())));
+ }
+
+ /** 删除 Session;Session 不存在即代表该登录会话失效。 */
+ public void delete(String sessionId) {
+ RedisUtils.delete(SESSION_PREFIX + sessionId);
+ }
+
+ /** 建立用户、租户和客户端到 Session 的管理索引。 */
+ public void index(RefreshSession session) {
+ this.addToIndex(ALL_INDEX_KEY, session.getSessionId(), session.getExpiresAt());
+ this.addToIndex(USER_INDEX_PREFIX + session.getUserId(), session.getSessionId(),
+ session.getExpiresAt());
+ if (session.getTenantId() != null) {
+ this.addToIndex(TENANT_INDEX_PREFIX + session.getTenantId(), session.getSessionId(),
+ session.getExpiresAt());
+ }
+ this.addToIndex(CLIENT_INDEX_PREFIX + session.getClientId(), session.getSessionId(),
+ session.getExpiresAt());
+ }
+
+ /** 查询指定用户的全部 Refresh Session ID。 */
+ public Set findByUser(Long userId) {
+ return this.findByIndex(USER_INDEX_PREFIX + userId);
+ }
+
+ /** 查询全部有效 Refresh Session ID。 */
+ public Set findAll() {
+ return this.findByIndex(ALL_INDEX_KEY);
+ }
+
+ /** 查询指定租户的全部 Refresh Session ID。 */
+ public Set findByTenant(Long tenantId) {
+ return this.findByIndex(TENANT_INDEX_PREFIX + tenantId);
+ }
+
+ /** 查询指定客户端的全部 Refresh Session ID。 */
+ public Set findByClient(String clientId) {
+ return this.findByIndex(CLIENT_INDEX_PREFIX + clientId);
+ }
+
+ /** 从所有管理索引移除指定 Session。 */
+ public void removeIndexes(RefreshSession session) {
+ this.getIndex(ALL_INDEX_KEY).remove(session.getSessionId());
+ this.getIndex(USER_INDEX_PREFIX + session.getUserId()).remove(session.getSessionId());
+ if (session.getTenantId() != null) {
+ this.getIndex(TENANT_INDEX_PREFIX + session.getTenantId())
+ .remove(session.getSessionId());
+ }
+ this.getIndex(CLIENT_INDEX_PREFIX + session.getClientId()).remove(session.getSessionId());
+ }
+
+ /** 保存短时加密轮换结果。 */
+ public void saveRotation(String oldTokenFingerprint, RefreshRotationResult result,
+ long ttlSeconds) {
+ RedisUtils.set(ROTATION_PREFIX + oldTokenFingerprint, JSONUtil.toJsonStr(result),
+ Duration.ofSeconds(Math.max(1, ttlSeconds)));
+ }
+
+ /** 读取指定旧 Token 的短时轮换结果。 */
+ public RefreshRotationResult getRotation(String oldTokenFingerprint) {
+ Object value = RedisUtils.get(ROTATION_PREFIX + oldTokenFingerprint);
+ return value == null ? null
+ : JSONUtil.toBean(value.toString(), RefreshRotationResult.class);
+ }
+
+ /** 删除指定 Token 的短时轮换结果。 */
+ public void deleteRotation(String tokenFingerprint) {
+ if (tokenFingerprint != null) {
+ RedisUtils.delete(ROTATION_PREFIX + tokenFingerprint);
+ }
+ }
+
+ /** 保存 Session 在当前宽限期内的最新轮换结果。 */
+ public void saveLatestRotation(String sessionId, RefreshRotationResult result,
+ long ttlSeconds) {
+ RedisUtils.set(LATEST_ROTATION_PREFIX + sessionId, JSONUtil.toJsonStr(result),
+ Duration.ofSeconds(Math.max(1, ttlSeconds)));
+ }
+
+ /** 读取 Session 在当前宽限期内的最新轮换结果。 */
+ public RefreshRotationResult getLatestRotation(String sessionId) {
+ Object value = RedisUtils.get(LATEST_ROTATION_PREFIX + sessionId);
+ return value == null ? null
+ : JSONUtil.toBean(value.toString(), RefreshRotationResult.class);
+ }
+
+ /** 删除 Session 的最新轮换结果。 */
+ public void deleteLatestRotation(String sessionId) {
+ RedisUtils.delete(LATEST_ROTATION_PREFIX + sessionId);
+ }
+
+ /** 记录会话失效原因,供客户端下次请求读取更有针对性的提示。 */
+ public void saveLogoutReason(String sessionId, LogoutReasonEnum reason) {
+ if (sessionId == null || reason == null) {
+ return;
+ }
+ RedisUtils.set(LOGOUT_REASON_PREFIX + sessionId, reason.name(),
+ Duration.ofSeconds(LOGOUT_REASON_TTL_SECONDS));
+ }
+
+ /** 读取会话失效原因;标记不存在或已过期时返回 null。 */
+ public LogoutReasonEnum getLogoutReason(String sessionId) {
+ if (sessionId == null) {
+ return null;
+ }
+ Object value = RedisUtils.get(LOGOUT_REASON_PREFIX + sessionId);
+ if (value == null) {
+ return null;
+ }
+ try {
+ return LogoutReasonEnum.valueOf(value.toString());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ /** 删除会话失效原因标记。 */
+ public void deleteLogoutReason(String sessionId) {
+ if (sessionId != null) {
+ RedisUtils.delete(LOGOUT_REASON_PREFIX + sessionId);
+ }
+ }
+
+ /** 获取指定 Session 的分布式互斥锁。 */
+ public RedisLockUtils lockSession(String sessionId) {
+ return this.acquireLock(SESSION_LOCK_PREFIX + sessionId);
+ }
+
+ /** 获取用户登录策略锁,串行执行同一用户的新登录及 Session 数量控制。 */
+ public AuthPolicyLock lockUserPolicy(Long userId) {
+ return this.acquirePolicyLock(redissonClient.getLock(USER_POLICY_LOCK_PREFIX + userId));
+ }
+
+ /** 获取租户安全策略读锁,不同用户可在同一租户内并发登录。 */
+ public AuthPolicyLock lockTenantPolicyRead(Long tenantId) {
+ return this.acquirePolicyLock(redissonClient
+ .getReadWriteLock(TENANT_POLICY_LOCK_PREFIX + tenantId)
+ .readLock());
+ }
+
+ /** 获取租户安全策略写锁,与租户内的新登录严格串行。 */
+ public AuthPolicyLock lockTenantPolicyWrite(Long tenantId) {
+ return this.acquirePolicyLock(redissonClient
+ .getReadWriteLock(TENANT_POLICY_LOCK_PREFIX + tenantId)
+ .writeLock());
+ }
+
+ /** 获取客户端安全策略读锁,不同用户可通过同一客户端并发登录。 */
+ public AuthPolicyLock lockClientPolicyRead(String clientId) {
+ return this.acquirePolicyLock(redissonClient
+ .getReadWriteLock(CLIENT_POLICY_LOCK_PREFIX + clientId)
+ .readLock());
+ }
+
+ /** 获取客户端安全策略写锁,与该客户端的新登录严格串行。 */
+ public AuthPolicyLock lockClientPolicyWrite(String clientId) {
+ return this.acquirePolicyLock(redissonClient
+ .getReadWriteLock(CLIENT_POLICY_LOCK_PREFIX + clientId)
+ .writeLock());
+ }
+
+ /** 尝试消耗一次指定维度的刷新配额。 */
+ public boolean tryAcquireRateLimit(String key, int limit, Duration period) {
+ Long current = redissonClient.getScript(StringCodec.INSTANCE)
+ .eval(RScript.Mode.READ_WRITE, RATE_LIMIT_SCRIPT, RScript.ReturnType.INTEGER,
+ List.of(key), period.toMillis());
+ return current != null && current <= limit;
+ }
+
+ /** 读取创建新会话时需要固化的安全版本。 */
+ public AuthSecurityVersion getSecurityVersion(Long userId, String clientId, Long tenantId) {
+ RBatch batch = redissonClient.createBatch();
+ RFuture userVersion = batch.getAtomicLong(USER_VERSION_PREFIX + userId).getAsync();
+ RFuture clientVersion = batch.getAtomicLong(CLIENT_VERSION_PREFIX + clientId)
+ .getAsync();
+ RFuture tenantVersion = tenantId == null ? null
+ : batch.getAtomicLong(TENANT_VERSION_PREFIX + tenantId).getAsync();
+ batch.execute();
+ return new AuthSecurityVersion(this.awaitVersion(userVersion),
+ this.awaitVersion(clientVersion),
+ tenantVersion == null ? 0 : this.awaitVersion(tenantVersion));
+ }
+
+ /** 读取批量结果中的安全版本;批量已执行完成,读取不会阻塞。 */
+ private long awaitVersion(RFuture version) {
+ return version.toCompletableFuture().join();
+ }
+
+ /** 判断 Session 固化的安全版本是否仍然有效。 */
+ public boolean isSecurityVersionCurrent(RefreshSession session) {
+ AuthSecurityVersion current = this.getSecurityVersion(session.getUserId(),
+ session.getClientId(), session.getTenantId());
+ return session.getUserSecurityVersion() == current.userVersion()
+ && session.getClientSecurityVersion() == current.clientVersion()
+ && session.getTenantSecurityVersion() == current.tenantVersion();
+ }
+
+ public void incrementUserSecurityVersion(Long userId) {
+ redissonClient.getAtomicLong(USER_VERSION_PREFIX + userId).incrementAndGet();
+ }
+
+ public void incrementTenantSecurityVersion(Long tenantId) {
+ redissonClient.getAtomicLong(TENANT_VERSION_PREFIX + tenantId).incrementAndGet();
+ }
+
+ public void incrementClientSecurityVersion(String clientId) {
+ redissonClient.getAtomicLong(CLIENT_VERSION_PREFIX + clientId).incrementAndGet();
+ }
+
+ private void addToIndex(String key, String sessionId, long expiresAt) {
+ RScoredSortedSet index = this.getIndex(key);
+ Instant expiration = Instant.ofEpochMilli(expiresAt);
+ // 先延长已有索引,避免旧 TTL 恰好在新增成员与设置 TTL 之间到期;新增 Key
+ // 再用 expireIfNotSet 补上 TTL,最后一次 GT 保证并发登录只能延长、不能缩短。
+ index.expireIfGreater(expiration);
+ index.add(expiresAt, sessionId);
+ index.expireIfNotSet(expiration);
+ index.expireIfGreater(expiration);
+ }
+
+ private Set findByIndex(String key) {
+ long now = System.currentTimeMillis();
+ RScoredSortedSet index = this.getIndex(key);
+ index.removeRangeByScore(Double.NEGATIVE_INFINITY, true, now, true);
+ Collection values = index.valueRange(now, false, Double.POSITIVE_INFINITY, true);
+ return new LinkedHashSet<>(values);
+ }
+
+ private RScoredSortedSet getIndex(String key) {
+ return redissonClient.getScoredSortedSet(key);
+ }
+
+ private RedisLockUtils acquireLock(String key) {
+ RedisLockUtils lock = RedisLockUtils.tryLockWithWatchdog(key, LOCK_WAIT_MILLIS);
+ if (lock.isLocked()) {
+ return lock;
+ }
+ lock.close();
+ throw RefreshTokenException.tooManyRequests("认证请求正在处理中,请稍后重试");
+ }
+
+ private AuthPolicyLock acquirePolicyLock(org.redisson.api.RLock lock) {
+ return AuthPolicyLock.acquire(lock, LOCK_WAIT_MILLIS);
+ }
+
+ private long remainingSeconds(long expiresAt) {
+ long remainingMillis = expiresAt - System.currentTimeMillis();
+ if (remainingMillis <= 0) {
+ return 1;
+ }
+ return remainingMillis / 1000 + (remainingMillis % 1000 == 0 ? 0 : 1);
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshTokenCodec.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshTokenCodec.java
new file mode 100644
index 0000000000..69d4996873
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshTokenCodec.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+import top.continew.admin.auth.exception.RefreshTokenException;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.regex.Pattern;
+
+/**
+ * Refresh Token 编解码器。
+ *
+ * 令牌格式固定为 {@code sessionId.secret}。sessionId 只负责定位服务端会话,
+ * secret 才是凭证;服务端只保存 secret 的 HMAC-SHA256 指纹。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Component
+@RequiredArgsConstructor
+public class RefreshTokenCodec {
+
+ private static final int SESSION_ID_BYTES = 16;
+ private static final int SECRET_BYTES = 32;
+ private static final int GCM_IV_BYTES = 12;
+ private static final int GCM_TAG_BITS = 128;
+ private static final int MAX_TOKEN_LENGTH = 96;
+ private static final byte[] ENCRYPTION_AAD =
+ "continew-refresh-rotation-v1".getBytes(StandardCharsets.UTF_8);
+ private static final Pattern SESSION_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{22}");
+ private static final Pattern SECRET_PATTERN = Pattern.compile("[A-Za-z0-9_-]{43}");
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private final RefreshTokenProperties properties;
+
+ /** 生成 128 bit 随机会话 ID。 */
+ public String newSessionId() {
+ return this.randomBase64Url(SESSION_ID_BYTES);
+ }
+
+ /** 为指定 Session 生成 256 bit Refresh Token。 */
+ public IssuedToken issue(String sessionId) {
+ if (!SESSION_ID_PATTERN.matcher(sessionId).matches()) {
+ throw new IllegalArgumentException("Refresh Session ID 格式无效");
+ }
+ String secret = this.randomBase64Url(SECRET_BYTES);
+ return new IssuedToken(sessionId + "." + secret, sessionId,
+ this.fingerprint(secret));
+ }
+
+ /** 严格解析 Refresh Token,并计算 secret 指纹。 */
+ public ParsedToken parse(String rawToken) {
+ if (rawToken == null || rawToken.isBlank() || rawToken.length() > MAX_TOKEN_LENGTH) {
+ throw this.invalidToken();
+ }
+ int separator = rawToken.indexOf('.');
+ if (separator <= 0 || separator != rawToken.lastIndexOf('.')) {
+ throw this.invalidToken();
+ }
+ String sessionId = rawToken.substring(0, separator);
+ String secret = rawToken.substring(separator + 1);
+ if (!SESSION_ID_PATTERN.matcher(sessionId).matches()
+ || !SECRET_PATTERN.matcher(secret).matches()) {
+ throw this.invalidToken();
+ }
+ return new ParsedToken(rawToken, sessionId, this.fingerprint(secret));
+ }
+
+ /** 对任意 bearer 凭证生成不可逆、带服务端密钥的稳定指纹。 */
+ public String fingerprint(String credential) {
+ if (credential == null || credential.isBlank()) {
+ throw new IllegalArgumentException("凭证不能为空");
+ }
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(this.deriveKey("fingerprint"), "HmacSHA256"));
+ byte[] digest = mac.doFinal(credential.getBytes(StandardCharsets.UTF_8));
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("无法计算 Refresh Token 指纹", e);
+ }
+ }
+
+ /** 使用常量时间比较两个指纹。 */
+ public boolean matches(String actualFingerprint, String expectedFingerprint) {
+ if (actualFingerprint == null || expectedFingerprint == null) {
+ return false;
+ }
+ return MessageDigest.isEqual(actualFingerprint.getBytes(StandardCharsets.US_ASCII),
+ expectedFingerprint.getBytes(StandardCharsets.US_ASCII));
+ }
+
+ /** 使用 AES-256-GCM 加密短时轮换快照。 */
+ public String encrypt(String value) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException("待加密凭证不能为空");
+ }
+ try {
+ byte[] iv = new byte[GCM_IV_BYTES];
+ SECURE_RANDOM.nextBytes(iv);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE,
+ new SecretKeySpec(this.deriveKey("encryption"), "AES"),
+ new GCMParameterSpec(GCM_TAG_BITS, iv));
+ cipher.updateAAD(ENCRYPTION_AAD);
+ byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
+ return Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(ByteBuffer.allocate(iv.length + encrypted.length)
+ .put(iv)
+ .put(encrypted)
+ .array());
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("无法加密 Refresh Token 轮换快照", e);
+ }
+ }
+
+ /** 解密并校验 AES-GCM 轮换快照。 */
+ public String decrypt(String value) {
+ if (value == null || value.isBlank()) {
+ throw this.invalidToken();
+ }
+ try {
+ byte[] payload = Base64.getUrlDecoder().decode(value);
+ if (payload.length <= GCM_IV_BYTES) {
+ throw this.invalidToken();
+ }
+ byte[] iv = new byte[GCM_IV_BYTES];
+ byte[] encrypted = new byte[payload.length - GCM_IV_BYTES];
+ System.arraycopy(payload, 0, iv, 0, iv.length);
+ System.arraycopy(payload, iv.length, encrypted, 0, encrypted.length);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE,
+ new SecretKeySpec(this.deriveKey("encryption"), "AES"),
+ new GCMParameterSpec(GCM_TAG_BITS, iv));
+ cipher.updateAAD(ENCRYPTION_AAD);
+ return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
+ } catch (GeneralSecurityException | IllegalArgumentException e) {
+ throw this.invalidToken();
+ }
+ }
+
+ private byte[] deriveKey(String purpose) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update(("continew-refresh-" + purpose + "\0")
+ .getBytes(StandardCharsets.UTF_8));
+ return digest.digest(properties.getSecret().getBytes(StandardCharsets.UTF_8));
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("无法派生 Refresh Token 密钥", e);
+ }
+ }
+
+ private String randomBase64Url(int byteLength) {
+ byte[] bytes = new byte[byteLength];
+ SECURE_RANDOM.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private RefreshTokenException invalidToken() {
+ return RefreshTokenException.unauthorized("登录状态已失效,请重新登录");
+ }
+
+ /** 新签发的 Refresh Token。 */
+ public record IssuedToken(String rawToken, String sessionId, String fingerprint) {
+ }
+
+ /** 已解析的 Refresh Token;不向调用方暴露 secret。 */
+ public record ParsedToken(String rawToken, String sessionId, String fingerprint) {
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshTokenRequestGuard.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshTokenRequestGuard.java
new file mode 100644
index 0000000000..137ff65298
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/RefreshTokenRequestGuard.java
@@ -0,0 +1,303 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import cn.hutool.core.util.StrUtil;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.exception.RefreshTokenException;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/**
+ * Refresh Token HTTP 请求守卫。
+ *
+ * 集中负责 Cookie/BODY 传输边界、Cookie 来源校验、刷新限流和禁止缓存响应,
+ * 避免这些安全规则散落在令牌轮换流程中。
+ *
+ * @author luoqiz
+ */
+@Component
+public class RefreshTokenRequestGuard {
+
+ private static final String RATE_LIMIT_PREFIX = "AUTH:REFRESH:V1:RATE_LIMIT:";
+
+ private final RefreshTokenProperties properties;
+ private final RefreshTokenCodec tokenCodec;
+ private final RefreshSessionStore sessionStore;
+ private final List cookieAllowedOriginMatchers;
+
+ public RefreshTokenRequestGuard(RefreshTokenProperties properties, RefreshTokenCodec tokenCodec,
+ RefreshSessionStore sessionStore) {
+ this.properties = properties;
+ this.tokenCodec = tokenCodec;
+ this.sessionStore = sessionStore;
+ this.cookieAllowedOriginMatchers = properties.getCookieAllowedOrigins().stream()
+ .map(this::compileAllowedOriginMatcher)
+ .toList();
+ }
+
+ /** 从 Cookie 或 BODY 中读取 Refresh Token;同时出现两种来源时拒绝请求。 */
+ public String resolve(String bodyRefreshToken, HttpServletRequest request) {
+ String bodyToken = StrUtil.trim(bodyRefreshToken);
+ String cookieToken = this.readCookie(request);
+ if (StrUtil.isNotBlank(bodyToken) && StrUtil.isNotBlank(cookieToken)) {
+ throw RefreshTokenException.forbidden("Refresh Token 来源冲突,请重新登录");
+ }
+ return StrUtil.isNotBlank(bodyToken) ? bodyToken : cookieToken;
+ }
+
+ /** 在解析不可信 Token 之前按客户端地址执行刷新限流。 */
+ public void checkRequestRateLimit(HttpServletRequest request) {
+ int ipLimit = properties.getIpRateLimit();
+ if (ipLimit <= 0) {
+ return;
+ }
+ String clientIp = request == null ? "unknown" : this.resolveClientIp(request);
+ clientIp = StrUtil.blankToDefault(clientIp, "unknown");
+ this.requireRateLimit("IP:" + tokenCodec.fingerprint(clientIp), ipLimit);
+ }
+
+ /** 对真正产生新令牌的一次 Session 轮换执行限流。 */
+ public void checkSessionRateLimit(String sessionId) {
+ this.requireRateLimit("SESSION:" + sessionId, properties.getSessionRateLimit(),
+ Duration.ofSeconds(properties.getSessionRateLimitPeriod()));
+ }
+
+ /** 校验已识别 Refresh Session 的令牌传输方式和 Cookie 请求来源。 */
+ public void validateRequest(String rawRefreshToken, HttpServletRequest request) {
+ if (StrUtil.isBlank(rawRefreshToken) || request == null) {
+ return;
+ }
+ RefreshTokenCodec.ParsedToken token = tokenCodec.parse(rawRefreshToken);
+ RefreshSession session = sessionStore.get(token.sessionId());
+ // 已轮换多次的旧 Token 可能不再是 current/previous,但仍可在极短宽限期内命中
+ // 幂等快照。此类请求同样必须执行 Cookie 来源校验,不能因走重放快照而绕过 CSRF。
+ if (session == null || !this.isKnownToken(session, token.fingerprint())
+ && sessionStore.getRotation(token.fingerprint()) == null) {
+ return;
+ }
+ String cookieToken = this.readCookie(request);
+ if (RefreshTokenModeEnum.COOKIE.equals(session.getMode())) {
+ if (!Objects.equals(rawRefreshToken, cookieToken) || !this.isTrustedOrigin(request)) {
+ throw RefreshTokenException.forbidden("请求来源不合法,请重新登录");
+ }
+ return;
+ }
+ if (StrUtil.isNotBlank(cookieToken)) {
+ throw RefreshTokenException.forbidden("Refresh Token 传输方式不合法,请重新登录");
+ }
+ }
+
+ /** 请求携带 Refresh Token Cookie 时,在解析 Cookie 前校验请求来源。 */
+ public void validateCookieOrigin(HttpServletRequest request) {
+ if (this.hasRefreshTokenCookie(request) && !this.isTrustedOrigin(request)) {
+ throw RefreshTokenException.forbidden("请求来源不合法,请重新登录");
+ }
+ }
+
+ /** 写入浏览器 HttpOnly Refresh Token Cookie。 */
+ public void writeCookie(HttpServletResponse response, String rawToken, long ttlSeconds) {
+ ResponseCookie cookie = ResponseCookie.from(properties.getCookieName(), rawToken)
+ .httpOnly(true)
+ .secure(properties.isCookieSecure())
+ .sameSite(properties.getCookieSameSite())
+ .path(properties.getCookiePath())
+ .maxAge(Duration.ofSeconds(ttlSeconds))
+ .build();
+ response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
+ }
+
+ /** 清理浏览器 Refresh Token Cookie。 */
+ public void clearCookie(HttpServletResponse response) {
+ this.disableCaching(response);
+ ResponseCookie cookie = ResponseCookie.from(properties.getCookieName(), "")
+ .httpOnly(true)
+ .secure(properties.isCookieSecure())
+ .sameSite(properties.getCookieSameSite())
+ .path(properties.getCookiePath())
+ .maxAge(Duration.ZERO)
+ .build();
+ response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
+ }
+
+ /** 禁止浏览器和中间代理缓存认证响应。 */
+ public void disableCaching(HttpServletResponse response) {
+ response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store");
+ response.setHeader(HttpHeaders.PRAGMA, "no-cache");
+ }
+
+ private String readCookie(HttpServletRequest request) {
+ if (request == null || request.getCookies() == null) {
+ return null;
+ }
+ String value = null;
+ for (Cookie cookie : request.getCookies()) {
+ if (!properties.getCookieName().equals(cookie.getName())) {
+ continue;
+ }
+ if (value != null) {
+ throw RefreshTokenException.forbidden("Refresh Token Cookie 重复,请重新登录");
+ }
+ value = cookie.getValue();
+ }
+ return value;
+ }
+
+ private boolean hasRefreshTokenCookie(HttpServletRequest request) {
+ if (request == null || request.getCookies() == null) {
+ return false;
+ }
+ for (Cookie cookie : request.getCookies()) {
+ if (properties.getCookieName().equals(cookie.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isKnownToken(RefreshSession session, String fingerprint) {
+ return tokenCodec.matches(fingerprint, session.getCurrentTokenFingerprint())
+ || tokenCodec.matches(fingerprint, session.getPreviousTokenFingerprint());
+ }
+
+ private void requireRateLimit(String keySuffix, int limit) {
+ this.requireRateLimit(keySuffix, limit,
+ Duration.ofSeconds(properties.getIpRateLimitPeriod()));
+ }
+
+ private void requireRateLimit(String keySuffix, int limit, Duration period) {
+ boolean allowed = sessionStore.tryAcquireRateLimit(RATE_LIMIT_PREFIX + keySuffix, limit,
+ period);
+ if (!allowed) {
+ throw RefreshTokenException.tooManyRequests("刷新请求过于频繁,请稍后重试");
+ }
+ }
+
+ private boolean isTrustedOrigin(HttpServletRequest request) {
+ String origin = request.getHeader(HttpHeaders.ORIGIN);
+ if (StrUtil.isNotBlank(origin)) {
+ return this.isAllowedOrigin(origin, request);
+ }
+ String referer = request.getHeader(HttpHeaders.REFERER);
+ if (StrUtil.isBlank(referer)) {
+ return false;
+ }
+ try {
+ URI refererUri = URI.create(referer);
+ String scheme = refererUri.getScheme();
+ String host = refererUri.getHost();
+ if (StrUtil.isBlank(scheme) || StrUtil.isBlank(host)
+ || !"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
+ return false;
+ }
+ int port = refererUri.getPort();
+ String refererOrigin = scheme + "://" + host + (port < 0 ? "" : ":" + port);
+ return this.isAllowedOrigin(refererOrigin, request);
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ private String resolveClientIp(HttpServletRequest request) {
+ String remoteAddress = StrUtil.blankToDefault(request.getRemoteAddr(), "unknown");
+ int trustedProxyHops = properties.getTrustedProxyHops();
+ if (trustedProxyHops <= 0
+ || !properties.getTrustedProxyAddresses().contains(remoteAddress)) {
+ return remoteAddress;
+ }
+ String forwardedFor = request.getHeader("X-Forwarded-For");
+ if (StrUtil.isBlank(forwardedFor)) {
+ return remoteAddress;
+ }
+ List addresses = new ArrayList<>();
+ for (String address : StrUtil.splitTrim(forwardedFor, ',')) {
+ if (StrUtil.isNotBlank(address)) {
+ addresses.add(address);
+ }
+ }
+ int clientIndex = addresses.size() - trustedProxyHops;
+ return clientIndex < 0 ? remoteAddress : addresses.get(clientIndex);
+ }
+
+ private boolean isAllowedOrigin(String origin, HttpServletRequest request) {
+ URI originUri = this.parseOrigin(origin);
+ if (originUri == null) {
+ return false;
+ }
+ String normalizedOrigin = this.normalizeOrigin(originUri);
+ if (cookieAllowedOriginMatchers.stream()
+ .anyMatch(pattern -> pattern.matcher(normalizedOrigin).matches())) {
+ return true;
+ }
+ return request.getScheme().equalsIgnoreCase(originUri.getScheme())
+ && request.getServerName().equalsIgnoreCase(originUri.getHost())
+ && this.normalizePort(request.getScheme(), request.getServerPort()) == this
+ .normalizePort(originUri.getScheme(), originUri.getPort());
+ }
+
+ private URI parseOrigin(String value) {
+ // 与 RefreshTokenProperties.isValidOrigin 共用同一份语义,避免两份校验漂移。
+ if (StrUtil.isBlank(value) || !RefreshTokenProperties.isValidOrigin(value)) {
+ return null;
+ }
+ try {
+ return URI.create(value);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private Pattern compileAllowedOriginMatcher(String allowedOrigin) {
+ URI allowedUri = this.parseOrigin(allowedOrigin);
+ if (allowedUri != null) {
+ return Pattern.compile(Pattern.quote(this.normalizeOrigin(allowedUri)),
+ Pattern.CASE_INSENSITIVE);
+ }
+ int wildcardIndex = allowedOrigin.indexOf('*');
+ String expression = Pattern.quote(allowedOrigin.substring(0, wildcardIndex))
+ + "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
+ + Pattern.quote(allowedOrigin.substring(wildcardIndex + 1));
+ return Pattern.compile(expression, Pattern.CASE_INSENSITIVE);
+ }
+
+ private String normalizeOrigin(URI uri) {
+ String scheme = uri.getScheme();
+ int port = this.normalizePort(scheme, uri.getPort());
+ boolean defaultPort = "https".equalsIgnoreCase(scheme) ? port == 443 : port == 80;
+ return scheme + "://" + uri.getHost() + (defaultPort ? "" : ":" + port);
+ }
+
+ private int normalizePort(String scheme, int port) {
+ if (port >= 0) {
+ return port;
+ }
+ return "https".equalsIgnoreCase(scheme) ? 443 : 80;
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/TenantArgumentPolicyLockTargetResolver.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/TenantArgumentPolicyLockTargetResolver.java
new file mode 100644
index 0000000000..cb783b1408
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/TenantArgumentPolicyLockTargetResolver.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.api.AuthPolicyLockTarget;
+import top.continew.admin.auth.api.AuthPolicyLockTargetResolver;
+
+import java.util.Collection;
+import java.util.List;
+
+/** 根据约定的业务方法参数解析租户策略锁目标。 */
+@Component
+public class TenantArgumentPolicyLockTargetResolver implements AuthPolicyLockTargetResolver {
+
+ @Override
+ public Collection resolve(Object[] args) {
+ for (Object value : args) {
+ if (value instanceof Long tenantId) {
+ return List.of(AuthPolicyLockTarget.tenant(tenantId));
+ }
+ if (value instanceof Collection> values) {
+ List targets = values.stream().filter(Long.class::isInstance)
+ .map(Long.class::cast).map(AuthPolicyLockTarget::tenant).toList();
+ if (!targets.isEmpty()) {
+ return targets;
+ }
+ }
+ }
+ throw new IllegalArgumentException("认证租户策略锁参数无效");
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/UserArgumentPolicyLockTargetResolver.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/UserArgumentPolicyLockTargetResolver.java
new file mode 100644
index 0000000000..add9acd5b1
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/support/UserArgumentPolicyLockTargetResolver.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.api.AuthPolicyLockTarget;
+import top.continew.admin.auth.api.AuthPolicyLockTargetResolver;
+
+import java.util.Collection;
+import java.util.List;
+
+/** 根据约定的业务方法参数解析用户策略锁目标。 */
+@Component
+public class UserArgumentPolicyLockTargetResolver implements AuthPolicyLockTargetResolver {
+
+ @Override
+ public Collection resolve(Object[] args) {
+ for (Object value : args) {
+ if (value instanceof Long userId) {
+ return List.of(AuthPolicyLockTarget.user(userId));
+ }
+ if (value instanceof Collection> values) {
+ List targets = values.stream().filter(Long.class::isInstance)
+ .map(Long.class::cast).map(AuthPolicyLockTarget::user).toList();
+ if (!targets.isEmpty()) {
+ return targets;
+ }
+ }
+ }
+ throw new IllegalArgumentException("认证用户策略锁参数无效");
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/web/RefreshTokenExceptionHandler.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/web/RefreshTokenExceptionHandler.java
new file mode 100644
index 0000000000..88666250db
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/web/RefreshTokenExceptionHandler.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.web;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.annotation.Order;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.starter.web.model.R;
+
+/** Refresh Token HTTP 协议异常处理器。 */
+@Slf4j
+@Order(98)
+@RestControllerAdvice
+public class RefreshTokenExceptionHandler {
+
+ /**
+ * 将认证会话协议异常转换为与 HTTP 状态一致的统一响应。
+ *
+ * @param e Refresh Token 协议异常
+ * @param request 当前请求
+ * @param response 当前响应
+ * @return 统一错误响应
+ */
+ @ExceptionHandler(RefreshTokenException.class)
+ public R handle(RefreshTokenException e, HttpServletRequest request,
+ HttpServletResponse response) {
+ log.warn("[{}] {}:{}", request.getMethod(), request.getRequestURI(), e.getMessage());
+ response.setStatus(e.getStatus().value());
+ return R.fail(String.valueOf(e.getStatus().value()), e.getMessage());
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/AuthWebSocketSessionService.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/AuthWebSocketSessionService.java
new file mode 100644
index 0000000000..d533a7d240
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/AuthWebSocketSessionService.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.websocket;
+
+import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.core.convert.Convert;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RTopic;
+import org.redisson.api.RedissonClient;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.stereotype.Service;
+import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.WebSocketSession;
+import top.continew.admin.auth.api.AuthSessionConstants;
+import top.continew.admin.auth.api.AccessSessionValidator;
+import top.continew.admin.auth.api.AuthSessionRevocationNotifier;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+import top.continew.starter.messaging.websocket.dao.WebSocketSessionDao;
+
+import java.io.IOException;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Refresh Session 与 WebSocket 连接联动服务。
+ *
+ * Starter 使用 Access Token 作为 WebSocket 客户端 ID。本服务通过 Access Token 中的
+ * {@code sid} 找到同一 Refresh Session 的连接,并使用 Redis Topic 通知所有应用实例,
+ * 避免用户禁用、强退或密码修改后已建立的连接继续存活。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuthWebSocketSessionService implements AuthSessionRevocationNotifier {
+
+ private static final String REVOCATION_TOPIC = "AUTH:REFRESH:V1:WEBSOCKET:REVOKED";
+
+ private final RedissonClient redissonClient;
+ private final ObjectProvider sessionDaoProvider;
+ private final ObjectProvider accessSessionValidatorProvider;
+ private final RefreshTokenProperties refreshTokenProperties;
+
+ /** 本实例 Access Token → Refresh Session ID 的本地索引,避免每次撤销通知回源 Redis。 */
+ private final Map tokenSessionIdCache = new ConcurrentHashMap<>();
+
+ private RTopic revocationTopic;
+ private Integer listenerId;
+
+ /** 订阅集群内 Refresh Session 撤销通知。 */
+ @PostConstruct
+ public void subscribe() {
+ revocationTopic = redissonClient.getTopic(REVOCATION_TOPIC);
+ listenerId = revocationTopic.addListener(String.class, (channel, sessionId) -> {
+ try {
+ this.closeLocal(sessionId);
+ } catch (RuntimeException e) {
+ // 与 notifyRevoked 的降级策略一致:DAO/Redis 抖动时不阻断监听器,
+ // 本次撤销消息丢失由周期校验与本地缓存 TTL 兜底。
+ log.warn("收到撤销通知后关闭会话 [{}] 的本地 WebSocket 连接失败", sessionId, e);
+ }
+ });
+ }
+
+ /** 释放 Redis Topic 监听器。 */
+ @PreDestroy
+ public void unsubscribe() {
+ if (revocationTopic != null && listenerId != null) {
+ revocationTopic.removeListener(listenerId);
+ }
+ }
+
+ /**
+ * 关闭本实例连接并通知集群内其他实例关闭同一登录会话的连接。
+ *
+ * @param sessionId Refresh Session ID
+ */
+ @Override
+ public void notifyRevoked(String sessionId) {
+ if (sessionId == null || sessionId.isBlank()) {
+ return;
+ }
+ try {
+ revocationTopic.publish(sessionId);
+ } catch (RuntimeException e) {
+ // Refresh Session 已经由认证服务删除,实时通知只是加速关闭连接的旁路机制。
+ log.warn("发布 Refresh Session [{}] 的 WebSocket 撤销通知失败", sessionId, e);
+ }
+ try {
+ this.closeLocal(sessionId);
+ } catch (RuntimeException e) {
+ log.warn("关闭 Refresh Session [{}] 的本地 WebSocket 连接失败", sessionId, e);
+ }
+ }
+
+ /**
+ * 周期校验本实例的 WebSocket 凭证,兜底 Redis Pub/Sub 丢消息和 Token 自然过期。
+ */
+ @Scheduled(fixedDelayString = "#{@refreshTokenProperties.websocketValidationInterval}")
+ public void validateLocalSessions() {
+ WebSocketSessionDao sessionDao = sessionDaoProvider.getIfAvailable();
+ AccessSessionValidator accessSessionValidator =
+ accessSessionValidatorProvider.getIfAvailable();
+ if (sessionDao == null || accessSessionValidator == null) {
+ return;
+ }
+ for (String accessToken : new LinkedHashSet<>(sessionDao.listAllSessionIds())) {
+ try {
+ if (accessSessionValidator.isInvalid(accessToken)) {
+ this.closeLocalAccessToken(sessionDao, accessToken);
+ this.tokenSessionIdCache.remove(accessToken);
+ }
+ } catch (RuntimeException e) {
+ // 基础设施短暂异常时保留连接,下一轮继续校验,避免误杀全部实时连接。
+ log.warn("校验 WebSocket Access Token 所属登录会话失败", e);
+ }
+ }
+ // 连接已关闭的 Token 不再保留本地索引条目,避免缓存无限增长。
+ Set currentTokens = new LinkedHashSet<>(sessionDao.listAllSessionIds());
+ this.tokenSessionIdCache.keySet().removeIf(token -> !currentTokens.contains(token));
+ }
+
+ private void closeLocal(String sessionId) {
+ WebSocketSessionDao sessionDao = sessionDaoProvider.getIfAvailable();
+ if (sessionDao == null) {
+ return;
+ }
+ // DAO 的 Key 是握手时保存的 Access Token。复制一份,避免关闭回调同步删除时
+ // 修改正在遍历的集合。
+ Set tokens = new LinkedHashSet<>(sessionDao.listAllSessionIds());
+ for (String accessToken : tokens) {
+ if (!this.belongsToSession(accessToken, sessionId)) {
+ continue;
+ }
+ this.closeLocalAccessToken(sessionDao, accessToken);
+ this.tokenSessionIdCache.remove(accessToken);
+ }
+ // 连接已关闭的 Token 不再保留本地索引条目,避免缓存无限增长。
+ this.tokenSessionIdCache.keySet().removeIf(token -> !tokens.contains(token));
+ }
+
+ private void closeLocalAccessToken(WebSocketSessionDao sessionDao, String accessToken) {
+ if (sessionDao instanceof MultiWebSocketSessionDao multiSessionDao) {
+ // 浏览器多标签页共用同一 Access Token 时,关闭该 Token 的全部连接。
+ for (WebSocketSession session : multiSessionDao.listByKey(accessToken)) {
+ this.closeSession(session);
+ }
+ multiSessionDao.removeAll(accessToken);
+ return;
+ }
+ this.closeSession(sessionDao.get(accessToken));
+ sessionDao.delete(accessToken);
+ }
+
+ private void closeSession(WebSocketSession webSocketSession) {
+ if (webSocketSession == null) {
+ return;
+ }
+ try {
+ if (webSocketSession.isOpen()) {
+ webSocketSession.close(CloseStatus.POLICY_VIOLATION);
+ }
+ } catch (IOException e) {
+ log.warn("关闭失效认证会话的 WebSocket 连接失败", e);
+ }
+ }
+
+ private boolean belongsToSession(String accessToken, String sessionId) {
+ String cachedSessionId = tokenSessionIdCache.get(accessToken);
+ if (cachedSessionId != null) {
+ return Objects.equals(sessionId, cachedSessionId);
+ }
+ try {
+ String claimedSessionId = Convert.toStr(StpUtil.getExtra(accessToken,
+ AuthSessionConstants.SESSION_ID_CLAIM));
+ // Access Token 的会话声明在签发后不可变,可以安全缓存,批量撤销时
+ // 只需首次回源 Redis,后续通知全部命中本地索引。
+ if (claimedSessionId != null) {
+ tokenSessionIdCache.put(accessToken, claimedSessionId);
+ }
+ return Objects.equals(sessionId, claimedSessionId);
+ } catch (RuntimeException e) {
+ log.debug("忽略无法解析会话声明的 WebSocket Access Token", e);
+ return false;
+ }
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/ConcurrentWebSocketSessionDao.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/ConcurrentWebSocketSessionDao.java
new file mode 100644
index 0000000000..fa1a5e723c
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/ConcurrentWebSocketSessionDao.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.websocket;
+
+import org.springframework.web.socket.WebSocketSession;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 支持多标签页的 WebSocket 会话 DAO 内存实现。
+ *
+ * 以 {@code Key → (连接 ID → 连接)} 两级登记:同一 Access Token 的多个标签页
+ * 连接互不覆盖。Starter 的 {@code afterConnectionClosed}/{@code handleTransportError}
+ * 关闭回调只携带 Key,因此 {@link #delete(String)} 采用「只移除已关闭连接」的语义,
+ * 保留同一 Key 下仍存活的其它标签页连接。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+public class ConcurrentWebSocketSessionDao implements MultiWebSocketSessionDao {
+
+ private final Map> sessions = new ConcurrentHashMap<>();
+
+ @Override
+ public void add(String key, WebSocketSession session) {
+ sessions.computeIfAbsent(key, k -> new ConcurrentHashMap<>())
+ .put(session.getId(), session);
+ }
+
+ @Override
+ public void delete(String key) {
+ Map byId = sessions.get(key);
+ if (byId == null) {
+ return;
+ }
+ byId.values().removeIf(session -> !session.isOpen());
+ if (byId.isEmpty()) {
+ sessions.remove(key);
+ }
+ }
+
+ @Override
+ public WebSocketSession get(String key) {
+ Map byId = sessions.get(key);
+ if (byId == null || byId.isEmpty()) {
+ return null;
+ }
+ // 推送是单接收方语义(WebSocketUtils.sendMessage),取最新一条存活连接。
+ WebSocketSession latest = null;
+ for (WebSocketSession session : byId.values()) {
+ if (session.isOpen()) {
+ latest = session;
+ }
+ }
+ return latest;
+ }
+
+ @Override
+ public Collection listAll() {
+ return sessions.values().stream().flatMap(byId -> byId.values().stream()).toList();
+ }
+
+ @Override
+ public Set listAllSessionIds() {
+ return sessions.keySet();
+ }
+
+ @Override
+ public Collection listByKey(String key) {
+ Map byId = sessions.get(key);
+ return byId == null ? List.of() : List.copyOf(byId.values());
+ }
+
+ @Override
+ public void removeAll(String key) {
+ sessions.remove(key);
+ }
+}
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/MultiWebSocketSessionDao.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/MultiWebSocketSessionDao.java
new file mode 100644
index 0000000000..0bbf7926d2
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/MultiWebSocketSessionDao.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.websocket;
+
+import org.springframework.web.socket.WebSocketSession;
+import top.continew.starter.messaging.websocket.dao.WebSocketSessionDao;
+
+import java.util.Collection;
+
+/**
+ * 支持同一客户端 Key 挂载多条连接的 WebSocket 会话 DAO。
+ *
+ * 浏览器多标签页共用同一 Access Token 时,Starter 默认 DAO 以 Key 为唯一维度、
+ * 后建连接覆盖前者;本接口补充按 Key 枚举与整组删除能力,供撤销路径全量关闭。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+public interface MultiWebSocketSessionDao extends WebSocketSessionDao {
+
+ /**
+ * 获取指定 Key 挂载的全部连接。
+ *
+ * @param key 客户端 Key(Access Token)
+ * @return 连接集合(可能为空)
+ */
+ Collection listByKey(String key);
+
+ /**
+ * 删除指定 Key 的全部连接登记。
+ *
+ * @param key 客户端 Key(Access Token)
+ */
+ void removeAll(String key);
+}
diff --git a/continew-common/src/main/java/top/continew/admin/common/config/websocket/WebSocketClientServiceImpl.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/WebSocketClientServiceImpl.java
similarity index 79%
rename from continew-common/src/main/java/top/continew/admin/common/config/websocket/WebSocketClientServiceImpl.java
rename to continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/WebSocketClientServiceImpl.java
index 62595799e8..7e898c4213 100644
--- a/continew-common/src/main/java/top/continew/admin/common/config/websocket/WebSocketClientServiceImpl.java
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/WebSocketClientServiceImpl.java
@@ -14,12 +14,14 @@
* limitations under the License.
*/
-package top.continew.admin.common.config.websocket;
+package top.continew.admin.auth.websocket;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Service;
+import top.continew.admin.auth.api.AccessSessionValidator;
import top.continew.starter.core.exception.BusinessException;
import top.continew.starter.messaging.websocket.core.WebSocketClientService;
@@ -30,13 +32,17 @@
* @since 2024/6/4 22:13
*/
@Service
+@RequiredArgsConstructor
public class WebSocketClientServiceImpl implements WebSocketClientService {
+ private final AccessSessionValidator accessSessionValidator;
+
@Override
public String getClientId(ServletServerHttpRequest request) {
HttpServletRequest servletRequest = request.getServletRequest();
String token = servletRequest.getParameter("token");
- if (StpUtil.getLoginIdByToken(token) == null) {
+ if (token == null || token.isBlank() || StpUtil.getLoginIdByToken(token) == null
+ || accessSessionValidator.isInvalid(token)) {
throw new BusinessException("登录已过期,请重新登录");
}
return token;
diff --git a/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/WebSocketSessionDaoConfiguration.java b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/WebSocketSessionDaoConfiguration.java
new file mode 100644
index 0000000000..a4f0a0dd9b
--- /dev/null
+++ b/continew-auth-refresh/src/main/java/top/continew/admin/auth/websocket/WebSocketSessionDaoConfiguration.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.websocket;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import top.continew.starter.messaging.websocket.dao.WebSocketSessionDao;
+
+/**
+ * WebSocket 会话 DAO 配置。
+ *
+ * 用多标签页实现替换 Starter 的 {@code @ConditionalOnMissingBean} 默认 DAO:
+ * 同一 Access Token 的多条连接互不覆盖,撤销时可全量关闭。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+@Configuration
+public class WebSocketSessionDaoConfiguration {
+
+ @Bean
+ public WebSocketSessionDao webSocketSessionDao() {
+ return new ConcurrentWebSocketSessionDao();
+ }
+}
diff --git a/continew-auth-refresh/src/test/java/top/continew/admin/auth/controller/SessionControllerTest.java b/continew-auth-refresh/src/test/java/top/continew/admin/auth/controller/SessionControllerTest.java
new file mode 100644
index 0000000000..4ccc33b941
--- /dev/null
+++ b/continew-auth-refresh/src/test/java/top/continew/admin/auth/controller/SessionControllerTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import top.continew.admin.auth.service.RefreshAccessTokenIssuer;
+import top.continew.admin.auth.service.RefreshTokenService;
+import top.continew.admin.auth.exception.RefreshTokenException;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** logout Cookie 安全边界测试。 */
+class SessionControllerTest {
+
+ private RefreshTokenService refreshTokenService;
+ private SessionController controller;
+
+ @BeforeEach
+ void setUp() {
+ refreshTokenService = mock(RefreshTokenService.class);
+ controller =
+ new SessionController(mock(RefreshAccessTokenIssuer.class), refreshTokenService);
+ }
+
+ @Test
+ void shouldClearMalformedCookieForTrustedRequest() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ RefreshTokenException malformed = RefreshTokenException.forbidden("Cookie 损坏");
+ when(refreshTokenService.resolve(null, request)).thenThrow(malformed);
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getLoginId(-1L)).thenReturn(1L);
+ stpUtil.when(StpUtil::getTokenValue).thenReturn(null);
+
+ assertThrows(RefreshTokenException.class,
+ () -> controller.logout(null, request, response));
+ }
+
+ verify(refreshTokenService).validateCookieOrigin(request);
+ verify(refreshTokenService).clearCookie(response);
+ }
+
+ @Test
+ void shouldNotClearCookieForUntrustedRequest() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ doThrow(RefreshTokenException.forbidden("请求来源不合法"))
+ .when(refreshTokenService)
+ .validateCookieOrigin(request);
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getLoginId(-1L)).thenReturn(1L);
+ stpUtil.when(StpUtil::getTokenValue).thenReturn(null);
+
+ assertThrows(RefreshTokenException.class,
+ () -> controller.logout(null, request, response));
+ }
+
+ verify(refreshTokenService, never()).resolve(any(), any());
+ verify(refreshTokenService, never()).clearCookie(response);
+ }
+
+ @Test
+ void shouldKeepCookieWhenSessionRevocationFails() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ when(refreshTokenService.resolve(null, request)).thenReturn("session.secret");
+ doThrow(new IllegalStateException("Redis unavailable"))
+ .when(refreshTokenService)
+ .revokeCurrent("access-token", "session.secret");
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getLoginId(-1L)).thenReturn(1L);
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("access-token");
+
+ assertThrows(IllegalStateException.class,
+ () -> controller.logout(null, request, response));
+ }
+
+ verify(refreshTokenService, never()).clearCookie(response);
+ }
+}
diff --git a/continew-auth-refresh/src/test/java/top/continew/admin/auth/service/RefreshTokenServiceImplTest.java b/continew-auth-refresh/src/test/java/top/continew/admin/auth/service/RefreshTokenServiceImplTest.java
new file mode 100644
index 0000000000..2cff6e35e0
--- /dev/null
+++ b/continew-auth-refresh/src/test/java/top/continew/admin/auth/service/RefreshTokenServiceImplTest.java
@@ -0,0 +1,564 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service;
+
+import cn.dev33.satoken.stp.StpUtil;
+import jakarta.servlet.http.Cookie;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import org.mockito.MockedStatic;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+import top.continew.admin.auth.enums.LogoutReasonEnum;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
+import top.continew.admin.auth.model.AuthSecurityVersion;
+import top.continew.admin.auth.model.RefreshClientPolicy;
+import top.continew.admin.auth.model.RefreshRotationResult;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.resp.LoginResp;
+import top.continew.admin.auth.api.AuthSessionConstants;
+import top.continew.admin.auth.enums.SessionReplacementScope;
+import top.continew.admin.auth.service.impl.RefreshTokenServiceImpl;
+import top.continew.admin.auth.service.RefreshTokenService.LoginAttempt;
+import top.continew.admin.auth.support.AccessSessionCache;
+import top.continew.admin.auth.support.RefreshSessionStore;
+import top.continew.admin.auth.support.RefreshTokenCodec;
+import top.continew.admin.auth.support.RefreshTokenRequestGuard;
+import top.continew.admin.auth.support.AuthPolicyLock;
+import top.continew.admin.auth.support.RefreshTokenCodec.IssuedToken;
+import top.continew.admin.auth.api.AuthSessionRevocationNotifier;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.starter.cache.redisson.util.RedisLockUtils;
+import top.continew.starter.core.exception.BusinessException;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Refresh Token 轮换、重放和传输边界测试。 */
+class RefreshTokenServiceImplTest {
+
+ private final Map rotations = new HashMap<>();
+ private final Map latestRotations = new HashMap<>();
+ private RefreshSessionStore sessionStore;
+ private RefreshTokenProperties properties;
+ private RefreshTokenCodec codec;
+ private RefreshTokenServiceImpl service;
+ private AuthSessionRevocationNotifier sessionRevocationNotifier;
+ private AccessSessionCache accessSessionCache;
+ private RefreshSession session;
+ private IssuedToken initialToken;
+
+ @BeforeEach
+ void setUp() {
+ properties = new RefreshTokenProperties();
+ properties.setSecret("test-only-refresh-token-secret-with-32-bytes");
+ properties.setIpRateLimit(0);
+ properties.setSessionRateLimit(10);
+ properties.setRotationGracePeriod(5);
+ codec = new RefreshTokenCodec(properties);
+
+ sessionStore = mock(RefreshSessionStore.class);
+ RedisLockUtils sessionLock = mock(RedisLockUtils.class);
+ AuthPolicyLock policyLock = mock(AuthPolicyLock.class);
+ when(sessionStore.lockSession(anyString())).thenReturn(sessionLock);
+ when(sessionStore.lockUserPolicy(anyLong())).thenReturn(policyLock);
+ when(sessionStore.lockTenantPolicyRead(anyLong())).thenReturn(policyLock);
+ when(sessionStore.lockTenantPolicyWrite(anyLong())).thenReturn(policyLock);
+ when(sessionStore.lockClientPolicyRead(anyString())).thenReturn(policyLock);
+ when(sessionStore.lockClientPolicyWrite(anyString())).thenReturn(policyLock);
+ when(sessionStore.tryAcquireRateLimit(anyString(), anyInt(), any(Duration.class)))
+ .thenReturn(true);
+ when(sessionStore.getSecurityVersion(anyLong(), anyString(), anyLong()))
+ .thenReturn(new AuthSecurityVersion(0, 0, 0));
+ when(sessionStore.isSecurityVersionCurrent(any(RefreshSession.class))).thenReturn(true);
+ when(sessionStore.getRotation(anyString())).thenAnswer(invocation -> rotations
+ .get(invocation.getArgument(0, String.class)));
+ org.mockito.Mockito.doAnswer(invocation -> {
+ rotations.put(invocation.getArgument(0, String.class),
+ invocation.getArgument(1, RefreshRotationResult.class));
+ return null;
+ }).when(sessionStore).saveRotation(anyString(), any(RefreshRotationResult.class),
+ anyLong());
+ when(sessionStore.getLatestRotation(anyString())).thenAnswer(invocation -> latestRotations
+ .get(invocation.getArgument(0, String.class)));
+ org.mockito.Mockito.doAnswer(invocation -> {
+ latestRotations.put(invocation.getArgument(0, String.class),
+ invocation.getArgument(1, RefreshRotationResult.class));
+ return null;
+ }).when(sessionStore).saveLatestRotation(anyString(), any(RefreshRotationResult.class),
+ anyLong());
+
+ initialToken = codec.issue(codec.newSessionId());
+ session = new RefreshSession();
+ session.setSessionId(initialToken.sessionId());
+ session.setUserId(1L);
+ session.setClientId("web");
+ session.setMode(RefreshTokenModeEnum.BODY);
+ session.setExpiresAt(System.currentTimeMillis() + Duration.ofDays(1).toMillis());
+ session.setCurrentTokenFingerprint(initialToken.fingerprint());
+ when(sessionStore.get(initialToken.sessionId())).thenAnswer(invocation -> session);
+
+ sessionRevocationNotifier = mock(AuthSessionRevocationNotifier.class);
+ accessSessionCache = mock(AccessSessionCache.class);
+ RefreshTokenRequestGuard requestGuard = new RefreshTokenRequestGuard(properties, codec,
+ sessionStore);
+ service = new RefreshTokenServiceImpl(properties, codec, sessionStore, requestGuard,
+ sessionRevocationNotifier, accessSessionCache);
+ }
+
+ @Test
+ void shouldRotateAndReplayExactlyTheSameResult() {
+ LoginResp first = service.rotate(initialToken.rawToken(), response(),
+ issuer("access-1"));
+
+ assertEquals("access-1", first.getAccessToken());
+ assertEquals(initialToken.sessionId(), codec.parse(first.getRefreshToken()).sessionId());
+ assertNotEquals(initialToken.rawToken(), first.getRefreshToken());
+ assertEquals(initialToken.fingerprint(), session.getPreviousTokenFingerprint());
+ assertTrue(codec.matches(codec.parse(first.getRefreshToken()).fingerprint(),
+ session.getCurrentTokenFingerprint()));
+ LoginResp replay =
+ service.rotate(initialToken.rawToken(), response(), ignored -> {
+ throw new AssertionError("幂等重放不应再次签发 Access Token");
+ });
+ assertEquals(first.getAccessToken(), replay.getAccessToken());
+ assertEquals(first.getRefreshToken(), replay.getRefreshToken());
+ verify(sessionStore, times(1)).tryAcquireRateLimit(anyString(), anyInt(),
+ any(Duration.class));
+ }
+
+ @Test
+ void shouldAcquirePolicyLocksBeforeSessionLockWhenRotating() {
+ session.setTenantId(2L);
+
+ service.rotate(initialToken.rawToken(), response(), issuer("access-1"));
+
+ InOrder order = inOrder(sessionStore);
+ order.verify(sessionStore).lockUserPolicy(1L);
+ order.verify(sessionStore).lockTenantPolicyRead(2L);
+ order.verify(sessionStore).lockClientPolicyRead("web");
+ order.verify(sessionStore).lockSession(initialToken.sessionId());
+ }
+
+ @Test
+ void shouldNotAdvanceAnotherGenerationInsideGracePeriod() {
+ LoginResp first = service.rotate(initialToken.rawToken(), response(), issuer("access-1"));
+
+ LoginResp replay = service.rotate(first.getRefreshToken(), response(), ignored -> {
+ throw new AssertionError("宽限期内不应再次签发 Access Token");
+ });
+
+ assertEquals(first.getAccessToken(), replay.getAccessToken());
+ assertEquals(first.getRefreshToken(), replay.getRefreshToken());
+ verify(sessionStore, times(1)).tryAcquireRateLimit(anyString(), anyInt(),
+ any(Duration.class));
+ }
+
+ @Test
+ void shouldNotRevokeSessionForUnknownSecret() {
+ IssuedToken unknown = codec.issue(initialToken.sessionId());
+
+ assertThrows(BusinessException.class,
+ () -> service.rotate(unknown.rawToken(), response(), issuer("unused")));
+
+ verify(sessionStore, never()).lockSession(anyString());
+ verify(sessionStore, never()).save(session);
+ verify(sessionStore, never()).delete(session.getSessionId());
+ verify(sessionStore, never()).removeIndexes(session);
+ }
+
+ @Test
+ void shouldRejectOldGenerationTokenWhenSessionAlreadyRevoked() {
+ // R0→R1→R2 后会话被撤销(强退/顶人),R0 指纹的轮换快照仍在宽限期内残留。
+ // 凭证已无对应会话,必须按无效令牌处理,不能进入候选会话空指针。
+ rotations.put(initialToken.fingerprint(), new RefreshRotationResult());
+ when(sessionStore.get(initialToken.sessionId())).thenReturn(null);
+
+ RefreshTokenException exception = assertThrows(RefreshTokenException.class,
+ () -> service.rotate(initialToken.rawToken(), response(), issuer("unused")));
+
+ assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
+ verify(sessionStore, never()).lockSession(anyString());
+ verify(sessionStore, never()).lockUserPolicy(anyLong());
+ }
+
+ @Test
+ void shouldRevokePreviousTokenWhenGraceSnapshotIsGone() {
+ service.rotate(initialToken.rawToken(), response(), issuer("access-1"));
+ rotations.clear();
+ latestRotations.clear();
+ // 超出宽限期的明确重放必须直接撤销,不能被已耗尽的刷新配额挡住。
+ when(sessionStore.tryAcquireRateLimit(anyString(), anyInt(), any(Duration.class)))
+ .thenReturn(false);
+
+ assertThrows(BusinessException.class,
+ () -> service.rotate(initialToken.rawToken(), response(), issuer("unused")));
+
+ verify(sessionStore).delete(session.getSessionId());
+ verify(sessionStore).removeIndexes(session);
+ verify(sessionStore, times(1)).tryAcquireRateLimit(anyString(), anyInt(),
+ any(Duration.class));
+ }
+
+ @Test
+ void shouldPreserveSessionAndResumeSameRotationAfterTemporaryFailure() {
+ RuntimeException failure = new RuntimeException("temporary issuer failure");
+
+ assertThrows(RuntimeException.class,
+ () -> service.rotate(initialToken.rawToken(), response(), ignored -> {
+ throw failure;
+ }));
+
+ verify(sessionStore, never()).delete(session.getSessionId());
+ RefreshRotationResult pending = rotations.get(initialToken.fingerprint());
+ assertTrue(pending != null && !pending.isComplete());
+ String pendingRefreshToken = codec.decrypt(pending.getEncryptedRefreshToken());
+
+ LoginResp recovered = service.rotate(initialToken.rawToken(), response(),
+ issuer("access-recovered"));
+
+ assertEquals("access-recovered", recovered.getAccessToken());
+ assertEquals(pendingRefreshToken, recovered.getRefreshToken());
+ }
+
+ @Test
+ void shouldRevokeWhenSecurityVersionChangesDuringRefresh() {
+ when(sessionStore.isSecurityVersionCurrent(session)).thenReturn(true, false);
+
+ RefreshTokenException exception = assertThrows(RefreshTokenException.class,
+ () -> service.rotate(initialToken.rawToken(), response(), issuer("access-1")));
+
+ assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
+ verify(sessionStore).delete(session.getSessionId());
+ verify(sessionStore).removeIndexes(session);
+ }
+
+ @Test
+ void shouldRejectCookieAndBodyConflict() {
+ MockHttpServletRequest request = request();
+ request.setCookies(new Cookie("refresh_token", initialToken.rawToken()));
+
+ RefreshTokenException exception = assertThrows(RefreshTokenException.class,
+ () -> service.resolve(initialToken.rawToken(), request));
+ assertEquals(HttpStatus.FORBIDDEN, exception.getStatus());
+ }
+
+ @Test
+ void shouldRequireTrustedOriginForCookieMode() {
+ session.setMode(RefreshTokenModeEnum.COOKIE);
+ MockHttpServletRequest request = request();
+ request.setCookies(new Cookie("refresh_token", initialToken.rawToken()));
+ request.addHeader("Origin", "https://attacker.example");
+
+ assertThrows(BusinessException.class,
+ () -> service.validateRequest(initialToken.rawToken(), request));
+
+ request.removeHeader("Origin");
+ request.addHeader("Origin", "https://admin.example");
+ service.validateRequest(initialToken.rawToken(), request);
+ }
+
+ @Test
+ void shouldValidateCookieOriginBeforeParsingMalformedToken() {
+ MockHttpServletRequest request = request();
+ request.setCookies(new Cookie("refresh_token", "malformed"));
+ request.addHeader("Origin", "https://admin.example");
+
+ service.validateCookieOrigin(request);
+ assertThrows(BusinessException.class,
+ () -> service.validateRequest("malformed", request));
+
+ request.removeHeader("Origin");
+ request.addHeader("Origin", "https://attacker.example");
+ RefreshTokenException exception = assertThrows(RefreshTokenException.class,
+ () -> service.validateCookieOrigin(request));
+ assertEquals(HttpStatus.FORBIDDEN, exception.getStatus());
+ }
+
+ @Test
+ void shouldRequireTrustedOriginForCachedOlderCookieToken() {
+ session.setMode(RefreshTokenModeEnum.COOKIE);
+ session.setCurrentTokenFingerprint(codec.issue(session.getSessionId()).fingerprint());
+ RefreshRotationResult cached = new RefreshRotationResult();
+ cached.setEncryptedAccessToken(codec.encrypt("access-1"));
+ rotations.put(initialToken.fingerprint(), cached);
+ MockHttpServletRequest request = request();
+ request.setCookies(new Cookie("refresh_token", initialToken.rawToken()));
+ request.addHeader("Origin", "https://attacker.example");
+
+ assertThrows(BusinessException.class,
+ () -> service.validateRequest(initialToken.rawToken(), request));
+ }
+
+ @Test
+ void shouldRejectWildcardCookieOriginConfiguration() {
+ properties.setCookieAllowedOrigins(List.of("*"));
+
+ assertFalse(properties.isCookieAllowedOriginsValid());
+ }
+
+ @Test
+ void shouldAllowCookieOriginWildcardForConfiguredSubdomains() {
+ properties.setCookieAllowedOrigins(List.of("http://*.luoqiz.top"));
+ assertTrue(properties.isCookieAllowedOriginsValid());
+ RefreshTokenRequestGuard requestGuard = new RefreshTokenRequestGuard(properties, codec,
+ sessionStore);
+ MockHttpServletRequest request = request();
+ request.setCookies(new Cookie("refresh_token", initialToken.rawToken()));
+ request.addHeader("Origin", "http://admin.luoqiz.top");
+
+ assertDoesNotThrow(() -> requestGuard.validateCookieOrigin(request));
+
+ request.removeHeader("Origin");
+ request.addHeader("Origin", "http://admin.luoqiz.top.attacker.example");
+ assertThrows(RefreshTokenException.class, () -> requestGuard.validateCookieOrigin(request));
+
+ request.removeHeader("Origin");
+ request.addHeader("Origin", "http://nested.admin.luoqiz.top");
+ assertThrows(RefreshTokenException.class, () -> requestGuard.validateCookieOrigin(request));
+ }
+
+ @Test
+ void shouldRevokeRefreshSessionEvenWhenAccessTokenIndexAlreadyExpired() {
+ RefreshSession oldSession = session("old-web", "WEB", 1L);
+ when(sessionStore.findByUser(1L)).thenReturn(Set.of(oldSession.getSessionId()));
+ when(sessionStore.get(oldSession.getSessionId())).thenReturn(oldSession);
+
+ RefreshClientPolicy client = client(false, SessionReplacementScope.ALL_CLIENT_TYPES, -1);
+ String result = service.executeLoginPolicy(1L, client.clientId(), 2L,
+ version -> new LoginAttempt<>(1L, client, null, null, () -> "issued"));
+
+ assertEquals("issued", result);
+ verify(sessionStore).delete(oldSession.getSessionId());
+ }
+
+ @Test
+ void shouldOnlyRevokeSameClientTypeForCurrentDevicePolicy() {
+ RefreshSession webSession = session("old-web", "WEB", 1L);
+ RefreshSession appSession = session("old-app", "APP", 2L);
+ when(sessionStore.findByUser(1L))
+ .thenReturn(Set.of(webSession.getSessionId(), appSession.getSessionId()));
+ when(sessionStore.get(webSession.getSessionId())).thenReturn(webSession);
+ when(sessionStore.get(appSession.getSessionId())).thenReturn(appSession);
+
+ RefreshClientPolicy client = client(false, SessionReplacementScope.CURRENT_CLIENT_TYPE, -1);
+ service.executeLoginPolicy(1L, client.clientId(), 2L,
+ version -> new LoginAttempt<>(1L, client, null, null, () -> null));
+
+ verify(sessionStore).delete(webSession.getSessionId());
+ verify(sessionStore, never()).delete(appSession.getSessionId());
+ }
+
+ @Test
+ void shouldApplyMaxLoginCountOnlyToCurrentClientType() {
+ RefreshSession oldest = session("oldest", "WEB", 1L);
+ RefreshSession newest = session("newest", "WEB", 2L);
+ RefreshSession app = session("app", "APP", 3L);
+ when(sessionStore.findByUser(1L))
+ .thenReturn(Set.of(newest.getSessionId(), oldest.getSessionId(), app.getSessionId()));
+ when(sessionStore.get(oldest.getSessionId())).thenReturn(oldest);
+ when(sessionStore.get(newest.getSessionId())).thenReturn(newest);
+ when(sessionStore.get(app.getSessionId())).thenReturn(app);
+
+ RefreshClientPolicy client = client(true, null, 2);
+ service.executeLoginPolicy(1L, client.clientId(), 2L,
+ version -> new LoginAttempt<>(1L, client, null, null, () -> null));
+
+ verify(sessionStore).delete(oldest.getSessionId());
+ verify(sessionStore, never()).delete(newest.getSessionId());
+ verify(sessionStore, never()).delete(app.getSessionId());
+ }
+
+ @Test
+ void shouldAcquireLoginLocksInFixedOrderAndReleaseInReverseOrder() {
+ AuthPolicyLock userLock = mock(AuthPolicyLock.class);
+ AuthPolicyLock tenantLock = mock(AuthPolicyLock.class);
+ AuthPolicyLock clientLock = mock(AuthPolicyLock.class);
+ when(sessionStore.lockUserPolicy(1L)).thenReturn(userLock);
+ when(sessionStore.lockTenantPolicyRead(2L)).thenReturn(tenantLock);
+ when(sessionStore.lockClientPolicyRead("web")).thenReturn(clientLock);
+
+ RefreshClientPolicy client = client(true, null, -1);
+ String result = service.executeLoginPolicy(1L, "web", 2L,
+ version -> new LoginAttempt<>(1L, client, null, null, () -> "issued"));
+
+ assertEquals("issued", result);
+ org.mockito.InOrder order = inOrder(sessionStore, userLock, tenantLock, clientLock);
+ order.verify(sessionStore).lockUserPolicy(1L);
+ order.verify(sessionStore).lockTenantPolicyRead(2L);
+ order.verify(sessionStore).lockClientPolicyRead("web");
+ order.verify(clientLock).close();
+ order.verify(tenantLock).close();
+ order.verify(userLock).close();
+ }
+
+ @Test
+ void shouldHoldInvalidationLockUntilTransactionCompletion() {
+ AuthPolicyLock userLock = mock(AuthPolicyLock.class);
+ RefreshSession oldSession = session("old-web", "WEB", 1L);
+ when(sessionStore.lockUserPolicy(1L)).thenReturn(userLock);
+ when(sessionStore.findByUser(1L)).thenReturn(Set.of(oldSession.getSessionId()));
+ when(sessionStore.get(oldSession.getSessionId())).thenReturn(oldSession);
+
+ TransactionSynchronizationManager.setActualTransactionActive(true);
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ service.revokeByUser(1L);
+
+ verify(sessionStore, times(1)).incrementUserSecurityVersion(1L);
+ verify(userLock, never()).close();
+ for (TransactionSynchronization synchronization : TransactionSynchronizationManager
+ .getSynchronizations()) {
+ synchronization.afterCompletion(TransactionSynchronization.STATUS_COMMITTED);
+ }
+ verify(userLock).close();
+ // 不再依赖 afterCommit 二次执行 Redis 失效操作。
+ verify(sessionStore, times(1)).incrementUserSecurityVersion(1L);
+ } finally {
+ TransactionSynchronizationManager.clearSynchronization();
+ TransactionSynchronizationManager.setActualTransactionActive(false);
+ }
+ }
+
+ @Test
+ void shouldFailClosedWhenTransactionSynchronizationIsUnavailable() {
+ AuthPolicyLock userLock = mock(AuthPolicyLock.class);
+ when(sessionStore.lockUserPolicy(1L)).thenReturn(userLock);
+ TransactionSynchronizationManager.setActualTransactionActive(true);
+ try {
+ assertThrows(IllegalStateException.class, () -> service.revokeByUser(1L));
+
+ verify(sessionStore, never()).incrementUserSecurityVersion(1L);
+ verify(userLock).close();
+ } finally {
+ TransactionSynchronizationManager.setActualTransactionActive(false);
+ }
+ }
+
+ @Test
+ void shouldNotifyWebSocketClusterWhenSessionIsRevoked() {
+ RefreshSession oldSession = session("old-web", "WEB", 1L);
+ when(sessionStore.findByUser(1L)).thenReturn(Set.of(oldSession.getSessionId()));
+ when(sessionStore.get(oldSession.getSessionId())).thenReturn(oldSession);
+
+ service.revokeByUser(1L);
+
+ verify(sessionRevocationNotifier).notifyRevoked(oldSession.getSessionId());
+ // 用户级强制下线在 Redis 中记录失效原因,让被踢方下次请求看到准确提示。
+ verify(sessionStore).saveLogoutReason(oldSession.getSessionId(), LogoutReasonEnum.KICKOUT);
+ }
+
+ @Test
+ void shouldSkipSessionStoreWhenAccessSessionCacheHit() {
+ String sessionId = initialToken.sessionId();
+ when(accessSessionCache.isValid(sessionId)).thenReturn(true);
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getExtra("access-token-1",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn(sessionId);
+ assertNull(service.getInvalidReason("access-token-1"));
+ }
+ verify(sessionStore, never()).get(anyString());
+ }
+
+ @Test
+ void shouldMarkValidAfterFullValidation() {
+ String sessionId = initialToken.sessionId();
+ when(accessSessionCache.isValid(sessionId)).thenReturn(false);
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getExtra("access-token-1",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn(sessionId);
+ stpUtil.when(() -> StpUtil.getLoginIdByToken("access-token-1")).thenReturn(1L);
+ assertNull(service.getInvalidReason("access-token-1"));
+ }
+ verify(sessionStore).get(sessionId);
+ verify(accessSessionCache).markValid(sessionId);
+ }
+
+ @Test
+ void shouldInvalidateAccessSessionCacheWhenSessionRevoked() {
+ service.revokeBySessionId(initialToken.sessionId());
+ verify(accessSessionCache).invalidate(initialToken.sessionId());
+ }
+
+ private MockHttpServletRequest request() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setScheme("https");
+ request.setServerName("admin.example");
+ request.setServerPort(443);
+ request.setRemoteAddr("127.0.0.1");
+ return request;
+ }
+
+ private RefreshSession session(String sessionId, String clientType, long createdAt) {
+ RefreshSession value = new RefreshSession();
+ value.setSessionId(sessionId);
+ value.setUserId(1L);
+ value.setClientId(clientType.toLowerCase());
+ value.setClientType(clientType);
+ value.setCreatedAt(createdAt);
+ value.setExpiresAt(System.currentTimeMillis() + Duration.ofDays(1).toMillis());
+ return value;
+ }
+
+ private RefreshClientPolicy client(boolean concurrent, SessionReplacementScope replacementScope,
+ int maxLoginCount) {
+ return new RefreshClientPolicy("web", "WEB", 2592000L,
+ RefreshTokenModeEnum.COOKIE, concurrent, replacementScope, maxLoginCount,
+ LogoutReasonEnum.REPLACED);
+ }
+
+ private MockHttpServletResponse response() {
+ return new MockHttpServletResponse();
+ }
+
+ private Function issuer(String accessToken) {
+ return ignored -> LoginResp.builder()
+ .accessToken(accessToken)
+ .tokenType("Bearer")
+ .expiresIn(900L)
+ .tenantId(1L)
+ .build();
+ }
+}
diff --git a/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/AccessSessionCacheTest.java b/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/AccessSessionCacheTest.java
new file mode 100644
index 0000000000..200cc7f1d6
--- /dev/null
+++ b/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/AccessSessionCacheTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.redisson.api.RTopic;
+import org.redisson.api.RedissonClient;
+import org.redisson.api.listener.MessageListener;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** 热路径会话缓存:命中/失效/广播隔离/载荷防御测试。 */
+class AccessSessionCacheTest {
+
+ private RefreshTokenProperties properties;
+ private RedissonClient redissonClient;
+ private RTopic invalidTopic;
+ private AccessSessionCache cache;
+
+ @BeforeEach
+ void setUp() {
+ properties = new RefreshTokenProperties();
+ redissonClient = mock(RedissonClient.class);
+ invalidTopic = mock(RTopic.class);
+ when(redissonClient.getTopic(anyString())).thenReturn(invalidTopic);
+ cache = new AccessSessionCache(properties, redissonClient, "test-app");
+ cache.init();
+ }
+
+ @Test
+ void shouldReturnFalseBeforeMarkedValid() {
+ assertFalse(cache.isValid("sid-1"));
+ }
+
+ @Test
+ void shouldReturnTrueAfterMarkedValid() {
+ cache.markValid("sid-1");
+ assertTrue(cache.isValid("sid-1"));
+ }
+
+ @Test
+ void shouldInvalidateLocallyAndBroadcast() {
+ cache.markValid("sid-1");
+ cache.invalidate("sid-1");
+ assertFalse(cache.isValid("sid-1"));
+ verify(invalidTopic).publish("sid-1");
+ }
+
+ @Test
+ void shouldNotCacheOrPublishWhenDisabled() {
+ properties.setAccessSessionCacheEnabled(false);
+ cache.markValid("sid-1");
+ assertFalse(cache.isValid("sid-1"));
+ cache.invalidate("sid-1");
+ verify(invalidTopic, never()).publish(anyString());
+ }
+
+ @Test
+ void shouldUseAppNameScopedTopicByDefault() {
+ verify(redissonClient).getTopic("auth:access-session-invalid:test-app");
+ }
+
+ @Test
+ void shouldUseExplicitTopicWhenConfigured() {
+ properties.setAccessSessionInvalidTopic("shared-topic");
+ AccessSessionCache sharedCache = new AccessSessionCache(properties, redissonClient,
+ "test-app");
+ sharedCache.init();
+ verify(redissonClient).getTopic("shared-topic");
+ }
+
+ @Test
+ void shouldFallbackToUnknownAppName() {
+ AccessSessionCache unnamedCache = new AccessSessionCache(properties, redissonClient, "");
+ unnamedCache.init();
+ verify(redissonClient).getTopic("auth:access-session-invalid:unknown");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void shouldIgnoreBlankBroadcastMessage() {
+ cache.markValid("sid-1");
+ ArgumentCaptor> captor = ArgumentCaptor
+ .forClass(MessageListener.class);
+ verify(invalidTopic).addListener(any(Class.class), captor.capture());
+ MessageListener listener = captor.getValue();
+ listener.onMessage("channel", "");
+ assertTrue(cache.isValid("sid-1"));
+ listener.onMessage("channel", "sid-1");
+ assertFalse(cache.isValid("sid-1"));
+ }
+}
diff --git a/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/RefreshTokenCodecTest.java b/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/RefreshTokenCodecTest.java
new file mode 100644
index 0000000000..a9c5db424a
--- /dev/null
+++ b/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/RefreshTokenCodecTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+import top.continew.admin.auth.support.RefreshTokenCodec.IssuedToken;
+import top.continew.admin.auth.support.RefreshTokenCodec.ParsedToken;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.starter.core.exception.BusinessException;
+
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Refresh Token 编解码安全边界测试。 */
+class RefreshTokenCodecTest {
+
+ private RefreshTokenCodec codec;
+
+ @BeforeEach
+ void setUp() {
+ RefreshTokenProperties properties = new RefreshTokenProperties();
+ properties.setSecret("test-only-refresh-token-secret-with-32-bytes");
+ codec = new RefreshTokenCodec(properties);
+ }
+
+ @Test
+ void shouldIssueSessionBoundToken() {
+ String sessionId = codec.newSessionId();
+ IssuedToken issued = codec.issue(sessionId);
+ ParsedToken parsed = codec.parse(issued.rawToken());
+
+ assertEquals(sessionId, parsed.sessionId());
+ assertEquals(66, issued.rawToken().length());
+ assertTrue(codec.matches(issued.fingerprint(), parsed.fingerprint()));
+ assertFalse(issued.rawToken().contains(issued.fingerprint()));
+ }
+
+ @Test
+ void shouldGenerateIndependentSecretsForSameSession() {
+ String sessionId = codec.newSessionId();
+ IssuedToken first = codec.issue(sessionId);
+ IssuedToken second = codec.issue(sessionId);
+
+ assertNotEquals(first.rawToken(), second.rawToken());
+ assertNotEquals(first.fingerprint(), second.fingerprint());
+ }
+
+ @Test
+ void shouldRejectMalformedToken() {
+ RefreshTokenException exception = assertThrows(RefreshTokenException.class,
+ () -> codec.parse("missing-separator"));
+ assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
+ assertThrows(BusinessException.class, () -> codec.parse("a.b.c"));
+ assertThrows(BusinessException.class, () -> codec.parse("../unsafe.secret"));
+ }
+
+ @Test
+ void shouldEncryptWithRandomIvAndDetectTampering() {
+ String value = codec.issue(codec.newSessionId()).rawToken();
+ String first = codec.encrypt(value);
+ String second = codec.encrypt(value);
+
+ assertNotEquals(first, second);
+ assertEquals(value, codec.decrypt(first));
+ byte[] tamperedPayload = Base64.getUrlDecoder().decode(first);
+ tamperedPayload[tamperedPayload.length - 1] ^= 1;
+ String tampered = Base64.getUrlEncoder().withoutPadding().encodeToString(tamperedPayload);
+ assertThrows(BusinessException.class, () -> codec.decrypt(tampered));
+ }
+}
diff --git a/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/UserArgumentPolicyLockTargetResolverTest.java b/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/UserArgumentPolicyLockTargetResolverTest.java
new file mode 100644
index 0000000000..dff1c088be
--- /dev/null
+++ b/continew-auth-refresh/src/test/java/top/continew/admin/auth/support/UserArgumentPolicyLockTargetResolverTest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.support;
+
+import org.junit.jupiter.api.Test;
+import top.continew.admin.auth.api.AuthPolicyLockTarget;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** 用户策略锁参数解析测试。 */
+class UserArgumentPolicyLockTargetResolverTest {
+
+ private final UserArgumentPolicyLockTargetResolver resolver =
+ new UserArgumentPolicyLockTargetResolver();
+
+ @Test
+ void shouldResolveUserIdFromUpdatePasswordSignature() {
+ List targets = List.copyOf(resolver.resolve(
+ new Object[] {"old-password", "new-password", 1L}));
+
+ assertEquals(List.of(AuthPolicyLockTarget.user(1L)), targets);
+ }
+
+ @Test
+ void shouldResolveUserIdsFromCollectionArgument() {
+ List targets = List.copyOf(resolver.resolve(
+ new Object[] {"ignored", List.of(1L, 2L)}));
+
+ assertEquals(List.of(AuthPolicyLockTarget.user(1L), AuthPolicyLockTarget.user(2L)),
+ targets);
+ }
+
+ @Test
+ void shouldRejectArgumentsWithoutUserTarget() {
+ assertThrows(IllegalArgumentException.class,
+ () -> resolver.resolve(new Object[] {"old-password", "new-password"}));
+ }
+}
diff --git a/continew-auth-refresh/src/test/java/top/continew/admin/auth/websocket/AuthWebSocketSessionServiceTest.java b/continew-auth-refresh/src/test/java/top/continew/admin/auth/websocket/AuthWebSocketSessionServiceTest.java
new file mode 100644
index 0000000000..aedb8d46c7
--- /dev/null
+++ b/continew-auth-refresh/src/test/java/top/continew/admin/auth/websocket/AuthWebSocketSessionServiceTest.java
@@ -0,0 +1,256 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.websocket;
+
+import cn.dev33.satoken.stp.StpUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.redisson.api.RTopic;
+import org.redisson.api.RedissonClient;
+import org.redisson.api.listener.MessageListener;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.WebSocketSession;
+import top.continew.admin.auth.api.AuthSessionConstants;
+import top.continew.admin.auth.api.AccessSessionValidator;
+import top.continew.admin.auth.config.RefreshTokenProperties;
+import top.continew.starter.messaging.websocket.dao.WebSocketSessionDao;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Refresh Session 撤销与 WebSocket 连接联动测试。 */
+class AuthWebSocketSessionServiceTest {
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void shouldCloseLocalConnectionAndPublishClusterRevocation() throws Exception {
+ RedissonClient redissonClient = mock(RedissonClient.class);
+ RTopic topic = mock(RTopic.class);
+ ObjectProvider sessionDaoProvider = mock(ObjectProvider.class);
+ ObjectProvider authSessionApiProvider = mock(ObjectProvider.class);
+ WebSocketSessionDao sessionDao = mock(WebSocketSessionDao.class);
+ WebSocketSession webSocketSession = mock(WebSocketSession.class);
+ when(redissonClient.getTopic(anyString())).thenReturn(topic);
+ when(topic.addListener(eq(String.class), any(MessageListener.class))).thenReturn(1);
+ when(sessionDaoProvider.getIfAvailable()).thenReturn(sessionDao);
+ when(sessionDao.listAllSessionIds()).thenReturn(Set.of("access-token"));
+ when(sessionDao.get("access-token")).thenReturn(webSocketSession);
+ when(webSocketSession.isOpen()).thenReturn(true);
+
+ AuthWebSocketSessionService service = this.service(redissonClient, sessionDaoProvider,
+ authSessionApiProvider);
+ service.subscribe();
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getExtra("access-token",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn("session-1");
+
+ service.notifyRevoked("session-1");
+ } finally {
+ service.unsubscribe();
+ }
+
+ verify(webSocketSession).close(CloseStatus.POLICY_VIOLATION);
+ verify(sessionDao).delete("access-token");
+ verify(topic).publish("session-1");
+ verify(topic).removeListener(1);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void shouldStillCloseLocalConnectionWhenClusterPublishFails() throws Exception {
+ RedissonClient redissonClient = mock(RedissonClient.class);
+ RTopic topic = mock(RTopic.class);
+ ObjectProvider sessionDaoProvider = mock(ObjectProvider.class);
+ ObjectProvider authSessionApiProvider = mock(ObjectProvider.class);
+ WebSocketSessionDao sessionDao = mock(WebSocketSessionDao.class);
+ WebSocketSession webSocketSession = mock(WebSocketSession.class);
+ when(redissonClient.getTopic(anyString())).thenReturn(topic);
+ when(topic.addListener(eq(String.class), any(MessageListener.class))).thenReturn(1);
+ when(topic.publish("session-1")).thenThrow(new IllegalStateException("Redis unavailable"));
+ when(sessionDaoProvider.getIfAvailable()).thenReturn(sessionDao);
+ when(sessionDao.listAllSessionIds()).thenReturn(Set.of("access-token"));
+ when(sessionDao.get("access-token")).thenReturn(webSocketSession);
+ when(webSocketSession.isOpen()).thenReturn(true);
+
+ AuthWebSocketSessionService service = this.service(redissonClient, sessionDaoProvider,
+ authSessionApiProvider);
+ service.subscribe();
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getExtra("access-token",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn("session-1");
+
+ service.notifyRevoked("session-1");
+ } finally {
+ service.unsubscribe();
+ }
+
+ verify(webSocketSession).close(CloseStatus.POLICY_VIOLATION);
+ verify(sessionDao).delete("access-token");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void shouldCloseOnlyInvalidConnectionsDuringPeriodicValidation() throws Exception {
+ RedissonClient redissonClient = mock(RedissonClient.class);
+ ObjectProvider sessionDaoProvider = mock(ObjectProvider.class);
+ ObjectProvider authSessionApiProvider = mock(ObjectProvider.class);
+ WebSocketSessionDao sessionDao = mock(WebSocketSessionDao.class);
+ AccessSessionValidator authSessionApi = mock(AccessSessionValidator.class);
+ WebSocketSession invalidSession = mock(WebSocketSession.class);
+ when(sessionDaoProvider.getIfAvailable()).thenReturn(sessionDao);
+ when(authSessionApiProvider.getIfAvailable()).thenReturn(authSessionApi);
+ when(sessionDao.listAllSessionIds()).thenReturn(Set.of("invalid-token", "valid-token"));
+ when(authSessionApi.isInvalid("invalid-token")).thenReturn(true);
+ when(authSessionApi.isInvalid("valid-token")).thenReturn(false);
+ when(sessionDao.get("invalid-token")).thenReturn(invalidSession);
+ when(invalidSession.isOpen()).thenReturn(true);
+
+ AuthWebSocketSessionService service = this.service(redissonClient, sessionDaoProvider,
+ authSessionApiProvider);
+ service.validateLocalSessions();
+
+ verify(invalidSession).close(CloseStatus.POLICY_VIOLATION);
+ verify(sessionDao).delete("invalid-token");
+ verify(sessionDao, never()).delete("valid-token");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void shouldCloseAllConnectionsOfSameTokenViaMultiDao() throws Exception {
+ RedissonClient redissonClient = mock(RedissonClient.class);
+ ObjectProvider sessionDaoProvider = mock(ObjectProvider.class);
+ ObjectProvider authSessionApiProvider = mock(ObjectProvider.class);
+ MultiWebSocketSessionDao sessionDao = mock(MultiWebSocketSessionDao.class);
+ WebSocketSession firstTab = mock(WebSocketSession.class);
+ WebSocketSession secondTab = mock(WebSocketSession.class);
+ when(sessionDaoProvider.getIfAvailable()).thenReturn(sessionDao);
+ when(sessionDao.listAllSessionIds()).thenReturn(Set.of("access-token"));
+ when(sessionDao.listByKey("access-token")).thenReturn(List.of(firstTab, secondTab));
+ when(firstTab.isOpen()).thenReturn(true);
+ when(secondTab.isOpen()).thenReturn(true);
+
+ AuthWebSocketSessionService service = this.service(redissonClient, sessionDaoProvider,
+ authSessionApiProvider);
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getExtra("access-token",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn("session-1");
+
+ service.notifyRevoked("session-1");
+ }
+
+ verify(firstTab).close(CloseStatus.POLICY_VIOLATION);
+ verify(secondTab).close(CloseStatus.POLICY_VIOLATION);
+ verify(sessionDao).removeAll("access-token");
+ verify(sessionDao, never()).delete(anyString());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void shouldReuseLocalSessionCacheAcrossBatchRevocations() throws Exception {
+ RedissonClient redissonClient = mock(RedissonClient.class);
+ ObjectProvider sessionDaoProvider = mock(ObjectProvider.class);
+ ObjectProvider authSessionApiProvider = mock(ObjectProvider.class);
+ WebSocketSessionDao sessionDao = mock(WebSocketSessionDao.class);
+ WebSocketSession sessionA = mock(WebSocketSession.class);
+ WebSocketSession sessionB = mock(WebSocketSession.class);
+ WebSocketSession sessionC = mock(WebSocketSession.class);
+ when(sessionDaoProvider.getIfAvailable()).thenReturn(sessionDao);
+ // 首次撤销后 token-a 的连接被关闭并从 DAO 移除,后续扫描只剩 b、c。
+ when(sessionDao.listAllSessionIds())
+ .thenReturn(Set.of("token-a", "token-b", "token-c"),
+ Set.of("token-b", "token-c"));
+ when(sessionDao.get("token-a")).thenReturn(sessionA);
+ when(sessionDao.get("token-b")).thenReturn(sessionB);
+ when(sessionDao.get("token-c")).thenReturn(sessionC);
+ when(sessionA.isOpen()).thenReturn(true);
+ when(sessionB.isOpen()).thenReturn(true);
+ when(sessionC.isOpen()).thenReturn(true);
+
+ AuthWebSocketSessionService service = this.service(redissonClient, sessionDaoProvider,
+ authSessionApiProvider);
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(() -> StpUtil.getExtra("token-a",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn("session-a");
+ stpUtil.when(() -> StpUtil.getExtra("token-b",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn("session-b");
+ stpUtil.when(() -> StpUtil.getExtra("token-c",
+ AuthSessionConstants.SESSION_ID_CLAIM)).thenReturn("session-c");
+
+ // 首次撤销:三个 Token 的会话声明全部冷启动回源并写入本地索引。
+ service.notifyRevoked("session-a");
+ verify(sessionA).close(CloseStatus.POLICY_VIOLATION);
+ verify(sessionB, never()).close(any());
+ stpUtil.verify(() -> StpUtil.getExtra(anyString(),
+ anyString()), times(3));
+
+ // 第二次撤销:token-b/token-c 命中本地索引,不再回源 Redis。
+ service.notifyRevoked("session-b");
+ verify(sessionB).close(CloseStatus.POLICY_VIOLATION);
+ stpUtil.verify(() -> StpUtil.getExtra(anyString(),
+ anyString()), times(3));
+ }
+ }
+
+ private AuthWebSocketSessionService service(RedissonClient redissonClient,
+ ObjectProvider sessionDaoProvider,
+ ObjectProvider accessSessionValidatorProvider) {
+ return new AuthWebSocketSessionService(redissonClient, sessionDaoProvider,
+ accessSessionValidatorProvider, new RefreshTokenProperties());
+ }
+
+ @Test
+ void daoShouldKeepOtherTabsWhenOneConnectionCloses() throws Exception {
+ ConcurrentWebSocketSessionDao dao = new ConcurrentWebSocketSessionDao();
+ WebSocketSession closed = mock(WebSocketSession.class);
+ WebSocketSession alive = mock(WebSocketSession.class);
+ when(closed.getId()).thenReturn("conn-1");
+ when(alive.getId()).thenReturn("conn-2");
+ when(closed.isOpen()).thenReturn(false);
+ when(alive.isOpen()).thenReturn(true);
+
+ dao.add("access-token", closed);
+ dao.add("access-token", alive);
+ List registered = List.copyOf(dao.listByKey("access-token"));
+ assertEquals(2, registered.size());
+ assertTrue(registered.containsAll(List.of(closed, alive)));
+ assertEquals(alive, dao.get("access-token"));
+ assertEquals(Set.of("access-token"), dao.listAllSessionIds());
+
+ // Starter 关闭回调只携带 Key:只移除已关闭的连接,保留存活的标签页。
+ dao.delete("access-token");
+ assertEquals(List.of(alive), dao.listByKey("access-token"));
+ assertEquals(alive, dao.get("access-token"));
+
+ dao.removeAll("access-token");
+ assertEquals(List.of(), dao.listByKey("access-token"));
+ assertNull(dao.get("access-token"));
+ }
+}
diff --git a/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantApi.java b/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantApi.java
index 7140d1dbd4..3d28da0da5 100644
--- a/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantApi.java
+++ b/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantApi.java
@@ -16,6 +16,8 @@
package top.continew.admin.common.api.tenant;
+import java.util.List;
+
/**
* 租户业务 API
*
@@ -24,6 +26,13 @@
*/
public interface TenantApi {
+ /**
+ * 校验租户是否可以继续提供服务。
+ *
+ * @param tenantId 租户 ID
+ */
+ void checkStatus(Long tenantId);
+
/**
* 绑定租户管理员用户
*
@@ -31,4 +40,12 @@ public interface TenantApi {
* @param userId 用户 ID
*/
void bindAdminUser(Long tenantId, Long userId);
+
+ /**
+ * 查询指定套餐当前关联的租户 ID。
+ *
+ * @param packageId 套餐 ID
+ * @return 租户 ID 列表
+ */
+ List listIdByPackageId(Long packageId);
}
diff --git a/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantDataApi.java b/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantDataApi.java
index 696cf8e467..d1c67b5611 100644
--- a/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantDataApi.java
+++ b/continew-common/src/main/java/top/continew/admin/common/api/tenant/TenantDataApi.java
@@ -34,6 +34,11 @@ public interface TenantDataApi {
*/
void init(TenantDTO tenant);
+ /**
+ * 使当前租户的认证会话失效。
+ */
+ void invalidateSessions();
+
/**
* 清除数据
*/
diff --git a/continew-common/src/main/java/top/continew/admin/common/config/TenantExtensionProperties.java b/continew-common/src/main/java/top/continew/admin/common/config/TenantExtensionProperties.java
index e7d3dbab28..f7ee0bdc3b 100644
--- a/continew-common/src/main/java/top/continew/admin/common/config/TenantExtensionProperties.java
+++ b/continew-common/src/main/java/top/continew/admin/common/config/TenantExtensionProperties.java
@@ -55,6 +55,6 @@ public class TenantExtensionProperties {
* @return 是否为默认租户
*/
public boolean isDefaultTenant() {
- return defaultTenantId.equals(TenantContextHolder.getTenantId());
+ return defaultTenantId != null && defaultTenantId.equals(TenantContextHolder.getTenantId());
}
}
diff --git a/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalSaTokenExceptionHandler.java b/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalSaTokenExceptionHandler.java
index c5fbbaf9df..5927c449c9 100644
--- a/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalSaTokenExceptionHandler.java
+++ b/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalSaTokenExceptionHandler.java
@@ -20,6 +20,7 @@
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
@@ -52,7 +53,8 @@ public class GlobalSaTokenExceptionHandler {
* 认证异常-登录认证
*/
@ExceptionHandler(NotLoginException.class)
- public R handleNotLoginException(NotLoginException e, HttpServletRequest request) {
+ public R handleNotLoginException(NotLoginException e, HttpServletRequest request,
+ HttpServletResponse response) {
log.warn(LOG_CLIENT_ERROR_TEMPLATE, request.getMethod(), request.getRequestURI(), e
.getMessage());
String errorMsg = switch (e.getType()) {
@@ -60,6 +62,7 @@ public R handleNotLoginException(NotLoginException e, HttpServletRequest request
case NotLoginException.BE_REPLACED -> "您已被顶下线";
default -> "您的登录状态已过期,请重新登录";
};
+ response.setStatus(HttpStatus.UNAUTHORIZED.value());
return R.fail(String.valueOf(HttpStatus.UNAUTHORIZED.value()), errorMsg);
}
@@ -67,9 +70,11 @@ public R handleNotLoginException(NotLoginException e, HttpServletRequest request
* 认证异常-权限认证
*/
@ExceptionHandler(NotPermissionException.class)
- public R handleNotPermissionException(NotPermissionException e, HttpServletRequest request) {
+ public R handleNotPermissionException(NotPermissionException e, HttpServletRequest request,
+ HttpServletResponse response) {
log.warn(LOG_CLIENT_ERROR_TEMPLATE, request.getMethod(), request.getRequestURI(), e
.getMessage());
+ response.setStatus(HttpStatus.FORBIDDEN.value());
return R.fail(String.valueOf(HttpStatus.FORBIDDEN.value()), "没有访问权限,请联系管理员授权");
}
@@ -77,9 +82,11 @@ public R handleNotPermissionException(NotPermissionException e, HttpServletReque
* 认证异常-角色认证
*/
@ExceptionHandler(NotRoleException.class)
- public R handleNotRoleException(NotRoleException e, HttpServletRequest request) {
+ public R handleNotRoleException(NotRoleException e, HttpServletRequest request,
+ HttpServletResponse response) {
log.warn(LOG_CLIENT_ERROR_TEMPLATE, request.getMethod(), request.getRequestURI(), e
.getMessage());
+ response.setStatus(HttpStatus.FORBIDDEN.value());
return R.fail(String.valueOf(HttpStatus.FORBIDDEN.value()), "没有访问权限,请联系管理员授权");
}
}
diff --git a/continew-common/src/main/java/top/continew/admin/common/context/UserContext.java b/continew-common/src/main/java/top/continew/admin/common/context/UserContext.java
index 083c3c6a55..7f970befb2 100644
--- a/continew-common/src/main/java/top/continew/admin/common/context/UserContext.java
+++ b/continew-common/src/main/java/top/continew/admin/common/context/UserContext.java
@@ -53,6 +53,11 @@ public class UserContext implements Serializable {
*/
private String username;
+ /**
+ * 昵称
+ */
+ private String nickname;
+
/**
* 部门 ID
*/
diff --git a/continew-common/src/main/java/top/continew/admin/common/context/UserContextHolder.java b/continew-common/src/main/java/top/continew/admin/common/context/UserContextHolder.java
index eed50738cf..ee4daca48d 100644
--- a/continew-common/src/main/java/top/continew/admin/common/context/UserContextHolder.java
+++ b/continew-common/src/main/java/top/continew/admin/common/context/UserContextHolder.java
@@ -126,6 +126,8 @@ public static UserExtraContext getExtraContext(String token) {
context.setBrowser(Convert.toStr(StpUtil.getExtra(token, "browser")));
context.setOs(Convert.toStr(StpUtil.getExtra(token, "os")));
context.setLoginTime(Convert.toLocalDateTime(StpUtil.getExtra(token, "loginTime")));
+ context.setTenantId(Convert.toLong(StpUtil.getExtra(token, "tenantId")));
+ context.setClientId(Convert.toStr(StpUtil.getExtra(token, "clientId")));
return context;
}
diff --git a/continew-common/src/main/java/top/continew/admin/common/context/UserExtraContext.java b/continew-common/src/main/java/top/continew/admin/common/context/UserExtraContext.java
index 2213d72971..c6a7331a42 100644
--- a/continew-common/src/main/java/top/continew/admin/common/context/UserExtraContext.java
+++ b/continew-common/src/main/java/top/continew/admin/common/context/UserExtraContext.java
@@ -69,11 +69,28 @@ public class UserExtraContext implements Serializable {
*/
private LocalDateTime loginTime;
- public UserExtraContext(HttpServletRequest request) {
+ /**
+ * 登录时确定的租户 ID。
+ *
+ * 该字段随 Access Token 保存,用于在线用户强退等按令牌维度的租户边界校验;
+ * 不能只依赖用户级 SaSession,因为同一用户可能同时存在多个租户会话。
+ */
+ private Long tenantId;
+
+ /**
+ * 登录时使用的客户端 ID。
+ *
+ * 客户端 ID 是令牌级属性,不能使用用户级 SaSession 中最后一次登录的值,
+ * 否则同一用户多客户端登录时在线用户查询会串数据。
+ */
+ private String clientId;
+
+ public UserExtraContext(HttpServletRequest request, Long tenantId) {
this.ip = JakartaServletUtil.getClientIP(request);
this.address = ExceptionUtils.exToNull(() -> IpUtils.getIpv4Address(this.ip));
this.setBrowser(ServletUtils.getBrowser(request));
this.setLoginTime(LocalDateTime.now(GlobalConstants.DEFAULT_ZONE_ID));
this.setOs(StrUtil.subBefore(ServletUtils.getOs(request), " or", false));
+ this.tenantId = tenantId;
}
}
diff --git a/continew-plugin/continew-plugin-tenant/pom.xml b/continew-plugin/continew-plugin-tenant/pom.xml
index d670d194d8..fad4afd9f0 100644
--- a/continew-plugin/continew-plugin-tenant/pom.xml
+++ b/continew-plugin/continew-plugin-tenant/pom.xml
@@ -14,4 +14,30 @@
${project.artifactId}
租户插件
-
\ No newline at end of file
+
+
+
+
+ ${project.groupId}
+ continew-auth-refresh
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ false
+
+
+
+
+
diff --git a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/api/TenantApiImpl.java b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/api/TenantApiImpl.java
index 704b5f6194..276f1f7d10 100644
--- a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/api/TenantApiImpl.java
+++ b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/api/TenantApiImpl.java
@@ -16,14 +16,24 @@
package top.continew.admin.tenant.api;
+import top.continew.admin.common.config.TenantExtensionProperties;
+import top.continew.admin.common.constant.GlobalConstants;
+import top.continew.admin.common.enums.DisEnableStatusEnum;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import top.continew.admin.common.api.tenant.TenantApi;
import top.continew.admin.tenant.constant.TenantCacheConstants;
+import top.continew.admin.tenant.mapper.PackageMapper;
import top.continew.admin.tenant.mapper.TenantMapper;
+import top.continew.admin.tenant.model.entity.PackageDO;
import top.continew.admin.tenant.model.entity.TenantDO;
import top.continew.starter.cache.redisson.util.RedisUtils;
+import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.extension.crud.model.entity.BaseIdDO;
+import top.continew.starter.extension.tenant.context.TenantContextHolder;
+
+import java.time.LocalDateTime;
+import java.util.List;
/**
* 租户业务 API 实现
@@ -36,6 +46,36 @@
public class TenantApiImpl implements TenantApi {
private final TenantMapper baseMapper;
+ private final PackageMapper packageMapper;
+ private final TenantExtensionProperties tenantExtensionProperties;
+
+ @Override
+ public void checkStatus(Long tenantId) {
+ // 默认租户承载超级管理员,不依赖租户套餐数据,保持与租户插件原有规则一致。
+ // 租户功能开启时,null 表示会话没有可靠的租户归属,不能按默认租户放行。
+ // 这也能阻断“租户功能关闭期间签发的旧 Refresh Session”在重新开启租户功能后
+ // 绕过租户状态校验继续换取 Access Token。
+ if (tenantId == null) {
+ CheckUtils.throwIf(TenantContextHolder.isTenantEnabled(), "租户信息不存在");
+ return;
+ }
+ if (tenantExtensionProperties.getDefaultTenantId() != null
+ && tenantExtensionProperties.getDefaultTenantId().equals(tenantId)) {
+ return;
+ }
+ TenantDO tenant = baseMapper.selectById(tenantId);
+ CheckUtils.throwIfNull(tenant, "租户不存在");
+ // 状态为空(异常数据)视为禁用,避免 throwIfEqual(DISABLE, null) 放行未知状态租户
+ CheckUtils.throwIf(tenant.getStatus() == null
+ || DisEnableStatusEnum.DISABLE.equals(tenant.getStatus()), "租户已被禁用");
+ CheckUtils.throwIf(tenant.getExpireTime() != null && tenant.getExpireTime()
+ .isBefore(LocalDateTime.now(GlobalConstants.DEFAULT_ZONE_ID)), "租户已过期");
+
+ PackageDO tenantPackage = packageMapper.selectById(tenant.getPackageId());
+ CheckUtils.throwIfNull(tenantPackage, "租户套餐不存在");
+ CheckUtils.throwIfEqual(DisEnableStatusEnum.DISABLE, tenantPackage.getStatus(),
+ "租户套餐已被禁用");
+ }
@Override
public void bindAdminUser(Long tenantId, Long userId) {
@@ -45,4 +85,15 @@ public void bindAdminUser(Long tenantId, Long userId) {
TenantDO entity = baseMapper.selectById(tenantId);
RedisUtils.set(TenantCacheConstants.TENANT_KEY_PREFIX + tenantId, entity);
}
+
+ @Override
+ public List listIdByPackageId(Long packageId) {
+ return baseMapper.lambdaQuery()
+ .select(TenantDO::getId)
+ .eq(TenantDO::getPackageId, packageId)
+ .list()
+ .stream()
+ .map(TenantDO::getId)
+ .toList();
+ }
}
diff --git a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/auth/PackageTenantPolicyLockTargetResolver.java b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/auth/PackageTenantPolicyLockTargetResolver.java
new file mode 100644
index 0000000000..eb74522db1
--- /dev/null
+++ b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/auth/PackageTenantPolicyLockTargetResolver.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.tenant.auth;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.api.AuthPolicyLockTarget;
+import top.continew.admin.auth.api.AuthPolicyLockTargetResolver;
+import top.continew.admin.common.api.tenant.TenantApi;
+
+import java.util.Collection;
+
+/** 将套餐变更影响的租户解析为认证会话策略锁目标。 */
+@Component
+@RequiredArgsConstructor
+public class PackageTenantPolicyLockTargetResolver implements AuthPolicyLockTargetResolver {
+
+ private final TenantApi tenantApi;
+
+ @Override
+ public Collection resolve(Object[] args) {
+ if (args.length <= 1 || !(args[1] instanceof Long packageId)) {
+ throw new IllegalArgumentException("认证套餐策略锁参数无效");
+ }
+ return tenantApi.listIdByPackageId(packageId).stream().map(AuthPolicyLockTarget::tenant)
+ .toList();
+ }
+}
diff --git a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/config/DefaultTenantProvider.java b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/config/DefaultTenantProvider.java
index 6fba391143..bb1468c5bb 100644
--- a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/config/DefaultTenantProvider.java
+++ b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/config/DefaultTenantProvider.java
@@ -44,8 +44,14 @@ public TenantContext getByTenantId(String tenantIdAsString, boolean verify) {
TenantContext context = new TenantContext();
Long defaultTenantId = tenantExtensionProperties.getDefaultTenantId();
context.setTenantId(defaultTenantId);
+ // 请求入口(verify=true,TenantInterceptor 从请求头解析租户)在刷新/退出时忽略
+ // 前端残留的租户请求头,统一返回默认租户;具体租户由业务层根据 RefreshSession
+ // 的 tenantId 显式重新进入上下文(TenantUtils.execute 传 verify=false),不受此限制。
+ if (verify && this.isTenantIndependentAuthRequest()) {
+ return context;
+ }
// 默认租户
- if (defaultTenantId.toString().equals(tenantIdAsString)) {
+ if (defaultTenantId != null && defaultTenantId.toString().equals(tenantIdAsString)) {
return context;
}
Long tenantId;
@@ -53,6 +59,11 @@ public TenantContext getByTenantId(String tenantIdAsString, boolean verify) {
if (StrUtil.isBlank(tenantIdAsString)) {
// 检查是否指定了租户编码(登录相关接口)
HttpServletRequest request = ServletUtils.getRequest();
+ // 异步任务没有 Servlet Request 上下文,也没有可供解析的租户请求头;此时
+ // 保持默认租户。显式传入 tenantId 的异步任务会在下面的分支正常处理。
+ if (request == null) {
+ return context;
+ }
String tenantCode = request.getHeader(tenantExtensionProperties.getTenantCodeHeader());
if (StrUtil.isBlank(tenantCode)) {
return context;
@@ -71,4 +82,19 @@ public TenantContext getByTenantId(String tenantIdAsString, boolean verify) {
context.setTenantId(tenantId);
return context;
}
+
+ private boolean isTenantIndependentAuthRequest() {
+ HttpServletRequest request = ServletUtils.getRequest();
+ // 权限、角色等异步加载任务不继承 Servlet Request。没有请求上下文不代表刷新或
+ // 退出接口,必须继续使用 TenantUtils.execute 显式传入的租户 ID。
+ if (request == null) {
+ return false;
+ }
+ String requestUri = request.getRequestURI();
+ String contextPath = request.getContextPath();
+ String path = StrUtil.removePrefix(requestUri, contextPath);
+ // 支持网关未重写的 /api、/v1 等前缀;租户认证接口不能因代理前缀变化而重新
+ // 读取前端租户请求头,否则普通租户的 Cookie 刷新会被误判为跨租户请求。
+ return path.endsWith("/auth/refresh") || path.endsWith("/auth/logout");
+ }
}
diff --git a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/TenantService.java b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/TenantService.java
index 80676e5d71..b0816c5cfe 100644
--- a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/TenantService.java
+++ b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/TenantService.java
@@ -67,6 +67,13 @@ public interface TenantService
*/
void updateTenantMenu(List newMenuIds, Long packageId);
+ /**
+ * 使指定套餐下全部租户的认证会话失效。
+ *
+ * @param packageId 套餐 ID
+ */
+ void invalidateSessionsByPackageId(Long packageId);
+
/**
* 根据套餐 ID 查询数量
*
diff --git a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/PackageServiceImpl.java b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/PackageServiceImpl.java
index 13afb3c97e..bd14f16dd9 100644
--- a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/PackageServiceImpl.java
+++ b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/PackageServiceImpl.java
@@ -20,6 +20,9 @@
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import top.continew.admin.auth.api.AuthPolicyWriteLocked;
+import top.continew.admin.tenant.auth.PackageTenantPolicyLockTargetResolver;
import top.continew.admin.common.base.service.BaseServiceImpl;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.tenant.mapper.PackageMapper;
@@ -64,17 +67,20 @@ public Long create(PackageReq req) {
}
@Override
+ @Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(PackageTenantPolicyLockTargetResolver.class)
public void update(PackageReq req, Long id) {
this.checkNameRepeat(req.getName(), id);
// 更新信息
super.update(req, id);
// 保存套餐和菜单关联
boolean isSaveMenuSuccess = packageMenuService.add(req.getMenuIds(), id);
- if (!isSaveMenuSuccess) {
- return;
+ if (isSaveMenuSuccess) {
+ // 更新租户菜单
+ tenantService.updateTenantMenu(req.getMenuIds(), id);
}
- // 更新租户菜单
- tenantService.updateTenantMenu(req.getMenuIds(), id);
+ // 套餐状态、权限或其他配置变化后,关联租户的旧会话统一重新认证。
+ tenantService.invalidateSessionsByPackageId(id);
}
@Override
diff --git a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/TenantServiceImpl.java b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/TenantServiceImpl.java
index 4ff079a916..4a002f2e32 100644
--- a/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/TenantServiceImpl.java
+++ b/continew-plugin/continew-plugin-tenant/src/main/java/top/continew/admin/tenant/service/impl/TenantServiceImpl.java
@@ -26,6 +26,8 @@
import me.ahoo.cosid.provider.IdGeneratorProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import top.continew.admin.auth.api.AuthPolicyWriteLocked;
+import top.continew.admin.auth.support.TenantArgumentPolicyLockTargetResolver;
import top.continew.admin.common.api.system.RoleApi;
import top.continew.admin.common.api.system.RoleMenuApi;
import top.continew.admin.common.api.tenant.TenantDataApi;
@@ -94,6 +96,20 @@ public Long create(TenantReq req) {
return id;
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(TenantArgumentPolicyLockTargetResolver.class)
+ public void update(TenantReq req, Long id) {
+ super.update(req, id);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(TenantArgumentPolicyLockTargetResolver.class)
+ public void delete(List ids) {
+ super.delete(ids);
+ }
+
@Override
public void beforeUpdate(TenantReq req, Long id) {
this.checkNameRepeat(req.getName(), id);
@@ -109,6 +125,7 @@ public void beforeUpdate(TenantReq req, Long id) {
public void afterUpdate(TenantReq req, TenantDO entity) {
RedisUtils
.deleteByPattern(TenantCacheConstants.TENANT_KEY_PREFIX + StringConstants.ASTERISK);
+ this.invalidateSessions(entity.getId());
}
@Override
@@ -150,10 +167,12 @@ public Long getIdByCode(String code) {
@Override
public void checkStatus(Long id) {
// 默认租户
- if (tenantExtensionProperties.getDefaultTenantId().equals(id)) {
+ if (tenantExtensionProperties.getDefaultTenantId() != null
+ && tenantExtensionProperties.getDefaultTenantId().equals(id)) {
return;
}
TenantDO tenant = this.getById(id);
+ CheckUtils.throwIfNull(tenant, "租户不存在");
CheckUtils.throwIfEqual(DisEnableStatusEnum.DISABLE, tenant.getStatus(), "租户已被禁用");
CheckUtils.throwIf(tenant.getExpireTime() != null && tenant.getExpireTime()
.isBefore(LocalDateTime.now(GlobalConstants.DEFAULT_ZONE_ID)), "租户已过期");
@@ -187,6 +206,11 @@ public void updateTenantMenu(List newMenuIds, Long packageId) {
RedisUtils.deleteByPattern(CacheConstants.ROLE_MENU_KEY_PREFIX + StringConstants.ASTERISK);
}
+ @Override
+ public void invalidateSessionsByPackageId(Long packageId) {
+ this.listIdByPackageId(packageId).forEach(this::invalidateSessions);
+ }
+
@Override
public Long countByPackageIds(List packageIds) {
return baseMapper.lambdaQuery().in(TenantDO::getPackageId, packageIds).count();
@@ -247,4 +271,9 @@ private List listIdByPackageId(Long id) {
.map(TenantDO::getId)
.toList();
}
+
+ private void invalidateSessions(Long tenantId) {
+ TenantUtils.execute(tenantId,
+ () -> tenantDataApiMap.forEach((key, value) -> value.invalidateSessions()));
+ }
}
diff --git a/continew-plugin/continew-plugin-tenant/src/test/java/top/continew/admin/tenant/config/DefaultTenantProviderTest.java b/continew-plugin/continew-plugin-tenant/src/test/java/top/continew/admin/tenant/config/DefaultTenantProviderTest.java
new file mode 100644
index 0000000000..898a97be83
--- /dev/null
+++ b/continew-plugin/continew-plugin-tenant/src/test/java/top/continew/admin/tenant/config/DefaultTenantProviderTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.tenant.config;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import top.continew.admin.common.config.TenantExtensionProperties;
+import top.continew.admin.tenant.service.TenantService;
+import top.continew.starter.extension.tenant.context.TenantContext;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 默认租户提供者测试:验证刷新/退出时请求入口忽略残留租户头,而业务代码显式切换的
+ * 会话租户不被覆盖。
+ *
+ * @author luoqiz
+ * @since 4.2.0
+ */
+class DefaultTenantProviderTest {
+
+ private TenantService tenantService;
+ private DefaultTenantProvider provider;
+
+ @BeforeEach
+ void setUp() {
+ TenantExtensionProperties properties = new TenantExtensionProperties();
+ properties.setDefaultTenantId(0L);
+ tenantService = mock(TenantService.class);
+ provider = new DefaultTenantProvider(properties, tenantService);
+ }
+
+ @AfterEach
+ void tearDown() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ @Test
+ void shouldIgnoreTenantHeaderAtRequestEntryOnRefresh() {
+ // 请求入口(verify=true):/auth/refresh 必须忽略前端残留的 X-Tenant-Id 请求头
+ setRequest("/api/auth/refresh", "2");
+ TenantContext context = provider.getByTenantId("2", true);
+ assertEquals(0L, context.getTenantId());
+ verify(tenantService, never()).checkStatus(any());
+ }
+
+ @Test
+ void shouldIgnoreTenantHeaderAtRequestEntryOnLogout() {
+ setRequest("/api/auth/logout", "2");
+ TenantContext context = provider.getByTenantId("2", true);
+ assertEquals(0L, context.getTenantId());
+ verify(tenantService, never()).checkStatus(any());
+ }
+
+ @Test
+ void shouldKeepExplicitTenantOnRefreshForBusinessSwitch() {
+ // 业务层显式切换(verify=false):TenantUtils.execute 传入的会话租户不得被覆盖
+ setRequest("/api/auth/refresh", "2");
+ TenantContext context = provider.getByTenantId("2", false);
+ assertEquals(2L, context.getTenantId());
+ verify(tenantService, never()).checkStatus(any());
+ }
+
+ @Test
+ void shouldResolveTenantFromHeaderForNormalRequest() {
+ setRequest("/api/system/user/page", "2");
+ TenantContext context = provider.getByTenantId("2", true);
+ assertEquals(2L, context.getTenantId());
+ verify(tenantService).checkStatus(2L);
+ }
+
+ @Test
+ void shouldReturnDefaultTenantForDefaultTenantId() {
+ setRequest("/api/system/user/page", "0");
+ TenantContext context = provider.getByTenantId("0", true);
+ assertEquals(0L, context.getTenantId());
+ verify(tenantService, never()).checkStatus(any());
+ }
+
+ private void setRequest(String uri, String tenantId) {
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", uri);
+ if (tenantId != null) {
+ request.addHeader("X-Tenant-Id", tenantId);
+ }
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ }
+}
diff --git a/continew-server/pom.xml b/continew-server/pom.xml
index dff3d74016..9fa158b097 100644
--- a/continew-server/pom.xml
+++ b/continew-server/pom.xml
@@ -22,6 +22,12 @@
continew-system
+
+
+ ${project.groupId}
+ continew-auth-refresh
+
+
${project.groupId}
@@ -93,6 +99,18 @@
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ false
+
+ **/ContiNewAdminApplicationTests.java
+
+
+
@@ -205,4 +223,4 @@
-
\ No newline at end of file
+
diff --git a/continew-server/src/main/java/top/continew/admin/config/log/DatabaseLogDao.java b/continew-server/src/main/java/top/continew/admin/config/log/DatabaseLogDao.java
index 4509c57f22..92d7d73c12 100644
--- a/continew-server/src/main/java/top/continew/admin/config/log/DatabaseLogDao.java
+++ b/continew-server/src/main/java/top/continew/admin/config/log/DatabaseLogDao.java
@@ -54,6 +54,7 @@
import top.continew.starter.web.model.R;
import java.time.LocalDateTime;
+import java.util.Collections;
import java.util.Map;
import java.util.Set;
@@ -93,7 +94,7 @@ public void add(LogRecord logRecord) {
// 保存记录
if (TenantContextHolder.isTenantEnabled()) {
// 异步无法获取租户 ID
- String tenantId = logRequest.getHeaders()
+ String tenantId = this.getRequestHeaders(logRequest)
.get(SpringUtil.getBean(TenantProperties.class).getTenantIdHeader());
if (StrUtil.isNotBlank(tenantId)) {
TenantUtils.execute(Long.parseLong(tenantId), () -> logMapper.insert(logDO));
@@ -112,8 +113,11 @@ public void add(LogRecord logRecord) {
private void setRequest(LogDO logDO, LogRequest logRequest) {
logDO.setRequestMethod(logRequest.getMethod());
logDO.setRequestUrl(logRequest.getUrl().toString());
- logDO.setRequestHeaders(JSONUtil.toJsonStr(logRequest.getHeaders()));
- logDO.setRequestBody(logRequest.getBody());
+ logDO.setRequestHeaders(JSONUtil.toJsonStr(this.getRequestHeaders(logRequest)));
+ String requestUri = URLUtil.getPath(logDO.getRequestUrl());
+ // 登录请求体仅在当前日志处理链路中用于解析操作人,禁止持久化加密密码报文。
+ logDO.setRequestBody(
+ AuthConstants.LOGIN_URI.equals(requestUri) ? null : logRequest.getBody());
logDO.setIp(logRequest.getIp());
logDO.setAddress(logRequest.getAddress());
logDO.setBrowser(logRequest.getBrowser());
@@ -127,13 +131,20 @@ private void setRequest(LogDO logDO, LogRequest logRequest) {
* @param logResponse 响应信息
*/
private void setResponse(LogDO logDO, LogResponse logResponse) {
+ if (logResponse == null) {
+ logDO.setStatusCode(HttpStatus.HTTP_INTERNAL_ERROR);
+ logDO.setStatus(LogStatusEnum.FAILURE);
+ return;
+ }
Map responseHeaders = logResponse.getHeaders();
+ responseHeaders = responseHeaders == null ? Collections.emptyMap() : responseHeaders;
logDO.setResponseHeaders(JSONUtil.toJsonStr(responseHeaders));
logDO.setTraceId(responseHeaders.get(traceProperties.getTraceIdName()));
String responseBody = logResponse.getBody();
logDO.setResponseBody(responseBody);
// 状态
Integer statusCode = logResponse.getStatus();
+ statusCode = statusCode == null ? HttpStatus.HTTP_INTERNAL_ERROR : statusCode;
logDO.setStatusCode(statusCode);
logDO.setStatus(statusCode >= HttpStatus.HTTP_BAD_REQUEST ? LogStatusEnum.FAILURE
: LogStatusEnum.SUCCESS);
@@ -156,31 +167,42 @@ private void setResponse(LogDO logDO, LogResponse logResponse) {
private void setCreateUser(LogDO logDO, LogRequest logRequest, LogResponse logResponse) {
String requestUri = URLUtil.getPath(logDO.getRequestUrl());
// 解析退出接口信息
- String responseBody = logResponse.getBody();
- if (requestUri.startsWith(AuthConstants.LOGOUT_URI) && StrUtil.isNotBlank(responseBody)) {
+ String responseBody = logResponse == null ? null : logResponse.getBody();
+ if (AuthConstants.LOGOUT_URI.equals(requestUri) && StrUtil.isNotBlank(responseBody)) {
R result = JSONUtil.toBean(responseBody, R.class);
- logDO.setCreateUser(Convert.toLong(result.getData(), null));
+ Long userId = Convert.toLong(result.getData(), null);
+ if (userId != null && userId > 0) {
+ logDO.setCreateUser(userId);
+ }
return;
}
// 解析登录接口信息
- if (requestUri.startsWith(AuthConstants.LOGIN_URI)
+ if (AuthConstants.LOGIN_URI.equals(requestUri)
&& LogStatusEnum.SUCCESS.equals(logDO.getStatus())) {
String requestBody = logRequest.getBody();
- logDO.setDescription(
- JSONUtil.toBean(requestBody, LoginReq.class).getAuthType().getDescription() + "登录");
+ LoginReq loginReq = ExceptionUtils.exToNull(() -> JSONUtil.toBean(requestBody,
+ LoginReq.class));
+ AuthTypeEnum authType = loginReq == null ? null : loginReq.getAuthType();
+ // 登录接口已配置脱敏,不同日志实现或旧管理端可能没有保留 authType。操作日志
+ // 不能因附加信息不完整而覆盖真实登录结果。
+ if (authType == null) {
+ logDO.setDescription("登录");
+ return;
+ }
+ logDO.setDescription(authType.getDescription() + "登录");
// 解析账号登录用户为操作人
- if (requestBody.contains(AuthTypeEnum.ACCOUNT.getValue())) {
+ if (AuthTypeEnum.ACCOUNT.equals(authType)) {
AccountLoginReq authReq = JSONUtil.toBean(requestBody, AccountLoginReq.class);
logDO.setCreateUser(
ExceptionUtils.exToNull(() -> userService.getByUsername(authReq.getUsername())
.getId()));
return;
- } else if (requestBody.contains(AuthTypeEnum.EMAIL.getValue())) {
+ } else if (AuthTypeEnum.EMAIL.equals(authType)) {
EmailLoginReq authReq = JSONUtil.toBean(requestBody, EmailLoginReq.class);
logDO.setCreateUser(ExceptionUtils
.exToNull(() -> userService.getByEmail(authReq.getEmail()).getId()));
return;
- } else if (requestBody.contains(AuthTypeEnum.PHONE.getValue())) {
+ } else if (AuthTypeEnum.PHONE.equals(authType)) {
PhoneLoginReq authReq = JSONUtil.toBean(requestBody, PhoneLoginReq.class);
logDO.setCreateUser(ExceptionUtils
.exToNull(() -> userService.getByPhone(authReq.getPhone()).getId()));
@@ -188,7 +210,7 @@ private void setCreateUser(LogDO logDO, LogRequest logRequest, LogResponse logRe
}
}
// 解析 Token 信息
- Map requestHeaders = logRequest.getHeaders();
+ Map requestHeaders = this.getRequestHeaders(logRequest);
String headerName = HttpHeaders.AUTHORIZATION;
boolean isContainsAuthHeader =
CollUtil.containsAny(requestHeaders.keySet(), Set.of(headerName, headerName
@@ -197,9 +219,19 @@ private void setCreateUser(LogDO logDO, LogRequest logRequest, LogResponse logRe
String authorization =
requestHeaders.getOrDefault(headerName, requestHeaders.get(headerName
.toLowerCase()));
+ if (StrUtil.isBlank(authorization)) {
+ return;
+ }
String token = authorization.replace(SaManager.getConfig()
.getTokenPrefix() + StringConstants.SPACE, StringConstants.EMPTY);
logDO.setCreateUser(Convert.toLong(StpUtil.getLoginIdByToken(token)));
}
}
+
+ private Map getRequestHeaders(LogRequest logRequest) {
+ if (logRequest == null || logRequest.getHeaders() == null) {
+ return Collections.emptyMap();
+ }
+ return logRequest.getHeaders();
+ }
}
diff --git a/continew-server/src/main/java/top/continew/admin/config/satoken/AccessTokenSecretValidator.java b/continew-server/src/main/java/top/continew/admin/config/satoken/AccessTokenSecretValidator.java
new file mode 100644
index 0000000000..1305458265
--- /dev/null
+++ b/continew-server/src/main/java/top/continew/admin/config/satoken/AccessTokenSecretValidator.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.config.satoken;
+
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/** 启动时校验认证密钥,禁止生产认证使用弱密钥或复用密钥。 */
+@Component
+@RequiredArgsConstructor
+public class AccessTokenSecretValidator {
+
+ private static final int MIN_SECRET_LENGTH = 32;
+
+ private final Environment environment;
+
+ @PostConstruct
+ public void validate() {
+ String accessSecret = this.bindSecret("sa-token.jwt-secret-key",
+ "Access Token JWT 密钥");
+ String refreshSecret = this.bindSecret("auth.refresh-token.secret", "Refresh Token 密钥");
+ if (MessageDigest.isEqual(accessSecret.getBytes(StandardCharsets.UTF_8),
+ refreshSecret.getBytes(StandardCharsets.UTF_8))) {
+ throw new IllegalStateException(
+ "Access Token 与 Refresh Token 密钥必须相互独立,禁止复用同一密钥");
+ }
+ }
+
+ private String bindSecret(String propertyName, String displayName) {
+ String secret = Binder.get(environment).bind(propertyName, String.class).orElse("");
+ if (secret.length() < MIN_SECRET_LENGTH) {
+ throw new IllegalStateException(displayName + "长度不能少于 32 个字符");
+ }
+ return secret;
+ }
+}
diff --git a/continew-server/src/main/java/top/continew/admin/config/satoken/SaExtensionInterceptor.java b/continew-server/src/main/java/top/continew/admin/config/satoken/SaExtensionInterceptor.java
index aac02aeb6f..15352cbe2c 100644
--- a/continew-server/src/main/java/top/continew/admin/config/satoken/SaExtensionInterceptor.java
+++ b/continew-server/src/main/java/top/continew/admin/config/satoken/SaExtensionInterceptor.java
@@ -24,13 +24,19 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;
+import top.continew.admin.auth.api.AccessSessionValidator;
+import top.continew.admin.auth.constant.AuthConstants;
import top.continew.admin.common.context.UserContext;
import top.continew.admin.common.context.UserContextHolder;
+import top.continew.admin.common.context.UserExtraContext;
+import top.continew.admin.open.util.OpenApiUtils;
import top.continew.starter.core.util.ServletUtils;
import top.continew.starter.extension.tenant.context.TenantContextHolder;
import top.continew.starter.json.jackson.util.JSONUtils;
import top.continew.starter.web.model.R;
+import java.util.Objects;
+
/**
* Sa-Token 扩展拦截器
*
@@ -40,8 +46,12 @@
@Slf4j
public class SaExtensionInterceptor extends SaInterceptor {
- public SaExtensionInterceptor(SaParamFunction auth) {
+ private final AccessSessionValidator accessSessionValidator;
+
+ public SaExtensionInterceptor(SaParamFunction auth,
+ AccessSessionValidator accessSessionValidator) {
super(auth);
+ this.accessSessionValidator = accessSessionValidator;
}
@Override
@@ -49,28 +59,65 @@ public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
boolean flag = super.preHandle(request, response, handler);
- if (!flag || !StpUtil.isLogin()) {
+ // AK/SK 请求已经由签名认证完成,不属于交互式登录 Session。
+ if (!flag || OpenApiUtils.isSignParamExists() || !StpUtil.isLogin()) {
return flag;
}
// 设置上下文
UserContext userContext = UserContextHolder.getContext();
+ // Sa-Token 只校验 Access Token 自身有效性;这里补充 Session 状态校验,确保用户、
+ // 租户或客户端强制下线后,尚未自然过期的 Access Token 也不能继续访问。
+ // 登录、刷新和退出都由认证服务依据请求体、Cookie 或令牌映射自行确定租户,不能
+ // 使用当前请求残留的租户上下文做普通业务接口的跨租户校验。
+ boolean authRequest = this.isAuthRequest(request);
+ if (!authRequest) {
+ // 失效原因区分被强退、被顶下线和普通失效,让客户端能给出准确的重新登录提示。
+ String invalidReason = accessSessionValidator.getInvalidReason(StpUtil.getTokenValue());
+ if (invalidReason != null) {
+ // preHandle 返回 false 时 afterCompletion 不再回调(Spring 只对已通过的
+ // 拦截器回调),若不在此清除,上一请求写入的 UserContext 线程本地会残留
+ // 在 Tomcat 池化线程上,被下一请求复用造成跨用户上下文串用。
+ UserContextHolder.clearContext();
+ R r = R.fail(String.valueOf(HttpStatus.UNAUTHORIZED.value()), invalidReason);
+ response.setStatus(HttpStatus.UNAUTHORIZED.value());
+ ServletUtils.writeJSON(response, JSONUtils.toJsonStr(r));
+ return false;
+ }
+ }
+ if (authRequest) {
+ return true;
+ }
if (userContext == null) {
return true;
}
// 检查用户租户权限
if (TenantContextHolder.isTenantEnabled()) {
- Long userTenantId = userContext.getTenantId();
+ // UserContext 保存在用户级 SaSession,同一用户多租户并发登录时会被后一次
+ // 登录覆盖;租户边界必须使用当前 Access Token 自身的额外上下文。
+ UserExtraContext extraContext = UserContextHolder.getExtraContext();
+ Long userTenantId = extraContext.getTenantId();
Long tenantId = TenantContextHolder.getTenantId();
- if (!userTenantId.equals(tenantId)) {
+ if (!Objects.equals(userTenantId, tenantId)) {
+ // 见上方 401 短路的注释:必须在此清除线程本地,避免残留上下文复用。
+ UserContextHolder.clearContext();
R r = R.fail(String.valueOf(HttpStatus.FORBIDDEN.value()), "您当前没有访问该租户的权限");
+ response.setStatus(HttpStatus.FORBIDDEN.value());
ServletUtils.writeJSON(response, JSONUtils.toJsonStr(r));
return false;
}
}
- UserContextHolder.getExtraContext();
return true;
}
+ private boolean isAuthRequest(HttpServletRequest request) {
+ String requestUri = request.getRequestURI();
+ String contextPath = request.getContextPath();
+ String path = requestUri.substring(contextPath.length());
+ return AuthConstants.LOGIN_URI.equals(path)
+ || AuthConstants.REFRESH_URI.equals(path)
+ || AuthConstants.LOGOUT_URI.equals(path);
+ }
+
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
diff --git a/continew-server/src/main/java/top/continew/admin/config/satoken/SaTokenConfiguration.java b/continew-server/src/main/java/top/continew/admin/config/satoken/SaTokenConfiguration.java
index bc97786880..735803d686 100644
--- a/continew-server/src/main/java/top/continew/admin/config/satoken/SaTokenConfiguration.java
+++ b/continew-server/src/main/java/top/continew/admin/config/satoken/SaTokenConfiguration.java
@@ -37,6 +37,7 @@
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.AnnotationUtils;
import top.continew.admin.common.config.crud.CrudApiPermissionPrefixCache;
+import top.continew.admin.auth.api.AccessSessionValidator;
import top.continew.admin.common.context.UserContext;
import top.continew.admin.common.context.UserContextHolder;
import top.continew.admin.open.sign.OpenApiSignTemplate;
@@ -69,6 +70,7 @@ public class SaTokenConfiguration {
private final LoginPasswordProperties loginPasswordProperties;
private final OpenApiSignTemplate signTemplate;
private final ApplicationContext applicationContext;
+ private final AccessSessionValidator accessSessionValidator;
/**
* Sa-Token 权限认证配置
@@ -105,7 +107,7 @@ public SaInterceptor saInterceptor() {
}
UserContext userContext = UserContextHolder.getContext();
CheckUtils.throwIf(userContext.isPasswordExpired(), "密码已过期,请修改密码");
- }));
+ }), accessSessionValidator);
}
/**
diff --git a/continew-server/src/main/resources/config/application-dev.yml b/continew-server/src/main/resources/config/application-dev.yml
index b0daf41eb6..e304d26625 100644
--- a/continew-server/src/main/resources/config/application-dev.yml
+++ b/continew-server/src/main/resources/config/application-dev.yml
@@ -3,6 +3,22 @@ application:
# URL(跨域配置默认放行此 URL,第三方登录回调默认使用此 URL 为前缀,请注意更改为你实际的前端 URL)
url: http://localhost:5173
+--- ### 认证配置
+auth:
+ ## Refresh Token 配置。浏览器使用 HttpOnly Cookie,App / 小程序使用 BODY 模式。
+ refresh-token:
+ # 团队共享的开发环境固定密钥;仅用于 dev,禁止与 Access Token 的 JWT 密钥复用。
+ secret: 80B38761234D4D108BAF2022065FE6E6
+ # 可配置精确 Origin 或 http[s]://*.example.com 子域通配符。
+ cookie-allowed-origins:
+ - http://localhost:5173
+ - http://localhost:5777
+
+--- ### Sa-Token 配置
+sa-token:
+ # 团队共享的开发环境固定密钥;生产环境必须通过环境变量覆盖。
+ jwt-secret-key: C1C626D887634D838EDB8D3C5391E3DC
+
--- ### 服务器配置
server:
# HTTP 端口(默认 8080)
diff --git a/continew-server/src/main/resources/config/application-prod.yml b/continew-server/src/main/resources/config/application-prod.yml
index 430c06a77f..849298894b 100644
--- a/continew-server/src/main/resources/config/application-prod.yml
+++ b/continew-server/src/main/resources/config/application-prod.yml
@@ -5,6 +5,21 @@ application:
# 是否为生产环境
production: true
+--- ### 认证配置
+auth:
+ ## Refresh Token 配置。浏览器使用 HttpOnly Cookie,App / 小程序使用 BODY 模式。
+ refresh-token:
+ # 生产环境必须显式提供独立的高熵密钥,不允许回退复用 JWT 密钥。
+ secret: ${REFRESH_TOKEN_SECRET}
+ # 生产环境只允许通过 HTTPS 发送长期凭证。
+ cookie-secure: true
+ # __Host- 前缀禁止 Domain 属性并强制 Path=/,可防止子域写入同名 Cookie。
+ cookie-name: __Host-refresh_token
+ # 覆盖网关前缀及前后端分离部署,Refresh Token Cookie 在站点根路径生效。
+ cookie-path: /
+ # Cookie 请求来源可填精确 Origin 或 http[s]://*.example.com 子域通配符,不继承通用 CORS 配置。
+ cookie-allowed-origins:
+ - ${application.url}
--- ### 服务器配置
server:
# HTTP 端口(默认 8080)
@@ -244,18 +259,21 @@ continew-starter.justauth:
type: REDIS
--- ### Sa-Token 扩展配置
-sa-token.extension:
- # 安全配置:排除(放行)路径配置
- security.excludes:
- - /error
- # 静态资源
- - /*.html
- - /*/*.html
- - /*/*.css
- - /*/*.js
- - /websocket/**
- # 本地存储资源
- - /file/**
+sa-token:
+ # 生产环境必须显式设置 Access Token JWT 密钥,不允许继承开发默认值。
+ jwt-secret-key: ${ACCESS_TOKEN_JWT_SECRET}
+ extension:
+ # 安全配置:排除(放行)路径配置
+ security.excludes:
+ - /error
+ # 静态资源
+ - /*.html
+ - /*/*.html
+ - /*/*.css
+ - /*/*.js
+ - /websocket/**
+ # 本地存储资源
+ - /file/**
--- ### Snail Job 配置
snail-job:
diff --git a/continew-server/src/main/resources/config/application.yml b/continew-server/src/main/resources/config/application.yml
index 3aaa9d9c8a..a2e030a805 100644
--- a/continew-server/src/main/resources/config/application.yml
+++ b/continew-server/src/main/resources/config/application.yml
@@ -275,7 +275,7 @@ sa-token:
# 是否输出操作日志
is-log: false
# JWT 秘钥
- jwt-secret-key: asdasdasifhueuiwyurfewbfjsdafjk
+ jwt-secret-key: ${ACCESS_TOKEN_JWT_SECRET}
## 扩展配置
extension:
enabled: true
@@ -356,6 +356,35 @@ cosid:
--- ### 认证配置
auth:
+ ## Refresh Token 配置。浏览器使用 HttpOnly Cookie,App / 小程序使用 BODY 模式。
+ refresh-token:
+ # 必须设置独立的高熵随机值,禁止与 Access Token 的 JWT 密钥复用。
+ secret: ${REFRESH_TOKEN_SECRET}
+ cookie-name: refresh_token
+ # 必须覆盖前端代理路径(如 /api、/dev-api),否则浏览器刷新请求不会携带 Cookie。
+ cookie-path: /
+ # 本地开发通常使用 HTTP;生产配置在 application-prod.yml 中覆盖为 true。
+ cookie-secure: false
+ cookie-same-site: Lax
+ # Cookie 来源使用独立白名单;可填精确 Origin 或 http[s]://*.example.com 子域通配符。
+ # 空列表仅允许同源,不继承通用 CORS 通配符。
+ cookie-allowed-origins: []
+ rotation-grace-period: 5
+ ip-rate-limit: 60
+ ip-rate-limit-period: 60
+ session-rate-limit: 10
+ session-rate-limit-period: 60
+ # 仅在网关地址和跳数均显式配置时解析 X-Forwarded-For;默认使用连接对端地址防伪造。
+ # 警告:经 Nginx / 网关部署必须显式配置 trusted-proxy-*,否则所有用户共享网关这一个
+ # IP 的限流桶(ip-rate-limit 60 次/分钟变全站共享),且 /auth/refresh 为匿名接口,
+ # 公共桶易被填满导致全站 429。
+ trusted-proxy-addresses: []
+ trusted-proxy-hops: 0
+ # 热路径会话校验本地缓存:命中后每个请求 0 次 Redis;撤销经广播子秒级失效,2s TTL 兜底。
+ access-session-cache-enabled: true
+ # 会话失效广播 Topic(默认按应用名隔离:auth:access-session-invalid:{spring.application.name},
+ # 同一服务多副本共享、不同服务互不串扰);多服务显式共享会话域时才覆盖为公共 Topic。
+ # access-session-invalid-topic: auth:access-session-invalid:${spring.application.name}
## 密码配置
password:
excludes:
diff --git a/continew-server/src/main/resources/db/changelog/db.changelog-master.yaml b/continew-server/src/main/resources/db/changelog/db.changelog-master.yaml
index 92ef3a642c..7b249bf2f9 100644
--- a/continew-server/src/main/resources/db/changelog/db.changelog-master.yaml
+++ b/continew-server/src/main/resources/db/changelog/db.changelog-master.yaml
@@ -23,4 +23,4 @@ databaseChangeLog:
# - include:
# file: db/changelog/postgresql/plugin/plugin_schedule.sql
# - include:
-# file: db/changelog/postgresql/plugin/plugin_generator.sql
\ No newline at end of file
+# file: db/changelog/postgresql/plugin/plugin_generator.sql
diff --git a/continew-server/src/main/resources/db/changelog/mysql/main_table.sql b/continew-server/src/main/resources/db/changelog/mysql/main_table.sql
index ef53f5cf5f..45e04f7756 100644
--- a/continew-server/src/main/resources/db/changelog/mysql/main_table.sql
+++ b/continew-server/src/main/resources/db/changelog/mysql/main_table.sql
@@ -406,3 +406,27 @@ CREATE TABLE IF NOT EXISTS `sys_sms_log` (
INDEX `idx_config_id`(`config_id`),
INDEX `idx_create_user`(`create_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信日志表';
+
+-- changeset luoqiz:refresh-token-timeout-client-field-mysql
+-- comment 客户端 Refresh Token 策略字段
+-- preconditions onFail:MARK_RAN
+-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'sys_client' AND column_name = 'refresh_token_timeout'
+ALTER TABLE `sys_client`
+ ADD COLUMN `refresh_token_timeout` bigint NOT NULL DEFAULT 2592000
+ COMMENT 'Refresh Token 绝对有效期(单位:秒)';
+
+-- changeset luoqiz:refresh-token-mode-client-field-mysql
+-- comment 客户端 Refresh Token 策略字段
+-- preconditions onFail:MARK_RAN
+-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'sys_client' AND column_name = 'refresh_token_mode'
+ALTER TABLE `sys_client`
+ ADD COLUMN `refresh_token_mode` varchar(16) NOT NULL DEFAULT 'COOKIE'
+ COMMENT 'Refresh Token 传输模式(COOKIE:浏览器;BODY:App/小程序)';
+
+-- changeset luoqiz:refresh-token-mode-by-client-type-mysql
+-- comment 存量 App/小程序客户端默认使用 BODY 传输 Refresh Token,避免升级后无法获取 Refresh Token
+-- preconditions onFail:MARK_RAN
+-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'sys_client' AND column_name = 'refresh_token_mode'
+UPDATE `sys_client`
+ SET `refresh_token_mode` = 'BODY'
+ WHERE `client_type` IN ('ANDROID', 'XCX');
diff --git a/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql b/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql
index 726455acd2..c3e139ec23 100644
--- a/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql
+++ b/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql
@@ -680,3 +680,31 @@ COMMENT ON COLUMN "sys_sms_log"."res_msg" IS '返回数据';
COMMENT ON COLUMN "sys_sms_log"."create_user" IS '创建人';
COMMENT ON COLUMN "sys_sms_log"."create_time" IS '创建时间';
COMMENT ON TABLE "sys_sms_log" IS '短信日志表';
+
+-- changeset luoqiz:refresh-token-timeout-client-field-postgresql
+-- comment 客户端 Refresh Token 策略字段
+-- preconditions onFail:MARK_RAN
+-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'sys_client' AND column_name = 'refresh_token_timeout'
+ALTER TABLE "sys_client"
+ ADD COLUMN "refresh_token_timeout" int8 NOT NULL DEFAULT 2592000;
+
+COMMENT ON COLUMN "sys_client"."refresh_token_timeout"
+ IS 'Refresh Token 绝对有效期(单位:秒)';
+
+-- changeset luoqiz:refresh-token-mode-client-field-postgresql
+-- comment 客户端 Refresh Token 策略字段
+-- preconditions onFail:MARK_RAN
+-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'sys_client' AND column_name = 'refresh_token_mode'
+ALTER TABLE "sys_client"
+ ADD COLUMN "refresh_token_mode" varchar(16) NOT NULL DEFAULT 'COOKIE';
+
+COMMENT ON COLUMN "sys_client"."refresh_token_mode"
+ IS 'Refresh Token 传输模式(COOKIE:浏览器;BODY:App/小程序)';
+
+-- changeset luoqiz:refresh-token-mode-by-client-type-postgresql
+-- comment 存量 App/小程序客户端默认使用 BODY 传输 Refresh Token,避免升级后无法获取 Refresh Token
+-- preconditions onFail:MARK_RAN
+-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'sys_client' AND column_name = 'refresh_token_mode'
+UPDATE "sys_client"
+ SET "refresh_token_mode" = 'BODY'
+ WHERE "client_type" IN ('ANDROID', 'XCX');
diff --git a/continew-server/src/test/java/top/continew/admin/auth/service/OnlineUserServiceImplTest.java b/continew-server/src/test/java/top/continew/admin/auth/service/OnlineUserServiceImplTest.java
new file mode 100644
index 0000000000..b3596dc954
--- /dev/null
+++ b/continew-server/src/test/java/top/continew/admin/auth/service/OnlineUserServiceImplTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import top.continew.admin.auth.model.SessionView;
+import top.continew.admin.auth.model.query.OnlineUserQuery;
+import top.continew.admin.auth.model.resp.OnlineUserResp;
+import top.continew.admin.auth.service.impl.OnlineUserServiceImpl;
+import top.continew.starter.extension.tenant.context.TenantContextHolder;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+/** 在线用户以 Refresh Session 为事实源测试。 */
+class OnlineUserServiceImplTest {
+
+ @Test
+ void shouldListRefreshSessionAfterAccessTokenExpires() {
+ SessionInvalidationService sessionInvalidationService =
+ mock(SessionInvalidationService.class);
+ SessionQueryService sessionQueryService = mock(SessionQueryService.class);
+ SessionView session = new SessionView();
+ session.setSessionId("session-1");
+ session.setUserId(1L);
+ session.setUsername("tester");
+ session.setNickname("Tester");
+ session.setClientId("web");
+ session.setClientType("WEB");
+ session.setCreatedAt(1_767_262_400_000L);
+ session.setLastRefreshAt(1_767_266_000_000L);
+ when(sessionQueryService.listSessions(null)).thenReturn(List.of(session));
+
+ OnlineUserServiceImpl service = new OnlineUserServiceImpl(sessionInvalidationService,
+ sessionQueryService);
+ try (MockedStatic tenantHolder = mockStatic(
+ TenantContextHolder.class)) {
+ tenantHolder.when(TenantContextHolder::isTenantEnabled).thenReturn(false);
+
+ List result = service.list(new OnlineUserQuery());
+
+ assertEquals(1, result.size());
+ assertEquals("session-1", result.get(0).getSessionId());
+ assertEquals(toLocalDateTime(session.getCreatedAt()), result.get(0).getLoginTime());
+ assertEquals(toLocalDateTime(session.getLastRefreshAt()),
+ result.get(0).getLastRefreshTime());
+ }
+ }
+
+ private LocalDateTime toLocalDateTime(long epochMilli) {
+ return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), ZoneId.systemDefault());
+ }
+}
diff --git a/continew-server/src/test/java/top/continew/admin/config/satoken/SaExtensionInterceptorTest.java b/continew-server/src/test/java/top/continew/admin/config/satoken/SaExtensionInterceptorTest.java
new file mode 100644
index 0000000000..f025af1210
--- /dev/null
+++ b/continew-server/src/test/java/top/continew/admin/config/satoken/SaExtensionInterceptorTest.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.config.satoken;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.context.SaTokenContextForThreadLocal;
+import cn.dev33.satoken.servlet.model.SaRequestForServlet;
+import cn.dev33.satoken.servlet.model.SaResponseForServlet;
+import cn.dev33.satoken.servlet.model.SaStorageForServlet;
+import cn.dev33.satoken.session.SaSession;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.extra.spring.SpringUtil;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.feiniaojin.gracefulresponse.api.ResponseStatusFactory;
+import com.feiniaojin.gracefulresponse.defaults.DefaultResponseStatus;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import top.continew.admin.auth.api.AccessSessionValidator;
+import top.continew.admin.common.context.UserContext;
+import top.continew.admin.common.context.UserContextHolder;
+import top.continew.starter.extension.tenant.context.TenantContextHolder;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+
+/**
+ * SaExtensionInterceptor 短路路径线程本地清理测试。
+ *
+ * 回归 C1:preHandle 返回 false 时 afterCompletion 不再回调,若不在短路前清除
+ * UserContext 线程本地,池化线程会残留上一请求的上下文被下一请求复用。断言方式是
+ * 短路后再次读取上下文:若已清除则重新走 {@link StpUtil#getSession()}(调用 2 次),
+ * 未清除则命中线程本地(仅 1 次)。
+ */
+class SaExtensionInterceptorTest {
+
+ private AccessSessionValidator accessSessionValidator;
+ private SaExtensionInterceptor interceptor;
+ private MockedStatic stpUtil;
+ private UserContext cachedContext;
+
+ @BeforeEach
+ void setUp() {
+ SaManager.setSaTokenContext(new SaTokenContextForThreadLocal());
+ accessSessionValidator = mock(AccessSessionValidator.class);
+ interceptor = new SaExtensionInterceptor(handle -> {
+ }, accessSessionValidator);
+ stpUtil = mockStatic(StpUtil.class);
+ stpUtil.when(StpUtil::isLogin).thenReturn(true);
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("access-token");
+ cachedContext = new UserContext();
+ SaSession saSession = mock(SaSession.class);
+ when(saSession.getModel(eq(SaSession.USER), eq(UserContext.class))).thenReturn(
+ cachedContext);
+ stpUtil.when(StpUtil::getSession).thenReturn(saSession);
+ }
+
+ @AfterEach
+ void tearDown() {
+ stpUtil.close();
+ UserContextHolder.clearContext();
+ SaManager.setSaTokenContext(null);
+ }
+
+ @Test
+ void shouldClearThreadLocalBefore401ShortCircuit() throws Exception {
+ when(accessSessionValidator.getInvalidReason("access-token")).thenReturn("已强制下线");
+ try (MockedStatic springUtil = mockStatic(SpringUtil.class)) {
+ ResponseStatusFactory factory = this.statusFactory();
+ springUtil.when(() -> SpringUtil.getBean(ResponseStatusFactory.class))
+ .thenReturn(factory);
+ springUtil.when(() -> SpringUtil.getBean(ObjectMapper.class))
+ .thenReturn(new ObjectMapper());
+ try (MockedStatic tenantHolder = mockStatic(
+ TenantContextHolder.class)) {
+ tenantHolder.when(TenantContextHolder::isTenantEnabled).thenReturn(false);
+ MockHttpServletRequest request = this.request("/system/user");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean flag = interceptor.preHandle(request, response, new Object());
+
+ assertFalse(flag);
+ assertEquals(401, response.getStatus());
+ // preHandle 内已写入过一次上下文(getContext),短路后必须清除;若残留,
+ // 第二次读取会命中线程本地(getSession 仅 1 次),清除后为 2 次。
+ UserContextHolder.getContext();
+ stpUtil.verify(() -> StpUtil.getSession(), times(2));
+ }
+ }
+ }
+
+ @Test
+ void shouldClearThreadLocalBefore403ShortCircuit() throws Exception {
+ when(accessSessionValidator.getInvalidReason("access-token")).thenReturn(null);
+ try (MockedStatic springUtil = mockStatic(SpringUtil.class)) {
+ ResponseStatusFactory factory = this.statusFactory();
+ springUtil.when(() -> SpringUtil.getBean(ResponseStatusFactory.class))
+ .thenReturn(factory);
+ springUtil.when(() -> SpringUtil.getBean(ObjectMapper.class))
+ .thenReturn(new ObjectMapper());
+ try (MockedStatic tenantHolder = mockStatic(
+ TenantContextHolder.class)) {
+ tenantHolder.when(TenantContextHolder::isTenantEnabled).thenReturn(true);
+ tenantHolder.when(TenantContextHolder::getTenantId).thenReturn(2L);
+ stpUtil.when(() -> StpUtil.getExtra("access-token", "tenantId")).thenReturn(1L);
+ MockHttpServletRequest request = this.request("/system/user");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean flag = interceptor.preHandle(request, response, new Object());
+
+ assertFalse(flag);
+ assertEquals(403, response.getStatus());
+ UserContextHolder.getContext();
+ stpUtil.verify(() -> StpUtil.getSession(), times(2));
+ }
+ }
+ }
+
+ @Test
+ void shouldClearThreadLocalInAfterCompletionOnNormalPath() throws Exception {
+ when(accessSessionValidator.getInvalidReason("access-token")).thenReturn(null);
+ try (MockedStatic springUtil = mockStatic(SpringUtil.class)) {
+ ResponseStatusFactory factory = this.statusFactory();
+ springUtil.when(() -> SpringUtil.getBean(ResponseStatusFactory.class))
+ .thenReturn(factory);
+ springUtil.when(() -> SpringUtil.getBean(ObjectMapper.class))
+ .thenReturn(new ObjectMapper());
+ try (MockedStatic tenantHolder = mockStatic(
+ TenantContextHolder.class)) {
+ tenantHolder.when(TenantContextHolder::isTenantEnabled).thenReturn(false);
+ MockHttpServletRequest request = this.request("/system/user");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean flag = interceptor.preHandle(request, response, new Object());
+ interceptor.afterCompletion(request, response, new Object(), null);
+
+ assertTrue(flag);
+ UserContextHolder.getContext();
+ stpUtil.verify(() -> StpUtil.getSession(), times(2));
+ }
+ }
+ }
+
+ private ResponseStatusFactory statusFactory() {
+ ResponseStatusFactory factory = mock(ResponseStatusFactory.class);
+ when(factory.defaultSuccess()).thenReturn(new DefaultResponseStatus("0", "操作成功"));
+ when(factory.defaultError()).thenReturn(new DefaultResponseStatus("1", "操作失败"));
+ return factory;
+ }
+
+ private MockHttpServletRequest request(String uri) {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setRequestURI(uri);
+ request.setScheme("https");
+ request.setServerName("admin.example");
+ request.setServerPort(443);
+ request.setRemoteAddr("127.0.0.1");
+ SaManager.getSaTokenContext()
+ .setContext(new SaRequestForServlet(request), new SaResponseForServlet(
+ new MockHttpServletResponse()), new SaStorageForServlet(request));
+ return request;
+ }
+}
diff --git a/continew-system/pom.xml b/continew-system/pom.xml
index 97805cf413..05475d48fb 100644
--- a/continew-system/pom.xml
+++ b/continew-system/pom.xml
@@ -22,10 +22,16 @@
continew-common
+
+
+ ${project.groupId}
+ continew-auth-refresh
+
+
org.dromara.sms4j
sms4j-spring-boot-starter
-
\ No newline at end of file
+
diff --git a/continew-system/src/main/java/top/continew/admin/auth/AbstractLoginHandler.java b/continew-system/src/main/java/top/continew/admin/auth/AbstractLoginHandler.java
index db03877848..1852f4672d 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/AbstractLoginHandler.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/AbstractLoginHandler.java
@@ -16,40 +16,20 @@
package top.continew.admin.auth;
-import cn.dev33.satoken.stp.StpUtil;
-import cn.dev33.satoken.stp.parameter.SaLoginParameter;
-import cn.dev33.satoken.stp.parameter.enums.SaLogoutMode;
-import cn.dev33.satoken.stp.parameter.enums.SaReplacedRange;
-import cn.hutool.core.bean.BeanUtil;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
-import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import top.continew.admin.auth.model.req.LoginReq;
import top.continew.admin.auth.model.resp.LoginResp;
-import top.continew.admin.common.context.RoleContext;
-import top.continew.admin.common.context.UserContext;
-import top.continew.admin.common.context.UserContextHolder;
-import top.continew.admin.common.context.UserExtraContext;
-import top.continew.admin.common.enums.DisEnableStatusEnum;
-import top.continew.admin.system.model.entity.DeptDO;
+import top.continew.admin.auth.service.AuthTokenService;
import top.continew.admin.system.model.entity.user.UserDO;
import top.continew.admin.system.model.resp.ClientResp;
-import top.continew.admin.system.service.DeptService;
import top.continew.admin.system.service.OptionService;
import top.continew.admin.system.service.RoleService;
import top.continew.admin.system.service.UserService;
-import top.continew.starter.core.util.ServletUtils;
-import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.core.util.validation.Validator;
import top.continew.starter.extension.tenant.context.TenantContextHolder;
-import top.continew.starter.extension.tenant.util.TenantUtils;
-
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.CompletableFuture;
-
-import static top.continew.admin.system.enums.PasswordPolicyEnum.PASSWORD_EXPIRATION_DAYS;
/**
* 登录处理器基类
@@ -68,13 +48,10 @@ public abstract class AbstractLoginHandler implements LoginH
@Resource
protected RoleService roleService;
@Resource
- private DeptService deptService;
- @Resource
- private ThreadPoolTaskExecutor threadPoolTaskExecutor;
+ protected AuthTokenService authTokenService;
protected static final String CAPTCHA_EXPIRED = "验证码已失效";
protected static final String CAPTCHA_ERROR = "验证码不正确";
- protected static final String CLIENT_ID = "clientId";
@Override
public void preLogin(T req, ClientResp client, HttpServletRequest request) {
@@ -89,73 +66,17 @@ public void postLogin(T req, ClientResp client, HttpServletRequest request) {
/**
* 认证
*
- * @param user 用户信息
- * @param client 客户端信息
+ * @param user 用户信息
+ * @param client 客户端信息
+ * @param request 请求对象
+ * @param response 响应对象
* @return 登录响应参数
*/
- protected LoginResp authenticate(UserDO user, ClientResp client) {
- // 获取权限、角色、密码过期天数
- Long userId = user.getId();
- Long tenantId = TenantContextHolder.getTenantId();
- CompletableFuture> permissionFuture = CompletableFuture.supplyAsync(() -> {
- Set permissions = new HashSet<>();
- TenantUtils.execute(tenantId,
- () -> permissions.addAll(roleService.listPermissionByUserId(userId)));
- return permissions;
- }, threadPoolTaskExecutor);
- CompletableFuture> roleFuture = CompletableFuture.supplyAsync(() -> {
- Set roles = new HashSet<>();
- TenantUtils.execute(tenantId, () -> roles.addAll(roleService.listByUserId(userId)));
- return roles;
- }, threadPoolTaskExecutor);
- CompletableFuture passwordExpirationDaysFuture =
- CompletableFuture.supplyAsync(() -> optionService
- .getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name()), threadPoolTaskExecutor);
- CompletableFuture.allOf(permissionFuture, roleFuture, passwordExpirationDaysFuture);
- UserContext userContext = new UserContext(permissionFuture.join(), roleFuture
- .join(), passwordExpirationDaysFuture.join());
- BeanUtil.copyProperties(user, userContext);
- // 设置登录配置参数
- SaLoginParameter loginParameter = new SaLoginParameter();
- loginParameter.setActiveTimeout(client.getActiveTimeout());
- loginParameter.setTimeout(client.getTimeout());
- loginParameter.setDeviceType(client.getClientType());
- loginParameter.setExtra(CLIENT_ID, client.getClientId());
- // 设置并发登录配置参数
- loginParameter.setIsConcurrent(client.getIsConcurrent());
- if (Boolean.FALSE.equals(client.getIsConcurrent())) {
- loginParameter
- .setReplacedRange(SaReplacedRange.valueOf(client.getReplacedRange().getValue()));
- }
- loginParameter.setMaxLoginCount(client.getMaxLoginCount());
- if (client.getMaxLoginCount() != -1) {
- loginParameter.setOverflowLogoutMode(
- SaLogoutMode.valueOf(client.getOverflowLogoutMode().getValue()));
- }
- userContext.setClientType(client.getClientType());
- userContext.setClientId(client.getClientId());
- userContext.setTenantId(tenantId);
- // 登录并缓存用户信息
- StpUtil.login(userContext.getId(), loginParameter.setExtraData(BeanUtil
- .beanToMap(new UserExtraContext(ServletUtils.getRequest()))));
- UserContextHolder.setContext(userContext);
- return LoginResp.builder()
- .token(StpUtil.getTokenValue())
- .tenantId(
- TenantContextHolder.isTenantEnabled() ? TenantContextHolder.getTenantId() : null)
- .build();
+ protected LoginResp authenticate(UserDO user, ClientResp client, HttpServletRequest request,
+ HttpServletResponse response) {
+ // 所有登录方式共用一套令牌签发流程,避免某种登录方式遗漏 Refresh Token。
+ return authTokenService.issue(user, client, TenantContextHolder.getTenantId(), request,
+ response);
}
- /**
- * 检查用户状态
- *
- * @param user 用户信息
- */
- protected void checkUserStatus(UserDO user) {
- CheckUtils.throwIfEqual(DisEnableStatusEnum.DISABLE, user.getStatus(),
- "此账号已被禁用,如有疑问,请联系管理员");
- DeptDO dept = deptService.getById(user.getDeptId());
- CheckUtils.throwIfEqual(DisEnableStatusEnum.DISABLE, dept.getStatus(),
- "此账号所属部门已被禁用,如有疑问,请联系管理员");
- }
}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/LoginHandler.java b/continew-system/src/main/java/top/continew/admin/auth/LoginHandler.java
index 284ef3439e..175cb77e53 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/LoginHandler.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/LoginHandler.java
@@ -17,6 +17,7 @@
package top.continew.admin.auth;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import top.continew.admin.auth.enums.AuthTypeEnum;
import top.continew.admin.auth.model.req.LoginReq;
import top.continew.admin.auth.model.resp.LoginResp;
@@ -36,10 +37,12 @@ public interface LoginHandler {
*
* @param req 登录请求参数
* @param client 客户端信息
- * @param request 请求对象
+ * @param request 请求对象
+ * @param response 响应对象
* @return 登录响应参数
*/
- LoginResp login(T req, ClientResp client, HttpServletRequest request);
+ LoginResp login(T req, ClientResp client, HttpServletRequest request,
+ HttpServletResponse response);
/**
* 登录前置处理
diff --git a/continew-system/src/main/java/top/continew/admin/auth/adapter/ClientPolicyLockTargetResolver.java b/continew-system/src/main/java/top/continew/admin/auth/adapter/ClientPolicyLockTargetResolver.java
new file mode 100644
index 0000000000..9e947dd38a
--- /dev/null
+++ b/continew-system/src/main/java/top/continew/admin/auth/adapter/ClientPolicyLockTargetResolver.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.adapter;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.api.AuthPolicyLockTarget;
+import top.continew.admin.auth.api.AuthPolicyLockTargetResolver;
+import top.continew.admin.system.mapper.ClientMapper;
+import top.continew.admin.system.model.entity.ClientDO;
+
+import java.util.Collection;
+import java.util.List;
+
+/** 将客户端数据库主键解析为认证会话使用的客户端标识。 */
+@Component
+@RequiredArgsConstructor
+public class ClientPolicyLockTargetResolver implements AuthPolicyLockTargetResolver {
+
+ private final ClientMapper clientMapper;
+
+ @Override
+ public Collection resolve(Object[] args) {
+ for (Object value : args) {
+ if (value instanceof Long id) {
+ return resolveClientIds(List.of(id));
+ }
+ if (value instanceof Collection> values) {
+ List ids = values.stream().filter(Long.class::isInstance)
+ .map(Long.class::cast).toList();
+ if (!ids.isEmpty()) {
+ return resolveClientIds(ids);
+ }
+ }
+ }
+ throw new IllegalArgumentException("认证客户端策略锁参数无效");
+ }
+
+ private Collection resolveClientIds(List ids) {
+ return clientMapper
+ .selectList(Wrappers.lambdaQuery().select(ClientDO::getClientId)
+ .in(ClientDO::getId, ids))
+ .stream().map(ClientDO::getClientId)
+ .filter(clientId -> clientId != null && !clientId.isBlank())
+ .map(AuthPolicyLockTarget::client).toList();
+ }
+}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/adapter/DeptUserPolicyLockTargetResolver.java b/continew-system/src/main/java/top/continew/admin/auth/adapter/DeptUserPolicyLockTargetResolver.java
new file mode 100644
index 0000000000..7c2a33cbe3
--- /dev/null
+++ b/continew-system/src/main/java/top/continew/admin/auth/adapter/DeptUserPolicyLockTargetResolver.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.adapter;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.api.AuthPolicyLockTarget;
+import top.continew.admin.auth.api.AuthPolicyLockTargetResolver;
+import top.continew.admin.system.mapper.user.UserMapper;
+import top.continew.admin.system.model.entity.user.UserDO;
+
+import java.util.Collection;
+
+/** 将部门变更影响的直属用户解析为认证会话策略锁目标。 */
+@Component
+@RequiredArgsConstructor
+public class DeptUserPolicyLockTargetResolver implements AuthPolicyLockTargetResolver {
+
+ private final UserMapper userMapper;
+
+ @Override
+ public Collection resolve(Object[] args) {
+ if (args.length <= 1 || !(args[1] instanceof Long deptId)) {
+ throw new IllegalArgumentException("认证部门策略锁参数无效");
+ }
+ return userMapper.selectList(Wrappers.lambdaQuery().select(UserDO::getId)
+ .eq(UserDO::getDeptId, deptId)).stream().map(UserDO::getId)
+ .map(AuthPolicyLockTarget::user).toList();
+ }
+}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/adapter/RefreshAccessTokenIssuerImpl.java b/continew-system/src/main/java/top/continew/admin/auth/adapter/RefreshAccessTokenIssuerImpl.java
new file mode 100644
index 0000000000..50967482e4
--- /dev/null
+++ b/continew-system/src/main/java/top/continew/admin/auth/adapter/RefreshAccessTokenIssuerImpl.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.adapter;
+
+import cn.hutool.core.util.StrUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.resp.LoginResp;
+import top.continew.admin.auth.service.AuthTokenService;
+import top.continew.admin.auth.service.RefreshAccessTokenIssuer;
+import top.continew.admin.common.api.tenant.TenantApi;
+import top.continew.admin.common.enums.DisEnableStatusEnum;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.admin.system.model.entity.user.UserDO;
+import top.continew.admin.system.model.resp.ClientResp;
+import top.continew.admin.system.service.ClientService;
+import top.continew.admin.system.service.UserService;
+import top.continew.starter.core.exception.BusinessException;
+import top.continew.starter.extension.tenant.util.TenantUtils;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+/** system 对刷新主体状态和 Access Token 签发的适配实现。 */
+@Component
+@RequiredArgsConstructor
+public class RefreshAccessTokenIssuerImpl implements RefreshAccessTokenIssuer {
+
+ private final AuthTokenService authTokenService;
+ private final ClientService clientService;
+ private final TenantApi tenantApi;
+ private final UserService userService;
+
+ @Override
+ public LoginResp issue(RefreshSession session, HttpServletRequest request,
+ HttpServletResponse response) {
+ ClientResp client = clientService.getByClientId(session.getClientId());
+ if (client == null || DisEnableStatusEnum.DISABLE.equals(client.getStatus())) {
+ throw RefreshTokenException.unauthorized("登录状态已失效,请重新登录");
+ }
+ try {
+ tenantApi.checkStatus(session.getTenantId());
+ } catch (BusinessException e) {
+ throw RefreshTokenException.unauthorized(e.getMessage());
+ }
+ AtomicReference userReference = new AtomicReference<>();
+ TenantUtils.execute(session.getTenantId(),
+ () -> userReference.set(userService.getById(session.getUserId())));
+ if (userReference.get() == null || StrUtil.isBlank(userReference.get().getUsername())) {
+ throw RefreshTokenException.unauthorized("登录状态已失效,请重新登录");
+ }
+ return authTokenService.issueAccessToken(userReference.get(), client, session.getTenantId(),
+ session, request, response);
+ }
+}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/adapter/RefreshClientPolicyMapper.java b/continew-system/src/main/java/top/continew/admin/auth/adapter/RefreshClientPolicyMapper.java
new file mode 100644
index 0000000000..974806df46
--- /dev/null
+++ b/continew-system/src/main/java/top/continew/admin/auth/adapter/RefreshClientPolicyMapper.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.adapter;
+
+import top.continew.admin.auth.enums.LogoutReasonEnum;
+import top.continew.admin.auth.enums.SessionReplacementScope;
+import top.continew.admin.auth.model.RefreshClientPolicy;
+import top.continew.admin.system.enums.LogoutModeEnum;
+import top.continew.admin.system.enums.ReplacedRangeEnum;
+import top.continew.admin.system.model.resp.ClientResp;
+
+/**
+ * 系统客户端配置到认证会话策略的边界转换器。
+ *
+ * @author luoqiz
+ */
+public final class RefreshClientPolicyMapper {
+
+ private RefreshClientPolicyMapper() {
+ }
+
+ /**
+ * 将系统客户端响应转换为认证会话模块的最小策略模型。
+ *
+ * @param client 系统客户端配置
+ * @return 认证会话客户端策略
+ */
+ public static RefreshClientPolicy from(ClientResp client) {
+ SessionReplacementScope replacementScope = client.getReplacedRange() == null ? null
+ : ReplacedRangeEnum.ALL_DEVICE_TYPE.equals(client.getReplacedRange())
+ ? SessionReplacementScope.ALL_CLIENT_TYPES
+ : SessionReplacementScope.CURRENT_CLIENT_TYPE;
+ return new RefreshClientPolicy(client.getClientId(), client.getClientType(),
+ client.getRefreshTokenTimeout(), client.getRefreshTokenMode(),
+ Boolean.TRUE.equals(client.getIsConcurrent()), replacementScope,
+ client.getMaxLoginCount(), toLogoutReason(client.getOverflowLogoutMode()));
+ }
+
+ /** 客户端配置的注销模式决定登录数量超限时被淘汰会话看到的提示。 */
+ private static LogoutReasonEnum toLogoutReason(LogoutModeEnum overflowLogoutMode) {
+ if (overflowLogoutMode == null) {
+ return LogoutReasonEnum.REPLACED;
+ }
+ try {
+ return LogoutReasonEnum.valueOf(overflowLogoutMode.getValue());
+ } catch (IllegalArgumentException e) {
+ return LogoutReasonEnum.REPLACED;
+ }
+ }
+}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/constant/AuthConstants.java b/continew-system/src/main/java/top/continew/admin/auth/constant/AuthConstants.java
index 37c4de7323..acb25fcede 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/constant/AuthConstants.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/constant/AuthConstants.java
@@ -34,6 +34,11 @@ public class AuthConstants {
*/
public static final String LOGOUT_URI = "/auth/logout";
+ /**
+ * 刷新令牌 URI
+ */
+ public static final String REFRESH_URI = "/auth/refresh";
+
private AuthConstants() {
}
}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/controller/AuthController.java b/continew-system/src/main/java/top/continew/admin/auth/controller/AuthController.java
index a375f3c79f..afe0f8325c 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/controller/AuthController.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/controller/AuthController.java
@@ -17,13 +17,13 @@
package top.continew.admin.auth.controller;
import cn.dev33.satoken.annotation.SaIgnore;
-import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import me.zhyd.oauth.request.AuthRequest;
@@ -47,6 +47,7 @@
import top.continew.admin.system.model.resp.user.UserDetailResp;
import top.continew.admin.system.service.UserService;
import top.continew.starter.auth.justauth.AuthRequestFactory;
+import top.continew.starter.log.enums.Include;
import top.continew.starter.log.annotation.Log;
import top.continew.starter.validation.constraints.EnumValue;
@@ -79,24 +80,11 @@ public class AuthController {
*/
@SaIgnore
@Operation(summary = "登录", description = "用户登录")
+ @Log(excludes = {Include.REQUEST_HEADERS, Include.RESPONSE_HEADERS, Include.RESPONSE_BODY})
@PostMapping("/login")
- public LoginResp login(@RequestBody @Valid LoginReq req, HttpServletRequest request) {
- return authService.login(req, request);
- }
-
- /**
- * 注销用户的当前登录
- *
- * @return 被登出的用户 ID
- */
- @Operation(summary = "登出", description = "注销用户的当前登录")
- @Parameter(name = "Authorization", description = "令牌", required = true,
- example = "Bearer xxxx-xxxx-xxxx-xxxx", in = ParameterIn.HEADER)
- @PostMapping("/logout")
- public Object logout() {
- Object loginId = StpUtil.getLoginId(-1L);
- StpUtil.logout();
- return loginId;
+ public LoginResp login(@RequestBody @Valid LoginReq req, HttpServletRequest request,
+ HttpServletResponse response) {
+ return authService.login(req, request, response);
}
/**
diff --git a/continew-system/src/main/java/top/continew/admin/auth/controller/OnlineUserController.java b/continew-system/src/main/java/top/continew/admin/auth/controller/OnlineUserController.java
index 537bf80df3..fc8b60c64a 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/controller/OnlineUserController.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/controller/OnlineUserController.java
@@ -18,6 +18,7 @@
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.core.convert.Convert;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
@@ -30,11 +31,19 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.continew.admin.auth.model.query.OnlineUserQuery;
+import top.continew.admin.auth.model.SessionView;
import top.continew.admin.auth.model.resp.OnlineUserResp;
import top.continew.admin.auth.service.OnlineUserService;
+import top.continew.admin.auth.service.SessionInvalidationService;
+import top.continew.admin.auth.service.SessionQueryService;
+import top.continew.admin.common.context.UserContextHolder;
+import top.continew.admin.auth.api.AuthSessionConstants;
import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
+import top.continew.starter.extension.tenant.context.TenantContextHolder;
+
+import java.util.Objects;
/**
* 在线用户 API
@@ -49,6 +58,8 @@
public class OnlineUserController {
private final OnlineUserService baseService;
+ private final SessionInvalidationService sessionInvalidationService;
+ private final SessionQueryService sessionQueryService;
/**
* 分页查询在线用户
@@ -67,17 +78,27 @@ public PageResp page(@Valid OnlineUserQuery query, @Valid PageQu
/**
* 强退在线用户
*
- * @param token 令牌
+ * @param sessionId 登录会话 ID
*/
@Operation(summary = "强退在线用户", description = "强退在线用户")
- @Parameter(name = "token", description = "令牌",
- example = "eyJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjF9.7q7U3ouoN7WPhH2kUEM7vPe5KF3G_qavSG-vRgIxKvE",
+ @Parameter(name = "sessionId", description = "登录会话 ID",
+ example = "Zm9vYmFyYmF6cXV4MTIzNA",
in = ParameterIn.PATH)
@SaCheckPermission("monitor:online:kickout")
- @DeleteMapping("/{token}")
- public void kickout(@PathVariable String token) {
- String currentToken = StpUtil.getTokenValue();
- CheckUtils.throwIfEqual(token, currentToken, "不能强退自己");
- StpUtil.kickoutByTokenValue(token);
+ @DeleteMapping("/{sessionId}")
+ public void kickout(@PathVariable String sessionId) {
+ String currentSessionId = Convert.toStr(StpUtil.getExtra(StpUtil.getTokenValue(),
+ AuthSessionConstants.SESSION_ID_CLAIM));
+ CheckUtils.throwIfEqual(sessionId, currentSessionId, "不能强退自己");
+ SessionView targetSession = sessionQueryService.getSession(sessionId);
+ CheckUtils.throwIfNull(targetSession, "登录会话不存在");
+ // 超级管理员可以跨租户管理在线用户;其他管理员只能操作当前租户,避免仅凭
+ // 一个 Access Token 就跨租户撤销会话。
+ if (TenantContextHolder.isTenantEnabled() && !UserContextHolder.isSuperAdmin()) {
+ CheckUtils.throwIf(() -> !Objects.equals(TenantContextHolder.getTenantId(),
+ targetSession.getTenantId()), "您当前没有操作该租户登录会话的权限");
+ }
+ // Session 是登录态的权威记录;删除后该设备的全部 Access/Refresh Token 都失效。
+ sessionInvalidationService.revokeSession(sessionId);
}
}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java b/continew-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java
index fad747557f..211e0869f9 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java
@@ -21,6 +21,7 @@
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@@ -55,7 +56,8 @@ public class AccountLoginHandler extends AbstractLoginHandler {
private final PasswordEncoder passwordEncoder;
@Override
- public LoginResp login(AccountLoginReq req, ClientResp client, HttpServletRequest request) {
+ public LoginResp login(AccountLoginReq req, ClientResp client, HttpServletRequest request,
+ HttpServletResponse response) {
// 解密密码
String password = SecureUtils.decryptPasswordByRsaPrivateKey(req.getPassword(), "密码解密失败");
// 验证用户名密码
@@ -66,10 +68,8 @@ public LoginResp login(AccountLoginReq req, ClientResp client, HttpServletReques
// 检查账号锁定状态
this.checkUserLocked(req.getUsername(), request, isError);
ValidationUtils.throwIf(isError, "用户名或密码不正确");
- // 检查用户状态
- super.checkUserStatus(user);
// 执行认证
- return super.authenticate(user, client);
+ return super.authenticate(user, client, request, response);
}
@Override
diff --git a/continew-system/src/main/java/top/continew/admin/auth/handler/EmailLoginHandler.java b/continew-system/src/main/java/top/continew/admin/auth/handler/EmailLoginHandler.java
index 4b0d98edd4..8ae0e0b3e9 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/handler/EmailLoginHandler.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/handler/EmailLoginHandler.java
@@ -17,6 +17,7 @@
package top.continew.admin.auth.handler;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import top.continew.admin.auth.AbstractLoginHandler;
import top.continew.admin.auth.enums.AuthTypeEnum;
@@ -39,18 +40,18 @@
public class EmailLoginHandler extends AbstractLoginHandler {
@Override
- public LoginResp login(EmailLoginReq req, ClientResp client, HttpServletRequest request) {
+ public LoginResp login(EmailLoginReq req, ClientResp client, HttpServletRequest request,
+ HttpServletResponse response) {
// 验证邮箱
UserDO user = userService.getByEmail(req.getEmail());
ValidationUtils.throwIfNull(user, "此邮箱未绑定本系统账号");
- // 检查用户状态
- super.checkUserStatus(user);
// 执行认证
- return super.authenticate(user, client);
+ return super.authenticate(user, client, request, response);
}
@Override
public void preLogin(EmailLoginReq req, ClientResp client, HttpServletRequest request) {
+ super.preLogin(req, client, request);
String email = req.getEmail();
String captchaKey = CacheConstants.CAPTCHA_KEY_PREFIX + email;
String captcha = RedisUtils.get(captchaKey);
diff --git a/continew-system/src/main/java/top/continew/admin/auth/handler/PhoneLoginHandler.java b/continew-system/src/main/java/top/continew/admin/auth/handler/PhoneLoginHandler.java
index 71b8cb2940..5f1a7ed3bf 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/handler/PhoneLoginHandler.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/handler/PhoneLoginHandler.java
@@ -17,6 +17,7 @@
package top.continew.admin.auth.handler;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import top.continew.admin.auth.AbstractLoginHandler;
import top.continew.admin.auth.enums.AuthTypeEnum;
@@ -39,18 +40,18 @@
public class PhoneLoginHandler extends AbstractLoginHandler {
@Override
- public LoginResp login(PhoneLoginReq req, ClientResp client, HttpServletRequest request) {
+ public LoginResp login(PhoneLoginReq req, ClientResp client, HttpServletRequest request,
+ HttpServletResponse response) {
// 验证手机号
UserDO user = userService.getByPhone(req.getPhone());
ValidationUtils.throwIfNull(user, "此手机号未绑定本系统账号");
- // 检查用户状态
- super.checkUserStatus(user);
// 执行认证
- return super.authenticate(user, client);
+ return super.authenticate(user, client, request, response);
}
@Override
public void preLogin(PhoneLoginReq req, ClientResp client, HttpServletRequest request) {
+ super.preLogin(req, client, request);
String phone = req.getPhone();
String captchaKey = CacheConstants.CAPTCHA_KEY_PREFIX + phone;
String captcha = RedisUtils.get(captchaKey);
diff --git a/continew-system/src/main/java/top/continew/admin/auth/handler/SocialLoginHandler.java b/continew-system/src/main/java/top/continew/admin/auth/handler/SocialLoginHandler.java
index 94648aafe1..d853b22832 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/handler/SocialLoginHandler.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/handler/SocialLoginHandler.java
@@ -18,7 +18,6 @@
import top.continew.admin.common.constant.GlobalConstants;
-import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.IdUtil;
@@ -27,6 +26,7 @@
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
@@ -80,15 +80,16 @@ public class SocialLoginHandler extends AbstractLoginHandler {
@Override
@Transactional
- public LoginResp login(SocialLoginReq req, ClientResp client, HttpServletRequest request) {
+ public LoginResp login(SocialLoginReq req, ClientResp client, HttpServletRequest request,
+ HttpServletResponse response) {
// 获取第三方登录信息
AuthRequest authRequest = authRequestFactory.getAuthRequest(req.getSource());
AuthCallback callback = new AuthCallback();
callback.setCode(req.getCode());
callback.setState(req.getState());
- AuthResponse response = authRequest.login(callback);
- ValidationUtils.throwIf(!response.ok(), response.getMsg());
- AuthUser authUser = response.getData();
+ AuthResponse authResponse = authRequest.login(callback);
+ ValidationUtils.throwIf(!authResponse.ok(), authResponse.getMsg());
+ AuthUser authUser = authResponse.getData();
// 如未绑定则自动注册新用户,保存或更新关联信息
String source = authUser.getSource();
String openId = authUser.getUuid();
@@ -132,21 +133,11 @@ public LoginResp login(SocialLoginReq req, ClientResp client, HttpServletRequest
user =
BeanUtil.copyProperties(userService.getById(userSocial.getUserId()), UserDO.class);
}
- // 检查用户状态
- super.checkUserStatus(user);
userSocial.setMetaJson(JSONUtil.toJsonStr(authUser));
userSocial.setLastLoginTime(LocalDateTime.now(GlobalConstants.DEFAULT_ZONE_ID));
userSocialService.saveOrUpdate(userSocial);
// 执行认证
- return super.authenticate(user, client);
- }
-
- @Override
- public void preLogin(SocialLoginReq req, ClientResp client, HttpServletRequest request) {
- super.preLogin(req, client, request);
- if (StpUtil.isLogin()) {
- StpUtil.logout();
- }
+ return super.authenticate(user, client, request, response);
}
@Override
diff --git a/continew-system/src/main/java/top/continew/admin/auth/model/req/LoginReq.java b/continew-system/src/main/java/top/continew/admin/auth/model/req/LoginReq.java
index ba5530c035..76cc89287a 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/model/req/LoginReq.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/model/req/LoginReq.java
@@ -60,4 +60,5 @@ public class LoginReq implements Serializable {
@Schema(description = "认证类型", example = "ACCOUNT")
@NotNull(message = "认证类型无效")
private AuthTypeEnum authType;
+
}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/model/resp/OnlineUserResp.java b/continew-system/src/main/java/top/continew/admin/auth/model/resp/OnlineUserResp.java
index fc26e331a3..fef667ec72 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/model/resp/OnlineUserResp.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/model/resp/OnlineUserResp.java
@@ -16,14 +16,8 @@
package top.continew.admin.auth.model.resp;
-import cn.crane4j.annotation.Assemble;
-import cn.crane4j.annotation.AssembleMethod;
-import cn.crane4j.annotation.ContainerMethod;
-import cn.crane4j.annotation.MappingType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
-import top.continew.admin.auth.service.OnlineUserService;
-import top.continew.admin.common.constant.ContainerConstants;
import java.io.Serial;
import java.io.Serializable;
@@ -46,18 +40,13 @@ public class OnlineUserResp implements Serializable {
* ID
*/
@Schema(description = "ID", example = "1")
- @Assemble(prop = ":nickname", container = ContainerConstants.USER_NICKNAME)
private Long id;
/**
- * 令牌
+ * 登录会话 ID
*/
- @Schema(description = "令牌",
- example = "eyJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjF9.7q7U3ouoN7WPhH2kUEM7vPe5KF3G_qavSG-vRgIxKvE")
- @AssembleMethod(prop = ":lastActiveTime", targetType = OnlineUserService.class,
- method = @ContainerMethod(bindMethod = "getLastActiveTime",
- type = MappingType.ORDER_OF_KEYS))
- private String token;
+ @Schema(description = "登录会话 ID", example = "Zm9vYmFyYmF6cXV4MTIzNA")
+ private String sessionId;
/**
* 用户名
@@ -114,8 +103,8 @@ public class OnlineUserResp implements Serializable {
private LocalDateTime loginTime;
/**
- * 最后活跃时间
+ * 最后刷新时间
*/
- @Schema(description = "最后活跃时间", example = "2023-08-08 08:08:08", type = "string")
- private LocalDateTime lastActiveTime;
+ @Schema(description = "最后刷新时间", example = "2023-08-08 08:08:08", type = "string")
+ private LocalDateTime lastRefreshTime;
}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/service/AuthService.java b/continew-system/src/main/java/top/continew/admin/auth/service/AuthService.java
index 3b61fc2904..538d856ed1 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/service/AuthService.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/service/AuthService.java
@@ -17,6 +17,7 @@
package top.continew.admin.auth.service;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import top.continew.admin.auth.model.req.LoginReq;
import top.continew.admin.auth.model.resp.LoginResp;
import top.continew.admin.auth.model.resp.RouteResp;
@@ -38,7 +39,7 @@ public interface AuthService {
* @param request 请求对象
* @return 登录响应参数
*/
- LoginResp login(LoginReq req, HttpServletRequest request);
+ LoginResp login(LoginReq req, HttpServletRequest request, HttpServletResponse response);
/**
* 构建路由树
diff --git a/continew-system/src/main/java/top/continew/admin/auth/service/AuthTokenService.java b/continew-system/src/main/java/top/continew/admin/auth/service/AuthTokenService.java
new file mode 100644
index 0000000000..f1b72f3eb4
--- /dev/null
+++ b/continew-system/src/main/java/top/continew/admin/auth/service/AuthTokenService.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.resp.LoginResp;
+import top.continew.admin.system.model.entity.user.UserDO;
+import top.continew.admin.system.model.resp.ClientResp;
+
+/**
+ * 统一访问令牌签发服务。
+ *
+ * 账号、手机、邮箱、第三方登录以及 Refresh Token 刷新都必须经过此服务,确保
+ * Sa-Token 登录参数、用户权限上下文和 Refresh Token 的创建逻辑保持一致。
+ *
+ * @author luoqiz
+ */
+public interface AuthTokenService {
+
+ /**
+ * 为一次登录或刷新请求签发 Access Token。
+ *
+ * @param user 用户信息
+ * @param client 客户端配置
+ * @param tenantId 当前登录确定的租户 ID
+ * @param request HTTP 请求
+ * @param response HTTP 响应,用于写入浏览器 Refresh Token Cookie
+ * @return 登录令牌响应
+ */
+ LoginResp issue(UserDO user, ClientResp client, Long tenantId,
+ HttpServletRequest request, HttpServletResponse response);
+
+ /**
+ * 仅签发 Access Token,不创建新的 Refresh Session。
+ *
+ * Refresh Token 轮换时必须复用原有 sessionId,由 RefreshTokenService 负责轮换
+ * Refresh Token;如果这里再次创建登录会话,会产生孤立会话。
+ *
+ * @param user 用户信息
+ * @param client 客户端配置
+ * @param tenantId 当前登录确定的租户 ID
+ * @param refreshSession 刷新时所属的登录 Session
+ * @param request HTTP 请求
+ * @param response HTTP 响应
+ * @return 登录令牌响应(不包含新的 Refresh Token)
+ */
+ LoginResp issueAccessToken(UserDO user, ClientResp client, Long tenantId,
+ RefreshSession refreshSession, HttpServletRequest request, HttpServletResponse response);
+
+}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/service/OnlineUserService.java b/continew-system/src/main/java/top/continew/admin/auth/service/OnlineUserService.java
index 3df02c147d..e23e106a1b 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/service/OnlineUserService.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/service/OnlineUserService.java
@@ -21,7 +21,6 @@
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
-import java.time.LocalDateTime;
import java.util.List;
/**
@@ -49,14 +48,6 @@ public interface OnlineUserService {
*/
List list(OnlineUserQuery query);
- /**
- * 查询 Token 最后活跃时间
- *
- * @param token Token
- * @return 最后活跃时间
- */
- LocalDateTime getLastActiveTime(String token);
-
/**
* 踢出用户
*
diff --git a/continew-system/src/main/java/top/continew/admin/auth/service/impl/AuthServiceImpl.java b/continew-system/src/main/java/top/continew/admin/auth/service/impl/AuthServiceImpl.java
index 9f942cac1d..f4e610b257 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/service/impl/AuthServiceImpl.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/service/impl/AuthServiceImpl.java
@@ -22,6 +22,7 @@
import cn.hutool.core.lang.tree.TreeNodeConfig;
import cn.hutool.core.lang.tree.TreeUtil;
import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import top.continew.admin.auth.LoginHandler;
@@ -66,7 +67,7 @@ public class AuthServiceImpl implements AuthService {
private final CrudProperties crudProperties;
@Override
- public LoginResp login(LoginReq req, HttpServletRequest request) {
+ public LoginResp login(LoginReq req, HttpServletRequest request, HttpServletResponse response) {
AuthTypeEnum authType = req.getAuthType();
// 校验客户端
ClientResp client = clientService.getByClientId(req.getClientId());
@@ -80,7 +81,7 @@ public LoginResp login(LoginReq req, HttpServletRequest request) {
// 登录前置处理
loginHandler.preLogin(req, client, request);
// 登录
- LoginResp loginResp = loginHandler.login(req, client, request);
+ LoginResp loginResp = loginHandler.login(req, client, request, response);
// 登录后置处理
loginHandler.postLogin(req, client, request);
return loginResp;
diff --git a/continew-system/src/main/java/top/continew/admin/auth/service/impl/AuthTokenServiceImpl.java b/continew-system/src/main/java/top/continew/admin/auth/service/impl/AuthTokenServiceImpl.java
new file mode 100644
index 0000000000..c5e9e402c2
--- /dev/null
+++ b/continew-system/src/main/java/top/continew/admin/auth/service/impl/AuthTokenServiceImpl.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.continew.admin.auth.service.impl;
+
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.stp.parameter.SaLoginParameter;
+import cn.hutool.core.bean.BeanUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Service;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
+import top.continew.admin.auth.model.AuthSecurityVersion;
+import top.continew.admin.auth.model.RefreshSession;
+import top.continew.admin.auth.model.RefreshClientPolicy;
+import top.continew.admin.auth.adapter.RefreshClientPolicyMapper;
+import top.continew.admin.auth.model.resp.LoginResp;
+import top.continew.admin.auth.service.AuthTokenService;
+import top.continew.admin.auth.service.RefreshTokenService;
+import top.continew.admin.auth.service.RefreshTokenService.LoginAttempt;
+import top.continew.admin.auth.api.AuthSessionConstants;
+import top.continew.admin.common.api.tenant.TenantApi;
+import top.continew.admin.common.context.RoleContext;
+import top.continew.admin.common.context.UserContext;
+import top.continew.admin.common.context.UserContextHolder;
+import top.continew.admin.common.context.UserExtraContext;
+import top.continew.admin.common.enums.DisEnableStatusEnum;
+import top.continew.admin.auth.exception.RefreshTokenException;
+import top.continew.admin.system.model.entity.DeptDO;
+import top.continew.admin.system.model.entity.user.UserDO;
+import top.continew.admin.system.model.resp.ClientResp;
+import top.continew.admin.system.service.ClientService;
+import top.continew.admin.system.service.DeptService;
+import top.continew.admin.system.service.OptionService;
+import top.continew.admin.system.service.RoleService;
+import top.continew.admin.system.service.UserService;
+import top.continew.starter.core.exception.BusinessException;
+import top.continew.starter.core.util.validation.CheckUtils;
+import top.continew.starter.extension.tenant.context.TenantContextHolder;
+import top.continew.starter.extension.tenant.util.TenantUtils;
+
+import java.util.HashSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import static top.continew.admin.system.enums.PasswordPolicyEnum.PASSWORD_EXPIRATION_DAYS;
+
+/**
+ * 统一 Access Token 签发实现。
+ *
+ * Access Token 使用 Sa-Token 管理,Refresh Token 则由独立的 Redis 会话服务管理。
+ * 两者职责分离:Access Token 负责短期接口访问,Refresh Token 负责在 Access Token
+ * 过期后安全地轮换新令牌。
+ *
+ * @author luoqiz
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AuthTokenServiceImpl implements AuthTokenService {
+
+ private static final String CLIENT_ID = "clientId";
+
+ private final RoleService roleService;
+ private final OptionService optionService;
+ private final DeptService deptService;
+ private final UserService userService;
+ private final ClientService clientService;
+ private final RefreshTokenService refreshTokenService;
+ private final TenantApi tenantApi;
+ private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
+
+ @Override
+ public LoginResp issue(UserDO user, ClientResp client, Long tenantId,
+ HttpServletRequest request, HttpServletResponse response) {
+ return this.issueInternal(user, client, tenantId, request, response, true);
+ }
+
+ @Override
+ public LoginResp issueAccessToken(UserDO user, ClientResp client, Long tenantId,
+ RefreshSession refreshSession, HttpServletRequest request, HttpServletResponse response) {
+ // 刷新场景只重新签发短期 Access Token,Refresh Session 的轮换由专门服务完成。
+ return this.issueInternal(user, client, tenantId, request, response, false,
+ refreshSession);
+ }
+
+ private LoginResp issueInternal(UserDO user, ClientResp client, Long tenantId,
+ HttpServletRequest request, HttpServletResponse response, boolean issueRefreshToken) {
+ return this.issueInternal(user, client, tenantId, request, response, issueRefreshToken,
+ null);
+ }
+
+ private LoginResp issueInternal(UserDO user, ClientResp client, Long tenantId,
+ HttpServletRequest request, HttpServletResponse response, boolean issueRefreshToken,
+ RefreshSession refreshSession) {
+ if (issueRefreshToken) {
+ return this.issueNewSession(user, client, tenantId, request, response);
+ }
+ AtomicReference result = new AtomicReference<>();
+ // 刷新时租户上下文不会由前端重新提交,必须以 Refresh Session 中的 tenantId 为准。
+ TenantUtils.execute(tenantId,
+ () -> {
+ this.checkUserStatus(user, true);
+ result.set(this.issueInTenant(user, client, tenantId, request, response, false,
+ refreshSession, null));
+ });
+ return result.get();
+ }
+
+ /** 新登录的最终数据库复查、安全版本读取、会话策略和签发必须处于同一组作用域锁内。 */
+ private LoginResp issueNewSession(UserDO authenticatedUser, ClientResp authenticatedClient,
+ Long tenantId, HttpServletRequest request, HttpServletResponse response) {
+ return refreshTokenService.executeLoginPolicy(authenticatedUser.getId(),
+ authenticatedClient.getClientId(), tenantId, securityVersion -> {
+ LoginState loginState = this.reloadLoginState(authenticatedUser,
+ authenticatedClient, tenantId);
+ // 登录早期的租户校验可能早于认证过程很久;在租户锁内再次读取数据库,保证
+ // 租户禁用、过期或套餐变更与新 Session 创建严格串行。
+ tenantApi.checkStatus(tenantId);
+ String currentAccessToken = StpUtil.getTokenValue();
+ String currentRefreshToken = refreshTokenService.resolve(null, request);
+ return new LoginAttempt<>(loginState.user().getId(), RefreshClientPolicyMapper
+ .from(loginState.client()),
+ currentAccessToken, currentRefreshToken,
+ () -> this.executeInTenant(tenantId,
+ () -> this.issueInTenant(loginState.user(), loginState.client(), tenantId,
+ request, response, true, null, securityVersion)));
+ });
+ }
+
+ private LoginResp issueInTenant(UserDO user, ClientResp client, Long tenantId,
+ HttpServletRequest request, HttpServletResponse response, boolean issueRefreshToken,
+ RefreshSession refreshSession, AuthSecurityVersion securityVersion) {
+ UserContext userContext = this.buildUserContext(user, tenantId);
+
+ String sessionId = issueRefreshToken ? refreshTokenService.newSessionId()
+ : refreshSession.getSessionId();
+
+ RefreshClientPolicy refreshClientPolicy = RefreshClientPolicyMapper.from(client);
+ long accessTokenTimeout = this.getEffectiveAccessTokenTimeout(client.getTimeout(),
+ issueRefreshToken ? refreshTokenService.getRefreshTimeout(refreshClientPolicy)
+ : this.remainingSeconds(refreshSession.getExpiresAt()));
+
+ // Sa-Token 只管理短期 Access Token 的生命周期。并发登录、顶人范围和最大登录
+ // 数量仅由 Refresh Session 执行,避免两个事实源产生跨端误淘汰。
+ SaLoginParameter loginParameter = new SaLoginParameter();
+ loginParameter.setActiveTimeout(client.getActiveTimeout());
+ loginParameter.setTimeout(accessTokenTimeout);
+ loginParameter.setDeviceType(client.getClientType());
+ // sid 同时进入 Access Token 和 Refresh Token,用于服务端核对两类凭证是否
+ // 属于同一次登录,客户端不能自行指定或覆盖该值。
+ loginParameter.setIsConcurrent(true);
+ loginParameter.setMaxLoginCount(-1);
+
+ userContext.setClientType(client.getClientType());
+ userContext.setClientId(client.getClientId());
+ userContext.setTenantId(tenantId);
+
+ String accessToken = null;
+ try {
+ // 用户上下文写入 SaSession,后续请求可直接读取权限、角色和租户信息。
+ UserExtraContext extraContext = new UserExtraContext(request, tenantId);
+ extraContext.setClientId(client.getClientId());
+ // SaLoginParameter#setExtraData 会整体替换 extra 数据。必须在同一 Map 中
+ // 写入会话声明,否则 sid 会被用户额外上下文覆盖,后续接口无法关联到
+ // Refresh Session 而被错误判定为 401。
+ Map loginExtraData = new HashMap<>(BeanUtil.beanToMap(extraContext));
+ loginExtraData.put(CLIENT_ID, client.getClientId());
+ loginExtraData.put(AuthSessionConstants.SESSION_ID_CLAIM, sessionId);
+ StpUtil.login(userContext.getId(), loginParameter.setExtraData(loginExtraData));
+ // 先保存本次签发的令牌,保证后续任一步骤失败时可以精确清理它。
+ accessToken = StpUtil.getTokenValue();
+ UserContextHolder.setContext(userContext);
+
+ LoginResp loginResp = LoginResp.builder()
+ .accessToken(accessToken)
+ .tokenType("Bearer")
+ .expiresIn(accessTokenTimeout)
+ .tenantId(TenantContextHolder.isTenantEnabled() ? tenantId : null)
+ .build();
+
+ // 新登录创建 Refresh Session。浏览器明文只进入 HttpOnly Cookie;BODY 模式
+ // 将明文返回给 App / 微信小程序客户端。
+ if (issueRefreshToken) {
+ String refreshToken =
+ refreshTokenService.issue(sessionId, userContext, refreshClientPolicy,
+ extraContext, securityVersion, response, accessToken, accessTokenTimeout);
+ loginResp.setRefreshExpiresIn(
+ refreshTokenService.getRefreshTimeout(refreshClientPolicy));
+ if (RefreshTokenModeEnum.BODY
+ .equals(refreshTokenService.getMode(refreshClientPolicy))) {
+ loginResp.setRefreshToken(refreshToken);
+ }
+ }
+ return loginResp;
+ } catch (Exception e) {
+ // 登录响应组装、Refresh Session 写入或 Cookie 写入失败时,不能留下孤立的
+ // Access Token。这里覆盖 StpUtil.login 后的所有异常路径,而不只是 Redis 失败。
+ if (accessToken != null) {
+ try {
+ StpUtil.logoutByTokenValue(accessToken);
+ } catch (Exception logoutException) {
+ // 记录清理失败,但保留原始异常,便于调用方得到真实失败原因。
+ log.error("Refresh Session 创建失败后,清理用户 [{}] 的 Access Token 失败",
+ user.getId(), logoutException);
+ }
+ }
+ throw e;
+ }
+ }
+
+ /** 构建刷新时也必须使用的最新权限上下文,避免角色变更后继续沿用旧权限。 */
+ private UserContext buildUserContext(UserDO user, Long tenantId) {
+ Long userId = user.getId();
+ CompletableFuture> permissionFuture = CompletableFuture.supplyAsync(() -> {
+ Set permissions = new HashSet<>();
+ TenantUtils.execute(tenantId,
+ () -> permissions.addAll(roleService.listPermissionByUserId(userId)));
+ return permissions;
+ }, threadPoolTaskExecutor);
+ CompletableFuture> roleFuture = CompletableFuture.supplyAsync(() -> {
+ Set roles = new HashSet<>();
+ TenantUtils.execute(tenantId, () -> roles.addAll(roleService.listByUserId(userId)));
+ return roles;
+ }, threadPoolTaskExecutor);
+ CompletableFuture passwordExpirationDaysFuture = CompletableFuture.supplyAsync(
+ () -> optionService.getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name()),
+ threadPoolTaskExecutor);
+ CompletableFuture.allOf(permissionFuture, roleFuture, passwordExpirationDaysFuture).join();
+
+ UserContext context = new UserContext(permissionFuture.join(), roleFuture.join(),
+ passwordExpirationDaysFuture.join());
+ BeanUtil.copyProperties(user, context);
+ return context;
+ }
+
+ /** 刷新不能绕过用户或部门禁用校验。 */
+ private void checkUserStatus(UserDO user, boolean refreshRequest) {
+ this.requireUserState(user != null, "用户不存在", refreshRequest);
+ this.requireUserState(!DisEnableStatusEnum.DISABLE.equals(user.getStatus()),
+ "此账号已被禁用,如有疑问,请联系管理员", refreshRequest);
+ DeptDO dept = deptService.getById(user.getDeptId());
+ this.requireUserState(dept != null, "此账号所属部门不存在", refreshRequest);
+ this.requireUserState(!DisEnableStatusEnum.DISABLE.equals(dept.getStatus()),
+ "此账号所属部门已被禁用,如有疑问,请联系管理员", refreshRequest);
+ }
+
+ private LoginState reloadLoginState(UserDO authenticatedUser, ClientResp authenticatedClient,
+ Long tenantId) {
+ ClientResp currentClient = clientService.getByClientId(authenticatedClient.getClientId());
+ CheckUtils.throwIfNull(currentClient, "客户端不存在");
+ CheckUtils.throwIfEqual(DisEnableStatusEnum.DISABLE, currentClient.getStatus(),
+ "客户端已禁用");
+ if (!this.isSameSecurityPolicy(authenticatedClient, currentClient)) {
+ throw new BusinessException("客户端认证配置已变更,请重新登录");
+ }
+
+ AtomicReference currentUser = new AtomicReference<>();
+ TenantUtils.execute(tenantId, () -> {
+ currentUser.set(userService.getById(authenticatedUser.getId()));
+ this.checkUserStatus(currentUser.get(), false);
+ });
+ if (!Objects.equals(authenticatedUser.getPwdResetTime(),
+ currentUser.get().getPwdResetTime())) {
+ throw new BusinessException("账号凭证已变更,请重新登录");
+ }
+ return new LoginState(currentUser.get(), currentClient);
+ }
+
+ private boolean isSameSecurityPolicy(ClientResp expected, ClientResp actual) {
+ return Objects.equals(expected.getClientType(), actual.getClientType())
+ && Objects.equals(expected.getAuthType(), actual.getAuthType())
+ && Objects.equals(expected.getActiveTimeout(), actual.getActiveTimeout())
+ && Objects.equals(expected.getTimeout(), actual.getTimeout())
+ && Objects.equals(expected.getRefreshTokenTimeout(), actual.getRefreshTokenTimeout())
+ && Objects.equals(expected.getRefreshTokenMode(), actual.getRefreshTokenMode())
+ && Objects.equals(expected.getIsConcurrent(), actual.getIsConcurrent())
+ && Objects.equals(expected.getReplacedRange(), actual.getReplacedRange())
+ && Objects.equals(expected.getMaxLoginCount(), actual.getMaxLoginCount())
+ && Objects.equals(expected.getOverflowLogoutMode(), actual.getOverflowLogoutMode());
+ }
+
+ private long getEffectiveAccessTokenTimeout(Long configuredTimeout, long sessionTimeout) {
+ if (configuredTimeout == null) {
+ throw new BusinessException("Access Token 有效期未配置");
+ }
+ if (configuredTimeout == -1 || configuredTimeout > sessionTimeout) {
+ return sessionTimeout;
+ }
+ return configuredTimeout;
+ }
+
+ private long remainingSeconds(long expiresAt) {
+ long remainingMillis = expiresAt - System.currentTimeMillis();
+ if (remainingMillis <= 0) {
+ throw RefreshTokenException.unauthorized("登录状态已失效,请重新登录");
+ }
+ return remainingMillis / 1000 + (remainingMillis % 1000 == 0 ? 0 : 1);
+ }
+
+ private void requireUserState(boolean valid, String message, boolean refreshRequest) {
+ if (valid) {
+ return;
+ }
+ if (refreshRequest) {
+ throw RefreshTokenException.unauthorized(message);
+ }
+ throw new BusinessException(message);
+ }
+
+ private T executeInTenant(Long tenantId, Supplier action) {
+ AtomicReference result = new AtomicReference<>();
+ TenantUtils.execute(tenantId, () -> result.set(action.get()));
+ return result.get();
+ }
+
+ private record LoginState(UserDO user, ClientResp client) {
+ }
+}
diff --git a/continew-system/src/main/java/top/continew/admin/auth/service/impl/OnlineUserServiceImpl.java b/continew-system/src/main/java/top/continew/admin/auth/service/impl/OnlineUserServiceImpl.java
index 9ec53c0522..d04c39a311 100644
--- a/continew-system/src/main/java/top/continew/admin/auth/service/impl/OnlineUserServiceImpl.java
+++ b/continew-system/src/main/java/top/continew/admin/auth/service/impl/OnlineUserServiceImpl.java
@@ -16,35 +16,27 @@
package top.continew.admin.auth.service.impl;
-import cn.crane4j.annotation.AutoOperate;
-import cn.dev33.satoken.dao.SaTokenDao;
-import cn.dev33.satoken.stp.StpUtil;
-import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
-import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import top.continew.admin.auth.model.SessionView;
import top.continew.admin.auth.model.query.OnlineUserQuery;
import top.continew.admin.auth.model.resp.OnlineUserResp;
import top.continew.admin.auth.service.OnlineUserService;
-import top.continew.admin.common.context.UserContext;
+import top.continew.admin.auth.service.SessionInvalidationService;
+import top.continew.admin.auth.service.SessionQueryService;
import top.continew.admin.common.context.UserContextHolder;
-import top.continew.admin.common.context.UserExtraContext;
-import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
import top.continew.starter.extension.tenant.context.TenantContextHolder;
import java.time.LocalDateTime;
-import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
-import java.util.stream.Collectors;
/**
* 在线用户业务实现
@@ -56,8 +48,10 @@
@RequiredArgsConstructor
public class OnlineUserServiceImpl implements OnlineUserService {
+ private final SessionInvalidationService sessionInvalidationService;
+ private final SessionQueryService sessionQueryService;
+
@Override
- @AutoOperate(type = OnlineUserResp.class, on = "list")
public PageResp page(OnlineUserQuery query, PageQuery pageQuery) {
List list = this.list(query);
return PageResp.build(pageQuery.getPage(), pageQuery.getSize(), list);
@@ -66,103 +60,75 @@ public PageResp page(OnlineUserQuery query, PageQuery pageQuery)
@Override
public List list(OnlineUserQuery query) {
List list = new ArrayList<>();
- // 查询所有在线 Token
- List tokenKeyList = StpUtil.searchTokenValue(StringConstants.EMPTY, 0, -1, false);
- Map> tokenMap = tokenKeyList.stream()
- // 提前映射,避免重复调用
- .map(tokenKey -> StrUtil.subAfter(tokenKey, StringConstants.COLON, true))
- .map(token -> {
- Object loginIdObj = StpUtil.getLoginIdByToken(token);
- long tokenTimeout = StpUtil.getStpLogic().getTokenActiveTimeoutByToken(token);
- // 将相关信息打包成对象或简单的Entry对,便于后续过滤与归类
- return new AbstractMap.SimpleEntry<>(token,
- new AbstractMap.SimpleEntry<>(loginIdObj, tokenTimeout));
- })
- // 过滤出未过期且loginId存在的Token
- .filter(entry -> {
- Object loginIdObj = entry.getValue().getKey();
- long tokenTimeout = entry.getValue().getValue();
- return loginIdObj != null && tokenTimeout >= SaTokenDao.NEVER_EXPIRE;
- })
- // 此时数据都有效,进行收集
- .collect(
- Collectors.groupingBy(entry -> Convert.toLong(entry.getValue().getKey()), Collectors
- .mapping(AbstractMap.SimpleEntry::getKey, Collectors.toList())));
- // 筛选数据
- for (Map.Entry> entry : tokenMap.entrySet()) {
- Long userId = entry.getKey();
- UserContext userContext = UserContextHolder.getContext(userId);
- // 过滤无效/不匹配数据;并仅显示本租户数据(依赖 || 短路,确保 userContext 非空后再读取租户)
- if (userContext == null || !this.isMatchNickname(query.getNickname(), userContext)
- || !this.isMatchClientId(query.getClientId(), userContext)
- || (TenantContextHolder.isTenantEnabled() && !TenantContextHolder.getTenantId()
- .equals(userContext.getTenantId()))) {
+ Long tenantId = TenantContextHolder.isTenantEnabled() && !UserContextHolder.isSuperAdmin()
+ ? TenantContextHolder.getTenantId()
+ : null;
+ // Refresh Session 才是可恢复登录态的事实源。Access Token 即使已经自然过期,
+ // 只要长期会话仍有效,管理员就必须能够看到并撤销该设备登录。
+ for (SessionView session : sessionQueryService.listSessions(tenantId)) {
+ if (query.getUserId() != null && !Objects.equals(query.getUserId(), session.getUserId())
+ || !this.isMatchNickname(query.getNickname(), session)
+ || !this.isMatchClientId(query.getClientId(), session.getClientId())) {
continue;
}
- List loginTimeList = query.getLoginTime();
- // 仅做内存过滤,顺序遍历即可:并发写入普通 ArrayList 会丢条目、留 null 空洞甚至扩容越界
- for (String token : entry.getValue()) {
- UserExtraContext extraContext = UserContextHolder.getExtraContext(token);
- // 附加上下文可能已从缓存中过期
- if (extraContext == null
- || !this.isMatchLoginTime(loginTimeList, extraContext.getLoginTime())) {
- continue;
- }
- OnlineUserResp resp = BeanUtil.copyProperties(userContext, OnlineUserResp.class);
- BeanUtil.copyProperties(extraContext, resp);
- resp.setToken(token);
- list.add(resp);
+ LocalDateTime loginTime = DateUtil.date(session.getCreatedAt()).toLocalDateTime();
+ if (!this.isMatchLoginTime(query.getLoginTime(), loginTime)) {
+ continue;
}
+ OnlineUserResp resp = new OnlineUserResp();
+ resp.setId(session.getUserId());
+ resp.setSessionId(session.getSessionId());
+ resp.setUsername(session.getUsername());
+ resp.setNickname(session.getNickname());
+ resp.setClientType(session.getClientType());
+ resp.setClientId(session.getClientId());
+ resp.setIp(session.getIp());
+ resp.setAddress(session.getAddress());
+ resp.setBrowser(session.getBrowser());
+ resp.setOs(session.getOs());
+ resp.setLoginTime(loginTime);
+ resp.setLastRefreshTime(DateUtil.date(session.getLastRefreshAt()).toLocalDateTime());
+ list.add(resp);
}
- // 设置排序(登录时间可能缺失,需空值安全)
+ // 登录时间可能缺失,排序必须空值安全。
CollUtil.sort(list, Comparator.comparing(OnlineUserResp::getLoginTime, Comparator
.nullsFirst(Comparator.naturalOrder())).reversed());
return list;
}
- @Override
- public LocalDateTime getLastActiveTime(String token) {
- long lastActiveTime = StpUtil.getStpLogic().getTokenLastActiveTime(token);
- return lastActiveTime == SaTokenDao.NOT_VALUE_EXPIRE ? null
- : DateUtil.date(lastActiveTime).toLocalDateTime();
- }
-
@Override
public void kickOut(Long userId) {
- if (!StpUtil.isLogin(userId)) {
- return;
- }
- StpUtil.logout(userId);
+ // 认证会话在事务提交后统一失效;Access Token 校验会立即拒绝已失效会话。
+ sessionInvalidationService.invalidateUser(userId);
}
/**
* 是否匹配昵称
*
- * @param nickname 昵称
- * @param userContext 用户上下文信息
+ * @param nickname 昵称
+ * @param session 登录会话
* @return 是否匹配昵称
*/
- private boolean isMatchNickname(String nickname, UserContext userContext) {
+ private boolean isMatchNickname(String nickname, SessionView session) {
if (StrUtil.isBlank(nickname)) {
return true;
}
- return StrUtil.contains(userContext.getUsername(), nickname)
- || StrUtil.contains(UserContextHolder
- .getNickname(userContext.getId()), nickname);
+ return StrUtil.contains(session.getUsername(), nickname)
+ || StrUtil.contains(session.getNickname(), nickname);
}
/**
* 是否匹配客户端 ID
*
- * @param clientId 客户端 ID
- * @param userContext 用户上下文信息
+ * @param clientId 客户端 ID
+ * @param userClientId 令牌对应的客户端 ID
* @return 是否匹配客户端 ID
*/
- private boolean isMatchClientId(String clientId, UserContext userContext) {
+ private boolean isMatchClientId(String clientId, String userClientId) {
if (StrUtil.isBlank(clientId)) {
return true;
}
- return Objects.equals(userContext.getClientId(), clientId);
+ return Objects.equals(userClientId, clientId);
}
/**
@@ -176,10 +142,12 @@ private boolean isMatchLoginTime(List loginTimeList, LocalDateTim
if (CollUtil.isEmpty(loginTimeList)) {
return true;
}
- // 登录时间缺失时无法参与区间比较,按不匹配处理
- if (loginTime == null) {
+ // 查询参数来自外部请求,必须防御只传一个时间点或空边界。
+ if (loginTime == null || loginTimeList.size() < 2 || loginTimeList.get(0) == null
+ || loginTimeList.get(1) == null) {
return false;
}
return loginTime.isAfter(loginTimeList.get(0)) && loginTime.isBefore(loginTimeList.get(1));
}
+
}
diff --git a/continew-system/src/main/java/top/continew/admin/system/api/TenantDataApiForSystemImpl.java b/continew-system/src/main/java/top/continew/admin/system/api/TenantDataApiForSystemImpl.java
index 236d5ab1f8..a8d540de3d 100644
--- a/continew-system/src/main/java/top/continew/admin/system/api/TenantDataApiForSystemImpl.java
+++ b/continew-system/src/main/java/top/continew/admin/system/api/TenantDataApiForSystemImpl.java
@@ -16,7 +16,6 @@
package top.continew.admin.system.api;
-import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.collection.ListUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -33,6 +32,7 @@
import top.continew.admin.common.enums.RoleCodeEnum;
import top.continew.admin.common.model.dto.TenantDTO;
import top.continew.admin.common.util.SecureUtils;
+import top.continew.admin.auth.service.SessionInvalidationService;
import top.continew.admin.system.mapper.DeptMapper;
import top.continew.admin.system.mapper.LogMapper;
import top.continew.admin.system.mapper.MessageMapper;
@@ -52,6 +52,7 @@
import top.continew.admin.system.service.RoleMenuService;
import top.continew.admin.system.service.UserRoleService;
import top.continew.starter.core.util.CollUtils;
+import top.continew.starter.extension.tenant.context.TenantContextHolder;
import top.continew.starter.extension.tenant.util.TenantUtils;
import java.time.LocalDateTime;
@@ -85,6 +86,7 @@ public class TenantDataApiForSystemImpl implements TenantDataApi {
private final UserPasswordHistoryMapper userPasswordHistoryMapper;
private final UserRoleMapper userRoleMapper;
private final UserSocialMapper userSocialMapper;
+ private final SessionInvalidationService sessionInvalidationService;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -110,11 +112,9 @@ public void init(TenantDTO tenant) {
@Override
@Transactional(rollbackFor = Exception.class)
public void clear() {
- // 退出所有用户
- List userList = userMapper.selectList(null);
- for (UserDO user : userList) {
- StpUtil.logout(user.getId());
- }
+ // 会话失效注册为事务提交后的动作,避免租户数据清理回滚时误踢出用户。
+ sessionInvalidationService.invalidateTenant(TenantContextHolder.getTenantId());
+ // Access Token 由认证会话校验统一拒绝,无需在事务内提前修改 Sa-Token 状态。
Wrapper queryWrapper = Wrappers.query().eq("1", 1);
// 部门清除
deptMapper.delete(queryWrapper);
@@ -141,6 +141,11 @@ public void clear() {
userSocialMapper.delete(queryWrapper);
}
+ @Override
+ public void invalidateSessions() {
+ sessionInvalidationService.invalidateTenant(TenantContextHolder.getTenantId());
+ }
+
/**
* 初始化部门数据
*
diff --git a/continew-system/src/main/java/top/continew/admin/system/model/entity/ClientDO.java b/continew-system/src/main/java/top/continew/admin/system/model/entity/ClientDO.java
index 1a68378034..e473640ef3 100644
--- a/continew-system/src/main/java/top/continew/admin/system/model/entity/ClientDO.java
+++ b/continew-system/src/main/java/top/continew/admin/system/model/entity/ClientDO.java
@@ -22,6 +22,7 @@
import lombok.Data;
import top.continew.admin.common.base.model.entity.BaseDO;
import top.continew.admin.common.enums.DisEnableStatusEnum;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
import top.continew.admin.system.enums.LogoutModeEnum;
import top.continew.admin.system.enums.ReplacedRangeEnum;
@@ -68,6 +69,12 @@ public class ClientDO extends BaseDO {
*/
private Long timeout;
+ /** Refresh Token 绝对有效期(单位:秒),轮换不会延长该期限。 */
+ private Long refreshTokenTimeout;
+
+ /** Refresh Token 传输模式:浏览器使用 COOKIE,App / 小程序使用 BODY。 */
+ private RefreshTokenModeEnum refreshTokenMode;
+
/**
* 是否允许同一账号多地同时登录(true:允许;false:新登录挤掉旧登录)
*/
diff --git a/continew-system/src/main/java/top/continew/admin/system/model/req/ClientReq.java b/continew-system/src/main/java/top/continew/admin/system/model/req/ClientReq.java
index 49ef2a8014..dd2f367f20 100644
--- a/continew-system/src/main/java/top/continew/admin/system/model/req/ClientReq.java
+++ b/continew-system/src/main/java/top/continew/admin/system/model/req/ClientReq.java
@@ -22,9 +22,12 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import top.continew.admin.common.enums.DisEnableStatusEnum;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
import top.continew.admin.system.enums.LogoutModeEnum;
import top.continew.admin.system.enums.ReplacedRangeEnum;
@@ -76,6 +79,18 @@ public class ClientReq implements Serializable {
@NotNull(message = "Token 有效期不能为空")
private Long timeout;
+ /** Refresh Token 绝对有效期(单位:秒)。 */
+ @Schema(description = "Refresh Token 有效期(单位:秒)", example = "2592000")
+ @NotNull(message = "Refresh Token 有效期不能为空")
+ @Min(value = 60, message = "Refresh Token 有效期不能少于 60 秒")
+ @Max(value = 315360000, message = "Refresh Token 有效期不能超过 10 年")
+ private Long refreshTokenTimeout;
+
+ /** Refresh Token 传输模式。 */
+ @Schema(description = "Refresh Token 传输模式", example = "COOKIE")
+ @NotNull(message = "Refresh Token 传输模式不能为空")
+ private RefreshTokenModeEnum refreshTokenMode;
+
/**
* 是否允许同一账号多地同时登录(true:允许;false:新登录挤掉旧登录)
*/
diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/ClientResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/ClientResp.java
index c6f292a7b9..8ef29f7c40 100644
--- a/continew-system/src/main/java/top/continew/admin/system/model/resp/ClientResp.java
+++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/ClientResp.java
@@ -24,6 +24,7 @@
import top.continew.admin.common.config.excel.DictExcelProperty;
import top.continew.admin.common.config.excel.ExcelDictConverter;
import top.continew.admin.common.enums.DisEnableStatusEnum;
+import top.continew.admin.auth.enums.RefreshTokenModeEnum;
import top.continew.admin.system.enums.LogoutModeEnum;
import top.continew.admin.system.enums.ReplacedRangeEnum;
import top.continew.starter.excel.converter.ExcelBaseEnumConverter;
@@ -83,38 +84,49 @@ public class ClientResp extends BaseDetailResp {
@ExcelProperty(value = "Token 有效期", order = 7)
private Long timeout;
+ /** Refresh Token 绝对有效期(单位:秒)。 */
+ @Schema(description = "Refresh Token 有效期(单位:秒)", example = "2592000")
+ @ExcelProperty(value = "Refresh Token 有效期", order = 8)
+ private Long refreshTokenTimeout;
+
+ /** Refresh Token 传输模式。 */
+ @Schema(description = "Refresh Token 传输模式", example = "COOKIE")
+ @ExcelProperty(value = "Refresh Token 传输模式", converter = ExcelBaseEnumConverter.class,
+ order = 9)
+ private RefreshTokenModeEnum refreshTokenMode;
+
/**
* 是否允许同一账号多地同时登录(true:允许;false:新登录挤掉旧登录)
*/
@Schema(description = "是否允许同一账号多地同时登录", example = "true")
- @ExcelProperty(value = "是否允许同一账号多地同时登录", order = 8)
+ @ExcelProperty(value = "是否允许同一账号多地同时登录", order = 10)
private Boolean isConcurrent;
/**
* 顶人下线的范围
*/
@Schema(description = "顶人下线的范围", example = "ALL_DEVICE_TYPE")
- @ExcelProperty(value = "顶人下线的范围", converter = ExcelBaseEnumConverter.class, order = 9)
+ @ExcelProperty(value = "顶人下线的范围", converter = ExcelBaseEnumConverter.class, order = 11)
private ReplacedRangeEnum replacedRange;
/**
* 同一账号最大登录数量(-1:不限制,只有在 isConcurrent=true,isShare=false 时才有效)
*/
@Schema(description = "同一账号最大登录数量", example = "-1")
- @ExcelProperty(value = "同一账号最大登录数量", order = 10)
+ @ExcelProperty(value = "同一账号最大登录数量", order = 12)
private Integer maxLoginCount;
/**
* 溢出人数的下线方式
*/
@Schema(description = "溢出人数的下线方式", example = "KICKOUT")
- @ExcelProperty(value = "溢出人数的下线方式", converter = ExcelBaseEnumConverter.class, order = 11)
+ @ExcelProperty(value = "溢出人数的下线方式", converter = ExcelBaseEnumConverter.class, order = 13)
private LogoutModeEnum overflowLogoutMode;
/**
* 状态
*/
@Schema(description = "状态", example = "1")
- @ExcelProperty(value = "状态", converter = ExcelBaseEnumConverter.class, order = 12)
+ @ExcelProperty(value = "状态", converter = ExcelBaseEnumConverter.class, order = 14)
private DisEnableStatusEnum status;
}
diff --git a/continew-system/src/main/java/top/continew/admin/system/service/UserService.java b/continew-system/src/main/java/top/continew/admin/system/service/UserService.java
index f173246ccc..c4b982be77 100644
--- a/continew-system/src/main/java/top/continew/admin/system/service/UserService.java
+++ b/continew-system/src/main/java/top/continew/admin/system/service/UserService.java
@@ -160,4 +160,12 @@ public interface UserService
* @return 用户数量
*/
Long countByDeptIds(List deptIds);
+
+ /**
+ * 根据部门 ID 查询直属用户 ID 列表。
+ *
+ * @param deptId 部门 ID
+ * @return 用户 ID 列表
+ */
+ List listIdByDeptId(Long deptId);
}
diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/ClientServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/ClientServiceImpl.java
index 2ad19912b2..2e90c1c814 100644
--- a/continew-system/src/main/java/top/continew/admin/system/service/impl/ClientServiceImpl.java
+++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/ClientServiceImpl.java
@@ -22,8 +22,12 @@
import cn.hutool.crypto.SecureUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import top.continew.admin.auth.model.query.OnlineUserQuery;
import top.continew.admin.auth.service.OnlineUserService;
+import top.continew.admin.auth.service.SessionInvalidationService;
+import top.continew.admin.auth.adapter.ClientPolicyLockTargetResolver;
+import top.continew.admin.auth.api.AuthPolicyWriteLocked;
import top.continew.admin.common.base.service.BaseServiceImpl;
import top.continew.admin.system.mapper.ClientMapper;
import top.continew.admin.system.model.entity.ClientDO;
@@ -34,6 +38,7 @@
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.util.validation.CheckUtils;
+import java.util.ArrayList;
import java.util.List;
/**
@@ -50,7 +55,7 @@ public class ClientServiceImpl
implements ClientService {
private final OnlineUserService onlineUserService;
-
+ private final SessionInvalidationService sessionInvalidationService;
@Override
public void beforeCreate(ClientReq req) {
req.setClientId(SecureUtil.md5(Base64.encode(IdUtil.fastSimpleUUID())
@@ -58,18 +63,45 @@ public void beforeCreate(ClientReq req) {
.replace(StringConstants.PLUS, StringConstants.EMPTY)));
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(ClientPolicyLockTargetResolver.class)
+ public void update(ClientReq req, Long id) {
+ super.update(req, id);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(ClientPolicyLockTargetResolver.class)
+ public void delete(List ids) {
+ super.delete(ids);
+ }
+
@Override
public void beforeDelete(List ids) {
// 如果还存在在线用户,则不能删除
OnlineUserQuery query = new OnlineUserQuery();
+ List clientIds = new ArrayList<>(ids.size());
for (Long id : ids) {
ClientDO client = this.getById(id);
query.setClientId(client.getClientId());
CheckUtils.throwIfNotEmpty(onlineUserService.list(query), "客户端 [{}] 还存在在线用户,不允许删除",
client.getClientId());
+ clientIds.add(client.getClientId());
+ }
+ // 所有客户端都通过在线校验后,再统一撤销长期会话,避免部分撤销后删除失败。
+ for (String clientId : clientIds) {
+ sessionInvalidationService.invalidateClient(clientId);
}
}
+ @Override
+ public void afterUpdate(ClientReq req, ClientDO entity) {
+ // 客户端配置全部属于认证策略。传输模式、有效期、并发规则或状态发生修改后,
+ // 旧 Session 不能继续按历史策略运行,统一要求重新登录。
+ sessionInvalidationService.invalidateClient(entity.getClientId());
+ }
+
@Override
public ClientResp getByClientId(String clientId) {
return baseMapper.lambdaQuery()
diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java
index dcf2dfa270..955d5aa4d4 100644
--- a/continew-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java
+++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java
@@ -23,6 +23,10 @@
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import top.continew.admin.auth.service.OnlineUserService;
+import top.continew.admin.auth.adapter.DeptUserPolicyLockTargetResolver;
+import top.continew.admin.auth.api.AuthPolicyWriteLocked;
import top.continew.admin.common.base.service.BaseServiceImpl;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.mapper.DeptMapper;
@@ -63,6 +67,9 @@ public class DeptServiceImpl
@Lazy
@Resource
private UserService userService;
+ @Lazy
+ @Resource
+ private OnlineUserService onlineUserService;
@Override
public void beforeCreate(DeptReq req) {
@@ -70,6 +77,13 @@ public void beforeCreate(DeptReq req) {
req.setAncestors(this.getAncestors(req.getParentId()));
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(DeptUserPolicyLockTargetResolver.class)
+ public void update(DeptReq req, Long id) {
+ super.update(req, id);
+ }
+
@Override
public void beforeUpdate(DeptReq req, Long id) {
this.checkNameRepeat(req.getName(), req.getParentId(), id);
@@ -106,6 +120,15 @@ public void beforeUpdate(DeptReq req, Long id) {
}
}
+ @Override
+ public void afterUpdate(DeptReq req, DeptDO entity) {
+ if (DisEnableStatusEnum.DISABLE.equals(req.getStatus())) {
+ // 部门禁用后,直属用户的 Access/Refresh Session 必须立即失效。下级部门要求
+ // 先逐级禁用,因此这里只处理当前部门即可。
+ userService.listIdByDeptId(entity.getId()).forEach(onlineUserService::kickOut);
+ }
+ }
+
@Override
public void beforeDelete(List ids) {
List list = baseMapper.lambdaQuery()
diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java
index 45a07d3714..195ee94456 100644
--- a/continew-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java
+++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java
@@ -18,7 +18,6 @@
import top.continew.admin.common.constant.GlobalConstants;
-import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.file.FileNameUtil;
@@ -53,6 +52,8 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.auth.service.OnlineUserService;
+import top.continew.admin.auth.support.UserArgumentPolicyLockTargetResolver;
+import top.continew.admin.auth.api.AuthPolicyWriteLocked;
import top.continew.admin.common.base.service.BaseServiceImpl;
import top.continew.admin.common.constant.CacheConstants;
import top.continew.admin.common.context.UserContext;
@@ -191,6 +192,7 @@ public void afterCreate(UserReq req, UserDO user) {
@Override
@Transactional(rollbackFor = Exception.class)
@CacheUpdate(key = "#id", value = "#req.nickname", name = CacheConstants.USER_KEY_PREFIX)
+ @AuthPolicyWriteLocked(UserArgumentPolicyLockTargetResolver.class)
public void update(UserReq req, Long id) {
this.checkUsernameRepeat(req.getUsername(), id);
this.checkEmailRepeat(req.getEmail(), id, "邮箱为 [{}] 的用户已存在");
@@ -231,6 +233,7 @@ public void update(UserReq req, Long id) {
@Override
@Transactional(rollbackFor = Exception.class)
@CacheInvalidate(key = "#ids", name = CacheConstants.USER_KEY_PREFIX, multi = true)
+ @AuthPolicyWriteLocked(UserArgumentPolicyLockTargetResolver.class)
public void delete(List ids) {
CheckUtils.throwIf(CollUtil.contains(ids, UserContextHolder.getUserId()), "不允许删除当前用户");
List list = baseMapper.lambdaQuery()
@@ -254,7 +257,9 @@ public void delete(List ids) {
// 删除用户
super.delete(ids);
// 踢出在线用户
- ids.forEach(onlineUserService::kickOut);
+ ids.forEach(id -> {
+ onlineUserService.kickOut(id);
+ });
}
@Override
@@ -413,6 +418,8 @@ public UserImportResp importUser(UserImportReq req) {
}
@Override
+ @Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(UserArgumentPolicyLockTargetResolver.class)
public void resetPassword(UserPasswordResetReq req, Long id) {
this.getById(id);
baseMapper.lambdaUpdate()
@@ -420,6 +427,8 @@ public void resetPassword(UserPasswordResetReq req, Long id) {
.set(UserDO::getPwdResetTime, LocalDateTime.now(GlobalConstants.DEFAULT_ZONE_ID))
.eq(UserDO::getId, id)
.update();
+ // 管理员重置密码后,旧设备上的长期凭证也必须全部失效。
+ onlineUserService.kickOut(id);
}
@Override
@@ -465,6 +474,7 @@ public void updateBasicInfo(UserBasicInfoUpdateReq req, Long id) {
@Override
@Transactional(rollbackFor = Exception.class)
+ @AuthPolicyWriteLocked(UserArgumentPolicyLockTargetResolver.class)
public void updatePassword(String oldPassword, String newPassword, Long id) {
CheckUtils.throwIfEqual(newPassword, oldPassword, "新密码不能与当前密码相同");
UserDO user = super.getById(id);
@@ -483,8 +493,8 @@ public void updatePassword(String oldPassword, String newPassword, Long id) {
.update();
// 保存历史密码
userPasswordHistoryService.add(id, password, passwordRepetitionTimes);
- // 修改后登出
- StpUtil.logout();
+ // 修改密码后全端登出,同时撤销全部长期 Refresh Session。
+ onlineUserService.kickOut(id);
}
@Override
@@ -538,6 +548,17 @@ public Long countByDeptIds(List deptIds) {
return baseMapper.lambdaQuery().in(UserDO::getDeptId, deptIds).count();
}
+ @Override
+ public List listIdByDeptId(Long deptId) {
+ return baseMapper.lambdaQuery()
+ .select(UserDO::getId)
+ .eq(UserDO::getDeptId, deptId)
+ .list()
+ .stream()
+ .map(UserDO::getId)
+ .toList();
+ }
+
@Override
protected List list(UserQuery query, SortQuery sortQuery, Class targetClass) {
QueryWrapper queryWrapper = this.buildQueryWrapper(query);
diff --git a/docker/nginx/conf/nginx.conf b/docker/nginx/conf/nginx.conf
index 71908b2c06..9c895cf576 100644
--- a/docker/nginx/conf/nginx.conf
+++ b/docker/nginx/conf/nginx.conf
@@ -49,7 +49,8 @@ http {
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # 覆盖客户端自带的 X-Forwarded-For,后端限流只接收本代理确认的来源地址。
+ proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -99,7 +100,7 @@ http {
# proxy_set_header Connection "Upgrade";
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
- # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # proxy_set_header X-Forwarded-For $remote_addr;
# proxy_set_header X-Forwarded-Proto $scheme;
#}
diff --git a/pom.xml b/pom.xml
index 18127405df..78c5e84710 100644
--- a/pom.xml
+++ b/pom.xml
@@ -27,6 +27,7 @@
continew-server
+ continew-auth-refresh
continew-system
continew-plugin
continew-common
@@ -62,6 +63,13 @@
${revision}
+
+
+ ${project.groupId}
+ continew-auth-refresh
+ ${revision}
+
+
${project.groupId}