diff --git a/docs/superpowers/plans/2026-08-03-single-backend-sso-readonly-implementation.md b/docs/superpowers/plans/2026-08-03-single-backend-sso-readonly-implementation.md new file mode 100644 index 0000000..16e4587 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-single-backend-sso-readonly-implementation.md @@ -0,0 +1,427 @@ +# 单后端 SSO 与只读身份 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在现有 `digital-rmb-backend` 中实现主平台 Token 单点登录和学校 CAS 直接登录,并且只读镜像主平台的教师/学生身份与角色。 + +**Architecture:** `platform-integration` 通过专用只读数据源查询主平台 `core_user`、`teacher`、`student`,验证主平台 Token 或 CAS 账号后产出 `PlatformActor`。`identity` 投影为本地只读用户和固定角色;`security` 只签发本系统 JWT、刷新令牌与一次性兑换码。 + +**Tech Stack:** Java 17、Spring Boot 4.1.0、Spring Security、MyBatis-Plus 3.5.17、Lombok、Flyway、MySQL 8、JDK HttpClient/JAXP。 + +## Global Constraints + +- 不新增独立认证服务,不改造 `E:\javawork\tianze-pro`。 +- 用户、`sys_role`、`sys_user_role` 无 CRUD API;仅内部同步写本地投影。 +- 仅 `TEACHER`、`STUDENT`;`JT_S_02 → TEACHER`,`JT_S_03 → STUDENT`。 +- 主平台数据源仅使用 `DIGITAL_RMB_PLATFORM_DB_*` 配置,账号仅有 SELECT。 +- `/api/v1/auth/sso` 的 Token 最多两分钟;日志不记录 Token,最终 URL 不含 Token。 +- CAS 及前端回调地址为固定配置白名单;生产禁用 Bootstrap Admin 本地密码入口。 + +--- + +### Task 1: 平台集成配置与领域类型 + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java` +- Modify: `src/main/resources/application.yml`, `application-test.yml` +- Test: `src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java` + +**Interfaces:** +- Produces `PlatformRole.fromJobType(String)` and `PlatformActor(long platformUserId, String account, String displayName, PlatformRole role, Instant tokenSigningTime)`. + +- [ ] **Step 1: Write the failing test** + +```java +@Test void mapsOnlySupportedRoles() { + assertThat(PlatformRole.fromJobType("JT_S_02")).isEqualTo(PlatformRole.TEACHER); + assertThat(PlatformRole.fromJobType("JT_S_03")).isEqualTo(PlatformRole.STUDENT); + assertThatThrownBy(() -> PlatformRole.fromJobType("JT_S_01")) + .isInstanceOf(IllegalArgumentException.class); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=PlatformIntegrationPropertiesTest test -DforkCount=0 -B` + +Expected: FAIL because the types and configuration do not exist. + +- [ ] **Step 3: Write minimal implementation** + +Bind this exact configuration using `@ConfigurationProperties("platform-integration")` and Bean Validation: + +```yaml +platform-integration: + datasource: + url: ${DIGITAL_RMB_PLATFORM_DB_URL} + username: ${DIGITAL_RMB_PLATFORM_DB_USERNAME} + password: ${DIGITAL_RMB_PLATFORM_DB_PASSWORD} + token: + max-age: PT2M + teacher-claim-value: teacher + student-claim-value: student + cas: + login-url: ${DIGITAL_RMB_CAS_LOGIN_URL} + validate-url: ${DIGITAL_RMB_CAS_VALIDATE_URL} + callback-url: ${DIGITAL_RMB_CAS_CALLBACK_URL} + frontend: + callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -Dtest=PlatformIntegrationPropertiesTest test -DforkCount=0 -B` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```text +git add src/main/java/com/yau/digitalrmb/platformintegration src/main/resources/application*.yml src/test/java/com/yau/digitalrmb/platformintegration +git commit -m "feat: add platform integration configuration" +``` + +### Task 2: 主平台只读数据源与身份仓储 + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java` +- Test: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java` + +**Interfaces:** +- `Optional findByPlatformUserId(long id)` +- `Optional findBySchoolAccount(String account)` + +- [ ] **Step 1: Write the failing test** + +```java +@Test void resolvesEnabledTeacher() { + insertCoreUser(101L, "t001", "教师甲", "S1", "JT_S_02", 0); + insertTeacher(1L, 101L, 1, timestamp("2026-01-01T00:00:00Z")); + assertThat(repository.findByPlatformUserId(101L).orElseThrow().role()) + .isEqualTo(PlatformRole.TEACHER); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=JdbcPlatformIdentityRepositoryTest test -DforkCount=0 -B` + +Expected: FAIL because the repository does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create a named Hikari `DataSource` plus `JdbcClient`; do not use the primary datasource. Use these queries: + +```sql +SELECT cu.ID, cu.CODE, cu.NAME, cu.STATE, cu.JOB_TYPE1, cu.DEL_FLAG, + t.add_time AS signing_time, t.teacher_status AS profile_status +FROM core_user cu JOIN teacher t ON t.user_id = cu.ID +WHERE cu.ID = :userId AND cu.JOB_TYPE1 = 'JT_S_02' +``` + +Use the corresponding `student` query with `s.add_time` and `s.student_status`. Require `STATE='S1'`, `DEL_FLAG=0`, profile status `1`, non-null `add_time`; return empty for all other cases. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -Dtest=JdbcPlatformIdentityRepositoryTest test -DforkCount=0 -B` + +Expected: PASS for teacher/student and empty for deleted, disabled, admin and missing profile. + +- [ ] **Step 5: Commit** + +```text +git add src/main/java/com/yau/digitalrmb/platformintegration src/test/java/com/yau/digitalrmb/platformintegration +git commit -m "feat: add readonly platform identity repository" +``` + +### Task 3: 主平台 Token 动态 HMAC 校验 + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenException.java` +- Test: `src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java` + +**Interfaces:** +- `VerifiedPlatformToken verify(String rawToken, Instant now)` returns platform user ID, role and SHA-256 fingerprint. + +- [ ] **Step 1: Write the failing test** + +```java +@Test void acceptsFreshTeacherTokenSignedWithAddTime() { + String token = platformToken(101L, "1", "teacher", actor.tokenSigningTime(), clock.instant()); + assertThat(verifier.verify(token, clock.instant()).platformUserId()).isEqualTo(101L); +} +@Test void rejectsTamperedOrStaleToken() { + assertThatThrownBy(() -> verifier.verify(tampered, clock.instant())) + .isInstanceOf(PlatformTokenException.class); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B` + +Expected: FAIL because the verifier does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Accept exactly a three-part JWT plus one decimal millisecond suffix. Parse unverified claims only to obtain its single audience user ID and identity claim; resolve the actor first. Require the configured `teacher` or `student` claim value, construct an HS256 key from `String.valueOf(actor.tokenSigningTime().toEpochMilli())`, then verify the three-part JWT. Reject token timestamps older than 120 seconds or more than 30 seconds ahead. Compute SHA-256 of the complete raw value; never log raw Token or hash. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B` + +Expected: PASS for valid teacher/student; PASS for malformed, modified, stale, future, role mismatch and unknown-user rejection. + +- [ ] **Step 5: Commit** + +```text +git add src/main/java/com/yau/digitalrmb/platformintegration src/test/java/com/yau/digitalrmb/platformintegration +git commit -m "feat: verify platform SSO tokens" +``` + +### Task 4: 用户/角色只读投影与会话表 + +**Files:** +- Create: `src/main/resources/db/migration/V2__create_platform_identity_and_auth_tables.sql` +- Create: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java` +- Create: `src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java` +- Create: `src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/mapper/PlatformUserSnapshotMapper.java` +- Test: `src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java` + +**Interfaces:** +- `void project(PlatformActor actor)` upserts snapshot and creates exactly one fixed role association. + +- [ ] **Step 1: Write the failing test** + +```java +@Test void projectionIsIdempotentAndOwnsExactlyOneRole() { + projection.project(teacher); + projection.project(teacher); + assertThat(userRoleCount(teacher.platformUserId())).isEqualTo(1); + assertThat(snapshotRole(teacher.platformUserId())).isEqualTo("TEACHER"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=PlatformIdentityProjectionServiceTest test -DforkCount=0 -B` + +Expected: FAIL because the migration and projection service do not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `platform_user_snapshot(platform_user_id BIGINT PRIMARY KEY, account VARCHAR(64), display_name VARCHAR(64), role_key VARCHAR(16), source_updated_at TIMESTAMP, synced_at TIMESTAMP)`, `auth_login_exchange_code(code_hash CHAR(64) PRIMARY KEY, platform_user_id BIGINT, expires_at TIMESTAMP, consumed_at TIMESTAMP NULL)`, and `auth_refresh_token(token_hash CHAR(64) PRIMARY KEY, platform_user_id BIGINT, expires_at TIMESTAMP, revoked_at TIMESTAMP NULL)`. Seed `sys_role` with stable IDs 1001/1002 for `TEACHER`/`STUDENT`. In one transaction upsert the snapshot, replace only its system-managed `sys_user_role` row, and insert its current role. No controller calls this service. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -Dtest=IdentityPersistenceTest,PlatformIdentityProjectionServiceTest test -DforkCount=0 -B` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```text +git add src/main/resources/db/migration src/main/java/com/yau/digitalrmb/identity src/test/java/com/yau/digitalrmb/identity +git commit -m "feat: add readonly platform identity projection" +``` + +### Task 5: 兑换码、刷新令牌与应用 JWT + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java` +- Create: `src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java` +- Create: `src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java` +- Create: `src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java` +- Modify: `src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java` +- Test: `src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java` + +**Interfaces:** +- `String issue(long userId)`, `long exchange(String code)` +- `JwtTokenService.issueFor(long id, String account, Set roles)` + +- [ ] **Step 1: Write the failing test** + +```java +@Test void exchangeCodeCanOnlyBeUsedOnce() { + String code = service.issue(101L); + assertThat(service.exchange(code)).isEqualTo(101L); + assertThatThrownBy(() -> service.exchange(code)).isInstanceOf(BusinessException.class); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=LoginExchangeCodeServiceTest test -DforkCount=0 -B` + +Expected: FAIL because the session services do not exist. + +- [ ] **Step 3: Write minimal implementation** + +Use `SecureRandom` URL-safe Base64 values; persist only SHA-256. Consume with `WHERE consumed_at IS NULL AND expires_at > CURRENT_TIMESTAMP`. Exchange code TTL is 60 seconds; access JWT TTL is 15 minutes; refresh TTL is 8 hours. JWT claims are `sub=platformUserId`, `preferred_username`, `roles`; map roles to `ROLE_TEACHER` and `ROLE_STUDENT` in Spring Security. Allow the existing bootstrap password endpoint only for local/dev profiles. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -Dtest=LoginExchangeCodeServiceTest,AuthControllerTest test -DforkCount=0 -B` + +Expected: PASS for expired/replayed codes and role-bearing JWT. + +- [ ] **Step 5: Commit** + +```text +git add src/main/java/com/yau/digitalrmb/security src/test/java/com/yau/digitalrmb/security +git commit -m "feat: add exchange-code application sessions" +``` + +### Task 6: 主平台 SSO 和 CAS 直接登录接口 + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java` +- Create: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java` +- Modify: `src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java` +- Modify: `src/main/java/com/yau/digitalrmb/shared/web/TraceIdFilter.java` +- Test: `src/test/java/com/yau/digitalrmb/platformintegration/interfaces/AuthenticationFlowControllerTest.java` + +**Interfaces:** +- `GET /api/v1/auth/sso?token=` and `GET /api/v1/auth/cas/callback?ticket=` redirect only to configured frontend callback with `code`. +- `GET /api/v1/auth/cas/login` redirects to configured CAS login URL. +- `POST /api/v1/auth/session/exchange` exchanges JSON `{"code":"..."}` for `SessionResponse`. + +- [ ] **Step 1: Write the failing test** + +```java +@Test void ssoRedirectDoesNotLeakIncomingToken() throws Exception { + mvc.perform(get("/api/v1/auth/sso").param("token", validToken)) + .andExpect(status().isFound()) + .andExpect(header().string("Location", startsWith(frontendCallback + "?code="))) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(header().string("Referrer-Policy", "no-referrer")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=AuthenticationFlowControllerTest test -DforkCount=0 -B` + +Expected: FAIL because controllers and CAS validator do not exist. + +- [ ] **Step 3: Write minimal implementation** + +Build CAS URL using the configured fixed callback. Use JDK `HttpClient` with five-second timeout to call `serviceValidate`. Parse `serviceResponse/authenticationSuccess/user` using namespace-aware JAXP with DOCTYPE, external entities and XInclude disabled. Both SSO and CAS flows call repository → projection → exchange-code service. Permit only SSO, CAS start/callback and session exchange anonymously. Add `Cache-Control: no-store` / `Referrer-Policy: no-referrer`; redact query parameter `token` in every request log. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -Dtest=AuthenticationFlowControllerTest test -DforkCount=0 -B` + +Expected: PASS for valid redirects, bad Ticket, XXE payload, disabled user and redirect without token/JWT. + +- [ ] **Step 5: Commit** + +```text +git add src/main/java/com/yau/digitalrmb/platformintegration src/main/java/com/yau/digitalrmb/security src/main/java/com/yau/digitalrmb/shared src/test/java/com/yau/digitalrmb/platformintegration +git commit -m "feat: add platform SSO and CAS login endpoints" +``` + +### Task 7: 当前用户、注销、同步任务和 Swagger + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java` +- Create: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java` +- Modify: `src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java` +- Modify: `src/main/java/com/yau/digitalrmb/shared/config/OpenApiConfig.java` +- Test: `src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java` + +**Interfaces:** +- `GET /api/v1/auth/me` returns user ID, account, display name and one role. +- `POST /api/v1/auth/logout` revokes only the current app refresh token. +- `syncChangedSince(Instant watermark)` performs source reads and projections only. + +- [ ] **Step 1: Write the failing test** + +```java +@Test void currentUserIsReadonlyTeacher() throws Exception { + mvc.perform(get("/api/v1/auth/me").header("Authorization", bearerTeacherJwt)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roles[0]").value("TEACHER")); + mvc.perform(post("/api/v1/users")).andExpect(status().is4xxClientError()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=CurrentUserAndLogoutTest test -DforkCount=0 -B` + +Expected: FAIL because current-user/logout/sync features do not exist. + +- [ ] **Step 3: Write minimal implementation** + +Enable scheduled sync only when `platform-integration.sync.enabled=true`; select changed source rows by `update_Time` or `CREATE_TIME`, then project each result. Expose no user or role controller. Swagger tags only authentication/current-user endpoints and shows the Bearer scheme. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -Dtest=CurrentUserAndLogoutTest,ApplicationContextTest test -DforkCount=0 -B` + +Expected: PASS; Swagger exposes no user/role CRUD. + +- [ ] **Step 5: Commit** + +```text +git add src/main/java/com/yau/digitalrmb/identity src/main/java/com/yau/digitalrmb/security src/main/java/com/yau/digitalrmb/shared src/test/java/com/yau/digitalrmb/security +git commit -m "feat: add readonly current-user and logout APIs" +``` + +### Task 8: 全量验证与运维文档 + +**Files:** +- Create: `README.md` +- Create: `src/test/java/com/yau/digitalrmb/security/EndToEndAuthenticationFlowTest.java` +- Modify: `docs/superpowers/specs/2026-08-03-platform-sso-readonly-design.md` + +- [ ] **Step 1: Write the failing test** + +```java +@Test void ssoExchangeThenMeCompletesWithoutPersistingParentToken() { + String code = startPlatformSso(validTeacherToken); + String jwt = exchange(code).accessToken(); + assertThat(getMe(jwt).roles()).containsExactly("TEACHER"); + assertThat(databaseContainsRawPlatformToken()).isFalse(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -Dtest=EndToEndAuthenticationFlowTest test -DforkCount=0 -B` + +Expected: FAIL until every flow component is wired. + +- [ ] **Step 3: Write minimal documentation** + +Document all of `DIGITAL_RMB_PLATFORM_DB_URL`, `DIGITAL_RMB_PLATFORM_DB_USERNAME`, `DIGITAL_RMB_PLATFORM_DB_PASSWORD`, `DIGITAL_RMB_CAS_LOGIN_URL`, `DIGITAL_RMB_CAS_VALIDATE_URL`, `DIGITAL_RMB_CAS_CALLBACK_URL`, `DIGITAL_RMB_FRONTEND_CALLBACK_URL`, and the app JWT secret. State that platform DB credentials have only SELECT. Include local Swagger `http://localhost:8081/swagger-ui/index.html`. + +- [ ] **Step 4: Run verification** + +Run: `mvn test -DforkCount=0 -B` + +Run: `mvn spring-boot:run -Dspring-boot.run.profiles=local -Dspring-boot.run.arguments=--server.port=8081` + +Expected: all tests pass, logs print Swagger URL, `/v3/api-docs` returns 200, and no raw platform Token is persisted or logged. + +- [ ] **Step 5: Commit** + +```text +git add README.md docs src/test/java/com/yau/digitalrmb/security +git commit -m "docs: document readonly SSO operations" +``` + +## Plan Self-Review + +- Spec coverage: Tasks 1-3 implement configuration, source lookup and dynamic-HMAC Token validation; Task 4 projects only teacher/student identity; Tasks 5-6 implement both login paths and safe handoff; Task 7 enforces read-only/current-user/logout/sync; Task 8 validates and documents operations. +- Placeholder scan: deployment values are explicit environment variables rather than source-controlled secrets; no implementation placeholder remains. +- Type consistency: Token and CAS paths both yield `PlatformActor`, both project it, then both issue a one-time code; only `JwtTokenService` creates API JWTs. +