diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java index 7a8eb5a2..6cb88497 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java @@ -18,48 +18,51 @@ public class GoalJsonAcceptanceController { @GetMapping public R get(@PathVariable Long goalId, Authentication auth) { - return R.ok(acceptance.get(goalId, username(auth))); + return authenticated(auth, username -> acceptance.get(goalId, username)); } @PutMapping("/requirements/{criterionKey}") public R configure(@PathVariable Long goalId, @PathVariable String criterionKey, @RequestBody GoalJsonAcceptanceService.ConfigureRequest request, Authentication auth) { - return R.ok(acceptance.configure(goalId, criterionKey, request, username(auth))); + return authenticated(auth, username -> acceptance.configure(goalId, criterionKey, request, username)); } @GetMapping("/artifacts") public R> artifacts(@PathVariable Long goalId, Authentication auth) { - return R.ok(artifacts.list(goalId, username(auth))); + return authenticated(auth, username -> artifacts.list(goalId, username)); } @PostMapping("/artifacts/{slot}") public R publish(@PathVariable Long goalId, @PathVariable String slot, @RequestBody ManagedGoalJsonService.PublishRequest request, Authentication auth) { - return R.ok(artifacts.publish(goalId, slot, request, username(auth))); + return authenticated(auth, username -> artifacts.publish(goalId, slot, request, username)); } @GetMapping("/artifacts/versions/{artifactId}") public R version(@PathVariable Long goalId, @PathVariable String artifactId, Authentication auth) { - return R.ok(artifacts.read(goalId, artifactId, username(auth))); + return authenticated(auth, username -> artifacts.read(goalId, artifactId, username)); } @GetMapping("/snapshot") public R snapshot(@PathVariable Long goalId, Authentication auth) { - return R.ok(bindings.snapshot(goalId, username(auth))); + return authenticated(auth, username -> bindings.snapshot(goalId, username)); } @GetMapping("/checks") public R> checks(@PathVariable Long goalId, Authentication auth) { - return R.ok(bindings.state(goalId, username(auth))); + return authenticated(auth, username -> bindings.state(goalId, username)); } @PostMapping("/checks/{criterionKey}") public R check(@PathVariable Long goalId, @PathVariable String criterionKey, @RequestBody vip.mate.goal.service.GoalJsonBindingService.CheckRequest request, Authentication auth) { - return R.ok(bindings.check(goalId, criterionKey, request, username(auth))); + return authenticated(auth, username -> bindings.check(goalId, criterionKey, request, username)); } - private static String username(Authentication auth) { - return auth != null && auth.isAuthenticated() ? auth.getName() : null; + private R authenticated(Authentication auth, java.util.function.Function operation) { + if (auth == null || !auth.isAuthenticated() || !(auth.getDetails() instanceof Long userId)) { + throw new vip.mate.exception.MateClawException(401, "Authenticated account ID required"); + } + return R.ok(acceptance.withAuthenticatedUser(userId, auth.getName(), operation)); } } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java index b7028059..d91d2c9b 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java @@ -28,6 +28,15 @@ public class GoalJsonAcceptanceService { public record View(boolean required, String status, List requirements) { } record GoalScope(long id, String conversationId, long workspaceId, long agentId, String status, boolean required) { } + /** Keep the HTTP caller's immutable identity locked through the complete managed operation. */ + @Transactional + public T withAuthenticatedUser(Long userId, String username, java.util.function.Function operation) { + if (userId == null || username == null || username.isBlank()) throw failure(401, "Authenticated account ID required"); + List names = jdbc.queryForList("SELECT username FROM mate_user WHERE id=? AND enabled=TRUE AND deleted=0 FOR UPDATE", String.class, userId); + if (names.size() != 1 || !username.equals(names.getFirst())) throw failure(403, "Authenticated account is no longer current"); + return operation.apply(names.getFirst()); + } + @Transactional public View get(Long goalId, String username) { GoalScope goal = authorizedGoal(goalId, username, true); 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 59d8383e..bac247c7 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 @@ -63,4 +63,4 @@ From V199, queued Web input stores the authenticated account ID at enqueue time, 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. +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. Managed HTTP operations also lock and recheck the authenticated account ID inside their transaction, retaining that lock until the read or write finishes. A username alone or an in-flight identity whose account was replaced cannot access these endpoints. 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 f60066ae..67264dcc 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 @@ -65,4 +65,4 @@ Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web 审批重放还原持久化的运行身份,但审批不会延长过期 attempt 租约,也不能覆盖账户撤权。缺少认证账户 ID 的旧快照不能仅凭显示用户名获得托管 JSON 权限。 -JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户重新创建后,旧账户令牌不能修改托管要求或取得新账户的运行身份;缺失或格式错误的ID需要重新登录。滑动续期沿用已验证的账户身份。 +JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户重新创建后,旧账户令牌不能修改托管要求或取得新账户的运行身份;缺失或格式错误的ID需要重新登录。滑动续期沿用已验证的账户身份。 托管HTTP操作还会在事务内按认证账户ID加锁复查,并持锁至读取或修改结束;仅有用户名或已被替换的在途身份不能访问这些接口。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java index 3b20a4db..519fa089 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -35,6 +35,7 @@ class GoalJsonAcceptanceIntegrationTest { @MockBean private MemoryManager memory; @Autowired private GoalService goals; @Autowired private GoalJsonAcceptanceService acceptance; + @Autowired private vip.mate.goal.controller.GoalJsonAcceptanceController jsonController; @Autowired private vip.mate.goal.service.ManagedGoalJsonService artifacts; @Autowired private JdbcTemplate jdbc; @Autowired private PlatformTransactionManager transactions; @@ -97,6 +98,37 @@ class GoalJsonAcceptanceIntegrationTest { assertNull(goals.findActiveByConversation(paused.getConversationId()), "History must not revive an inactive goal"); } + @ParameterizedTest @ValueSource(strings = {"reassigned", "missing"}) + void managedHttpBoundaryRechecksTheAuthenticatedAccountInsideItsTransaction(String kind) { + GoalEntity goal = goal(false); + Long originalId = jdbc.queryForObject("SELECT id FROM mate_user WHERE username=?", Long.class, alice); + var auth = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(alice, null, + List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_USER"))); + auth.setDetails(originalId); + jsonController.configure(goal.getId(), "r", request(0, "summary"), auth); + var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":false}"), alice); + bindings.check(goal.getId(), "r", checkRequest(1, version), alice); + if (kind.equals("reassigned")) { + // An in-flight request passed authentication before the account was replaced. + jdbc.update("UPDATE mate_user SET username=?,deleted=1,enabled=FALSE WHERE id=?", "retired-" + originalId, originalId); + jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", + IdWorker.getId(), alice, "unused-test-password"); + } else auth.setDetails(null); + assertAll( + () -> assertThrows(MateClawException.class, () -> jsonController.get(goal.getId(), auth)), + () -> assertThrows(MateClawException.class, () -> jsonController.artifacts(goal.getId(), auth)), + () -> assertThrows(MateClawException.class, () -> jsonController.version(goal.getId(), version.artifactId(), auth)), + () -> assertThrows(MateClawException.class, () -> jsonController.snapshot(goal.getId(), auth)), + () -> assertThrows(MateClawException.class, () -> jsonController.checks(goal.getId(), auth)), + () -> assertThrows(MateClawException.class, () -> jsonController.check(goal.getId(), "r", checkRequest(1, version), auth)), + () -> assertThrows(MateClawException.class, () -> jsonController.configure(goal.getId(), "r", request(1, "summary"), auth)), + () -> assertThrows(MateClawException.class, () -> jsonController.publish(goal.getId(), "report", publication(1, "{\"summary\":0}"), auth))); + assertEquals(1, bindings.snapshot(goal.getId(), alice).versionCount()); + assertEquals(1, acceptance.get(goal.getId(), alice).requirements().getFirst().revision()); + auth.setDetails(jdbc.queryForObject("SELECT id FROM mate_user WHERE username=? AND deleted=0", Long.class, alice)); + assertTrue(jsonController.get(goal.getId(), auth).getData().required()); + } + @Test void ownerCanPersistAndReviseRequirementsWithoutAcceptingAStaleEdit() { GoalEntity goal = goal(false); assertFalse(acceptance.get(goal.getId(), alice).required());