19 KiB
单后端 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)andPlatformActor(long platformUserId, String account, String displayName, PlatformRole role, Instant tokenSigningTime). -
Step 1: Write the failing test
@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:
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
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
@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:
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
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
@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
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
@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
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
@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
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=andGET /api/v1/auth/cas/callback?ticket=redirect only to configured frontend callback withcode. -
GET /api/v1/auth/cas/loginredirects to configured CAS login URL. -
POST /api/v1/auth/session/exchangeexchanges JSON{"code":"..."}forSessionResponse. -
Step 1: Write the failing test
@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
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/mereturns user ID, account, display name and one role. -
POST /api/v1/auth/logoutrevokes only the current app refresh token. -
syncChangedSince(Instant watermark)performs source reads and projections only. -
Step 1: Write the failing test
@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
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
@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
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; onlyJwtTokenServicecreates API JWTs.