diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenException.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenException.java new file mode 100644 index 0000000..17a96af --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenException.java @@ -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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java new file mode 100644 index 0000000..2659a79 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java @@ -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"); + } +} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java new file mode 100644 index 0000000..939fa7e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java @@ -0,0 +1,6 @@ +package com.yau.digitalrmb.platformintegration.application; + +import com.yau.digitalrmb.platformintegration.domain.PlatformActor; + +public record VerifiedPlatformToken(PlatformActor actor, String fingerprint) { +} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java index 024db8b..01b6990 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java @@ -4,6 +4,7 @@ import java.time.Instant; public record PlatformActor( long platformUserId, + long profileId, String account, String displayName, PlatformRole role, diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java index 239f7fb..172f0be 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java @@ -14,13 +14,13 @@ import java.util.Optional; @Repository public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository { 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 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 """; 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 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 @@ -57,6 +57,7 @@ public class JdbcPlatformIdentityRepository implements PlatformIdentityRepositor private PlatformActor map(ResultSet resultSet, PlatformRole role) throws SQLException { return new PlatformActor( resultSet.getLong("ID"), + resultSet.getLong("profile_id"), resultSet.getString("CODE"), resultSet.getString("NAME"), role, diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java new file mode 100644 index 0000000..ab2a154 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java @@ -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 findByPlatformUserId(long platformUserId) { + return platformUserId == 101L ? Optional.of(teacher) : Optional.empty(); + } + + @Override + public Optional 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); + } +} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java index d54a8f7..1cbf706 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java @@ -38,6 +38,7 @@ class JdbcPlatformIdentityRepositoryTest { assertThat(actor.account()).isEqualTo("t001"); assertThat(actor.role()).isEqualTo(PlatformRole.TEACHER); + assertThat(actor.profileId()).isEqualTo(1L); assertThat(actor.tokenSigningTime()).isEqualTo(Instant.parse("2025-12-31T16:00:00Z")); }