feat: add exchange-code application sessions
parent
e84d424c42
commit
5d97fabb21
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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…
Reference in New Issue