From adfcf5a4be8c36dcf9f66359ccef321a45dfafa5 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Tue, 15 Sep 2026 01:57:16 +0800 Subject: [PATCH] fix(auth): preserve signed account identity across username reuse --- .../java/vip/mate/config/JwtAuthFilter.java | 7 ++- .../docs/en/managed-json-acceptance.md | 2 + .../docs/zh/managed-json-acceptance.md | 2 + .../config/JwtAuthFilterIdentityTest.java | 52 +++++++++++++++++++ .../GoalJsonHttpRuntimeIntegrationTest.java | 30 +++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/config/JwtAuthFilterIdentityTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java b/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java index 71aaf2a1..745c5b17 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java @@ -95,6 +95,10 @@ public class JwtAuthFilter extends OncePerRequestFilter { String username = claims.getSubject(); UserEntity user = authService.findByUsername(username); if (user == null || !Boolean.TRUE.equals(user.getEnabled())) return; + // A reused username must not turn an old signed token into the new account's identity. + // AuthService issues userId; missing or malformed claims require a fresh login. + Long tokenUserId = claims.get("userId", Long.class); + if (tokenUserId == null || !tokenUserId.equals(user.getId())) return; var auth = new UsernamePasswordAuthenticationToken( username, null, @@ -105,7 +109,8 @@ public class JwtAuthFilter extends OncePerRequestFilter { // 滑动窗口续期:Token 接近过期时自动签发新 Token if (authService.isNearExpiry(claims)) { - String newToken = authService.renewToken(username); + // Renew the validated identity, without resolving a potentially reassigned username again. + String newToken = authService.generateToken(user); if (newToken != null) { response.setHeader("X-New-Token", newToken); response.setHeader("Access-Control-Expose-Headers", "X-New-Token"); diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md index 977782d4..59d8383e 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -62,3 +62,5 @@ Recovery attempts receive guidance to inspect existing evidence before repeating From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers retain their existing attempt-owner validation when consuming input; this does not introduce an account path without a lease check. Approval replay restores the persisted runtime identity; approval does not renew an expired attempt lease or override account revocation. Legacy snapshots without an authenticated account ID cannot gain managed JSON access from a display username alone. + +JWT requests match the signed userId to the current enabled account ID. Recreating an account with the same username does not let the old token modify managed requirements or acquire the new runtime identity. A missing or malformed ID requires a fresh login. Sliding renewal retains the validated account identity. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md index b5329f37..f60066ae 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -64,3 +64,5 @@ Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web 会话归档或改绑 Agent 后,旧运行上下文不能再读取托管运行状态、发布、检查或完成目标;有权用户仍可读取已保存的证据。此检查与当前 Goal 身份和租约校验处于同一事务。 审批重放还原持久化的运行身份,但审批不会延长过期 attempt 租约,也不能覆盖账户撤权。缺少认证账户 ID 的旧快照不能仅凭显示用户名获得托管 JSON 权限。 + +JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户重新创建后,旧账户令牌不能修改托管要求或取得新账户的运行身份;缺失或格式错误的ID需要重新登录。滑动续期沿用已验证的账户身份。 diff --git a/mateclaw-server/src/test/java/vip/mate/config/JwtAuthFilterIdentityTest.java b/mateclaw-server/src/test/java/vip/mate/config/JwtAuthFilterIdentityTest.java new file mode 100644 index 00000000..afb89402 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/config/JwtAuthFilterIdentityTest.java @@ -0,0 +1,52 @@ +package vip.mate.config; + +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.pat.PersonalAccessTokenService; +import vip.mate.auth.service.AuthService; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class JwtAuthFilterIdentityTest { + @AfterEach void clearContext() { SecurityContextHolder.clearContext(); } + + @ParameterizedTest @ValueSource(strings = {"matching", "different", "missing", "malformed", "integer", "snowflake"}) + void onlyTheAccountNamedByTheSignedIdentityIsAuthenticated(String kind) throws Exception { + var service = mock(AuthService.class); + var user = new UserEntity(); user.setId(kind.equals("snowflake") ? 2099554193585278979L : 42L); + user.setUsername("alice"); user.setEnabled(true); user.setRole("user"); + var claims = Jwts.claims().subject("alice"); + switch (kind) { + case "matching", "snowflake" -> claims.add("userId", user.getId()); + case "integer" -> claims.add("userId", 42); + case "different" -> claims.add("userId", 41L); + case "malformed" -> claims.add("userId", "invalid"); + default -> { } + } + when(service.parseClaims("fixture")).thenReturn(claims.build()); + when(service.findByUsername("alice")).thenReturn(user); + when(service.isNearExpiry(any())).thenReturn(true); + when(service.generateToken(user)).thenReturn("renewed-same-identity"); + var request = new MockHttpServletRequest("GET", "/api/v1/goals/1/json-acceptance"); + request.addHeader("Authorization", "Bearer fixture"); + var response = new MockHttpServletResponse(); + new JwtAuthFilter(service, mock(PersonalAccessTokenService.class)).doFilter(request, response, (req, res) -> { }); + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (kind.equals("matching") || kind.equals("integer") || kind.equals("snowflake")) { + assertNotNull(authentication); + assertEquals(user.getId(), authentication.getDetails()); + assertEquals("renewed-same-identity", response.getHeader("X-New-Token")); + verify(service).generateToken(user); + } else { + assertNull(authentication); + verify(service, never()).generateToken(any()); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java index 066eaf3a..838bd5a0 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java @@ -274,6 +274,36 @@ class GoalJsonHttpRuntimeIntegrationTest { verify(modelFactory, atLeastOnce()).buildFor(any(), any()); } + @org.junit.jupiter.api.Test + void oldJwtCannotConfigureManagedRequirementsAfterUsernameIsReassigned() throws Exception { + String username = "reassigned-json-" + UUID.randomUUID(); + String conversation = UUID.randomUUID().toString(); + String password = "OfflineFixtureOnly-20260914"; + String hash = new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder().encode(password); + long oldId = IdWorker.getId(), newId = IdWorker.getId(); + jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", oldId, username, hash); + jdbc.update("INSERT INTO mate_conversation(id,conversation_id,username,workspace_id,agent_id,create_time,update_time,deleted) VALUES (?,?,?,1,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", IdWorker.getId(), conversation, username); + var create = new GoalCreateRequest(); create.setConversationId(conversation); create.setAgentId(1L); create.setWorkspaceId(1L); + create.setTitle("Reassigned account JSON fixture"); create.setDescription("Produce JSON"); create.setPersistentExecution(false); create.setAutoFollowupEnabled(false); + GoalEntity goal = goals.create(create, username); + String token = request("POST", "/api/v1/auth/login", null, Map.of("username", username, "password", password)).path("data").path("token").asText(); + assertFalse(token.isBlank()); + String path = "/api/v1/goals/" + goal.getId() + "/json-acceptance/requirements/r"; + assertEquals(200, request("PUT", path, token, Map.of("expectedRevision", "0", "artifactSlot", "report", "requiredFields", List.of("summary"))).path("code").asInt()); + // Simulate account retirement and a new account receiving the same username. + jdbc.update("UPDATE mate_user SET username=?,deleted=1,enabled=FALSE WHERE id=?", "retired-" + oldId, oldId); + jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", newId, username, hash); + var stale = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)) + .timeout(Duration.ofSeconds(15)).header("Content-Type", "application/json").header("Authorization", "Bearer " + token) + .PUT(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(Map.of("expectedRevision", "1", "artifactSlot", "report", "requiredFields", List.of("changed"))))).build(); + var rejected = HttpClient.newHttpClient().send(stale, HttpResponse.BodyHandlers.ofString()); + assertTrue(rejected.statusCode() == 401 || rejected.statusCode() == 403, rejected.statusCode() + ": " + rejected.body()); + assertEquals(1L, jdbc.queryForObject("SELECT revision FROM mate_goal_json_requirement WHERE goal_id=? AND criterion_key='r'", Long.class, goal.getId())); + String fresh = request("POST", "/api/v1/auth/login", null, Map.of("username", username, "password", password)).path("data").path("token").asText(); + assertFalse(fresh.isBlank()); + assertEquals(200, request("GET", "/api/v1/goals/" + goal.getId() + "/json-acceptance", fresh, null).path("code").asInt()); + } + private GoalRunCoordinator.ClaimedRun claim(GoalEntity goal) { var run = coordinator.claim(continuations.get(goal.getId()), goals.getById(goal.getId()), java.time.LocalDateTime.now()); assertNotNull(run);