fix(auth): preserve signed account identity across username reuse

This commit is contained in:
mateaix 2026-09-15 01:57:16 +08:00
parent fd1fa859ba
commit adfcf5a4be
5 changed files with 92 additions and 1 deletions

View File

@ -95,6 +95,10 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String username = claims.getSubject(); String username = claims.getSubject();
UserEntity user = authService.findByUsername(username); UserEntity user = authService.findByUsername(username);
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) return; 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( var auth = new UsernamePasswordAuthenticationToken(
username, null, username, null,
@ -105,7 +109,8 @@ public class JwtAuthFilter extends OncePerRequestFilter {
// 滑动窗口续期Token 接近过期时自动签发新 Token // 滑动窗口续期Token 接近过期时自动签发新 Token
if (authService.isNearExpiry(claims)) { 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) { if (newToken != null) {
response.setHeader("X-New-Token", newToken); response.setHeader("X-New-Token", newToken);
response.setHeader("Access-Control-Expose-Headers", "X-New-Token"); response.setHeader("Access-Control-Expose-Headers", "X-New-Token");

View File

@ -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. 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. 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.

View File

@ -64,3 +64,5 @@ Web排队消息从V199起保存入队时已认证账户的内部ID普通Web
会话归档或改绑 Agent 后,旧运行上下文不能再读取托管运行状态、发布、检查或完成目标;有权用户仍可读取已保存的证据。此检查与当前 Goal 身份和租约校验处于同一事务。 会话归档或改绑 Agent 后,旧运行上下文不能再读取托管运行状态、发布、检查或完成目标;有权用户仍可读取已保存的证据。此检查与当前 Goal 身份和租约校验处于同一事务。
审批重放还原持久化的运行身份,但审批不会延长过期 attempt 租约,也不能覆盖账户撤权。缺少认证账户 ID 的旧快照不能仅凭显示用户名获得托管 JSON 权限。 审批重放还原持久化的运行身份,但审批不会延长过期 attempt 租约,也不能覆盖账户撤权。缺少认证账户 ID 的旧快照不能仅凭显示用户名获得托管 JSON 权限。
JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户重新创建后旧账户令牌不能修改托管要求或取得新账户的运行身份缺失或格式错误的ID需要重新登录。滑动续期沿用已验证的账户身份。

View File

@ -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());
}
}
}

View File

@ -274,6 +274,36 @@ class GoalJsonHttpRuntimeIntegrationTest {
verify(modelFactory, atLeastOnce()).buildFor(any(), any()); 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) { private GoalRunCoordinator.ClaimedRun claim(GoalEntity goal) {
var run = coordinator.claim(continuations.get(goal.getId()), goals.getById(goal.getId()), java.time.LocalDateTime.now()); var run = coordinator.claim(continuations.get(goal.getId()), goals.getById(goal.getId()), java.time.LocalDateTime.now());
assertNotNull(run); assertNotNull(run);