|
|
@@ -0,0 +1,520 @@
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.config.JwtConfig;
|
|
|
+import com.etotem.cfc.config.JwtInterceptor;
|
|
|
+import com.etotem.cfc.dto.LoginResultDTO;
|
|
|
+import io.jsonwebtoken.Claims;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.InjectMocks;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
+import org.springframework.mock.web.MockHttpServletResponse;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:多端登录 / 会话互斥(能力键 {@code auth.session.mutex})。
|
|
|
+ *
|
|
|
+ * <p><b>溯源</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-85 —— 「多端登录/会话互斥」:
|
|
|
+ * 同账号多设备场景下,新设备登录 → 旧设备踢下线 / 二次验证;边界:异常设备风控;退出清 token。
|
|
|
+ * 模块载体 {@code AuthController}。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.1 认证与用户({@code /api/auth} 登录/登出
|
|
|
+ * token 生命周期);缺口定义见 {@code docs/superpowers/specs/2026-11-07-parent-rest-contract.md}
|
|
|
+ * §4 缺口 G-5 与 {@code docs/superpowers/specs/2026-11-07-cross-role-contract.md} §3 缺口 X-G-2:
|
|
|
+ * 「JWT 为无状态(JwtInterceptor 仅校验签名/过期),无会话注册表、无旧 token 吊销/踢出逻辑;
|
|
|
+ * 建议后端新增会话注册(user → 唯一 token/sessionId 映射,新登录失效旧 token)+
|
|
|
+ * {@code POST /api/auth/logout}」。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>契约 AC(本类断言面)</b></p>
|
|
|
+ * <ol>
|
|
|
+ * <li>AC-1 正常流:{@code POST /api/auth/logout} 存在(登出载体),返回 {@code Result},
|
|
|
+ * 能取到当前请求以定位待吊销 token。</li>
|
|
|
+ * <li>AC-2 正常流:登录签发 token 携带会话标识 claim({@code sid}),且每次登录唯一
|
|
|
+ * —— 无会话标识则无法区分设备、无法实现单端互斥。</li>
|
|
|
+ * <li>AC-3 正常流:存在会话注册表组件(user → 唯一 sid 映射,见 AC-5 候选类名),
|
|
|
+ * 并被 {@code AuthController} 注入用于登录登记。</li>
|
|
|
+ * <li>AC-4 边界流(互斥):注册表提供「按 userId 绑定唯一会话」+「按 (userId, sid) 校验活跃」两类方法
|
|
|
+ * —— 新设备登录覆盖旧 sid → 旧设备 token 校验失败(踢下线)。</li>
|
|
|
+ * <li>AC-5 边界流(踢出执行点):{@code JwtInterceptor} 注入会话校验组件,解析 token 后校验 sid 是否活跃,
|
|
|
+ * 失效 → 401(前端据此清登录态并回登录页)。</li>
|
|
|
+ * <li>AC-6 边界流(异常设备风控):登录结果暴露「需二次验证」标记,供前端对异常设备走二次验证。</li>
|
|
|
+ * <li>AC-7 异常流(退出清 token):注册表提供吊销/清除会话方法(登出后旧 token 立即失效)。</li>
|
|
|
+ * <li>AC-8 权限流:登出接口必须登录 —— 不得列入 {@code PUBLIC_PATHS},且须列入
|
|
|
+ * {@code LOGIN_REQUIRED_PATHS}(无 token 匿名请求直接 401,不得降级为匿名 userId=-1 放行)。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(RED 依据,2026-11-15 实测 origin/cfclub)</b>:{@code AuthController} 无 {@code /logout};
|
|
|
+ * {@code JwtConfig} 仅写 {@code userId}/{@code role}/{@code roles}/{@code mpCode},无会话标识;
|
|
|
+ * 全仓无 tokenVersion / kickout / blacklist / 会话注册表;{@code JwtInterceptor} 无会话校验依赖;
|
|
|
+ * {@code LoginResultDTO} 无二次验证标记;{@code LOGIN_REQUIRED_PATHS} 无 {@code /api/auth/logout}
|
|
|
+ * (无 token 时该路径走「匿名放行」分支,实测 preHandle 返回 true)。故本类断言在实现补齐前应红。</p>
|
|
|
+ *
|
|
|
+ * <p><b>GREEN 落地清单(本类断言的最小契约面,命名可按此对齐)</b></p>
|
|
|
+ * <ol>
|
|
|
+ * <li>{@code AuthController}:{@code @PostMapping("/logout") public Result<?> logout(HttpServletRequest request)},
|
|
|
+ * 从 {@code Authorization: Bearer} 取 token → 吊销当前会话。</li>
|
|
|
+ * <li>{@code JwtConfig.generateToken(...)}:写入 claim {@code sid}(每次登录新生成,唯一标识一次登录会话)。</li>
|
|
|
+ * <li>新增会话注册表组件,推荐类名 {@code com.etotem.cfc.service.UserSessionService}
|
|
|
+ * (候选:{@code SessionRegistryService} / {@code TokenSessionService} / {@code AuthSessionService}),
|
|
|
+ * 提供 {@code bindSession(Long userId, String sid)} / {@code isActive(Long userId, String sid)} /
|
|
|
+ * {@code revoke(Long userId, String sid)}(或语义等价命名)。</li>
|
|
|
+ * <li>{@code JwtInterceptor}:注入该注册表,token 解析后校验 sid 活跃,失效 → 401(body 含 {@code "code":401})。</li>
|
|
|
+ * <li>{@code LoginResultDTO}:暴露异常设备二次验证标记({@code needSecondVerify} 等)。</li>
|
|
|
+ * <li>{@code JwtInterceptor.LOGIN_REQUIRED_PATHS}:加入 {@code "/api/auth/logout"}。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p><b>测试手法</b>:纯反射 / Mockito,无 Spring 容器、无 DB、无 Redis、无外部服务;
|
|
|
+ * 与同批 RED 契约测试({@code TeamProfitHookContractTest} / {@code FamilyJoinValidationRulesContractTest})风格一致。</p>
|
|
|
+ */
|
|
|
+class AuthSessionMutexContractTest {
|
|
|
+
|
|
|
+ private static final String AUTH_CONTROLLER = "com.etotem.cfc.controller.auth.AuthController";
|
|
|
+
|
|
|
+ /** 会话注册表组件候选类名(第一个为推荐命名,GREEN 阶段任选其一即可) */
|
|
|
+ private static final String[] SESSION_REGISTRY_CANDIDATES = {
|
|
|
+ "com.etotem.cfc.service.UserSessionService",
|
|
|
+ "com.etotem.cfc.service.SessionRegistryService",
|
|
|
+ "com.etotem.cfc.service.TokenSessionService",
|
|
|
+ "com.etotem.cfc.service.AuthSessionService",
|
|
|
+ "com.etotem.cfc.security.UserSessionRegistry"
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 登录族端点(多端登录入口,Signing side 需登记会话) */
|
|
|
+ private static final String[] LOGIN_PATHS = {
|
|
|
+ "/phone-login", "/wechat-login", "/silent-login", "/auto-login", "/sso-login"
|
|
|
+ };
|
|
|
+
|
|
|
+ private static final String LOGOUT_PATH = "/logout";
|
|
|
+
|
|
|
+ /** 会话标识 claim 候选键(契约取 sid,兼容 jti) */
|
|
|
+ private static final String[] SESSION_ID_CLAIMS = {"sid", "jti", "sessionId", "session_id"};
|
|
|
+
|
|
|
+ /** 异常设备风控二次验证标记候选字段 */
|
|
|
+ private static final String[] SECOND_VERIFY_FIELDS = {
|
|
|
+ "needSecondVerify", "needsSecondVerify", "secondVerifyRequired",
|
|
|
+ "verifyRequired", "needVerify", "needDeviceVerify", "deviceVerifyRequired"
|
|
|
+ };
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private JwtInterceptor jwtInterceptor;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 正常流 AC-1:登出端点存在(token 生命周期收口)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:AuthController 应提供 POST /api/auth/logout 登出端点")
|
|
|
+ void normal_logoutEndpoint_declared() {
|
|
|
+ Class<?> controller = loadClass(AUTH_CONTROLLER);
|
|
|
+ assertNotNull(controller, "[auth.session.mutex] AuthController 类不存在:" + AUTH_CONTROLLER);
|
|
|
+
|
|
|
+ Method logout = findMethodByPostPath(controller, LOGOUT_PATH);
|
|
|
+ if (logout == null) {
|
|
|
+ fail("[auth.session.mutex] AuthController 缺少 @PostMapping(\"" + LOGOUT_PATH + "\") 登出端点:"
|
|
|
+ + "SC-P-85 要求「退出清 token」,契约设计(缺口 G-5 / X-G-2)明确需新增 POST /api/auth/logout。"
|
|
|
+ + "已声明映射:" + describedPostPaths(controller));
|
|
|
+ }
|
|
|
+ assertEquals(Result.class, logout.getReturnType(),
|
|
|
+ "[auth.session.mutex] /api/auth/logout 应返回统一 Result(code/message/data)。");
|
|
|
+ boolean takesRequest = false;
|
|
|
+ for (Class<?> p : logout.getParameterTypes()) {
|
|
|
+ if (HttpServletRequest.class.isAssignableFrom(p)) {
|
|
|
+ takesRequest = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(takesRequest,
|
|
|
+ "[auth.session.mutex] /api/auth/logout 应接收 HttpServletRequest,以便从 Authorization Header "
|
|
|
+ + "取出当前 token 并吊销该会话(登出清 token)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 正常流 AC-2:token 会话标识(互斥/踢出的判定依据)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:登录 token 应携带会话标识 claim(sid),且每次登录唯一")
|
|
|
+ void normal_loginToken_carriesUniqueSessionIdClaim() {
|
|
|
+ JwtConfig config = newJwtConfig("contract-test-secret-key-0123456789abcdef", 7200_000L);
|
|
|
+
|
|
|
+ String first = config.generateToken(1001L, "parent");
|
|
|
+ String second = config.generateToken(1001L, "parent");
|
|
|
+
|
|
|
+ Claims firstClaims = config.parseToken(first);
|
|
|
+ String firstSid = firstSessionId(firstClaims);
|
|
|
+ assertNotNull(firstSid,
|
|
|
+ "[auth.session.mutex] token claims 缺少会话标识(sid):当前 claims=" + claimKeys(firstClaims)
|
|
|
+ + "。无会话标识则无法区分设备会话,无法实现「新设备登录 → 旧设备踢下线」"
|
|
|
+ + "(缺口 G-5 / X-G-2:会话注册 user → 唯一 token/sessionId 映射)。");
|
|
|
+
|
|
|
+ String secondSid = firstSessionId(config.parseToken(second));
|
|
|
+ assertNotNull(secondSid,
|
|
|
+ "[auth.session.mutex] 第二次登录 token 同样应携带会话标识(sid)。");
|
|
|
+ assertFalse(firstSid.equals(secondSid),
|
|
|
+ "[auth.session.mutex] 同一 userId 的两次登录必须生成不同 sid(每次登录代表一个独立会话),"
|
|
|
+ + "否则无法判定哪一端是被踢下线的旧会话;实际两次均为 sid=" + firstSid);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 正常流 AC-3:会话注册表组件存在,且登录侧注入使用
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:会话注册表组件应存在并被 AuthController 注入(登录登记会话)")
|
|
|
+ void normal_sessionRegistry_existsAndInjectedByLoginController() {
|
|
|
+ Class<?> registry = findSessionRegistryClass();
|
|
|
+ assertNotNull(registry,
|
|
|
+ "[auth.session.mutex] 会话注册表组件不存在:契约要求 user → 唯一 token/sessionId 映射"
|
|
|
+ + "(新登录失效旧 token),推荐类名 com.etotem.cfc.service.UserSessionService,"
|
|
|
+ + "候选:" + Arrays.toString(SESSION_REGISTRY_CANDIDATES)
|
|
|
+ + "。当前仓库无任何会话注册/吊销载体(缺口 G-5 / X-G-2)。");
|
|
|
+
|
|
|
+ Class<?> controller = loadClass(AUTH_CONTROLLER);
|
|
|
+ assertNotNull(controller, "[auth.session.mutex] AuthController 类不存在:" + AUTH_CONTROLLER);
|
|
|
+ assertTrue(hasFieldOfType(controller, registry) || hasSessionLikeField(controller),
|
|
|
+ "[auth.session.mutex] AuthController 未注入会话注册表(" + registry.getSimpleName() + "):"
|
|
|
+ + "登录成功签发 token 时必须登记会话(user → sid),否则互斥无判定依据。"
|
|
|
+ + "当前字段:" + describedFieldTypes(controller));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 边界流 AC-4:注册表「覆盖式绑定 + 活跃校验」= 新设备踢旧设备
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:注册表应支持按 userId 绑定唯一会话并按 sid 校验活跃(新登录覆盖旧会话)")
|
|
|
+ void boundary_registry_singleSessionPerUser() {
|
|
|
+ Class<?> registry = findSessionRegistryClass();
|
|
|
+ if (registry == null) {
|
|
|
+ fail("[auth.session.mutex] 会话注册表组件不存在,无法验证单端互斥语义"
|
|
|
+ + "(同一 userId 仅保留一个活跃 sid;新登录覆盖旧会话 → 旧设备踢下线)。"
|
|
|
+ + "推荐类名 com.etotem.cfc.service.UserSessionService。");
|
|
|
+ }
|
|
|
+
|
|
|
+ assertTrue(hasBindMethod(registry),
|
|
|
+ "[auth.session.mutex] " + registry.getSimpleName()
|
|
|
+ + " 缺少「按 userId 绑定会话」方法(推荐 bindSession(Long userId, String sid)):"
|
|
|
+ + "同一 userId 只保留一个活跃 sid,新登录覆盖旧会话。当前方法:"
|
|
|
+ + describedMethodNames(registry));
|
|
|
+ assertTrue(hasActiveCheckMethod(registry),
|
|
|
+ "[auth.session.mutex] " + registry.getSimpleName()
|
|
|
+ + " 缺少「按 (userId, sid) 校验会话活跃」方法(推荐 isActive(Long userId, String sid)):"
|
|
|
+ + "JwtInterceptor 需据此判定旧设备 token 是否已被踢下线。当前方法:"
|
|
|
+ + describedMethodNames(registry));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 边界流 AC-5:踢下线执行点(JwtInterceptor 会话校验)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-2:JwtInterceptor 应注入会话校验组件(旧设备被踢 token → 401)")
|
|
|
+ void boundary_jwtInterceptor_enforcesSessionMutex() {
|
|
|
+ Class<?> registry = findSessionRegistryClass();
|
|
|
+ assertNotNull(registry,
|
|
|
+ "[auth.session.mutex] 会话注册表组件不存在:JwtInterceptor 无从校验 token 所属会话是否仍活跃,"
|
|
|
+ + "「旧设备踢下线」无法生效(当前 JwtInterceptor 仅校验签名/过期)。");
|
|
|
+
|
|
|
+ Class<?> interceptor = JwtInterceptor.class;
|
|
|
+ boolean injected = hasFieldOfType(interceptor, registry) || hasSessionLikeField(interceptor);
|
|
|
+ assertTrue(injected,
|
|
|
+ "[auth.session.mutex] JwtInterceptor 未注入会话注册表(" + registry.getSimpleName() + "):"
|
|
|
+ + "token 解析成功后必须校验 sid 是否活跃,失效 → 401 并返回统一 code=401"
|
|
|
+ + "(前端据此清登录态回登录页)。当前字段:" + describedFieldTypes(interceptor));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 边界流 AC-6:异常设备风控(二次验证)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-3:异常设备登录应暴露二次验证标记(风控边界)")
|
|
|
+ void boundary_abnormalDevice_requiresSecondVerification() {
|
|
|
+ boolean declared = false;
|
|
|
+ List<String> actual = new ArrayList<>();
|
|
|
+ for (Field f : LoginResultDTO.class.getDeclaredFields()) {
|
|
|
+ actual.add(f.getName());
|
|
|
+ for (String candidate : SECOND_VERIFY_FIELDS) {
|
|
|
+ if (f.getName().equalsIgnoreCase(candidate)) {
|
|
|
+ declared = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(declared,
|
|
|
+ "[auth.session.mutex] SC-P-85 边界「异常设备风控」无载体:LoginResultDTO 应暴露"
|
|
|
+ + "二次验证标记(推荐 needSecondVerify:Boolean),供前端对异常设备走二次验证后再放行登录。"
|
|
|
+ + "当前字段:" + actual);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 异常流 AC-7:退出清 token(吊销接口)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-1:注册表应提供吊销/清除会话方法(登出后旧 token 立即失效)")
|
|
|
+ void exception_logout_revokesSession() {
|
|
|
+ Class<?> registry = findSessionRegistryClass();
|
|
|
+ if (registry == null) {
|
|
|
+ fail("[auth.session.mutex] 会话注册表组件不存在,无法验证「退出清 token」:"
|
|
|
+ + "SC-P-85 要求登出后旧 token 立即失效(服务端吊销,而非仅前端清本地缓存)。"
|
|
|
+ + "推荐类名 com.etotem.cfc.service.UserSessionService。");
|
|
|
+ }
|
|
|
+ assertTrue(hasRevokeMethod(registry),
|
|
|
+ "[auth.session.mutex] " + registry.getSimpleName()
|
|
|
+ + " 缺少吊销/清除会话方法(推荐 revoke(Long userId, String sid)):"
|
|
|
+ + "POST /api/auth/logout 必须服务端吊销当前会话,登出后旧 token 再来请求应 401。"
|
|
|
+ + "当前方法:" + describedMethodNames(registry));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 权限流 AC-8:登出必须登录(不得匿名放行)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-1:/api/auth/logout 不得列入公开白名单(无 token 匿名请求必须 401)")
|
|
|
+ void permission_logout_mustRequireLogin() throws Exception {
|
|
|
+ String[] publicPaths = readStaticStringArray(JwtInterceptor.class, "PUBLIC_PATHS");
|
|
|
+ assertFalse(Arrays.asList(publicPaths).contains("/api/auth/logout"),
|
|
|
+ "[auth.session.mutex] /api/auth/logout 不得列入 JwtInterceptor.PUBLIC_PATHS:"
|
|
|
+ + "登出属凭证操作,必须携带有效 token 才能吊销对应会话。");
|
|
|
+
|
|
|
+ String[] loginRequiredPaths = readStaticStringArray(JwtInterceptor.class, "LOGIN_REQUIRED_PATHS");
|
|
|
+ assertTrue(Arrays.asList(loginRequiredPaths).contains("/api/auth/logout"),
|
|
|
+ "[auth.session.mutex] /api/auth/logout 应列入 JwtInterceptor.LOGIN_REQUIRED_PATHS:"
|
|
|
+ + "否则无 token 请求会被降级为匿名(userId=-1)放行到 controller,"
|
|
|
+ + "「退出清 token」对未登录态无意义且口径漂移。"
|
|
|
+ + "当前 LOGIN_REQUIRED_PATHS=" + Arrays.toString(loginRequiredPaths));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-2:无 token 调用 /api/auth/logout 应直接 401(不得匿名放行)")
|
|
|
+ void permission_logout_withoutToken_returns401() throws Exception {
|
|
|
+ MockHttpServletRequest request = new MockHttpServletRequest();
|
|
|
+ request.setMethod("POST");
|
|
|
+ request.setRequestURI("/api/auth/logout");
|
|
|
+ MockHttpServletResponse response = new MockHttpServletResponse();
|
|
|
+
|
|
|
+ boolean result = jwtInterceptor.preHandle(request, response, null);
|
|
|
+
|
|
|
+ assertFalse(result,
|
|
|
+ "[auth.session.mutex] 无 token 调用 /api/auth/logout 必须被拦截:当前 preHandle 返回 true"
|
|
|
+ + "(匿名 userId=-1 放行),登出接口对未登录请求无意义且会污染匿名上下文。");
|
|
|
+ assertEquals(401, response.getStatus(),
|
|
|
+ "[auth.session.mutex] 无 token 调用登出接口应返回 401。");
|
|
|
+ assertTrue(response.getContentAsString().contains("\"code\":401"),
|
|
|
+ "[auth.session.mutex] 应返回统一 code=401,实际: " + response.getContentAsString());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 反射 / 静态辅助
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Class<?> findSessionRegistryClass() {
|
|
|
+ for (String candidate : SESSION_REGISTRY_CANDIDATES) {
|
|
|
+ Class<?> clazz = loadClass(candidate);
|
|
|
+ if (clazz != null) {
|
|
|
+ return clazz;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 找到 @PostMapping 值命中指定路径的方法 */
|
|
|
+ private Method findMethodByPostPath(Class<?> clazz, String path) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ PostMapping mapping = m.getAnnotation(PostMapping.class);
|
|
|
+ if (mapping == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (String value : mapping.value()) {
|
|
|
+ if (normalize(value).equals(normalize(path))) {
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalize(String path) {
|
|
|
+ if (path == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String p = path.trim();
|
|
|
+ return p.startsWith("/") ? p : "/" + p;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> describedPostPaths(Class<?> clazz) {
|
|
|
+ List<String> paths = new ArrayList<>();
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ PostMapping mapping = m.getAnnotation(PostMapping.class);
|
|
|
+ if (mapping != null) {
|
|
|
+ paths.addAll(Arrays.asList(mapping.value()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return paths;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasFieldOfType(Class<?> clazz, Class<?> type) {
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (f.getType().equals(type)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 宽松探测:字段类型或字段名带会话互斥语义(允许实现者自命名组件) */
|
|
|
+ private boolean hasSessionLikeField(Class<?> clazz) {
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ String typeName = f.getType().getSimpleName().toLowerCase();
|
|
|
+ String name = f.getName().toLowerCase();
|
|
|
+ boolean typeHit = (typeName.contains("session") || typeName.contains("token"))
|
|
|
+ && (typeName.contains("registry") || typeName.contains("store") || typeName.contains("guard")
|
|
|
+ || typeName.contains("mutex") || typeName.contains("valid") || typeName.contains("revoke")
|
|
|
+ || typeName.contains("blacklist"));
|
|
|
+ boolean nameHit = name.contains("session") && (name.contains("valid") || name.contains("guard")
|
|
|
+ || name.contains("registry") || name.contains("mutex"));
|
|
|
+ if (typeHit || nameHit) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> describedFieldTypes(Class<?> clazz) {
|
|
|
+ List<String> types = new ArrayList<>();
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ types.add(f.getType().getSimpleName() + " " + f.getName());
|
|
|
+ }
|
|
|
+ return types;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> describedMethodNames(Class<?> clazz) {
|
|
|
+ List<String> names = new ArrayList<>();
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ names.add(m.getName());
|
|
|
+ }
|
|
|
+ return names;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 绑定方法:名称含 bind/register/create/open/activate/issue 语义,且至少 (userId, sid) 两个入参 */
|
|
|
+ private boolean hasBindMethod(Class<?> clazz) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ boolean nameHit = name.contains("bind") || name.contains("register") || name.contains("create")
|
|
|
+ || name.contains("open") || name.contains("activate") || name.contains("issue");
|
|
|
+ if (!nameHit) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Class<?>[] params = m.getParameterTypes();
|
|
|
+ if (params.length >= 2 && (params[0].equals(Long.class) || params[0].equals(long.class))
|
|
|
+ && params[1].equals(String.class)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 活跃校验方法:名称含 isActive/valid/check/exists 语义,返回 boolean */
|
|
|
+ private boolean hasActiveCheckMethod(Class<?> clazz) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ boolean nameHit = name.contains("isactive") || name.contains("active") || name.contains("valid")
|
|
|
+ || name.contains("alive") || name.contains("check") || name.contains("exists")
|
|
|
+ || name.contains("match");
|
|
|
+ if (!nameHit) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Class<?> ret = m.getReturnType();
|
|
|
+ if ((ret.equals(boolean.class) || ret.equals(Boolean.class)) && m.getParameterTypes().length >= 1) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 吊销方法:名称含 revoke/invalidate/clear/remove/delete/kill/logout 语义 */
|
|
|
+ private boolean hasRevokeMethod(Class<?> clazz) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ if (name.contains("revoke") || name.contains("invalidate") || name.contains("clear")
|
|
|
+ || name.contains("remove") || name.contains("delete") || name.contains("kill")
|
|
|
+ || name.contains("logout") || name.contains("expire")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String firstSessionId(Claims claims) {
|
|
|
+ for (String key : SESSION_ID_CLAIMS) {
|
|
|
+ Object value = claims.get(key);
|
|
|
+ if (value != null && value.toString().trim().length() > 0) {
|
|
|
+ return value.toString().trim();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> claimKeys(Claims claims) {
|
|
|
+ return new ArrayList<>(claims.keySet());
|
|
|
+ }
|
|
|
+
|
|
|
+ private String[] readStaticStringArray(Class<?> clazz, String fieldName) throws Exception {
|
|
|
+ Field field = clazz.getDeclaredField(fieldName);
|
|
|
+ field.setAccessible(true);
|
|
|
+ return (String[]) field.get(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private JwtConfig newJwtConfig(String secret, long expirationMillis) {
|
|
|
+ JwtConfig config = new JwtConfig();
|
|
|
+ setField(config, "secret", secret);
|
|
|
+ setField(config, "expiration", expirationMillis);
|
|
|
+ return config;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void setField(Object target, String name, Object value) {
|
|
|
+ try {
|
|
|
+ Field field = target.getClass().getDeclaredField(name);
|
|
|
+ field.setAccessible(true);
|
|
|
+ field.set(target, value);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new IllegalStateException("无法注入测试字段 " + name + ":" + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|