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