Merge remote-tracking branch 'origin/master'
# Conflicts: # src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java # src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java # src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java # src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java # src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java # src/main/resources/application-test.yml # src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.javamaster
commit
7c078e7d1b
@ -0,0 +1,185 @@
|
|||||||
|
# Current User Context 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:** Add no-argument current-user access for backend code and return the same complete profile from GET /api/v1/auth/me.
|
||||||
|
|
||||||
|
**Architecture:** PlatformTokenVerifier parses verified profile claims into VerifiedPlatformToken. LocalSsoAccountService saves the profile in platform_user_snapshot. CurrentUserService reads that snapshot from the Jwt in SecurityContext, and AuthController delegates to it.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 8, Spring Boot 2.7, Spring Security, MyBatis-Plus, H2, JUnit 5, MockMvc.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Keep Java 8 and ApiResponse.
|
||||||
|
- userId and raw roleid are long; school, college, major, class, and student identifiers are String.
|
||||||
|
- Do not put profile data in application JWTs or expose passwords or Tokens.
|
||||||
|
- Business requests only read the local snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Parse complete profile claims
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java
|
||||||
|
- Modify: src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java
|
||||||
|
- Modify: src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java
|
||||||
|
|
||||||
|
**Interfaces:** VerifiedPlatformToken gains schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleId, name, classId, className, studentId.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
~~~java
|
||||||
|
@Test
|
||||||
|
void parsesCompleteUserProfileFromVerifiedToken() throws Exception {
|
||||||
|
VerifiedPlatformToken token = verifier.verify(token(487L, "tzs001", "new-password", 2L,
|
||||||
|
now.plus(Duration.ofMinutes(5)), completeProfile()));
|
||||||
|
|
||||||
|
assertThat(token.getSchoolId()).isEqualTo("610000");
|
||||||
|
assertThat(token.getCollegeName()).isEqualTo("Computer College");
|
||||||
|
assertThat(token.getMajorName()).isEqualTo("Software Engineering");
|
||||||
|
assertThat(token.getRoleId()).isEqualTo(2L);
|
||||||
|
assertThat(token.getName()).isEqualTo("Test Student");
|
||||||
|
assertThat(token.getClassId()).isEqualTo("202401");
|
||||||
|
assertThat(token.getStudentId()).isEqualTo("20240001");
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify red**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B
|
||||||
|
Expected: FAIL because the new getters do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Add immutable fields and a full constructor to VerifiedPlatformToken while retaining the old constructor for existing callers. In PlatformTokenVerifier, preserve parsed roleid as roleId; parse name with username fallback and all remaining profile fields via:
|
||||||
|
|
||||||
|
~~~java
|
||||||
|
private String optionalText(JsonNode payload, String field) {
|
||||||
|
JsonNode node = payload.path(field);
|
||||||
|
return node.isMissingNode() || node.isNull() ? null : node.asText();
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify green**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 2: Persist the profile snapshot
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: src/main/resources/schema.sql
|
||||||
|
- Modify: src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java
|
||||||
|
- Modify: src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java
|
||||||
|
- Modify: src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java
|
||||||
|
|
||||||
|
**Interfaces:** synchronize(VerifiedPlatformToken) upserts every complete profile field in platform_user_snapshot.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
~~~java
|
||||||
|
@Test
|
||||||
|
void synchronizesCompleteProfileIntoLocalSnapshot() {
|
||||||
|
service.synchronize(completeToken(603L, "sso603", 2L));
|
||||||
|
|
||||||
|
Map<String, Object> row = new JdbcTemplate(dataSource).queryForMap(
|
||||||
|
"SELECT school_id, college_name, major_name, role_id, class_name, student_id "
|
||||||
|
+ "FROM platform_user_snapshot WHERE platform_user_id = 603");
|
||||||
|
assertThat(row).containsEntry("school_id", "610000")
|
||||||
|
.containsEntry("college_name", "Computer College")
|
||||||
|
.containsEntry("major_name", "Software Engineering")
|
||||||
|
.containsEntry("role_id", 2L)
|
||||||
|
.containsEntry("class_name", "Class 1")
|
||||||
|
.containsEntry("student_id", "20240001");
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify red**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=LocalSsoAccountServiceTest test -DforkCount=0 -B
|
||||||
|
Expected: FAIL because the columns do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Add nullable school_id, school_name, college_id, college_name, major_id, major_name, role_id, class_id, class_name, student_id columns to the table creation and idempotent ADD COLUMN IF NOT EXISTS statements afterward. Map them with TableField in the snapshot Entity and copy token fields in upsertSnapshot.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify green**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=LocalSsoAccountServiceTest test -DforkCount=0 -B
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Implement the common current-user service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java
|
||||||
|
- Create: src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java
|
||||||
|
- Create: src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java
|
||||||
|
|
||||||
|
**Interfaces:** CurrentUserService.getCurrentUser() returns schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleid, userId, username, name, classId, className, studentid.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Synchronize a complete profile, put JwtAuthenticationToken for that user into SecurityContextHolder, call currentUserService.getCurrentUser(), and assert all fields. Also test no snapshot and assert BusinessException ErrorCode.UNAUTHORIZED. Clear SecurityContextHolder in AfterEach.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify red**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=CurrentUserServiceTest test -DforkCount=0 -B
|
||||||
|
Expected: FAIL because the service does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
~~~java
|
||||||
|
public CurrentUser getCurrentUser() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) {
|
||||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份无效");
|
||||||
|
}
|
||||||
|
long userId = parseUserId((Jwt) authentication.getPrincipal());
|
||||||
|
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(userId);
|
||||||
|
if (snapshot == null) {
|
||||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在");
|
||||||
|
}
|
||||||
|
return map(snapshot);
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
map converts the complete snapshot, and parseUserId maps malformed subjects to UNAUTHORIZED.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify green**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=CurrentUserServiceTest test -DforkCount=0 -B
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 4: Extend /auth/me
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java
|
||||||
|
- Modify: src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java
|
||||||
|
- Modify: src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java
|
||||||
|
- Modify: src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java
|
||||||
|
|
||||||
|
**Interfaces:** GET /api/v1/auth/me returns exactly the 13 CurrentUser fields in data.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing endpoint test**
|
||||||
|
|
||||||
|
Synchronize complete Token data and assert data.userId, username, name, schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleid, classId, className, studentid. Update local login assertion to username.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify red**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=CurrentUserAndLogoutTest,AuthControllerTest test -DforkCount=0 -B
|
||||||
|
Expected: FAIL because the response DTO lacks the fields.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Replace CurrentUserResponse with the 13 CurrentUser fields. Inject CurrentUserService and have /me map currentUserService.getCurrentUser() into the response; do not query the Mapper in that method.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify green and build**
|
||||||
|
|
||||||
|
Run: mvn -Dtest=CurrentUserAndLogoutTest,AuthControllerTest test -DforkCount=0 -B
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
Run: mvn test -DforkCount=0 -B
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
Run: mvn package -DskipTests -B
|
||||||
|
Expected: BUILD SUCCESS.
|
||||||
@ -0,0 +1,262 @@
|
|||||||
|
# Local Token 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:** Replace database-coupled platform SSO with shared-secret JWT verification, local user/password synchronization, and direct system-JWT redirect.
|
||||||
|
|
||||||
|
**Architecture:** The SSO boundary verifies a three-segment HS256 parent JWT with a configured shared secret and produces validated claims. A transactional local account service finds or creates `sys_user` by `userId`, synchronizes the profile, role, and BCrypt password hash, then the controller issues the existing system JWT and redirects it to the frontend. No runtime component connects to or queries a platform database.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 8, Spring Boot 2.7.18, Spring Security OAuth2 JOSE/Nimbus, MyBatis-Plus, H2 test profile, BCrypt.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Never connect to, query, or synchronize from the main platform database.
|
||||||
|
- Verify the parent JWT with `platform-integration.token.link-secret-key`; never log or commit the shared secret, parent token, or password claim.
|
||||||
|
- Require parent claims `userId`, `username`, `password`, and `roleid`.
|
||||||
|
- Use `sys_user.id = userId`; map `roleid == 3` to `TEACHER`, all other values to `STUDENT`.
|
||||||
|
- Every valid SSO login overwrites the local password hash with BCrypt of the parent `password` claim.
|
||||||
|
- The application has one local datasource and `spring.datasource.hikari.minimum-idle: 2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Verify the parent Token without a platform repository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java`
|
||||||
|
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java`
|
||||||
|
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java`
|
||||||
|
- Test: `src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java`
|
||||||
|
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java`
|
||||||
|
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces `VerifiedPlatformToken verify(String rawToken)` with `userId()`, `username()`, `displayName()`, `rawPassword()`, `roleKey()`, and `optionalClaims()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing verifier tests.**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void acceptsSignedParentTokenWithoutDatabaseLookup() {
|
||||||
|
VerifiedPlatformToken token = verifier.verify(parentToken(claims(
|
||||||
|
"userId", 487L, "username", "tzs001", "password", "new-password", "roleid", 2L)));
|
||||||
|
assertThat(token.userId()).isEqualTo(487L);
|
||||||
|
assertThat(token.username()).isEqualTo("tzs001");
|
||||||
|
assertThat(token.rawPassword()).isEqualTo("new-password");
|
||||||
|
assertThat(token.roleKey()).isEqualTo("STUDENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTamperedExpiredOrIncompleteToken() {
|
||||||
|
assertThatThrownBy(() -> verifier.verify(tamperedToken)).isInstanceOf(PlatformTokenException.class);
|
||||||
|
assertThatThrownBy(() -> verifier.verify(expiredToken)).isInstanceOf(PlatformTokenException.class);
|
||||||
|
assertThatThrownBy(() -> verifier.verify(parentToken(claims("userId", 487L))))
|
||||||
|
.isInstanceOf(PlatformTokenException.class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the verifier test to prove the old four-segment, repository-backed verifier fails.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=PlatformTokenVerifierTest test`
|
||||||
|
|
||||||
|
Expected: FAIL because the old verifier requires `PlatformIdentityRepository`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal shared-secret verifier.**
|
||||||
|
|
||||||
|
Use the already available Spring Security JOSE support with a `SecretKeySpec(linkSecretKey.getBytes(UTF_8), "HmacSHA256")` to decode an HS256 JWT and validate its standard expiration. Reject blank or missing `userId`, `username`, `password`, or non-numeric `roleid`. Set `roleKey` to `TEACHER` only for `roleid == 3`; retain raw password only in-process and exclude it from logs, exceptions, and responses. Replace the old `Token` properties with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public static class Token {
|
||||||
|
@NotBlank
|
||||||
|
private String linkSecretKey;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the verifier tests and commit.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=PlatformTokenVerifierTest test`
|
||||||
|
|
||||||
|
Expected: PASS with no repository mock or platform datasource fixture.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/com/yau/digitalrmb/platformintegration src/test/java/com/yau/digitalrmb/platformintegration
|
||||||
|
git commit -m "feat: verify platform jwt locally"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 2: Synchronize only the local user, role, snapshot, and password
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java`
|
||||||
|
- Test: `src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java`
|
||||||
|
- Delete: `src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes `VerifiedPlatformToken`, `UserMapper`, `PlatformUserSnapshotMapper`, `PasswordEncoder`, and primary-datasource JDBC access.
|
||||||
|
- Produces `LocalSsoAccount synchronize(VerifiedPlatformToken token)` with `userId()`, `username()`, `displayName()`, and `roleKey()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing account synchronization tests.**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void createsUserRoleSnapshotAndBcryptPassword() {
|
||||||
|
LocalSsoAccount account = service.synchronize(studentToken(487L, "tzs001", "first-password"));
|
||||||
|
assertThat(account.userId()).isEqualTo(487L);
|
||||||
|
assertThat(passwordEncoder.matches("first-password", userMapper.selectById(487L).getPasswordHash())).isTrue();
|
||||||
|
assertThat(roleIdsFor(487L)).containsExactly(1002L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshesPasswordAndRoleForExistingUser() {
|
||||||
|
service.synchronize(studentToken(487L, "tzs001", "old-password"));
|
||||||
|
service.synchronize(teacherToken(487L, "tzs002", "new-password"));
|
||||||
|
assertThat(passwordEncoder.matches("new-password", userMapper.selectById(487L).getPasswordHash())).isTrue();
|
||||||
|
assertThat(passwordEncoder.matches("old-password", userMapper.selectById(487L).getPasswordHash())).isFalse();
|
||||||
|
assertThat(roleIdsFor(487L)).containsExactly(1001L);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the new test to prove it fails.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test`
|
||||||
|
|
||||||
|
Expected: FAIL because `LocalSsoAccountService` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the transactional local synchronizer.**
|
||||||
|
|
||||||
|
Within one `@Transactional` method, load `sys_user` by `token.userId()`, create it with that ID when absent, and otherwise update it. In both branches set username, enabled status, and `passwordHash = passwordEncoder.encode(token.rawPassword())`. Upsert `platform_user_snapshot` from non-sensitive claims. Delete that user's `sys_user_role` rows and insert role ID `1001` for teachers or `1002` for students. Do not inject a named or secondary datasource.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test and commit.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test`
|
||||||
|
|
||||||
|
Expected: PASS; repeated SSO has one user, one role row, and the current BCrypt password.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/com/yau/digitalrmb/identity src/test/java/com/yau/digitalrmb/identity
|
||||||
|
git commit -m "feat: sync local account from platform token"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 3: Redirect the system JWT and remove exchange-code login
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java`
|
||||||
|
- Modify: `src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java`
|
||||||
|
- Test: `src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java`
|
||||||
|
- Modify: `src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java`
|
||||||
|
- Delete: `src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes `PlatformTokenVerifier.verify`, `LocalSsoAccountService.synchronize`, and `JwtTokenService.issueFor(long, String, Set<String>)`.
|
||||||
|
- Produces `GET /api/v1/auth/sso?token=...` with 302 `Location: {frontendCallbackUrl}?token={urlEncodedSystemJwt}`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing MVC tests.**
|
||||||
|
|
||||||
|
```java
|
||||||
|
mockMvc.perform(get("/api/v1/auth/sso").param("token", validParentToken))
|
||||||
|
.andExpect(status().isFound())
|
||||||
|
.andExpect(header().string("Location", startsWith("https://rmb.example.edu/sso-callback?token=")))
|
||||||
|
.andExpect(header().string("Cache-Control", "no-store"))
|
||||||
|
.andExpect(header().string("Referrer-Policy", "no-referrer"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"username\":\"tzs001\",\"password\":\"new-password\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run controller tests to prove the old exchange-code redirect fails.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test`
|
||||||
|
|
||||||
|
Expected: FAIL because the old `Location` has `code=`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement direct SSO redirect.**
|
||||||
|
|
||||||
|
Verify the parent Token, synchronize the local account, issue the existing system JWT for the local ID and one role, URL-encode that system JWT, and redirect to `frontend.callback-url` with query key `token`. Preserve `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. Remove `/session/exchange`; preserve `/login`, `/me`, and `/logout`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run controller tests and commit.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test`
|
||||||
|
|
||||||
|
Expected: PASS; neither the incoming Token nor its password appears in the redirect.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/com/yau/digitalrmb/platformintegration/interfaces src/main/java/com/yau/digitalrmb/security src/test/java/com/yau/digitalrmb/platformintegration/interfaces src/test/java/com/yau/digitalrmb/security
|
||||||
|
git commit -m "feat: redirect local jwt after sso"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 4: Remove the second datasource and verify Java 8 startup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/resources/application.yml`
|
||||||
|
- Modify: `src/main/resources/application-dev.yml`
|
||||||
|
- Modify: `src/main/resources/application-local.yml`
|
||||||
|
- Modify: `src/main/resources/application-test.yml`
|
||||||
|
- Modify: `src/main/resources/schema.sql`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java`
|
||||||
|
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java`
|
||||||
|
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java`
|
||||||
|
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java`
|
||||||
|
- Modify: `src/test/java/com/yau/digitalrmb/ApplicationContextTest.java`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces an application context with primary `dataSource` only; no `platformReadOnlyDataSource` or `platformNamedParameterJdbcTemplate` bean.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing one-datasource context test.**
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Autowired ApplicationContext context;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startsWithOnlyLocalDatasource() {
|
||||||
|
assertThat(context.containsBean("dataSource")).isTrue();
|
||||||
|
assertThat(context.containsBean("platformReadOnlyDataSource")).isFalse();
|
||||||
|
assertThat(context.containsBean("platformNamedParameterJdbcTemplate")).isFalse();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it and verify it fails while the platform datasource exists.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=ApplicationContextTest test`
|
||||||
|
|
||||||
|
Expected: FAIL because `platformReadOnlyDataSource` exists.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove platform database, CAS, and sync configuration.**
|
||||||
|
|
||||||
|
Delete secondary datasource/CAS/sync properties from all profiles and remove their classes/tests. Retain `platform_user_snapshot` and `auth_login_exchange_code` tables to avoid destructive database migration, but remove executable references to exchange codes. Configure only `platform-integration.token.link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY}` and `platform-integration.frontend.callback-url`; set the primary Hikari `minimum-idle` to `2`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run context/full Java 8 tests and build.**
|
||||||
|
|
||||||
|
Run: `mvn -B -Dtest=ApplicationContextTest test`
|
||||||
|
|
||||||
|
Expected: PASS; only `dataSource` is built.
|
||||||
|
|
||||||
|
Run: `mvn -B test`
|
||||||
|
|
||||||
|
Expected: PASS under Java 8.
|
||||||
|
|
||||||
|
Run: `mvn -B -DskipTests package`
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS with Java 8.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update documentation, verify removed references, and commit.**
|
||||||
|
|
||||||
|
Document `DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY`, `DIGITAL_RMB_FRONTEND_CALLBACK_URL`, required claims, direct redirect, and password refresh. Never include a real secret or password.
|
||||||
|
|
||||||
|
Run: `rg -n "DIGITAL_RMB_PLATFORM_DB|platformReadOnlyDataSource|platformNamedParameterJdbcTemplate|LoginExchangeCodeService|/session/exchange" src README.md`
|
||||||
|
|
||||||
|
Expected: no executable source reference.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java src/main/resources src/test/java README.md
|
||||||
|
git commit -m "refactor: remove platform database dependency"
|
||||||
|
```
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.yau.digitalrmb.identity.application;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
|
||||||
|
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
|
||||||
|
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper;
|
||||||
|
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper;
|
||||||
|
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LocalSsoAccountService {
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final PlatformUserSnapshotMapper snapshotMapper;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public LocalSsoAccountService(UserMapper userMapper, PlatformUserSnapshotMapper snapshotMapper,
|
||||||
|
JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
|
||||||
|
this.userMapper = userMapper;
|
||||||
|
this.snapshotMapper = snapshotMapper;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void synchronize(VerifiedPlatformToken token) {
|
||||||
|
UserEntity user = userMapper.selectById(token.getUserId());
|
||||||
|
boolean newUser = user == null;
|
||||||
|
if (user == null) {
|
||||||
|
user = new UserEntity();
|
||||||
|
user.setId(token.getUserId());
|
||||||
|
}
|
||||||
|
user.setUsername(token.getUsername());
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(token.getRawPassword()));
|
||||||
|
user.setEnabled(true);
|
||||||
|
if (newUser) {
|
||||||
|
userMapper.insert(user);
|
||||||
|
} else {
|
||||||
|
userMapper.updateById(user);
|
||||||
|
}
|
||||||
|
upsertSnapshot(token);
|
||||||
|
jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", token.getUserId());
|
||||||
|
jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)",
|
||||||
|
token.getUserId(), "TEACHER".equals(token.getRoleKey()) ? 1001L : 1002L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upsertSnapshot(VerifiedPlatformToken token) {
|
||||||
|
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(token.getUserId());
|
||||||
|
boolean newSnapshot = snapshot == null;
|
||||||
|
if (snapshot == null) {
|
||||||
|
snapshot = new PlatformUserSnapshotEntity();
|
||||||
|
snapshot.setPlatformUserId(token.getUserId());
|
||||||
|
}
|
||||||
|
snapshot.setAccount(token.getUsername());
|
||||||
|
snapshot.setDisplayName(token.getDisplayName());
|
||||||
|
snapshot.setRoleKey(token.getRoleKey());
|
||||||
|
snapshot.setSchoolId(token.getSchoolId());
|
||||||
|
snapshot.setSchoolName(token.getSchoolName());
|
||||||
|
snapshot.setCollegeId(token.getCollegeId());
|
||||||
|
snapshot.setCollegeName(token.getCollegeName());
|
||||||
|
snapshot.setMajorId(token.getMajorId());
|
||||||
|
snapshot.setMajorName(token.getMajorName());
|
||||||
|
snapshot.setRoleId(token.getRoleId());
|
||||||
|
snapshot.setClassId(token.getClassId());
|
||||||
|
snapshot.setClassName(token.getClassName());
|
||||||
|
snapshot.setStudentId(token.getStudentId());
|
||||||
|
snapshot.setSourceUpdatedAt(LocalDateTime.now());
|
||||||
|
snapshot.setSyncedAt(LocalDateTime.now());
|
||||||
|
if (newSnapshot) {
|
||||||
|
snapshotMapper.insert(snapshot);
|
||||||
|
} else {
|
||||||
|
snapshotMapper.updateById(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,85 +0,0 @@
|
|||||||
package com.yau.digitalrmb.identity.application;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
|
|
||||||
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
|
|
||||||
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper;
|
|
||||||
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper;
|
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class PlatformIdentityProjectionService {
|
|
||||||
private final UserMapper userMapper;
|
|
||||||
private final PlatformUserSnapshotMapper snapshotMapper;
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PasswordEncoder passwordEncoder;
|
|
||||||
|
|
||||||
public PlatformIdentityProjectionService(UserMapper userMapper,
|
|
||||||
PlatformUserSnapshotMapper snapshotMapper,
|
|
||||||
JdbcTemplate jdbcTemplate,
|
|
||||||
PasswordEncoder passwordEncoder) {
|
|
||||||
this.userMapper = userMapper;
|
|
||||||
this.snapshotMapper = snapshotMapper;
|
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
|
||||||
this.passwordEncoder = passwordEncoder;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void project(PlatformActor actor) {
|
|
||||||
projectUser(actor);
|
|
||||||
projectSnapshot(actor);
|
|
||||||
projectRole(actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void projectUser(PlatformActor actor) {
|
|
||||||
UserEntity user = userMapper.selectById(actor.platformUserId());
|
|
||||||
if (user == null) {
|
|
||||||
user = new UserEntity();
|
|
||||||
user.setId(actor.platformUserId());
|
|
||||||
user.setUsername(actor.account());
|
|
||||||
user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
|
|
||||||
user.setEnabled(true);
|
|
||||||
userMapper.insert(user);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
user.setUsername(actor.account());
|
|
||||||
user.setEnabled(true);
|
|
||||||
userMapper.updateById(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void projectSnapshot(PlatformActor actor) {
|
|
||||||
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(actor.platformUserId());
|
|
||||||
boolean newSnapshot = snapshot == null;
|
|
||||||
if (newSnapshot) {
|
|
||||||
snapshot = new PlatformUserSnapshotEntity();
|
|
||||||
snapshot.setPlatformUserId(actor.platformUserId());
|
|
||||||
}
|
|
||||||
snapshot.setAccount(actor.account());
|
|
||||||
snapshot.setDisplayName(actor.displayName());
|
|
||||||
snapshot.setRoleKey(actor.role().name());
|
|
||||||
snapshot.setSourceUpdatedAt(LocalDateTime.ofInstant(actor.tokenSigningTime(), ZoneOffset.UTC));
|
|
||||||
snapshot.setSyncedAt(LocalDateTime.now(ZoneOffset.UTC));
|
|
||||||
if (newSnapshot) {
|
|
||||||
snapshotMapper.insert(snapshot);
|
|
||||||
} else {
|
|
||||||
snapshotMapper.updateById(snapshot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void projectRole(PlatformActor actor) {
|
|
||||||
jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", actor.platformUserId());
|
|
||||||
jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)",
|
|
||||||
actor.platformUserId(), roleId(actor));
|
|
||||||
}
|
|
||||||
|
|
||||||
private long roleId(PlatformActor actor) {
|
|
||||||
return actor.role().name().equals("TEACHER") ? 1001L : 1002L;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package com.yau.digitalrmb.identity.application;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@ConditionalOnProperty(prefix = "platform-integration.sync", name = "enabled", havingValue = "true")
|
|
||||||
public class PlatformIdentitySyncJob {
|
|
||||||
private final PlatformIdentityRepository identityRepository;
|
|
||||||
private final PlatformIdentityProjectionService projectionService;
|
|
||||||
private Instant watermark = Instant.EPOCH;
|
|
||||||
|
|
||||||
public PlatformIdentitySyncJob(PlatformIdentityRepository identityRepository,
|
|
||||||
PlatformIdentityProjectionService projectionService) {
|
|
||||||
this.identityRepository = identityRepository;
|
|
||||||
this.projectionService = projectionService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${platform-integration.sync.fixed-delay:PT15M}")
|
|
||||||
public synchronized void sync() {
|
|
||||||
Instant nextWatermark = Instant.now();
|
|
||||||
syncChangedSince(watermark);
|
|
||||||
watermark = nextWatermark;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void syncChangedSince(Instant since) {
|
|
||||||
identityRepository.findChangedSince(since).forEach(projectionService::project);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.application;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface PlatformIdentityRepository {
|
|
||||||
Optional<PlatformActor> findByPlatformUserId(long platformUserId);
|
|
||||||
|
|
||||||
Optional<PlatformActor> findBySchoolAccount(String schoolAccount);
|
|
||||||
|
|
||||||
List<PlatformActor> findChangedSince(Instant watermark);
|
|
||||||
}
|
|
||||||
@ -1,13 +1,50 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.application;
|
package com.yau.digitalrmb.platformintegration.application;
|
||||||
|
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@EqualsAndHashCode
|
|
||||||
public class VerifiedPlatformToken {
|
public class VerifiedPlatformToken {
|
||||||
private final PlatformActor actor; private final String fingerprint;
|
private final long userId;
|
||||||
public VerifiedPlatformToken(PlatformActor actor, String fingerprint) { this.actor = actor; this.fingerprint = fingerprint; }
|
private final String username;
|
||||||
public PlatformActor actor() { return actor; } public String fingerprint() { return fingerprint; }
|
private final String displayName;
|
||||||
|
private final String rawPassword;
|
||||||
|
private final long roleId;
|
||||||
|
private final String roleKey;
|
||||||
|
private final String schoolId;
|
||||||
|
private final String schoolName;
|
||||||
|
private final String collegeId;
|
||||||
|
private final String collegeName;
|
||||||
|
private final String majorId;
|
||||||
|
private final String majorName;
|
||||||
|
private final String classId;
|
||||||
|
private final String className;
|
||||||
|
private final String studentId;
|
||||||
|
|
||||||
|
public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, String roleKey) {
|
||||||
|
this(userId, username, displayName, rawPassword, "TEACHER".equals(roleKey) ? 3L : 2L, roleKey,
|
||||||
|
null, null, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, long roleId,
|
||||||
|
String roleKey, String schoolId, String schoolName, String collegeId,
|
||||||
|
String collegeName, String majorId, String majorName, String classId,
|
||||||
|
String className, String studentId) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.username = username;
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.rawPassword = rawPassword;
|
||||||
|
this.roleId = roleId;
|
||||||
|
this.roleKey = roleKey;
|
||||||
|
this.schoolId = schoolId;
|
||||||
|
this.schoolName = schoolName;
|
||||||
|
this.collegeId = collegeId;
|
||||||
|
this.collegeName = collegeName;
|
||||||
|
this.majorId = majorId;
|
||||||
|
this.majorName = majorName;
|
||||||
|
this.classId = classId;
|
||||||
|
this.className = className;
|
||||||
|
this.studentId = studentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return displayName; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.domain;
|
|
||||||
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.Getter;
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@EqualsAndHashCode
|
|
||||||
public class PlatformActor {
|
|
||||||
private final long platformUserId; private final long profileId; private final String account; private final String displayName; private final PlatformRole role; private final Instant tokenSigningTime;
|
|
||||||
public PlatformActor(long platformUserId, long profileId, String account, String displayName, PlatformRole role, Instant tokenSigningTime) { this.platformUserId = platformUserId; this.profileId = profileId; this.account = account; this.displayName = displayName; this.role = role; this.tokenSigningTime = tokenSigningTime; }
|
|
||||||
public long platformUserId() { return platformUserId; } public long profileId() { return profileId; } public String account() { return account; } public String displayName() { return displayName; } public PlatformRole role() { return role; } public Instant tokenSigningTime() { return tokenSigningTime; }
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.domain;
|
|
||||||
|
|
||||||
public enum PlatformRole {
|
|
||||||
TEACHER("JT_S_02"),
|
|
||||||
STUDENT("JT_S_03");
|
|
||||||
|
|
||||||
private final String jobType;
|
|
||||||
|
|
||||||
PlatformRole(String jobType) {
|
|
||||||
this.jobType = jobType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PlatformRole fromJobType(String jobType) {
|
|
||||||
for (PlatformRole role : values()) {
|
|
||||||
if (role.jobType.equals(jobType)) {
|
|
||||||
return role;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("Unsupported platform job type: " + jobType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String jobType() {
|
|
||||||
return jobType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.yau.digitalrmb.security.application;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CurrentUser {
|
||||||
|
private final String schoolId;
|
||||||
|
private final String schoolName;
|
||||||
|
private final String collegeId;
|
||||||
|
private final String collegeName;
|
||||||
|
private final String majorId;
|
||||||
|
private final String majorName;
|
||||||
|
private final Long roleid;
|
||||||
|
private final long userId;
|
||||||
|
private final String username;
|
||||||
|
private final String name;
|
||||||
|
private final String classId;
|
||||||
|
private final String className;
|
||||||
|
private final String studentid;
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.yau.digitalrmb.security.application;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
|
||||||
|
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper;
|
||||||
|
import com.yau.digitalrmb.shared.api.ErrorCode;
|
||||||
|
import com.yau.digitalrmb.shared.exception.BusinessException;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CurrentUserService {
|
||||||
|
private final PlatformUserSnapshotMapper snapshotMapper;
|
||||||
|
|
||||||
|
public CurrentUserService(PlatformUserSnapshotMapper snapshotMapper) {
|
||||||
|
this.snapshotMapper = snapshotMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CurrentUser getCurrentUser() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) {
|
||||||
|
throw unauthorized("用户身份无效");
|
||||||
|
}
|
||||||
|
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(parseUserId((Jwt) authentication.getPrincipal()));
|
||||||
|
if (snapshot == null) {
|
||||||
|
throw unauthorized("用户身份不存在");
|
||||||
|
}
|
||||||
|
return new CurrentUser(snapshot.getSchoolId(), snapshot.getSchoolName(), snapshot.getCollegeId(),
|
||||||
|
snapshot.getCollegeName(), snapshot.getMajorId(), snapshot.getMajorName(), snapshot.getRoleId(),
|
||||||
|
snapshot.getPlatformUserId(), snapshot.getAccount(), snapshot.getDisplayName(), snapshot.getClassId(),
|
||||||
|
snapshot.getClassName(), snapshot.getStudentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long parseUserId(Jwt jwt) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(jwt.getSubject());
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw unauthorized("用户身份无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BusinessException unauthorized(String message) {
|
||||||
|
return new BusinessException(ErrorCode.UNAUTHORIZED, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,79 +0,0 @@
|
|||||||
package com.yau.digitalrmb.security.application;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.security.config.SecurityProperties;
|
|
||||||
import com.yau.digitalrmb.shared.api.ErrorCode;
|
|
||||||
import com.yau.digitalrmb.shared.exception.BusinessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class LoginExchangeCodeService {
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final SecurityProperties properties;
|
|
||||||
|
|
||||||
public LoginExchangeCodeService(JdbcTemplate jdbcTemplate, SecurityProperties properties) {
|
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
|
||||||
this.properties = properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String issue(long platformUserId) {
|
|
||||||
String code = randomValue();
|
|
||||||
jdbcTemplate.update("INSERT INTO auth_login_exchange_code (code_hash, platform_user_id, expires_at, consumed_at) VALUES (?, ?, ?, NULL)",
|
|
||||||
hash(code), platformUserId, Timestamp.from(Instant.now().plus(properties.getSession().getExchangeCodeTtl())));
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long exchange(String code) {
|
|
||||||
String hash = hash(code);
|
|
||||||
List<Long> platformUserIds = jdbcTemplate.query(
|
|
||||||
"SELECT platform_user_id FROM auth_login_exchange_code WHERE code_hash = ?",
|
|
||||||
(resultSet, rowNum) -> resultSet.getLong(1), hash);
|
|
||||||
if (platformUserIds.isEmpty()) {
|
|
||||||
throw invalidCode();
|
|
||||||
}
|
|
||||||
int consumed = jdbcTemplate.update(
|
|
||||||
"UPDATE auth_login_exchange_code SET consumed_at = CURRENT_TIMESTAMP "
|
|
||||||
+ "WHERE code_hash = ? AND consumed_at IS NULL AND expires_at > CURRENT_TIMESTAMP", hash);
|
|
||||||
if (consumed != 1) {
|
|
||||||
throw invalidCode();
|
|
||||||
}
|
|
||||||
return platformUserIds.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String hash(String value) {
|
|
||||||
try {
|
|
||||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
|
||||||
StringBuilder hash = new StringBuilder(digest.length * 2);
|
|
||||||
for (byte item : digest) {
|
|
||||||
String hex = Integer.toHexString(item & 0xff);
|
|
||||||
if (hex.length() == 1) {
|
|
||||||
hash.append('0');
|
|
||||||
}
|
|
||||||
hash.append(hex);
|
|
||||||
}
|
|
||||||
return hash.toString();
|
|
||||||
} catch (Exception exception) {
|
|
||||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String randomValue() {
|
|
||||||
byte[] bytes = new byte[32];
|
|
||||||
RANDOM.nextBytes(bytes);
|
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private BusinessException invalidCode() {
|
|
||||||
return new BusinessException(ErrorCode.UNAUTHORIZED, "登录兑换码无效或已过期");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,22 @@
|
|||||||
package com.yau.digitalrmb.security.interfaces;
|
package com.yau.digitalrmb.security.interfaces;
|
||||||
import lombok.AllArgsConstructor; import lombok.Getter; import java.util.List;
|
|
||||||
@Getter @AllArgsConstructor
|
import lombok.AllArgsConstructor;
|
||||||
public class CurrentUserResponse { private final long platformUserId; private final String account; private final String displayName; private final List<String> roles; }
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CurrentUserResponse {
|
||||||
|
private final String schoolId;
|
||||||
|
private final String schoolName;
|
||||||
|
private final String collegeId;
|
||||||
|
private final String collegeName;
|
||||||
|
private final String majorId;
|
||||||
|
private final String majorName;
|
||||||
|
private final Long roleid;
|
||||||
|
private final long userId;
|
||||||
|
private final String username;
|
||||||
|
private final String name;
|
||||||
|
private final String classId;
|
||||||
|
private final String className;
|
||||||
|
private final String studentid;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
package com.yau.digitalrmb.security.interfaces;
|
|
||||||
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank;
|
|
||||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
|
|
||||||
public class ExchangeCodeRequest { @NotBlank private String code; public String code() { return code; } }
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
package com.yau.digitalrmb.security.interfaces;
|
|
||||||
import lombok.AllArgsConstructor; import lombok.Getter;
|
|
||||||
@Getter @AllArgsConstructor
|
|
||||||
public class SessionResponse { private final String accessToken; private final String refreshToken; private final String tokenType; private final long expiresIn; }
|
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.yau.digitalrmb.identity;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
|
||||||
|
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class LocalSsoAccountServiceTest {
|
||||||
|
@Autowired private LocalSsoAccountService service;
|
||||||
|
@Autowired private DataSource dataSource;
|
||||||
|
@Autowired private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsLocalUserWithTokenPasswordAndStudentRole() {
|
||||||
|
service.synchronize(new VerifiedPlatformToken(601L, "sso601", "张三", "first-password", "STUDENT"));
|
||||||
|
|
||||||
|
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||||
|
String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 601", String.class);
|
||||||
|
Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 601", Long.class);
|
||||||
|
assertThat(passwordEncoder.matches("first-password", passwordHash)).isTrue();
|
||||||
|
assertThat(roleId).isEqualTo(1002L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshesLocalPasswordWhenPlatformPasswordChanges() {
|
||||||
|
service.synchronize(new VerifiedPlatformToken(602L, "sso602", "李四", "old-password", "STUDENT"));
|
||||||
|
service.synchronize(new VerifiedPlatformToken(602L, "sso602-new", "李四", "new-password", "TEACHER"));
|
||||||
|
|
||||||
|
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||||
|
String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 602", String.class);
|
||||||
|
Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 602", Long.class);
|
||||||
|
assertThat(passwordEncoder.matches("new-password", passwordHash)).isTrue();
|
||||||
|
assertThat(passwordEncoder.matches("old-password", passwordHash)).isFalse();
|
||||||
|
assertThat(roleId).isEqualTo(1001L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void synchronizesCompleteProfileIntoLocalSnapshot() {
|
||||||
|
service.synchronize(completeToken(603L, "sso603", 2L));
|
||||||
|
|
||||||
|
Map<String, Object> row = new JdbcTemplate(dataSource).queryForMap(
|
||||||
|
"SELECT school_id, college_name, major_name, role_id, class_name, student_id "
|
||||||
|
+ "FROM platform_user_snapshot WHERE platform_user_id = 603");
|
||||||
|
assertThat(row).containsEntry("school_id", "610000")
|
||||||
|
.containsEntry("college_name", "Computer College")
|
||||||
|
.containsEntry("major_name", "Software Engineering")
|
||||||
|
.containsEntry("role_id", 2L)
|
||||||
|
.containsEntry("class_name", "Class 1")
|
||||||
|
.containsEntry("student_id", "20240001");
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerifiedPlatformToken completeToken(long userId, String username, long roleId) {
|
||||||
|
return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT",
|
||||||
|
"610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering",
|
||||||
|
"202401", "Class 1", "20240001");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,60 +0,0 @@
|
|||||||
package com.yau.digitalrmb.identity;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
|
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
class PlatformIdentityProjectionServiceTest {
|
|
||||||
@Autowired
|
|
||||||
private PlatformIdentityProjectionService projectionService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private DataSource dataSource;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private PasswordEncoder passwordEncoder;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void projectionIsIdempotentAndOwnsExactlyOneRole() {
|
|
||||||
PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
|
|
||||||
Instant.parse("2026-01-01T00:00:00Z"));
|
|
||||||
|
|
||||||
projectionService.project(teacher);
|
|
||||||
projectionService.project(teacher);
|
|
||||||
|
|
||||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
|
||||||
Integer associationCount = jdbc.queryForObject("SELECT COUNT(*) FROM sys_user_role WHERE user_id = 101", Integer.class);
|
|
||||||
String role = jdbc.queryForObject("SELECT role_key FROM platform_user_snapshot WHERE platform_user_id = 101", String.class);
|
|
||||||
String userName = jdbc.queryForObject("SELECT username FROM sys_user WHERE id = 101", String.class);
|
|
||||||
|
|
||||||
assertThat(associationCount).isEqualTo(1);
|
|
||||||
assertThat(role).isEqualTo("TEACHER");
|
|
||||||
assertThat(userName).isEqualTo("t001");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void ssoProjectionCreatesUserWithRandomBcryptPassword() {
|
|
||||||
PlatformActor actor = new PlatformActor(302L, 3L, "sso-user", "教师", PlatformRole.TEACHER,
|
|
||||||
Instant.parse("2026-01-01T00:00:00Z"));
|
|
||||||
|
|
||||||
projectionService.project(actor);
|
|
||||||
|
|
||||||
String passwordHash = new JdbcTemplate(dataSource)
|
|
||||||
.queryForObject("SELECT password_hash FROM sys_user WHERE id = 302", String.class);
|
|
||||||
assertThat(passwordHash).startsWith("$2");
|
|
||||||
assertThat(passwordEncoder.matches("EXTERNAL_SSO_ONLY", passwordHash)).isFalse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.application;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
|
|
||||||
import com.yau.digitalrmb.shared.exception.BusinessException;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.util.ReflectionUtils;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class CasTicketValidatorTest {
|
|
||||||
@Test
|
|
||||||
void configuresFiveSecondTimeoutsForCasRequests() throws Exception {
|
|
||||||
PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
|
|
||||||
CasTicketValidator validator = new CasTicketValidator(properties);
|
|
||||||
Field restTemplateField = ReflectionUtils.findField(CasTicketValidator.class, "restTemplate");
|
|
||||||
ReflectionUtils.makeAccessible(restTemplateField);
|
|
||||||
RestTemplate restTemplate = (RestTemplate) ReflectionUtils.getField(restTemplateField, validator);
|
|
||||||
|
|
||||||
assertThat(restTemplate.getRequestFactory()).isInstanceOf(SimpleClientHttpRequestFactory.class);
|
|
||||||
SimpleClientHttpRequestFactory requestFactory = (SimpleClientHttpRequestFactory) restTemplate.getRequestFactory();
|
|
||||||
assertThat(readIntField(requestFactory, "connectTimeout")).isEqualTo(5000);
|
|
||||||
assertThat(readIntField(requestFactory, "readTimeout")).isEqualTo(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void parsesSuccessfulCasAccountAndRejectsExternalEntityPayloads() {
|
|
||||||
String success = "<cas:serviceResponse xmlns:cas=\"http://www.yale.edu/tp/cas\">"
|
|
||||||
+ "<cas:authenticationSuccess><cas:user>t001</cas:user></cas:authenticationSuccess>"
|
|
||||||
+ "</cas:serviceResponse>";
|
|
||||||
String xxe = "<!DOCTYPE serviceResponse [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>"
|
|
||||||
+ "<serviceResponse><authenticationSuccess><user>&xxe;</user></authenticationSuccess></serviceResponse>";
|
|
||||||
|
|
||||||
assertThat(CasTicketValidator.parseAccount(success)).isEqualTo("t001");
|
|
||||||
assertThatThrownBy(() -> CasTicketValidator.parseAccount(xxe)).isInstanceOf(BusinessException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int readIntField(Object target, String fieldName) throws Exception {
|
|
||||||
Field field = ReflectionUtils.findField(target.getClass(), fieldName);
|
|
||||||
ReflectionUtils.makeAccessible(field);
|
|
||||||
return (Integer) ReflectionUtils.getField(field, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.config;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
||||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class PlatformIntegrationPropertiesTest {
|
|
||||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
|
||||||
.withConfiguration(AutoConfigurations.of(PropertiesConfiguration.class))
|
|
||||||
.withPropertyValues(
|
|
||||||
"platform-integration.datasource.url=jdbc:mysql://localhost:3306/tianze",
|
|
||||||
"platform-integration.datasource.username=readonly",
|
|
||||||
"platform-integration.datasource.password=secret",
|
|
||||||
"platform-integration.token.max-age=PT2M",
|
|
||||||
"platform-integration.token.teacher-claim-value=teacher",
|
|
||||||
"platform-integration.token.student-claim-value=student",
|
|
||||||
"platform-integration.cas.login-url=https://sso.example.edu/login",
|
|
||||||
"platform-integration.cas.validate-url=https://sso.example.edu/p3/serviceValidate",
|
|
||||||
"platform-integration.cas.callback-url=https://rmb.example.edu/api/v1/auth/cas/callback",
|
|
||||||
"platform-integration.frontend.callback-url=https://rmb.example.edu/sso-callback");
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void bindsReadOnlyDatasourceAndAuthenticationEndpoints() {
|
|
||||||
contextRunner.run(context -> {
|
|
||||||
PlatformIntegrationProperties properties = context.getBean(PlatformIntegrationProperties.class);
|
|
||||||
|
|
||||||
assertThat(properties.getDatasource().getUsername()).isEqualTo("readonly");
|
|
||||||
assertThat(properties.getToken().getMaxAge()).isEqualTo(Duration.ofMinutes(2));
|
|
||||||
assertThat(properties.getCas().getCallbackUrl()).isEqualTo("https://rmb.example.edu/api/v1/auth/cas/callback");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
|
||||||
@EnableConfigurationProperties(PlatformIntegrationProperties.class)
|
|
||||||
static class PropertiesConfiguration {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.domain;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class PlatformRoleTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void mapsOnlyTeacherAndStudentJobTypes() {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.infrastructure;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
|
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
|
|
||||||
import org.h2.jdbcx.JdbcDataSource;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class JdbcPlatformIdentityRepositoryTest {
|
|
||||||
private DataSource dataSource;
|
|
||||||
private JdbcPlatformIdentityRepository repository;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() throws Exception {
|
|
||||||
JdbcDataSource source = new JdbcDataSource();
|
|
||||||
source.setURL("jdbc:h2:mem:platform_identity;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
|
||||||
source.setUser("sa");
|
|
||||||
dataSource = source;
|
|
||||||
execute("DROP ALL OBJECTS");
|
|
||||||
createSchema();
|
|
||||||
repository = new JdbcPlatformIdentityRepository(new NamedParameterJdbcTemplate(dataSource));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolvesEnabledTeacherFromCoreUserAndTeacherProfile() throws Exception {
|
|
||||||
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (101, 't001', '教师甲', 'S1', 'JT_S_02', 0)");
|
|
||||||
execute("INSERT INTO teacher(teacher_id, user_id, teacher_status, add_time) VALUES (1, 101, 1, '2026-01-01 00:00:00')");
|
|
||||||
|
|
||||||
PlatformActor actor = repository.findByPlatformUserId(101L)
|
|
||||||
.orElseThrow(() -> new IllegalStateException("teacher not found"));
|
|
||||||
|
|
||||||
assertThat(actor.account()).isEqualTo("t001");
|
|
||||||
assertThat(actor.role()).isEqualTo(PlatformRole.TEACHER);
|
|
||||||
assertThat(actor.profileId()).isEqualTo(1L);
|
|
||||||
assertThat(actor.tokenSigningTime()).isEqualTo(Instant.parse("2025-12-31T16:00:00Z"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void excludesDisabledOrUnsupportedUsers() throws Exception {
|
|
||||||
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (201, 's001', '学生甲', 'S1', 'JT_S_03', 0)");
|
|
||||||
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (1, 201, 2, '2026-01-01 00:00:00')");
|
|
||||||
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (202, 'admin', '管理员', 'S1', 'JT_S_01', 0)");
|
|
||||||
|
|
||||||
assertThat(repository.findByPlatformUserId(201L)).isEmpty();
|
|
||||||
assertThat(repository.findByPlatformUserId(202L)).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsDuplicateSchoolAccounts() throws Exception {
|
|
||||||
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (301, 'duplicate', 'student one', 'S1', 'JT_S_03', 0)");
|
|
||||||
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (302, 'duplicate', 'student two', 'S1', 'JT_S_03', 0)");
|
|
||||||
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (11, 301, 1, '2026-01-01 00:00:00')");
|
|
||||||
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (12, 302, 1, '2026-01-01 00:00:00')");
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> repository.findBySchoolAccount("duplicate"))
|
|
||||||
.isInstanceOf(IncorrectResultSizeDataAccessException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
private void createSchema() throws Exception {
|
|
||||||
execute("CREATE TABLE core_user(ID BIGINT PRIMARY KEY, CODE VARCHAR(64), NAME VARCHAR(64), STATE VARCHAR(16), JOB_TYPE1 VARCHAR(16), DEL_FLAG INT)");
|
|
||||||
execute("CREATE TABLE teacher(teacher_id BIGINT PRIMARY KEY, user_id BIGINT, teacher_status INT, add_time TIMESTAMP)");
|
|
||||||
execute("CREATE TABLE student(student_id BIGINT PRIMARY KEY, user_id BIGINT, student_status INT, add_time TIMESTAMP)");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void execute(String sql) throws Exception {
|
|
||||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
|
||||||
statement.execute(sql);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +1,44 @@
|
|||||||
package com.yau.digitalrmb.platformintegration.interfaces;
|
package com.yau.digitalrmb.platformintegration.interfaces;
|
||||||
|
|
||||||
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
|
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
|
||||||
import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier;
|
import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier;
|
||||||
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
|
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
|
||||||
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
|
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
import com.yau.digitalrmb.security.application.JwtTokenService;
|
||||||
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
|
|
||||||
import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.util.Collections;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
class PlatformSsoControllerTest {
|
class PlatformSsoControllerTest {
|
||||||
@Test
|
@Test
|
||||||
void ssoRedirectDoesNotLeakIncomingToken() throws Exception {
|
void ssoRedirectIssuesOnlyLocalToken() throws Exception {
|
||||||
PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class);
|
PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class);
|
||||||
PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class);
|
LocalSsoAccountService localAccounts = mock(LocalSsoAccountService.class);
|
||||||
LoginExchangeCodeService exchangeCodes = mock(LoginExchangeCodeService.class);
|
JwtTokenService jwtTokenService = mock(JwtTokenService.class);
|
||||||
PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
|
PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
|
||||||
properties.getFrontend().setCallbackUrl("https://rmb.example.edu/sso-callback");
|
properties.getFrontend().setCallbackUrl("https://rmb.example.edu/sso-callback");
|
||||||
PlatformActor actor = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
|
VerifiedPlatformToken verified = new VerifiedPlatformToken(101L, "t001", "Teacher", "password", "TEACHER");
|
||||||
Instant.parse("2026-01-01T00:00:00Z"));
|
when(verifier.verify(anyString())).thenReturn(verified);
|
||||||
when(verifier.verify(anyString())).thenReturn(new VerifiedPlatformToken(actor, "fingerprint"));
|
when(jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")))
|
||||||
when(exchangeCodes.issue(101L)).thenReturn("one-time-code");
|
.thenReturn(new JwtTokenService.Token("local-system-jwt", 1800L));
|
||||||
MockMvc mvc = MockMvcBuilders.standaloneSetup(new PlatformSsoController(verifier, projection, exchangeCodes, properties)).build();
|
MockMvc mvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
new PlatformSsoController(verifier, localAccounts, jwtTokenService, properties)).build();
|
||||||
|
|
||||||
mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token"))
|
mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token"))
|
||||||
.andExpect(status().isFound())
|
.andExpect(status().isFound())
|
||||||
.andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?code=one-time-code"))
|
.andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?token=local-system-jwt"))
|
||||||
.andExpect(header().string("Cache-Control", "no-store"))
|
.andExpect(header().string("Cache-Control", "no-store"))
|
||||||
.andExpect(header().string("Referrer-Policy", "no-referrer"));
|
.andExpect(header().string("Referrer-Policy", "no-referrer"));
|
||||||
verify(projection).project(actor);
|
verify(localAccounts).synchronize(verified);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
package com.yau.digitalrmb.security;
|
||||||
|
|
||||||
|
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
|
||||||
|
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
|
||||||
|
import com.yau.digitalrmb.security.application.CurrentUser;
|
||||||
|
import com.yau.digitalrmb.security.application.CurrentUserService;
|
||||||
|
import com.yau.digitalrmb.shared.api.ErrorCode;
|
||||||
|
import com.yau.digitalrmb.shared.exception.BusinessException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class CurrentUserServiceTest {
|
||||||
|
@Autowired private CurrentUserService currentUserService;
|
||||||
|
@Autowired private LocalSsoAccountService localSsoAccountService;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearSecurityContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsCompleteProfileForCurrentSecurityContextUser() {
|
||||||
|
localSsoAccountService.synchronize(completeToken(701L, "sso701", 2L));
|
||||||
|
authenticateAs(701L);
|
||||||
|
|
||||||
|
CurrentUser user = currentUserService.getCurrentUser();
|
||||||
|
|
||||||
|
assertThat(user.getUserId()).isEqualTo(701L);
|
||||||
|
assertThat(user.getUsername()).isEqualTo("sso701");
|
||||||
|
assertThat(user.getName()).isEqualTo("Test Student");
|
||||||
|
assertThat(user.getSchoolId()).isEqualTo("610000");
|
||||||
|
assertThat(user.getCollegeName()).isEqualTo("Computer College");
|
||||||
|
assertThat(user.getMajorName()).isEqualTo("Software Engineering");
|
||||||
|
assertThat(user.getRoleid()).isEqualTo(2L);
|
||||||
|
assertThat(user.getClassId()).isEqualTo("202401");
|
||||||
|
assertThat(user.getStudentid()).isEqualTo("20240001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCurrentSecurityContextUserWithoutSnapshot() {
|
||||||
|
authenticateAs(702L);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> currentUserService.getCurrentUser())
|
||||||
|
.isInstanceOfSatisfying(BusinessException.class,
|
||||||
|
exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void authenticateAs(long userId) {
|
||||||
|
Jwt jwt = Jwt.withTokenValue("test-token")
|
||||||
|
.header("alg", "none")
|
||||||
|
.subject(String.valueOf(userId))
|
||||||
|
.issuedAt(Instant.parse("2026-08-04T00:00:00Z"))
|
||||||
|
.expiresAt(Instant.parse("2026-08-04T01:00:00Z"))
|
||||||
|
.build();
|
||||||
|
AbstractAuthenticationToken authentication = new JwtAuthenticationToken(jwt);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerifiedPlatformToken completeToken(long userId, String username, long roleId) {
|
||||||
|
return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT",
|
||||||
|
"610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering",
|
||||||
|
"202401", "Class 1", "20240001");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package com.yau.digitalrmb.security;
|
|
||||||
|
|
||||||
import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
|
|
||||||
import com.yau.digitalrmb.shared.exception.BusinessException;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
class LoginExchangeCodeServiceTest {
|
|
||||||
@Autowired
|
|
||||||
private LoginExchangeCodeService service;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void exchangeCodeCanOnlyBeUsedOnce() {
|
|
||||||
String code = service.issue(101L);
|
|
||||||
|
|
||||||
assertThat(service.exchange(code)).isEqualTo(101L);
|
|
||||||
assertThatThrownBy(() -> service.exchange(code)).isInstanceOf(BusinessException.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue