feat: add current user context
parent
f0e10a5567
commit
8c49659d68
@ -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<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,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,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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue