docs: plan single backend SSO implementation

chore/issuance-comments-zh
chenyuan 2 months ago
parent 56b9899947
commit 0fb94fdf86

@ -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<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.
Loading…
Cancel
Save