feat: verify platform SSO tokens
parent
ac78354498
commit
977d7eb6ce
@ -0,0 +1,11 @@
|
||||
package com.yau.digitalrmb.platformintegration.application;
|
||||
|
||||
public class PlatformTokenException extends RuntimeException {
|
||||
public PlatformTokenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public PlatformTokenException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package com.yau.digitalrmb.platformintegration.application;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
|
||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
|
||||
@Component
|
||||
public class PlatformTokenVerifier {
|
||||
private static final Duration CLOCK_SKEW = Duration.ofSeconds(30);
|
||||
|
||||
private final PlatformIdentityRepository identityRepository;
|
||||
private final PlatformIntegrationProperties.Token properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public PlatformTokenVerifier(PlatformIdentityRepository identityRepository,
|
||||
PlatformIntegrationProperties properties) {
|
||||
this(identityRepository, properties.getToken(), new ObjectMapper(), Clock.systemUTC());
|
||||
}
|
||||
|
||||
PlatformTokenVerifier(PlatformIdentityRepository identityRepository,
|
||||
PlatformIntegrationProperties.Token properties) {
|
||||
this(identityRepository, properties, new ObjectMapper(), Clock.systemUTC());
|
||||
}
|
||||
|
||||
PlatformTokenVerifier(PlatformIdentityRepository identityRepository,
|
||||
PlatformIntegrationProperties.Token properties,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock) {
|
||||
this.identityRepository = identityRepository;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public VerifiedPlatformToken verify(String rawToken) {
|
||||
return verify(rawToken, clock.instant());
|
||||
}
|
||||
|
||||
VerifiedPlatformToken verify(String rawToken, Instant now) {
|
||||
try {
|
||||
String[] parts = rawToken.split("\\.", -1);
|
||||
if (parts.length != 4) {
|
||||
throw invalid();
|
||||
}
|
||||
verifyLoginTime(parts[3], now);
|
||||
|
||||
JsonNode header = readJson(parts[0]);
|
||||
if (!"HS256".equals(header.path("alg").asText())) {
|
||||
throw invalid();
|
||||
}
|
||||
JsonNode payload = readJson(parts[1]);
|
||||
long platformUserId = parseAudience(payload.path("aud"));
|
||||
PlatformActor actor = identityRepository.findByPlatformUserId(platformUserId)
|
||||
.orElseThrow(this::invalid);
|
||||
verifyIdentityClaim(payload, actor);
|
||||
verifySignature(parts, actor);
|
||||
|
||||
return new VerifiedPlatformToken(actor, fingerprint(rawToken));
|
||||
} catch (PlatformTokenException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new PlatformTokenException("Invalid platform token", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyLoginTime(String value, Instant now) {
|
||||
if (!value.matches("\\d{13}")) {
|
||||
throw invalid();
|
||||
}
|
||||
Instant loginTime;
|
||||
try {
|
||||
loginTime = Instant.ofEpochMilli(Long.parseLong(value));
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid();
|
||||
}
|
||||
if (loginTime.isAfter(now.plus(CLOCK_SKEW))
|
||||
|| loginTime.isBefore(now.minus(properties.getMaxAge()))) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode readJson(String encoded) throws Exception {
|
||||
return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded));
|
||||
}
|
||||
|
||||
private long parseAudience(JsonNode audience) {
|
||||
JsonNode value = audience;
|
||||
if (audience.isArray() && audience.size() == 1) {
|
||||
value = audience.get(0);
|
||||
}
|
||||
if (!value.isTextual()) {
|
||||
throw invalid();
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(value.textValue());
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyIdentityClaim(JsonNode payload, PlatformActor actor) {
|
||||
String expected = switch (actor.role()) {
|
||||
case TEACHER -> properties.getTeacherClaimValue();
|
||||
case STUDENT -> properties.getStudentClaimValue();
|
||||
};
|
||||
if (!expected.equals(payload.path(String.valueOf(actor.profileId())).asText())) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private void verifySignature(String[] parts, PlatformActor actor) throws Exception {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
byte[] key = String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8);
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
byte[] expected = mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));
|
||||
byte[] actual = Base64.getUrlDecoder().decode(parts[2]);
|
||||
if (!MessageDigest.isEqual(expected, actual)) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private String fingerprint(String rawToken) throws Exception {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
}
|
||||
|
||||
private PlatformTokenException invalid() {
|
||||
return new PlatformTokenException("Invalid platform token");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.yau.digitalrmb.platformintegration.application;
|
||||
|
||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
||||
|
||||
public record VerifiedPlatformToken(PlatformActor actor, String fingerprint) {
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package com.yau.digitalrmb.platformintegration.application;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
|
||||
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
|
||||
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class PlatformTokenVerifierTest {
|
||||
private final Instant now = Instant.parse("2026-08-03T04:00:00Z");
|
||||
private final PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
|
||||
Instant.parse("2026-01-01T00:00:00Z"));
|
||||
private PlatformTokenVerifier verifier;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
PlatformIntegrationProperties.Token properties = new PlatformIntegrationProperties.Token();
|
||||
properties.setMaxAge(Duration.ofMinutes(2));
|
||||
properties.setTeacherClaimValue("teacher");
|
||||
properties.setStudentClaimValue("student");
|
||||
PlatformIdentityRepository repository = new PlatformIdentityRepository() {
|
||||
@Override
|
||||
public Optional<PlatformActor> findByPlatformUserId(long platformUserId) {
|
||||
return platformUserId == 101L ? Optional.of(teacher) : Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlatformActor> findBySchoolAccount(String schoolAccount) {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
verifier = new PlatformTokenVerifier(repository, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsFreshTeacherTokenSignedWithProfileAddTime() throws Exception {
|
||||
String token = issueToken(teacher, now);
|
||||
|
||||
VerifiedPlatformToken verified = verifier.verify(token, now);
|
||||
|
||||
assertThat(verified.actor()).isEqualTo(teacher);
|
||||
assertThat(verified.fingerprint()).hasSize(64);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTamperedStaleAndRoleMismatchedTokens() throws Exception {
|
||||
String valid = issueToken(teacher, now);
|
||||
String stale = issueToken(teacher, now.minus(Duration.ofMinutes(3)));
|
||||
String wrongRole = issueToken(teacher, now, "student");
|
||||
|
||||
assertThatThrownBy(() -> verifier.verify(valid + "x", now)).isInstanceOf(PlatformTokenException.class);
|
||||
assertThatThrownBy(() -> verifier.verify(stale, now)).isInstanceOf(PlatformTokenException.class);
|
||||
assertThatThrownBy(() -> verifier.verify(wrongRole, now)).isInstanceOf(PlatformTokenException.class);
|
||||
}
|
||||
|
||||
private String issueToken(PlatformActor actor, Instant loginTime) throws Exception {
|
||||
return issueToken(actor, loginTime, "teacher");
|
||||
}
|
||||
|
||||
private String issueToken(PlatformActor actor, Instant loginTime, String identityClaimValue) throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
String header = encode(mapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT")));
|
||||
String payload = encode(mapper.writeValueAsBytes(Map.of("aud", new String[]{String.valueOf(actor.platformUserId())},
|
||||
String.valueOf(actor.profileId()), identityClaimValue)));
|
||||
String unsigned = header + "." + payload;
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return unsigned + "." + encode(mac.doFinal(unsigned.getBytes(StandardCharsets.US_ASCII))) + "." + loginTime.toEpochMilli();
|
||||
}
|
||||
|
||||
private String encode(byte[] value) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue