# 单后端 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.