feat: verify platform SSO tokens

master
chenyuan 4 weeks ago
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) {
}

@ -4,6 +4,7 @@ import java.time.Instant;
public record PlatformActor( public record PlatformActor(
long platformUserId, long platformUserId,
long profileId,
String account, String account,
String displayName, String displayName,
PlatformRole role, PlatformRole role,

@ -14,13 +14,13 @@ import java.util.Optional;
@Repository @Repository
public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository { public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository {
private static final String TEACHER_QUERY = """ private static final String TEACHER_QUERY = """
SELECT cu.ID, cu.CODE, cu.NAME, t.add_time AS signing_time SELECT cu.ID, cu.CODE, cu.NAME, t.teacher_id AS profile_id, t.add_time AS signing_time
FROM core_user cu JOIN teacher t ON t.user_id = cu.ID FROM core_user cu JOIN teacher t ON t.user_id = cu.ID
WHERE cu.JOB_TYPE1 = 'JT_S_02' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0 WHERE cu.JOB_TYPE1 = 'JT_S_02' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0
AND t.teacher_status = 1 AND t.add_time IS NOT NULL AND %s AND t.teacher_status = 1 AND t.add_time IS NOT NULL AND %s
"""; """;
private static final String STUDENT_QUERY = """ private static final String STUDENT_QUERY = """
SELECT cu.ID, cu.CODE, cu.NAME, s.add_time AS signing_time SELECT cu.ID, cu.CODE, cu.NAME, s.student_id AS profile_id, s.add_time AS signing_time
FROM core_user cu JOIN student s ON s.user_id = cu.ID FROM core_user cu JOIN student s ON s.user_id = cu.ID
WHERE cu.JOB_TYPE1 = 'JT_S_03' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0 WHERE cu.JOB_TYPE1 = 'JT_S_03' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0
AND s.student_status = 1 AND s.add_time IS NOT NULL AND %s AND s.student_status = 1 AND s.add_time IS NOT NULL AND %s
@ -57,6 +57,7 @@ public class JdbcPlatformIdentityRepository implements PlatformIdentityRepositor
private PlatformActor map(ResultSet resultSet, PlatformRole role) throws SQLException { private PlatformActor map(ResultSet resultSet, PlatformRole role) throws SQLException {
return new PlatformActor( return new PlatformActor(
resultSet.getLong("ID"), resultSet.getLong("ID"),
resultSet.getLong("profile_id"),
resultSet.getString("CODE"), resultSet.getString("CODE"),
resultSet.getString("NAME"), resultSet.getString("NAME"),
role, role,

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

@ -38,6 +38,7 @@ class JdbcPlatformIdentityRepositoryTest {
assertThat(actor.account()).isEqualTo("t001"); assertThat(actor.account()).isEqualTo("t001");
assertThat(actor.role()).isEqualTo(PlatformRole.TEACHER); assertThat(actor.role()).isEqualTo(PlatformRole.TEACHER);
assertThat(actor.profileId()).isEqualTo(1L);
assertThat(actor.tokenSigningTime()).isEqualTo(Instant.parse("2025-12-31T16:00:00Z")); assertThat(actor.tokenSigningTime()).isEqualTo(Instant.parse("2025-12-31T16:00:00Z"));
} }

Loading…
Cancel
Save