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 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}