diff --git a/docs/superpowers/plans/2026-08-04-current-user-context.md b/docs/superpowers/plans/2026-08-04-current-user-context.md new file mode 100644 index 0000000..e039bd2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-current-user-context.md @@ -0,0 +1,186 @@ +# 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 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. + diff --git a/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java index 3cfb099..f47ba21 100644 --- a/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java +++ b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java @@ -59,6 +59,16 @@ public class LocalSsoAccountService { 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) { diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java index abd8913..c320678 100644 --- a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java +++ b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java @@ -22,6 +22,36 @@ public class PlatformUserSnapshotEntity { private String roleKey; + @TableField("school_id") + private String schoolId; + + @TableField("school_name") + private String schoolName; + + @TableField("college_id") + private String collegeId; + + @TableField("college_name") + private String collegeName; + + @TableField("major_id") + private String majorId; + + @TableField("major_name") + private String majorName; + + @TableField("role_id") + private Long roleId; + + @TableField("class_id") + private String classId; + + @TableField("class_name") + private String className; + + @TableField("student_id") + private String studentId; + private LocalDateTime sourceUpdatedAt; @TableField("synced_at") diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java index d282c09..037699f 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java @@ -54,8 +54,16 @@ public class PlatformTokenVerifier { String username = requiredText(payload, "username"); String password = requiredText(payload, "password"); long roleId = requiredLong(payload, "roleid"); - String displayName = payload.path("name").asText(username); - return new VerifiedPlatformToken(userId, username, displayName, password, roleId == 3L ? "TEACHER" : "STUDENT"); + String displayName = optionalText(payload, "name"); + if (displayName == null || displayName.trim().isEmpty()) { + displayName = username; + } + return new VerifiedPlatformToken(userId, username, displayName, password, roleId, + roleId == 3L ? "TEACHER" : "STUDENT", optionalText(payload, "schoolId"), + optionalText(payload, "schoolName"), optionalText(payload, "collegeId"), + optionalText(payload, "collegeName"), optionalText(payload, "majorId"), + optionalText(payload, "majorName"), optionalText(payload, "classId"), + optionalText(payload, "className"), optionalText(payload, "studentid")); } catch (PlatformTokenException exception) { throw exception; } catch (Exception exception) { @@ -76,5 +84,6 @@ public class PlatformTokenVerifier { private long requiredPositiveLong(JsonNode payload, String name) { long value = requiredLong(payload, name); if (value <= 0) throw invalid(); return value; } private long requiredLong(JsonNode payload, String name) { JsonNode node = payload.path(name); if (!node.canConvertToLong()) throw invalid(); return node.asLong(); } private String requiredText(JsonNode payload, String name) { String value = payload.path(name).asText(); if (value == null || value.trim().isEmpty()) throw invalid(); return value; } + private String optionalText(JsonNode payload, String name) { JsonNode node = payload.path(name); return node.isMissingNode() || node.isNull() ? null : node.asText(); } private PlatformTokenException invalid() { return new PlatformTokenException("Invalid platform token"); } } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java index 4824e44..2ec3fc8 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java @@ -8,13 +8,43 @@ public class VerifiedPlatformToken { private final String username; 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; } } diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java new file mode 100644 index 0000000..2bd0e07 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java @@ -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; +} diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java new file mode 100644 index 0000000..09f451f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java @@ -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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java index e191c7e..d1f1a38 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java @@ -1,7 +1,7 @@ package com.yau.digitalrmb.security.interfaces; -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; -import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; import com.yau.digitalrmb.security.application.JwtTokenService; import com.yau.digitalrmb.security.application.LocalAccountAuthenticationService; import com.yau.digitalrmb.security.application.RefreshTokenService; @@ -9,7 +9,6 @@ import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.web.TraceIdFilter; -import java.util.Collections; import javax.validation.Valid; import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -24,13 +23,13 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/v1/auth") public class AuthController { private final RefreshTokenService refreshTokenService; - private final PlatformUserSnapshotMapper snapshotMapper; + private final CurrentUserService currentUserService; private final LocalAccountAuthenticationService localAccountAuthenticationService; - public AuthController(RefreshTokenService refreshTokenService, PlatformUserSnapshotMapper snapshotMapper, + public AuthController(RefreshTokenService refreshTokenService, CurrentUserService currentUserService, LocalAccountAuthenticationService localAccountAuthenticationService) { this.refreshTokenService = refreshTokenService; - this.snapshotMapper = snapshotMapper; + this.currentUserService = currentUserService; this.localAccountAuthenticationService = localAccountAuthenticationService; } @@ -42,14 +41,12 @@ public class AuthController { } @GetMapping("/me") - public ApiResponse currentUser(@AuthenticationPrincipal Jwt jwt) { - long userId = userId(jwt); - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(userId); - if (snapshot == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); - } - return ApiResponse.success(new CurrentUserResponse(userId, snapshot.getAccount(), snapshot.getDisplayName(), - Collections.singletonList(snapshot.getRoleKey())), MDC.get(TraceIdFilter.MDC_KEY)); + public ApiResponse currentUser() { + CurrentUser user = currentUserService.getCurrentUser(); + return ApiResponse.success(new CurrentUserResponse(user.getSchoolId(), user.getSchoolName(), + user.getCollegeId(), user.getCollegeName(), user.getMajorId(), user.getMajorName(), user.getRoleid(), + user.getUserId(), user.getUsername(), user.getName(), user.getClassId(), user.getClassName(), + user.getStudentid()), MDC.get(TraceIdFilter.MDC_KEY)); } @PostMapping("/logout") @@ -62,7 +59,7 @@ public class AuthController { try { return Long.parseLong(jwt.getSubject()); } catch (NumberFormatException exception) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份无效"); + throw new BusinessException(ErrorCode.UNAUTHORIZED, "User identity is invalid"); } } } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java index eb74b4f..24c7e0f 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java @@ -1,4 +1,22 @@ package com.yau.digitalrmb.security.interfaces; -import lombok.AllArgsConstructor; import lombok.Getter; import java.util.List; -@Getter @AllArgsConstructor -public class CurrentUserResponse { private final long platformUserId; private final String account; private final String displayName; private final List roles; } + +import lombok.AllArgsConstructor; +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; +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index b749a73..5eb0645 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -34,9 +34,30 @@ CREATE TABLE IF NOT EXISTS platform_user_snapshot ( display_name VARCHAR(64) NOT NULL, role_key VARCHAR(16) NOT NULL, source_updated_at TIMESTAMP NOT NULL, - synced_at TIMESTAMP NOT NULL + synced_at TIMESTAMP NOT NULL, + school_id VARCHAR(64) NULL, + school_name VARCHAR(128) NULL, + college_id VARCHAR(64) NULL, + college_name VARCHAR(128) NULL, + major_id VARCHAR(64) NULL, + major_name VARCHAR(128) NULL, + role_id BIGINT NULL, + class_id VARCHAR(64) NULL, + class_name VARCHAR(128) NULL, + student_id VARCHAR(64) NULL ); +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS school_name VARCHAR(128) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS college_id VARCHAR(64) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS college_name VARCHAR(128) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS major_id VARCHAR(64) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS major_name VARCHAR(128) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS role_id BIGINT NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS class_name VARCHAR(128) NULL; +ALTER TABLE platform_user_snapshot ADD COLUMN IF NOT EXISTS student_id VARCHAR(64) NULL; + CREATE TABLE IF NOT EXISTS auth_login_exchange_code ( code_hash CHAR(64) PRIMARY KEY, platform_user_id BIGINT NOT NULL, diff --git a/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java index e308d1c..cdac355 100644 --- a/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java @@ -10,6 +10,7 @@ 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; @@ -43,4 +44,25 @@ class LocalSsoAccountServiceTest { assertThat(passwordEncoder.matches("old-password", passwordHash)).isFalse(); assertThat(roleId).isEqualTo(1001L); } + + @Test + void synchronizesCompleteProfileIntoLocalSnapshot() { + service.synchronize(completeToken(603L, "sso603", 2L)); + + Map 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"); + } } diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java index 59a42a4..467d2e3 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java @@ -41,6 +41,21 @@ class PlatformTokenVerifierTest { assertThat(verified.getRoleKey()).isEqualTo("STUDENT"); } + @Test + void parsesCompleteUserProfileFromVerifiedToken() throws Exception { + VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L, + now.plus(Duration.ofMinutes(5)), completeProfile())); + + assertThat(verified.getSchoolId()).isEqualTo("610000"); + assertThat(verified.getSchoolName()).isEqualTo("Yan'an University"); + assertThat(verified.getCollegeName()).isEqualTo("Computer College"); + assertThat(verified.getMajorName()).isEqualTo("Software Engineering"); + assertThat(verified.getRoleId()).isEqualTo(2L); + assertThat(verified.getName()).isEqualTo("Test Student"); + assertThat(verified.getClassId()).isEqualTo("202401"); + assertThat(verified.getStudentId()).isEqualTo("20240001"); + } + @Test void rejectsTamperedExpiredOrIncompleteToken() throws Exception { String valid = token(487L, "tzs001", "new-password", 3L, now.plus(Duration.ofMinutes(5))); @@ -52,10 +67,29 @@ class PlatformTokenVerifierTest { } private String token(long userId, String username, String password, long roleId, Instant expiresAt) throws Exception { + return token(userId, username, password, roleId, expiresAt, new HashMap()); + } + private String token(long userId, String username, String password, long roleId, Instant expiresAt, + Map profile) throws Exception { Map claims = new HashMap(); claims.put("userId", userId); claims.put("username", username); claims.put("password", password); claims.put("roleid", roleId); + claims.putAll(profile); return unsignedToken(claims, expiresAt); } + private Map completeProfile() { + Map profile = new HashMap(); + profile.put("name", "Test Student"); + profile.put("schoolId", "610000"); + profile.put("schoolName", "Yan'an University"); + profile.put("collegeId", "100"); + profile.put("collegeName", "Computer College"); + profile.put("majorId", "101"); + profile.put("majorName", "Software Engineering"); + profile.put("classId", "202401"); + profile.put("className", "Class 1"); + profile.put("studentid", "20240001"); + return profile; + } private String unsignedToken(Map claims, Instant expiresAt) throws Exception { claims.put("exp", expiresAt.getEpochSecond()); ObjectMapper mapper = new ObjectMapper(); diff --git a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java index c6dcb08..ab26f91 100644 --- a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java @@ -35,8 +35,8 @@ class AuthControllerTest { mvc.perform(get("/api/v1/auth/me") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.account").value("tzs001")) - .andExpect(jsonPath("$.data.roles[0]").value("STUDENT")); + .andExpect(jsonPath("$.data.userId").value(487)) + .andExpect(jsonPath("$.data.username").value("tzs001")); } @Test diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java index 412d785..52eed24 100644 --- a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java @@ -33,7 +33,9 @@ class CurrentUserAndLogoutTest { @BeforeEach void setUp() { - localSsoAccountService.synchronize(new VerifiedPlatformToken(101L, "t001", "Teacher", "password", "TEACHER")); + localSsoAccountService.synchronize(new VerifiedPlatformToken(101L, "t001", "Teacher User", "password", 3L, + "TEACHER", "610000", "Yan'an University", "100", "Computer College", "101", + "Software Engineering", "202401", "Class 1", "20240001")); teacherJwt = jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")).accessToken(); refreshToken = refreshTokenService.issue(101L); } @@ -42,8 +44,19 @@ class CurrentUserAndLogoutTest { void currentUserIsReadonlyTeacherAndLogoutRevokesOwnRefreshToken() throws Exception { mvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + teacherJwt)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.account").value("t001")) - .andExpect(jsonPath("$.data.roles[0]").value("TEACHER")); + .andExpect(jsonPath("$.data.userId").value(101)) + .andExpect(jsonPath("$.data.username").value("t001")) + .andExpect(jsonPath("$.data.name").value("Teacher User")) + .andExpect(jsonPath("$.data.schoolId").value("610000")) + .andExpect(jsonPath("$.data.schoolName").value("Yan'an University")) + .andExpect(jsonPath("$.data.collegeId").value("100")) + .andExpect(jsonPath("$.data.collegeName").value("Computer College")) + .andExpect(jsonPath("$.data.majorId").value("101")) + .andExpect(jsonPath("$.data.majorName").value("Software Engineering")) + .andExpect(jsonPath("$.data.roleid").value(3)) + .andExpect(jsonPath("$.data.classId").value("202401")) + .andExpect(jsonPath("$.data.className").value("Class 1")) + .andExpect(jsonPath("$.data.studentid").value("20240001")); mvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + teacherJwt) .contentType(MediaType.APPLICATION_JSON).content("{\"refreshToken\":\"" + refreshToken + "\"}")) .andExpect(status().isOk()); diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java new file mode 100644 index 0000000..d9acd2a --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java @@ -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"); + } +}