You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
digital-rmb-backend/docs/superpowers/plans/2026-08-03-single-backend-s...

428 lines
19 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 单后端 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<PlatformActor> findByPlatformUserId(long id)`
- `Optional<PlatformActor> 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<String> 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.