feat: add exchange-code application sessions

master
chenyuan 4 weeks ago
parent e84d424c42
commit 5d97fabb21

@ -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<String> roles) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl());
List<String> 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) { }
}

@ -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<Long> 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, "登录兑换码无效或已过期");
}
}

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

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

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

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

@ -0,0 +1,4 @@
package com.yau.digitalrmb.security.interfaces;
public record SessionResponse(String accessToken, String refreshToken, String tokenType, long expiresIn) {
}

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