From 5d97fabb215727b19298fdb88d50bb9ec4aefadc Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 3 Aug 2026 13:04:33 +0800 Subject: [PATCH] feat: add exchange-code application sessions --- .../security/application/JwtTokenService.java | 16 +++++ .../application/LoginExchangeCodeService.java | 72 +++++++++++++++++++ .../application/RefreshTokenService.java | 38 ++++++++++ .../security/config/SecurityProperties.java | 11 +++ .../security/interfaces/AuthController.java | 30 +++++++- .../interfaces/ExchangeCodeRequest.java | 6 ++ .../security/interfaces/SessionResponse.java | 4 ++ .../LoginExchangeCodeServiceTest.java | 26 +++++++ 8 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java create mode 100644 src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java create mode 100644 src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java diff --git a/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java index cef391f..ec51e13 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import java.time.Instant; import java.util.List; +import java.util.Set; @Service public class JwtTokenService { @@ -31,5 +32,20 @@ public class JwtTokenService { return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().toSeconds()); } + public Token issueFor(long platformUserId, String account, Set roles) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl()); + List authorities = roles.stream().sorted().map(role -> "ROLE_" + role).toList(); + JwtClaimsSet claims = JwtClaimsSet.builder() + .subject(String.valueOf(platformUserId)) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .claim("preferred_username", account) + .claim("roles", authorities) + .build(); + Jwt jwt = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)); + return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().toSeconds()); + } + public record Token(String accessToken, long expiresIn) { } } diff --git a/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java b/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java new file mode 100644 index 0000000..63dddd5 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java @@ -0,0 +1,72 @@ +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.HexFormat; +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 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 { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } 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, "登录兑换码无效或已过期"); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java new file mode 100644 index 0000000..e8773a3 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java @@ -0,0 +1,38 @@ +package com.yau.digitalrmb.security.application; + +import com.yau.digitalrmb.security.config.SecurityProperties; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Base64; + +@Service +public class RefreshTokenService { + private static final SecureRandom RANDOM = new SecureRandom(); + + private final JdbcTemplate jdbcTemplate; + private final SecurityProperties properties; + + public RefreshTokenService(JdbcTemplate jdbcTemplate, SecurityProperties properties) { + this.jdbcTemplate = jdbcTemplate; + this.properties = properties; + } + + public String issue(long platformUserId) { + byte[] bytes = new byte[48]; + RANDOM.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + jdbcTemplate.update("INSERT INTO auth_refresh_token (token_hash, platform_user_id, expires_at, revoked_at) VALUES (?, ?, ?, NULL)", + LoginExchangeCodeService.hash(token), platformUserId, + Timestamp.from(Instant.now().plus(properties.getSession().getRefreshTokenTtl()))); + return token; + } + + public void revoke(String token) { + jdbcTemplate.update("UPDATE auth_refresh_token SET revoked_at = CURRENT_TIMESTAMP " + + "WHERE token_hash = ? AND revoked_at IS NULL", LoginExchangeCodeService.hash(token)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java index cb5d626..959fef6 100644 --- a/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java @@ -10,9 +10,11 @@ import java.time.Duration; @ConfigurationProperties(prefix = "security") public class SecurityProperties { private final Jwt jwt = new Jwt(); + private final Session session = new Session(); private final BootstrapAdmin bootstrapAdmin = new BootstrapAdmin(); public Jwt getJwt() { return jwt; } + public Session getSession() { return session; } public BootstrapAdmin getBootstrapAdmin() { return bootstrapAdmin; } @PostConstruct @@ -43,4 +45,13 @@ public class SecurityProperties { public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } + + public static class Session { + private Duration exchangeCodeTtl = Duration.ofMinutes(1); + private Duration refreshTokenTtl = Duration.ofHours(8); + public Duration getExchangeCodeTtl() { return exchangeCodeTtl; } + public void setExchangeCodeTtl(Duration exchangeCodeTtl) { this.exchangeCodeTtl = exchangeCodeTtl; } + public Duration getRefreshTokenTtl() { return refreshTokenTtl; } + public void setRefreshTokenTtl(Duration refreshTokenTtl) { this.refreshTokenTtl = refreshTokenTtl; } + } } 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 412aa6c..ebba31a 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java @@ -2,6 +2,10 @@ package com.yau.digitalrmb.security.interfaces; import com.yau.digitalrmb.security.application.BootstrapAdminAuthenticator; 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; @@ -13,15 +17,25 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +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) { + public AuthController(BootstrapAdminAuthenticator authenticator, JwtTokenService tokenService, + LoginExchangeCodeService exchangeCodeService, RefreshTokenService refreshTokenService, + PlatformUserSnapshotMapper snapshotMapper) { this.authenticator = authenticator; this.tokenService = tokenService; + this.exchangeCodeService = exchangeCodeService; + this.refreshTokenService = refreshTokenService; + this.snapshotMapper = snapshotMapper; } @PostMapping("/login") @@ -33,4 +47,18 @@ public class AuthController { 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()); + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(platformUserId); + if (snapshot == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); + } + JwtTokenService.Token accessToken = tokenService.issueFor(platformUserId, snapshot.getAccount(), + Set.of(snapshot.getRoleKey())); + String refreshToken = refreshTokenService.issue(platformUserId); + return ApiResponse.success(new SessionResponse(accessToken.accessToken(), refreshToken, "Bearer", accessToken.expiresIn()), + MDC.get(TraceIdFilter.MDC_KEY)); + } } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java b/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java new file mode 100644 index 0000000..bc80131 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java @@ -0,0 +1,6 @@ +package com.yau.digitalrmb.security.interfaces; + +import jakarta.validation.constraints.NotBlank; + +public record ExchangeCodeRequest(@NotBlank String code) { +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java new file mode 100644 index 0000000..47f585d --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java @@ -0,0 +1,4 @@ +package com.yau.digitalrmb.security.interfaces; + +public record SessionResponse(String accessToken, String refreshToken, String tokenType, long expiresIn) { +} diff --git a/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java b/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java new file mode 100644 index 0000000..d3c687b --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java @@ -0,0 +1,26 @@ +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); + } +}