feat: add readonly current-user and logout APIs

master
chenyuan 4 weeks ago
parent cf46616429
commit 171de3d078

@ -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) {

@ -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);
}
}

@ -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());
}

@ -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<PlatformActor> findByPlatformUserId(long platformUserId);
Optional<PlatformActor> findBySchoolAccount(String schoolAccount);
List<PlatformActor> findChangedSince(Instant watermark);
}

@ -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);
}
}

@ -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<PlatformActor> findChangedSince(Instant watermark) {
String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)";
Timestamp since = Timestamp.from(watermark);
List<PlatformActor> 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<PlatformActor> findBy(String predicate, Object value) {
Optional<PlatformActor> 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<PlatformActor> 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"),

@ -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();

@ -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);
}
}

@ -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<LoginResponse> 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<SessionResponse> 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<CurrentUserResponse> 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<Void> 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, "用户身份无效");
}
}
}

@ -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<LoginResponse> 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));
}
}

@ -0,0 +1,6 @@
package com.yau.digitalrmb.security.interfaces;
import java.util.List;
public record CurrentUserResponse(long platformUserId, String account, String displayName, List<String> roles) {
}

@ -0,0 +1,6 @@
package com.yau.digitalrmb.security.interfaces;
import jakarta.validation.constraints.NotBlank;
public record LogoutRequest(@NotBlank String refreshToken) {
}

@ -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<PlatformActor> findBySchoolAccount(String schoolAccount) {
return Optional.empty();
}
@Override
public List<PlatformActor> findChangedSince(Instant watermark) {
return List.of();
}
};
verifier = new PlatformTokenVerifier(repository, properties);
}

@ -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());
}
}
Loading…
Cancel
Save