From 171de3d0783f8c35df8b974786a01a27fc3e8370 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 3 Aug 2026 13:16:07 +0800 Subject: [PATCH] feat: add readonly current-user and logout APIs --- .../yau/digitalrmb/DigitalRmbApplication.java | 2 + .../application/PlatformIdentitySyncJob.java | 33 ++++++++++ .../application/CasTicketValidator.java | 2 + .../PlatformIdentityRepository.java | 4 ++ .../config/PlatformIntegrationProperties.java | 10 ++++ .../JdbcPlatformIdentityRepository.java | 20 +++++++ .../BootstrapAdminAuthenticator.java | 2 + .../application/RefreshTokenService.java | 5 +- .../security/interfaces/AuthController.java | 51 ++++++++++------ .../interfaces/BootstrapLoginController.java | 38 ++++++++++++ .../interfaces/CurrentUserResponse.java | 6 ++ .../security/interfaces/LogoutRequest.java | 6 ++ .../PlatformTokenVerifierTest.java | 6 ++ .../security/CurrentUserAndLogoutTest.java | 60 +++++++++++++++++++ 14 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/BootstrapLoginController.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/LogoutRequest.java create mode 100644 src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java diff --git a/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java b/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java index ecdd7b9..74edcfb 100644 --- a/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java +++ b/src/main/java/com/yau/digitalrmb/DigitalRmbApplication.java @@ -3,9 +3,11 @@ package com.yau.digitalrmb; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling public class DigitalRmbApplication { public static void main(String[] args) { diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java new file mode 100644 index 0000000..0268c84 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java @@ -0,0 +1,33 @@ +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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java index e1a73fb..27acbe5 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java @@ -3,6 +3,7 @@ package com.yau.digitalrmb.platformintegration.application; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.util.UriComponentsBuilder; import org.w3c.dom.Document; @@ -25,6 +26,7 @@ public class CasTicketValidator { private final PlatformIntegrationProperties.Cas properties; private final HttpClient httpClient; + @Autowired public CasTicketValidator(PlatformIntegrationProperties properties) { this(properties.getCas(), HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()); } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java index d5b935d..81bd2c1 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java @@ -3,9 +3,13 @@ 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 findByPlatformUserId(long platformUserId); Optional findBySchoolAccount(String schoolAccount); + + List findChangedSince(Instant watermark); } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java index 62358db..eae0250 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java @@ -24,6 +24,8 @@ public class PlatformIntegrationProperties { private Cas cas = new Cas(); @Valid private Frontend frontend = new Frontend(); + @Valid + private Sync sync = new Sync(); @Getter @Setter @@ -69,4 +71,12 @@ public class PlatformIntegrationProperties { @NotBlank private String callbackUrl; } + + @Getter + @Setter + public static class Sync { + private boolean enabled = false; + @NotNull + private Duration fixedDelay = Duration.ofMinutes(15); + } } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java index 172f0be..95cf6b8 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java @@ -9,6 +9,10 @@ import org.springframework.stereotype.Repository; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.ArrayList; import java.util.Optional; @Repository @@ -42,6 +46,15 @@ public class JdbcPlatformIdentityRepository implements PlatformIdentityRepositor return findBy("cu.CODE = :value", schoolAccount); } + @Override + public List findChangedSince(Instant watermark) { + String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)"; + Timestamp since = Timestamp.from(watermark); + List actors = new ArrayList<>(queryAll(TEACHER_QUERY.formatted(predicate), since, PlatformRole.TEACHER)); + actors.addAll(queryAll(STUDENT_QUERY.formatted(predicate), since, PlatformRole.STUDENT)); + return actors; + } + private Optional findBy(String predicate, Object value) { Optional teacher = query(TEACHER_QUERY.formatted(predicate), value, PlatformRole.TEACHER); return teacher.isPresent() ? teacher : query(STUDENT_QUERY.formatted(predicate), value, PlatformRole.STUDENT); @@ -54,6 +67,13 @@ public class JdbcPlatformIdentityRepository implements PlatformIdentityRepositor .optional(); } + private List queryAll(String sql, Timestamp watermark, PlatformRole role) { + return jdbcClient.sql(sql) + .param("watermark", watermark) + .query((resultSet, rowNumber) -> map(resultSet, role)) + .list(); + } + private PlatformActor map(ResultSet resultSet, PlatformRole role) throws SQLException { return new PlatformActor( resultSet.getLong("ID"), diff --git a/src/main/java/com/yau/digitalrmb/security/application/BootstrapAdminAuthenticator.java b/src/main/java/com/yau/digitalrmb/security/application/BootstrapAdminAuthenticator.java index c3f2fa1..f4ecde8 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/BootstrapAdminAuthenticator.java +++ b/src/main/java/com/yau/digitalrmb/security/application/BootstrapAdminAuthenticator.java @@ -1,10 +1,12 @@ package com.yau.digitalrmb.security.application; import com.yau.digitalrmb.security.config.SecurityProperties; +import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; @Component +@Profile({"local", "test"}) public class BootstrapAdminAuthenticator { private final SecurityProperties properties; private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); diff --git a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java index e8773a3..8f0ab3d 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java @@ -31,8 +31,9 @@ public class RefreshTokenService { return token; } - public void revoke(String token) { + public void revokeForUser(String token, long platformUserId) { jdbcTemplate.update("UPDATE auth_refresh_token SET revoked_at = CURRENT_TIMESTAMP " - + "WHERE token_hash = ? AND revoked_at IS NULL", LoginExchangeCodeService.hash(token)); + + "WHERE token_hash = ? AND platform_user_id = ? AND revoked_at IS NULL", + LoginExchangeCodeService.hash(token), platformUserId); } } 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 ebba31a..874e404 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java @@ -1,53 +1,43 @@ package com.yau.digitalrmb.security.interfaces; -import com.yau.digitalrmb.security.application.BootstrapAdminAuthenticator; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; import com.yau.digitalrmb.security.application.JwtTokenService; import com.yau.digitalrmb.security.application.LoginExchangeCodeService; import com.yau.digitalrmb.security.application.RefreshTokenService; -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; -import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; 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 jakarta.validation.Valid; import org.slf4j.MDC; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Set; @RestController @RequestMapping("/api/v1/auth") public class AuthController { - private final BootstrapAdminAuthenticator authenticator; private final JwtTokenService tokenService; private final LoginExchangeCodeService exchangeCodeService; private final RefreshTokenService refreshTokenService; private final PlatformUserSnapshotMapper snapshotMapper; - public AuthController(BootstrapAdminAuthenticator authenticator, JwtTokenService tokenService, - LoginExchangeCodeService exchangeCodeService, RefreshTokenService refreshTokenService, - PlatformUserSnapshotMapper snapshotMapper) { - this.authenticator = authenticator; + public AuthController(JwtTokenService tokenService, LoginExchangeCodeService exchangeCodeService, + RefreshTokenService refreshTokenService, PlatformUserSnapshotMapper snapshotMapper) { this.tokenService = tokenService; this.exchangeCodeService = exchangeCodeService; this.refreshTokenService = refreshTokenService; this.snapshotMapper = snapshotMapper; } - @PostMapping("/login") - public ApiResponse login(@Valid @RequestBody LoginRequest request) { - if (!authenticator.matches(request.username(), request.password())) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); - } - JwtTokenService.Token token = tokenService.issueFor(request.username()); - return ApiResponse.success(new LoginResponse(token.accessToken(), "Bearer", token.expiresIn()), - MDC.get(TraceIdFilter.MDC_KEY)); - } - @PostMapping("/session/exchange") public ApiResponse exchange(@Valid @RequestBody ExchangeCodeRequest request) { long platformUserId = exchangeCodeService.exchange(request.code()); @@ -61,4 +51,29 @@ public class AuthController { return ApiResponse.success(new SessionResponse(accessToken.accessToken(), refreshToken, "Bearer", accessToken.expiresIn()), MDC.get(TraceIdFilter.MDC_KEY)); } + + @GetMapping("/me") + public ApiResponse currentUser(@AuthenticationPrincipal Jwt jwt) { + long platformUserId = platformUserId(jwt); + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(platformUserId); + if (snapshot == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); + } + return ApiResponse.success(new CurrentUserResponse(platformUserId, snapshot.getAccount(), snapshot.getDisplayName(), + List.of(snapshot.getRoleKey())), MDC.get(TraceIdFilter.MDC_KEY)); + } + + @PostMapping("/logout") + public ApiResponse logout(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody LogoutRequest request) { + refreshTokenService.revokeForUser(request.refreshToken(), platformUserId(jwt)); + return ApiResponse.success(null, MDC.get(TraceIdFilter.MDC_KEY)); + } + + private long platformUserId(Jwt jwt) { + try { + return Long.parseLong(jwt.getSubject()); + } catch (NumberFormatException exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份无效"); + } + } } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/BootstrapLoginController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/BootstrapLoginController.java new file mode 100644 index 0000000..e578f97 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/BootstrapLoginController.java @@ -0,0 +1,38 @@ +package com.yau.digitalrmb.security.interfaces; + +import com.yau.digitalrmb.security.application.BootstrapAdminAuthenticator; +import com.yau.digitalrmb.security.application.JwtTokenService; +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 jakarta.validation.Valid; +import org.slf4j.MDC; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Profile({"local", "test"}) +@RestController +@RequestMapping("/api/v1/auth") +public class BootstrapLoginController { + private final BootstrapAdminAuthenticator authenticator; + private final JwtTokenService tokenService; + + public BootstrapLoginController(BootstrapAdminAuthenticator authenticator, JwtTokenService tokenService) { + this.authenticator = authenticator; + this.tokenService = tokenService; + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + if (!authenticator.matches(request.username(), request.password())) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); + } + JwtTokenService.Token token = tokenService.issueFor(request.username()); + return ApiResponse.success(new LoginResponse(token.accessToken(), "Bearer", token.expiresIn()), + MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java new file mode 100644 index 0000000..2b916b6 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java @@ -0,0 +1,6 @@ +package com.yau.digitalrmb.security.interfaces; + +import java.util.List; + +public record CurrentUserResponse(long platformUserId, String account, String displayName, List roles) { +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/LogoutRequest.java b/src/main/java/com/yau/digitalrmb/security/interfaces/LogoutRequest.java new file mode 100644 index 0000000..6e2d166 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/LogoutRequest.java @@ -0,0 +1,6 @@ +package com.yau.digitalrmb.security.interfaces; + +import jakarta.validation.constraints.NotBlank; + +public record LogoutRequest(@NotBlank String refreshToken) { +} 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 ab2a154..2543c81 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java @@ -15,6 +15,7 @@ import java.time.Instant; import java.util.Base64; import java.util.Map; import java.util.Optional; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -41,6 +42,11 @@ class PlatformTokenVerifierTest { public Optional findBySchoolAccount(String schoolAccount) { return Optional.empty(); } + + @Override + public List findChangedSince(Instant watermark) { + return List.of(); + } }; verifier = new PlatformTokenVerifier(repository, properties); } diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java new file mode 100644 index 0000000..9541af3 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java @@ -0,0 +1,60 @@ +package com.yau.digitalrmb.security; + +import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.platformintegration.domain.PlatformActor; +import com.yau.digitalrmb.platformintegration.domain.PlatformRole; +import com.yau.digitalrmb.security.application.JwtTokenService; +import com.yau.digitalrmb.security.application.RefreshTokenService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.Set; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CurrentUserAndLogoutTest { + @Autowired + private MockMvc mvc; + @Autowired + private PlatformIdentityProjectionService projectionService; + @Autowired + private JwtTokenService jwtTokenService; + @Autowired + private RefreshTokenService refreshTokenService; + + private String teacherJwt; + private String refreshToken; + + @BeforeEach + void setUp() { + projectionService.project(new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, + Instant.parse("2026-01-01T00:00:00Z"))); + teacherJwt = jwtTokenService.issueFor(101L, "t001", Set.of("TEACHER")).accessToken(); + refreshToken = refreshTokenService.issue(101L); + } + + @Test + 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")); + mvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + teacherJwt) + .contentType(MediaType.APPLICATION_JSON).content("{\"refreshToken\":\"" + refreshToken + "\"}")) + .andExpect(status().isOk()); + mvc.perform(post("/api/v1/users")).andExpect(status().is4xxClientError()); + } +}