From ed2a65aa43cab32215ee921e8e0dc35fe7c73c06 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 4 Aug 2026 10:20:22 +0800 Subject: [PATCH] refactor: remove main platform datasource dependency --- .../application/LocalSsoAccountService.java | 70 +++++++++++ .../PlatformIdentityProjectionService.java | 85 ------------- .../application/PlatformIdentitySyncJob.java | 33 ----- .../application/CasTicketValidator.java | 94 -------------- .../PlatformIdentityRepository.java | 15 --- .../application/PlatformTokenVerifier.java | 115 ++++-------------- .../application/VerifiedPlatformToken.java | 19 ++- .../config/PlatformIntegrationProperties.java | 55 +-------- .../domain/PlatformActor.java | 13 -- .../domain/PlatformRole.java | 25 ---- .../JdbcPlatformIdentityRepository.java | 97 --------------- .../PlatformReadOnlyDataSourceConfig.java | 32 ----- .../CasAuthenticationController.java | 63 ---------- .../interfaces/PlatformSsoController.java | 34 +++--- .../application/LoginExchangeCodeService.java | 79 ------------ .../application/RefreshTokenService.java | 19 ++- .../security/config/SecurityConfig.java | 1 - .../security/interfaces/AuthController.java | 37 ++---- .../interfaces/ExchangeCodeRequest.java | 4 - .../security/interfaces/SessionResponse.java | 4 - src/main/resources/application-dev.yml | 6 +- src/main/resources/application-test.yml | 12 +- src/main/resources/application.yml | 14 +-- .../digitalrmb/ApplicationContextTest.java | 9 +- .../identity/LocalSsoAccountServiceTest.java | 46 +++++++ ...PlatformIdentityProjectionServiceTest.java | 60 --------- .../application/CasTicketValidatorTest.java | 47 ------- .../PlatformTokenVerifierTest.java | 90 +++++--------- .../PlatformIntegrationPropertiesTest.java | 43 ------- .../domain/PlatformRoleTest.java | 17 --- .../JdbcPlatformIdentityRepositoryTest.java | 82 ------------- .../PlatformReadOnlyDataSourceConfigTest.java | 43 ------- .../interfaces/PlatformSsoControllerTest.java | 31 +++-- .../security/CurrentUserAndLogoutTest.java | 26 ++-- .../LoginExchangeCodeServiceTest.java | 26 ---- 35 files changed, 257 insertions(+), 1189 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java delete mode 100644 src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java delete mode 100644 src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java delete mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java delete mode 100644 src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java delete mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java delete mode 100644 src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java create mode 100644 src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java delete mode 100644 src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java diff --git a/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java new file mode 100644 index 0000000..3cfb099 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java @@ -0,0 +1,70 @@ +package com.yau.digitalrmb.identity.application; + +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class LocalSsoAccountService { + private final UserMapper userMapper; + private final PlatformUserSnapshotMapper snapshotMapper; + private final JdbcTemplate jdbcTemplate; + private final PasswordEncoder passwordEncoder; + + public LocalSsoAccountService(UserMapper userMapper, PlatformUserSnapshotMapper snapshotMapper, + JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { + this.userMapper = userMapper; + this.snapshotMapper = snapshotMapper; + this.jdbcTemplate = jdbcTemplate; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public void synchronize(VerifiedPlatformToken token) { + UserEntity user = userMapper.selectById(token.getUserId()); + boolean newUser = user == null; + if (user == null) { + user = new UserEntity(); + user.setId(token.getUserId()); + } + user.setUsername(token.getUsername()); + user.setPasswordHash(passwordEncoder.encode(token.getRawPassword())); + user.setEnabled(true); + if (newUser) { + userMapper.insert(user); + } else { + userMapper.updateById(user); + } + upsertSnapshot(token); + jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", token.getUserId()); + jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)", + token.getUserId(), "TEACHER".equals(token.getRoleKey()) ? 1001L : 1002L); + } + + private void upsertSnapshot(VerifiedPlatformToken token) { + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(token.getUserId()); + boolean newSnapshot = snapshot == null; + if (snapshot == null) { + snapshot = new PlatformUserSnapshotEntity(); + snapshot.setPlatformUserId(token.getUserId()); + } + snapshot.setAccount(token.getUsername()); + snapshot.setDisplayName(token.getDisplayName()); + snapshot.setRoleKey(token.getRoleKey()); + snapshot.setSourceUpdatedAt(LocalDateTime.now()); + snapshot.setSyncedAt(LocalDateTime.now()); + if (newSnapshot) { + snapshotMapper.insert(snapshot); + } else { + snapshotMapper.updateById(snapshot); + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java deleted file mode 100644 index 6a619fd..0000000 --- a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.yau.digitalrmb.identity.application; - -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; -import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; -import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.UUID; - -@Service -public class PlatformIdentityProjectionService { - private final UserMapper userMapper; - private final PlatformUserSnapshotMapper snapshotMapper; - private final JdbcTemplate jdbcTemplate; - private final PasswordEncoder passwordEncoder; - - public PlatformIdentityProjectionService(UserMapper userMapper, - PlatformUserSnapshotMapper snapshotMapper, - JdbcTemplate jdbcTemplate, - PasswordEncoder passwordEncoder) { - this.userMapper = userMapper; - this.snapshotMapper = snapshotMapper; - this.jdbcTemplate = jdbcTemplate; - this.passwordEncoder = passwordEncoder; - } - - @Transactional - public void project(PlatformActor actor) { - projectUser(actor); - projectSnapshot(actor); - projectRole(actor); - } - - private void projectUser(PlatformActor actor) { - UserEntity user = userMapper.selectById(actor.platformUserId()); - if (user == null) { - user = new UserEntity(); - user.setId(actor.platformUserId()); - user.setUsername(actor.account()); - user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString())); - user.setEnabled(true); - userMapper.insert(user); - return; - } - user.setUsername(actor.account()); - user.setEnabled(true); - userMapper.updateById(user); - } - - private void projectSnapshot(PlatformActor actor) { - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(actor.platformUserId()); - boolean newSnapshot = snapshot == null; - if (newSnapshot) { - snapshot = new PlatformUserSnapshotEntity(); - snapshot.setPlatformUserId(actor.platformUserId()); - } - snapshot.setAccount(actor.account()); - snapshot.setDisplayName(actor.displayName()); - snapshot.setRoleKey(actor.role().name()); - snapshot.setSourceUpdatedAt(LocalDateTime.ofInstant(actor.tokenSigningTime(), ZoneOffset.UTC)); - snapshot.setSyncedAt(LocalDateTime.now(ZoneOffset.UTC)); - if (newSnapshot) { - snapshotMapper.insert(snapshot); - } else { - snapshotMapper.updateById(snapshot); - } - } - - private void projectRole(PlatformActor actor) { - jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", actor.platformUserId()); - jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)", - actor.platformUserId(), roleId(actor)); - } - - private long roleId(PlatformActor actor) { - return actor.role().name().equals("TEACHER") ? 1001L : 1002L; - } -} diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java deleted file mode 100644 index 0268c84..0000000 --- a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.yau.digitalrmb.identity.application; - -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.Instant; - -@Component -@ConditionalOnProperty(prefix = "platform-integration.sync", name = "enabled", havingValue = "true") -public class PlatformIdentitySyncJob { - private final PlatformIdentityRepository identityRepository; - private final PlatformIdentityProjectionService projectionService; - private Instant watermark = Instant.EPOCH; - - public PlatformIdentitySyncJob(PlatformIdentityRepository identityRepository, - PlatformIdentityProjectionService projectionService) { - this.identityRepository = identityRepository; - this.projectionService = projectionService; - } - - @Scheduled(fixedDelayString = "${platform-integration.sync.fixed-delay:PT15M}") - public synchronized void sync() { - Instant nextWatermark = Instant.now(); - syncChangedSince(watermark); - watermark = nextWatermark; - } - - public void syncChangedSince(Instant since) { - identityRepository.findChangedSince(since).forEach(projectionService::project); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java deleted file mode 100644 index 23098d8..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.yau.digitalrmb.platformintegration.application; - -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.shared.api.ErrorCode; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.http.ResponseEntity; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; -import org.w3c.dom.Document; -import org.xml.sax.InputSource; -import org.xml.sax.helpers.DefaultHandler; - -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.xpath.XPathConstants; -import javax.xml.xpath.XPathFactory; -import java.io.StringReader; -import java.net.URI; - -@Component -public class CasTicketValidator { - private final PlatformIntegrationProperties.Cas properties; - private final RestTemplate restTemplate; - - @Autowired - public CasTicketValidator(PlatformIntegrationProperties properties) { - this(properties.getCas(), createRestTemplate()); - } - - private static RestTemplate createRestTemplate() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(5000); - requestFactory.setReadTimeout(5000); - return new RestTemplate(requestFactory); - } - - CasTicketValidator(PlatformIntegrationProperties.Cas properties, RestTemplate restTemplate) { - this.properties = properties; - this.restTemplate = restTemplate; - } - - public String validate(String ticket) { - try { - URI uri = UriComponentsBuilder.fromUriString(properties.getValidateUrl()) - .queryParam("service", properties.getCallbackUrl()) - .queryParam("ticket", ticket) - .build().encode().toUri(); - ResponseEntity response = restTemplate.getForEntity(uri, String.class); - if (response.getStatusCodeValue() < 200 || response.getStatusCodeValue() >= 300) { - throw rejected(); - } - return parseAccount(response.getBody()); - } catch (BusinessException exception) { - throw exception; - } catch (Exception exception) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "CAS 票据校验失败"); - } - } - - static String parseAccount(String xml) { - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder(); - builder.setErrorHandler(new DefaultHandler()); - Document document = builder.parse(new InputSource(new StringReader(xml))); - String account = (String) XPathFactory.newInstance().newXPath().evaluate( - "string(//*[local-name()='authenticationSuccess']/*[local-name()='user'])", - document, XPathConstants.STRING); - if (account == null || account.trim().isEmpty()) { - throw rejected(); - } - return account.trim(); - } catch (BusinessException exception) { - throw exception; - } catch (Exception exception) { - throw rejected(); - } - } - - private static BusinessException rejected() { - return new BusinessException(ErrorCode.UNAUTHORIZED, "CAS 票据无效"); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java deleted file mode 100644 index 81bd2c1..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.yau.digitalrmb.platformintegration.application; - -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; - -import java.util.Optional; -import java.time.Instant; -import java.util.List; - -public interface PlatformIdentityRepository { - Optional findByPlatformUserId(long platformUserId); - - Optional findBySchoolAccount(String schoolAccount); - - List findChangedSince(Instant watermark); -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java index 921b0f7..d282c09 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java @@ -3,7 +3,6 @@ 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; @@ -12,35 +11,21 @@ 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; @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()); + public PlatformTokenVerifier(PlatformIntegrationProperties properties) { + this(properties.getToken(), new ObjectMapper(), Clock.systemUTC()); } - PlatformTokenVerifier(PlatformIdentityRepository identityRepository, - PlatformIntegrationProperties.Token properties, - ObjectMapper objectMapper, - Clock clock) { - this.identityRepository = identityRepository; + PlatformTokenVerifier(PlatformIntegrationProperties.Token properties, ObjectMapper objectMapper, Clock clock) { this.properties = properties; this.objectMapper = objectMapper; this.clock = clock; @@ -53,23 +38,24 @@ public class PlatformTokenVerifier { VerifiedPlatformToken verify(String rawToken, Instant now) { try { String[] parts = rawToken.split("\\.", -1); - if (parts.length != 4) { + if (parts.length != 3) { throw invalid(); } - verifyLoginTime(parts[3], now); - JsonNode header = readJson(parts[0]); if (!"HS256".equals(header.path("alg").asText())) { throw invalid(); } + verifySignature(parts); 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)); + if (!payload.path("exp").canConvertToLong() || Instant.ofEpochSecond(payload.path("exp").asLong()).compareTo(now) <= 0) { + throw invalid(); + } + long userId = requiredPositiveLong(payload, "userId"); + String username = requiredText(payload, "username"); + String password = requiredText(payload, "password"); + long roleId = requiredLong(payload, "roleid"); + String displayName = payload.path("name").asText(username); + return new VerifiedPlatformToken(userId, username, displayName, password, roleId == 3L ? "TEACHER" : "STUDENT"); } catch (PlatformTokenException exception) { throw exception; } catch (Exception exception) { @@ -77,75 +63,18 @@ public class PlatformTokenVerifier { } } - 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 = actor.role().name().equals("TEACHER") - ? properties.getTeacherClaimValue() - : properties.getStudentClaimValue(); - if (!expected.equals(payload.path(String.valueOf(actor.profileId())).asText())) { - throw invalid(); - } - } - - private void verifySignature(String[] parts, PlatformActor actor) throws Exception { + private void verifySignature(String[] parts) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); - byte[] key = String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8); - mac.init(new SecretKeySpec(key, "HmacSHA256")); + mac.init(new SecretKeySpec(properties.getLinkSecretKey().getBytes(StandardCharsets.UTF_8), "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)) { + if (!MessageDigest.isEqual(expected, Base64.getUrlDecoder().decode(parts[2]))) { throw invalid(); } } - private String fingerprint(String rawToken) throws Exception { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8)); - StringBuilder fingerprint = new StringBuilder(digest.length * 2); - for (byte value : digest) { - String hex = Integer.toHexString(value & 0xff); - if (hex.length() == 1) { - fingerprint.append('0'); - } - fingerprint.append(hex); - } - return fingerprint.toString(); - } - - private PlatformTokenException invalid() { - return new PlatformTokenException("Invalid platform token"); - } + private JsonNode readJson(String encoded) throws Exception { return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded)); } + private long requiredPositiveLong(JsonNode payload, String name) { long value = requiredLong(payload, name); if (value <= 0) throw invalid(); return value; } + private long requiredLong(JsonNode payload, String name) { JsonNode node = payload.path(name); if (!node.canConvertToLong()) throw invalid(); return node.asLong(); } + private String requiredText(JsonNode payload, String name) { String value = payload.path(name).asText(); if (value == null || value.trim().isEmpty()) throw invalid(); return value; } + 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 index b4aa863..4824e44 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java @@ -1,13 +1,20 @@ package com.yau.digitalrmb.platformintegration.application; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import lombok.EqualsAndHashCode; import lombok.Getter; @Getter -@EqualsAndHashCode public class VerifiedPlatformToken { - private final PlatformActor actor; private final String fingerprint; - public VerifiedPlatformToken(PlatformActor actor, String fingerprint) { this.actor = actor; this.fingerprint = fingerprint; } - public PlatformActor actor() { return actor; } public String fingerprint() { return fingerprint; } + private final long userId; + private final String username; + private final String displayName; + private final String rawPassword; + private final String roleKey; + + public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, String roleKey) { + this.userId = userId; + this.username = username; + this.displayName = displayName; + this.rawPassword = rawPassword; + this.roleKey = roleKey; + } } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java index 7868a99..a40298f 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java @@ -1,68 +1,28 @@ package com.yau.digitalrmb.platformintegration.config; -import javax.validation.Valid; -import javax.validation.constraints.AssertTrue; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; -import java.time.Duration; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; @Getter @Setter @Validated @ConfigurationProperties("platform-integration") public class PlatformIntegrationProperties { - @Valid - private Datasource datasource = new Datasource(); @Valid private Token token = new Token(); @Valid - private Cas cas = new Cas(); - @Valid private Frontend frontend = new Frontend(); - @Valid - private Sync sync = new Sync(); - - @Getter - @Setter - public static class Datasource { - @NotBlank - private String url; - @NotBlank - private String username; - @NotBlank - private String password; - } @Getter @Setter public static class Token { - @NotNull - private Duration maxAge; - @NotBlank - private String teacherClaimValue; @NotBlank - private String studentClaimValue; - - @AssertTrue(message = "token.max-age must be positive") - public boolean isMaxAgePositive() { - return maxAge != null && !maxAge.isNegative() && !maxAge.isZero(); - } - } - - @Getter - @Setter - public static class Cas { - @NotBlank - private String loginUrl; - @NotBlank - private String validateUrl; - @NotBlank - private String callbackUrl; + private String linkSecretKey; } @Getter @@ -71,13 +31,4 @@ public class PlatformIntegrationProperties { @NotBlank private String callbackUrl; } - - @Getter - @Setter - public static class Sync { - private boolean enabled = false; - @NotNull - private Duration fixedDelay = Duration.ofMinutes(15); - } - } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java deleted file mode 100644 index ab3c9d2..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.yau.digitalrmb.platformintegration.domain; - -import lombok.EqualsAndHashCode; -import lombok.Getter; -import java.time.Instant; - -@Getter -@EqualsAndHashCode -public class PlatformActor { - private final long platformUserId; private final long profileId; private final String account; private final String displayName; private final PlatformRole role; private final Instant tokenSigningTime; - public PlatformActor(long platformUserId, long profileId, String account, String displayName, PlatformRole role, Instant tokenSigningTime) { this.platformUserId = platformUserId; this.profileId = profileId; this.account = account; this.displayName = displayName; this.role = role; this.tokenSigningTime = tokenSigningTime; } - public long platformUserId() { return platformUserId; } public long profileId() { return profileId; } public String account() { return account; } public String displayName() { return displayName; } public PlatformRole role() { return role; } public Instant tokenSigningTime() { return tokenSigningTime; } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java deleted file mode 100644 index 63bf6f8..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.yau.digitalrmb.platformintegration.domain; - -public enum PlatformRole { - TEACHER("JT_S_02"), - STUDENT("JT_S_03"); - - private final String jobType; - - PlatformRole(String jobType) { - this.jobType = jobType; - } - - public static PlatformRole fromJobType(String jobType) { - for (PlatformRole role : values()) { - if (role.jobType.equals(jobType)) { - return role; - } - } - throw new IllegalArgumentException("Unsupported platform job type: " + jobType); - } - - public String jobType() { - return jobType; - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java deleted file mode 100644 index 9a6a282..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.List; -import java.util.ArrayList; -import java.util.Optional; - -@Repository -public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository { - private static final String TEACHER_QUERY = - "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.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"; - - private final NamedParameterJdbcTemplate jdbcTemplate; - - public JdbcPlatformIdentityRepository( - @Qualifier("platformNamedParameterJdbcTemplate") NamedParameterJdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - @Override - public Optional findByPlatformUserId(long platformUserId) { - return findBy("cu.ID = :value", platformUserId); - } - - @Override - public Optional findBySchoolAccount(String schoolAccount) { - return findBy("cu.CODE = :value", schoolAccount); - } - - @Override - public List findChangedSince(Instant watermark) { - String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)"; - Timestamp since = Timestamp.from(watermark); - List actors = new ArrayList( - queryAll(String.format(TEACHER_QUERY, predicate), since, PlatformRole.TEACHER)); - actors.addAll(queryAll(String.format(STUDENT_QUERY, predicate), since, PlatformRole.STUDENT)); - return actors; - } - - private Optional findBy(String predicate, Object value) { - Optional teacher = query(String.format(TEACHER_QUERY, predicate), value, PlatformRole.TEACHER); - return teacher.isPresent() ? teacher : query(String.format(STUDENT_QUERY, predicate), value, PlatformRole.STUDENT); - } - - private Optional query(String sql, Object value, PlatformRole role) { - List actors = jdbcTemplate.query(sql, - new MapSqlParameterSource("value", value), rowMapper(role)); - if (actors.size() > 1) { - throw new IncorrectResultSizeDataAccessException(1, actors.size()); - } - return actors.isEmpty() ? Optional.empty() : Optional.of(actors.get(0)); - } - - private List queryAll(String sql, Timestamp watermark, PlatformRole role) { - return jdbcTemplate.query(sql, new MapSqlParameterSource("watermark", watermark), rowMapper(role)); - } - - private RowMapper rowMapper(final PlatformRole role) { - return new RowMapper() { - @Override - public PlatformActor mapRow(ResultSet resultSet, int rowNumber) throws SQLException { - return map(resultSet, role); - } - }; - } - - 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, - resultSet.getTimestamp("signing_time").toInstant()); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java b/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java deleted file mode 100644 index c7d3459..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -import javax.sql.DataSource; - -@Configuration(proxyBeanMethods = false) -public class PlatformReadOnlyDataSourceConfig { - @Bean(name = "platformReadOnlyDataSource", destroyMethod = "close") - public HikariDataSource platformReadOnlyDataSource(PlatformIntegrationProperties properties) { - HikariConfig config = new HikariConfig(); - config.setJdbcUrl(properties.getDatasource().getUrl()); - config.setUsername(properties.getDatasource().getUsername()); - config.setPassword(properties.getDatasource().getPassword()); - config.setReadOnly(true); - config.setMaximumPoolSize(5); - config.setPoolName("platform-readonly"); - return new HikariDataSource(config); - } - - @Bean(name = "platformNamedParameterJdbcTemplate") - public NamedParameterJdbcTemplate platformNamedParameterJdbcTemplate( - @Qualifier("platformReadOnlyDataSource") DataSource dataSource) { - return new NamedParameterJdbcTemplate(dataSource); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java deleted file mode 100644 index 9e5a2ae..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.yau.digitalrmb.platformintegration.interfaces; - -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; -import com.yau.digitalrmb.platformintegration.application.CasTicketValidator; -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; -import com.yau.digitalrmb.shared.api.ErrorCode; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.util.UriComponentsBuilder; - -@RestController -@RequestMapping("/api/v1/auth/cas") -public class CasAuthenticationController { - private final CasTicketValidator ticketValidator; - private final PlatformIdentityRepository identityRepository; - private final PlatformIdentityProjectionService projectionService; - private final LoginExchangeCodeService exchangeCodeService; - private final PlatformIntegrationProperties.Cas cas; - private final PlatformIntegrationProperties.Frontend frontend; - - public CasAuthenticationController(CasTicketValidator ticketValidator, - PlatformIdentityRepository identityRepository, - PlatformIdentityProjectionService projectionService, - LoginExchangeCodeService exchangeCodeService, - PlatformIntegrationProperties properties) { - this.ticketValidator = ticketValidator; - this.identityRepository = identityRepository; - this.projectionService = projectionService; - this.exchangeCodeService = exchangeCodeService; - this.cas = properties.getCas(); - this.frontend = properties.getFrontend(); - } - - @GetMapping("/login") - public ResponseEntity login() { - String location = UriComponentsBuilder.fromUriString(cas.getLoginUrl()) - .queryParam("service", cas.getCallbackUrl()).build().encode().toUriString(); - return ResponseEntity.status(302).header(HttpHeaders.LOCATION, location) - .header(HttpHeaders.CACHE_CONTROL, "no-store").build(); - } - - @GetMapping("/callback") - public ResponseEntity callback(@RequestParam("ticket") String ticket) { - String account = ticketValidator.validate(ticket); - PlatformActor actor = identityRepository.findBySchoolAccount(account) - .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED, "用户无权访问本系统")); - projectionService.project(actor); - String exchangeCode = exchangeCodeService.issue(actor.platformUserId()); - String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) - .queryParam("code", exchangeCode).build().encode().toUriString(); - return ResponseEntity.status(302).header(HttpHeaders.LOCATION, location) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .header("Referrer-Policy", "no-referrer").build(); - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java index 60f464e..cd22f3a 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java @@ -1,10 +1,10 @@ package com.yau.digitalrmb.platformintegration.interfaces; -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; +import com.yau.digitalrmb.security.application.JwtTokenService; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -13,35 +13,33 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.util.UriComponentsBuilder; +import java.util.Collections; + @RestController @RequestMapping("/api/v1/auth") public class PlatformSsoController { private final PlatformTokenVerifier tokenVerifier; - private final PlatformIdentityProjectionService projectionService; - private final LoginExchangeCodeService exchangeCodeService; + private final LocalSsoAccountService localSsoAccountService; + private final JwtTokenService jwtTokenService; private final PlatformIntegrationProperties.Frontend frontend; - public PlatformSsoController(PlatformTokenVerifier tokenVerifier, - PlatformIdentityProjectionService projectionService, - LoginExchangeCodeService exchangeCodeService, - PlatformIntegrationProperties properties) { + public PlatformSsoController(PlatformTokenVerifier tokenVerifier, LocalSsoAccountService localSsoAccountService, + JwtTokenService jwtTokenService, PlatformIntegrationProperties properties) { this.tokenVerifier = tokenVerifier; - this.projectionService = projectionService; - this.exchangeCodeService = exchangeCodeService; + this.localSsoAccountService = localSsoAccountService; + this.jwtTokenService = jwtTokenService; this.frontend = properties.getFrontend(); } @GetMapping("/sso") public ResponseEntity loginFromPlatform(@RequestParam("token") String token) { VerifiedPlatformToken verified = tokenVerifier.verify(token); - projectionService.project(verified.actor()); - String exchangeCode = exchangeCodeService.issue(verified.actor().platformUserId()); + localSsoAccountService.synchronize(verified); + JwtTokenService.Token localToken = jwtTokenService.issueFor(verified.getUserId(), verified.getUsername(), + Collections.singleton(verified.getRoleKey())); String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) - .queryParam("code", exchangeCode).build().encode().toUriString(); - return ResponseEntity.status(302) - .header(HttpHeaders.LOCATION, location) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .header("Referrer-Policy", "no-referrer") - .build(); + .queryParam("token", localToken.accessToken()).build().encode().toUriString(); + return ResponseEntity.status(302).header(HttpHeaders.LOCATION, location) + .header(HttpHeaders.CACHE_CONTROL, "no-store").header("Referrer-Policy", "no-referrer").build(); } } diff --git a/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java b/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java deleted file mode 100644 index 4da3dbe..0000000 --- a/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java +++ /dev/null @@ -1,79 +0,0 @@ -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.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 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 { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder hash = new StringBuilder(digest.length * 2); - for (byte item : digest) { - String hex = Integer.toHexString(item & 0xff); - if (hex.length() == 1) { - hash.append('0'); - } - hash.append(hex); - } - return hash.toString(); - } 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, "登录兑换码无效或已过期"); - } -} diff --git a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java index 8f0ab3d..5ca6871 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java @@ -4,6 +4,7 @@ import com.yau.digitalrmb.security.config.SecurityProperties; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import java.security.MessageDigest; import java.security.SecureRandom; import java.sql.Timestamp; import java.time.Instant; @@ -26,7 +27,7 @@ public class RefreshTokenService { 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, + hash(token), platformUserId, Timestamp.from(Instant.now().plus(properties.getSession().getRefreshTokenTtl()))); return token; } @@ -34,6 +35,20 @@ public class RefreshTokenService { public void revokeForUser(String token, long platformUserId) { jdbcTemplate.update("UPDATE auth_refresh_token SET revoked_at = CURRENT_TIMESTAMP " + "WHERE token_hash = ? AND platform_user_id = ? AND revoked_at IS NULL", - LoginExchangeCodeService.hash(token), platformUserId); + hash(token), platformUserId); + } + + private static String hash(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte item : digest) { + result.append(String.format("%02x", item)); + } + return result.toString(); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } } } diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java index 3df3699..99640ce 100644 --- a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java @@ -51,7 +51,6 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeRequests(authorize -> authorize .antMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso", - "/api/v1/auth/cas/**", "/api/v1/auth/session/exchange", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() .anyRequest().authenticated()) diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java index 968b9ae..e191c7e 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java @@ -3,13 +3,13 @@ package com.yau.digitalrmb.security.interfaces; import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; import com.yau.digitalrmb.security.application.JwtTokenService; -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; import com.yau.digitalrmb.security.application.LocalAccountAuthenticationService; import com.yau.digitalrmb.security.application.RefreshTokenService; import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.web.TraceIdFilter; +import java.util.Collections; import javax.validation.Valid; import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -20,24 +20,15 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; -import java.util.Set; -import java.util.Collections; - @RestController @RequestMapping("/api/v1/auth") public class AuthController { - private final JwtTokenService tokenService; - private final LoginExchangeCodeService exchangeCodeService; private final RefreshTokenService refreshTokenService; private final PlatformUserSnapshotMapper snapshotMapper; private final LocalAccountAuthenticationService localAccountAuthenticationService; - public AuthController(JwtTokenService tokenService, LoginExchangeCodeService exchangeCodeService, - RefreshTokenService refreshTokenService, PlatformUserSnapshotMapper snapshotMapper, + public AuthController(RefreshTokenService refreshTokenService, PlatformUserSnapshotMapper snapshotMapper, LocalAccountAuthenticationService localAccountAuthenticationService) { - this.tokenService = tokenService; - this.exchangeCodeService = exchangeCodeService; this.refreshTokenService = refreshTokenService; this.snapshotMapper = snapshotMapper; this.localAccountAuthenticationService = localAccountAuthenticationService; @@ -50,38 +41,24 @@ public class AuthController { MDC.get(TraceIdFilter.MDC_KEY)); } - @PostMapping("/session/exchange") - public ApiResponse 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(), - Collections.singleton(snapshot.getRoleKey())); - String refreshToken = refreshTokenService.issue(platformUserId); - return ApiResponse.success(new SessionResponse(accessToken.accessToken(), refreshToken, "Bearer", accessToken.expiresIn()), - MDC.get(TraceIdFilter.MDC_KEY)); - } - @GetMapping("/me") public ApiResponse currentUser(@AuthenticationPrincipal Jwt jwt) { - long platformUserId = platformUserId(jwt); - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(platformUserId); + long userId = userId(jwt); + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(userId); if (snapshot == null) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); } - return ApiResponse.success(new CurrentUserResponse(platformUserId, snapshot.getAccount(), snapshot.getDisplayName(), + return ApiResponse.success(new CurrentUserResponse(userId, snapshot.getAccount(), snapshot.getDisplayName(), Collections.singletonList(snapshot.getRoleKey())), MDC.get(TraceIdFilter.MDC_KEY)); } @PostMapping("/logout") public ApiResponse logout(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody LogoutRequest request) { - refreshTokenService.revokeForUser(request.refreshToken(), platformUserId(jwt)); + refreshTokenService.revokeForUser(request.refreshToken(), userId(jwt)); return ApiResponse.success(null, MDC.get(TraceIdFilter.MDC_KEY)); } - private long platformUserId(Jwt jwt) { + private long userId(Jwt jwt) { try { return Long.parseLong(jwt.getSubject()); } catch (NumberFormatException exception) { diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java b/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java deleted file mode 100644 index 84addff..0000000 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.yau.digitalrmb.security.interfaces; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank; -@Getter @Setter @NoArgsConstructor @AllArgsConstructor -public class ExchangeCodeRequest { @NotBlank private String code; public String code() { return code; } } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java deleted file mode 100644 index 9748d9c..0000000 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.yau.digitalrmb.security.interfaces; -import lombok.AllArgsConstructor; import lombok.Getter; -@Getter @AllArgsConstructor -public class SessionResponse { private final String accessToken; private final String refreshToken; private final String tokenType; private final long expiresIn; } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6b2c878..8a14bb0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -8,10 +8,8 @@ springdoc: swagger-ui: enabled: true platform-integration: - datasource: - url: ${spring.datasource.url} - username: ${spring.datasource.username} - password: ${spring.datasource.password} + token: + link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY:local-token-sso-test-secret-key-123456} security: jwt: secret: 0123456789012345678901234567890123456789012345678901234567890123 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 487192c..36a3a99 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -9,17 +9,7 @@ security: secret: 0123456789012345678901234567890123456789012345678901234567890123 access-token-ttl: PT30M platform-integration: - datasource: - url: jdbc:h2:mem:platform;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE - username: sa - password: test-password token: - max-age: PT2M - teacher-claim-value: teacher - student-claim-value: student - cas: - login-url: https://sso.example.edu/login - validate-url: https://sso.example.edu/p3/serviceValidate - callback-url: https://rmb.example.edu/api/v1/auth/cas/callback + link-secret-key: local-token-sso-test-secret-key-123456 frontend: callback-url: https://rmb.example.edu/sso-callback diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 199be9a..a5efa81 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,8 @@ spring: url: ${DIGITAL_RMB_DB_URL} username: ${DIGITAL_RMB_DB_USERNAME} password: ${DIGITAL_RMB_DB_PASSWORD} + hikari: + minimum-idle: 2 driver-class-name: com.mysql.cj.jdbc.Driver sql: init: @@ -22,17 +24,7 @@ springdoc: swagger-ui: enabled: false platform-integration: - datasource: - url: ${DIGITAL_RMB_PLATFORM_DB_URL} - username: ${DIGITAL_RMB_PLATFORM_DB_USERNAME} - password: ${DIGITAL_RMB_PLATFORM_DB_PASSWORD} token: - max-age: PT2M - teacher-claim-value: teacher - student-claim-value: student - cas: - login-url: ${DIGITAL_RMB_CAS_LOGIN_URL} - validate-url: ${DIGITAL_RMB_CAS_VALIDATE_URL} - callback-url: ${DIGITAL_RMB_CAS_CALLBACK_URL} + link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY} frontend: callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL} diff --git a/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java b/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java index fb10e0a..34ec9bc 100644 --- a/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java +++ b/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; @@ -18,13 +19,11 @@ class ApplicationContextTest { private HikariDataSource applicationDataSource; @Autowired - @Qualifier("platformReadOnlyDataSource") - private HikariDataSource platformReadOnlyDataSource; + private ApplicationContext applicationContext; @Test - void applicationDatasourceIsSeparateAndWritable() { - assertThat(applicationDataSource).isNotSameAs(platformReadOnlyDataSource); + void usesOnlyTheWritableLocalDatasource() { assertThat(applicationDataSource.isReadOnly()).isFalse(); - assertThat(platformReadOnlyDataSource.isReadOnly()).isTrue(); + assertThat(applicationContext.getBeansOfType(HikariDataSource.class)).hasSize(1); } } diff --git a/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java new file mode 100644 index 0000000..e308d1c --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java @@ -0,0 +1,46 @@ +package com.yau.digitalrmb.identity; + +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class LocalSsoAccountServiceTest { + @Autowired private LocalSsoAccountService service; + @Autowired private DataSource dataSource; + @Autowired private PasswordEncoder passwordEncoder; + + @Test + void createsLocalUserWithTokenPasswordAndStudentRole() { + service.synchronize(new VerifiedPlatformToken(601L, "sso601", "张三", "first-password", "STUDENT")); + + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 601", String.class); + Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 601", Long.class); + assertThat(passwordEncoder.matches("first-password", passwordHash)).isTrue(); + assertThat(roleId).isEqualTo(1002L); + } + + @Test + void refreshesLocalPasswordWhenPlatformPasswordChanges() { + service.synchronize(new VerifiedPlatformToken(602L, "sso602", "李四", "old-password", "STUDENT")); + service.synchronize(new VerifiedPlatformToken(602L, "sso602-new", "李四", "new-password", "TEACHER")); + + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 602", String.class); + Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 602", Long.class); + assertThat(passwordEncoder.matches("new-password", passwordHash)).isTrue(); + assertThat(passwordEncoder.matches("old-password", passwordHash)).isFalse(); + assertThat(roleId).isEqualTo(1001L); + } +} diff --git a/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java deleted file mode 100644 index 9ce2f5a..0000000 --- a/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.yau.digitalrmb.identity; - -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.security.crypto.password.PasswordEncoder; - -import javax.sql.DataSource; -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@ActiveProfiles("test") -class PlatformIdentityProjectionServiceTest { - @Autowired - private PlatformIdentityProjectionService projectionService; - - @Autowired - private DataSource dataSource; - - @Autowired - private PasswordEncoder passwordEncoder; - - @Test - void projectionIsIdempotentAndOwnsExactlyOneRole() { - PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z")); - - projectionService.project(teacher); - projectionService.project(teacher); - - JdbcTemplate jdbc = new JdbcTemplate(dataSource); - Integer associationCount = jdbc.queryForObject("SELECT COUNT(*) FROM sys_user_role WHERE user_id = 101", Integer.class); - String role = jdbc.queryForObject("SELECT role_key FROM platform_user_snapshot WHERE platform_user_id = 101", String.class); - String userName = jdbc.queryForObject("SELECT username FROM sys_user WHERE id = 101", String.class); - - assertThat(associationCount).isEqualTo(1); - assertThat(role).isEqualTo("TEACHER"); - assertThat(userName).isEqualTo("t001"); - } - - @Test - void ssoProjectionCreatesUserWithRandomBcryptPassword() { - PlatformActor actor = new PlatformActor(302L, 3L, "sso-user", "教师", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z")); - - projectionService.project(actor); - - String passwordHash = new JdbcTemplate(dataSource) - .queryForObject("SELECT password_hash FROM sys_user WHERE id = 302", String.class); - assertThat(passwordHash).startsWith("$2"); - assertThat(passwordEncoder.matches("EXTERNAL_SSO_ONLY", passwordHash)).isFalse(); - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java deleted file mode 100644 index b5d010b..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.yau.digitalrmb.platformintegration.application; - -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.junit.jupiter.api.Test; -import org.springframework.util.ReflectionUtils; -import org.springframework.web.client.RestTemplate; -import org.springframework.http.client.SimpleClientHttpRequestFactory; - -import java.lang.reflect.Field; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class CasTicketValidatorTest { - @Test - void configuresFiveSecondTimeoutsForCasRequests() throws Exception { - PlatformIntegrationProperties properties = new PlatformIntegrationProperties(); - CasTicketValidator validator = new CasTicketValidator(properties); - Field restTemplateField = ReflectionUtils.findField(CasTicketValidator.class, "restTemplate"); - ReflectionUtils.makeAccessible(restTemplateField); - RestTemplate restTemplate = (RestTemplate) ReflectionUtils.getField(restTemplateField, validator); - - assertThat(restTemplate.getRequestFactory()).isInstanceOf(SimpleClientHttpRequestFactory.class); - SimpleClientHttpRequestFactory requestFactory = (SimpleClientHttpRequestFactory) restTemplate.getRequestFactory(); - assertThat(readIntField(requestFactory, "connectTimeout")).isEqualTo(5000); - assertThat(readIntField(requestFactory, "readTimeout")).isEqualTo(5000); - } - - @Test - void parsesSuccessfulCasAccountAndRejectsExternalEntityPayloads() { - String success = "" - + "t001" - + ""; - String xxe = "]>" - + "&xxe;"; - - assertThat(CasTicketValidator.parseAccount(success)).isEqualTo("t001"); - assertThatThrownBy(() -> CasTicketValidator.parseAccount(xxe)).isInstanceOf(BusinessException.class); - } - - private int readIntField(Object target, String fieldName) throws Exception { - Field field = ReflectionUtils.findField(target.getClass(), fieldName); - ReflectionUtils.makeAccessible(field); - return (Integer) ReflectionUtils.getField(field, target); - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java index d237664..59a42a4 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java @@ -2,99 +2,67 @@ 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.Clock; import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; import java.util.Base64; import java.util.HashMap; import java.util.Map; -import java.util.Optional; -import java.util.Collections; -import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class PlatformTokenVerifierTest { + private static final String SECRET = "local-token-sso-test-secret-key-123456"; 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(); - } - - @Override - public List findChangedSince(Instant watermark) { - return Collections.emptyList(); - } - }; - verifier = new PlatformTokenVerifier(repository, properties); + properties.setLinkSecretKey(SECRET); + verifier = new PlatformTokenVerifier(properties, new ObjectMapper(), Clock.fixed(now, ZoneOffset.UTC)); } @Test - void acceptsFreshTeacherTokenSignedWithProfileAddTime() throws Exception { - String token = issueToken(teacher, now); + void acceptsStandardThreeSegmentTokenWithoutPlatformDatabaseIdentity() throws Exception { + VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L, now.plus(Duration.ofMinutes(5)))); - VerifiedPlatformToken verified = verifier.verify(token, now); - - assertThat(verified.actor()).isEqualTo(teacher); - assertThat(verified.fingerprint()).hasSize(64); + assertThat(verified.getUserId()).isEqualTo(487L); + assertThat(verified.getUsername()).isEqualTo("tzs001"); + assertThat(verified.getRawPassword()).isEqualTo("new-password"); + assertThat(verified.getRoleKey()).isEqualTo("STUDENT"); } @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); + void rejectsTamperedExpiredOrIncompleteToken() throws Exception { + String valid = token(487L, "tzs001", "new-password", 3L, now.plus(Duration.ofMinutes(5))); + assertThatThrownBy(() -> verifier.verify(valid + "x")).isInstanceOf(PlatformTokenException.class); + assertThatThrownBy(() -> verifier.verify(token(487L, "tzs001", "new-password", 2L, now.minusSeconds(1)))).isInstanceOf(PlatformTokenException.class); + Map incomplete = new HashMap(); + incomplete.put("userId", 487L); + assertThatThrownBy(() -> verifier.verify(unsignedToken(incomplete, now.plus(Duration.ofMinutes(5))))).isInstanceOf(PlatformTokenException.class); } - private String issueToken(PlatformActor actor, Instant loginTime) throws Exception { - return issueToken(actor, loginTime, "teacher"); + private String token(long userId, String username, String password, long roleId, Instant expiresAt) throws Exception { + Map claims = new HashMap(); + claims.put("userId", userId); claims.put("username", username); claims.put("password", password); claims.put("roleid", roleId); + return unsignedToken(claims, expiresAt); } - - private String issueToken(PlatformActor actor, Instant loginTime, String identityClaimValue) throws Exception { + private String unsignedToken(Map claims, Instant expiresAt) throws Exception { + claims.put("exp", expiresAt.getEpochSecond()); ObjectMapper mapper = new ObjectMapper(); - Map headerClaims = new HashMap(); - headerClaims.put("alg", "HS256"); - headerClaims.put("typ", "JWT"); - Map payloadClaims = new HashMap(); - payloadClaims.put("aud", new String[]{String.valueOf(actor.platformUserId())}); - payloadClaims.put(String.valueOf(actor.profileId()), identityClaimValue); - String header = encode(mapper.writeValueAsBytes(headerClaims)); - String payload = encode(mapper.writeValueAsBytes(payloadClaims)); - 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); + Map header = new HashMap(); header.put("alg", "HS256"); + String unsigned = encode(mapper.writeValueAsBytes(header)) + "." + encode(mapper.writeValueAsBytes(claims)); + Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return unsigned + "." + encode(mac.doFinal(unsigned.getBytes(StandardCharsets.US_ASCII))); } + private String encode(byte[] value) { return Base64.getUrlEncoder().withoutPadding().encodeToString(value); } } diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java deleted file mode 100644 index b6ef7c4..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.yau.digitalrmb.platformintegration.config; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Configuration; - -import java.time.Duration; - -import static org.assertj.core.api.Assertions.assertThat; - -class PlatformIntegrationPropertiesTest { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(PropertiesConfiguration.class)) - .withPropertyValues( - "platform-integration.datasource.url=jdbc:mysql://localhost:3306/tianze", - "platform-integration.datasource.username=readonly", - "platform-integration.datasource.password=secret", - "platform-integration.token.max-age=PT2M", - "platform-integration.token.teacher-claim-value=teacher", - "platform-integration.token.student-claim-value=student", - "platform-integration.cas.login-url=https://sso.example.edu/login", - "platform-integration.cas.validate-url=https://sso.example.edu/p3/serviceValidate", - "platform-integration.cas.callback-url=https://rmb.example.edu/api/v1/auth/cas/callback", - "platform-integration.frontend.callback-url=https://rmb.example.edu/sso-callback"); - - @Test - void bindsReadOnlyDatasourceAndAuthenticationEndpoints() { - contextRunner.run(context -> { - PlatformIntegrationProperties properties = context.getBean(PlatformIntegrationProperties.class); - - assertThat(properties.getDatasource().getUsername()).isEqualTo("readonly"); - assertThat(properties.getToken().getMaxAge()).isEqualTo(Duration.ofMinutes(2)); - assertThat(properties.getCas().getCallbackUrl()).isEqualTo("https://rmb.example.edu/api/v1/auth/cas/callback"); - }); - } - - @Configuration(proxyBeanMethods = false) - @EnableConfigurationProperties(PlatformIntegrationProperties.class) - static class PropertiesConfiguration { - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java deleted file mode 100644 index 353f32e..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.yau.digitalrmb.platformintegration.domain; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class PlatformRoleTest { - - @Test - void mapsOnlyTeacherAndStudentJobTypes() { - assertThat(PlatformRole.fromJobType("JT_S_02")).isEqualTo(PlatformRole.TEACHER); - assertThat(PlatformRole.fromJobType("JT_S_03")).isEqualTo(PlatformRole.STUDENT); - assertThatThrownBy(() -> PlatformRole.fromJobType("JT_S_01")) - .isInstanceOf(IllegalArgumentException.class); - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java deleted file mode 100644 index 4c5b0bc..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import org.h2.jdbcx.JdbcDataSource; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.Statement; -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class JdbcPlatformIdentityRepositoryTest { - private DataSource dataSource; - private JdbcPlatformIdentityRepository repository; - - @BeforeEach - void setUp() throws Exception { - JdbcDataSource source = new JdbcDataSource(); - source.setURL("jdbc:h2:mem:platform_identity;MODE=MySQL;DB_CLOSE_DELAY=-1"); - source.setUser("sa"); - dataSource = source; - execute("DROP ALL OBJECTS"); - createSchema(); - repository = new JdbcPlatformIdentityRepository(new NamedParameterJdbcTemplate(dataSource)); - } - - @Test - void resolvesEnabledTeacherFromCoreUserAndTeacherProfile() throws Exception { - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (101, 't001', '教师甲', 'S1', 'JT_S_02', 0)"); - execute("INSERT INTO teacher(teacher_id, user_id, teacher_status, add_time) VALUES (1, 101, 1, '2026-01-01 00:00:00')"); - - PlatformActor actor = repository.findByPlatformUserId(101L) - .orElseThrow(() -> new IllegalStateException("teacher not found")); - - 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")); - } - - @Test - void excludesDisabledOrUnsupportedUsers() throws Exception { - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (201, 's001', '学生甲', 'S1', 'JT_S_03', 0)"); - execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (1, 201, 2, '2026-01-01 00:00:00')"); - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (202, 'admin', '管理员', 'S1', 'JT_S_01', 0)"); - - assertThat(repository.findByPlatformUserId(201L)).isEmpty(); - assertThat(repository.findByPlatformUserId(202L)).isEmpty(); - } - - @Test - void rejectsDuplicateSchoolAccounts() throws Exception { - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (301, 'duplicate', 'student one', 'S1', 'JT_S_03', 0)"); - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (302, 'duplicate', 'student two', 'S1', 'JT_S_03', 0)"); - execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (11, 301, 1, '2026-01-01 00:00:00')"); - execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (12, 302, 1, '2026-01-01 00:00:00')"); - - assertThatThrownBy(() -> repository.findBySchoolAccount("duplicate")) - .isInstanceOf(IncorrectResultSizeDataAccessException.class); - } - - @Test - private void createSchema() throws Exception { - execute("CREATE TABLE core_user(ID BIGINT PRIMARY KEY, CODE VARCHAR(64), NAME VARCHAR(64), STATE VARCHAR(16), JOB_TYPE1 VARCHAR(16), DEL_FLAG INT)"); - execute("CREATE TABLE teacher(teacher_id BIGINT PRIMARY KEY, user_id BIGINT, teacher_status INT, add_time TIMESTAMP)"); - execute("CREATE TABLE student(student_id BIGINT PRIMARY KEY, user_id BIGINT, student_status INT, add_time TIMESTAMP)"); - } - - private void execute(String sql) throws Exception { - try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { - statement.execute(sql); - } - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java deleted file mode 100644 index c30412a..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.zaxxer.hikari.HikariDataSource; -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -import static org.assertj.core.api.Assertions.assertThat; - -class PlatformReadOnlyDataSourceConfigTest { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(PropertiesConfiguration.class, PlatformReadOnlyDataSourceConfig.class)) - .withPropertyValues( - "platform-integration.datasource.url=jdbc:h2:mem:platform_config", - "platform-integration.datasource.username=sa", - "platform-integration.datasource.password=test-password", - "platform-integration.token.max-age=PT2M", - "platform-integration.token.teacher-claim-value=teacher", - "platform-integration.token.student-claim-value=student", - "platform-integration.cas.login-url=https://sso.example.edu/login", - "platform-integration.cas.validate-url=https://sso.example.edu/p3/serviceValidate", - "platform-integration.cas.callback-url=https://rmb.example.edu/api/v1/auth/cas/callback", - "platform-integration.frontend.callback-url=https://rmb.example.edu/sso-callback"); - - @Test - void createsNamedReadOnlyDatasourceAndJdbcTemplate() { - contextRunner.run(context -> { - HikariDataSource dataSource = context.getBean("platformReadOnlyDataSource", HikariDataSource.class); - - assertThat(dataSource.isReadOnly()).isTrue(); - assertThat(context.getBean("platformNamedParameterJdbcTemplate", NamedParameterJdbcTemplate.class)).isNotNull(); - }); - } - - @Configuration(proxyBeanMethods = false) - @EnableConfigurationProperties(PlatformIntegrationProperties.class) - static class PropertiesConfiguration { - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java index 4d7facd..635c24d 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java @@ -1,45 +1,44 @@ package com.yau.digitalrmb.platformintegration.interfaces; -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; +import com.yau.digitalrmb.security.application.JwtTokenService; import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import java.time.Instant; +import java.util.Collections; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class PlatformSsoControllerTest { @Test - void ssoRedirectDoesNotLeakIncomingToken() throws Exception { + void ssoRedirectIssuesOnlyLocalToken() throws Exception { PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class); - PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class); - LoginExchangeCodeService exchangeCodes = mock(LoginExchangeCodeService.class); + LocalSsoAccountService localAccounts = mock(LocalSsoAccountService.class); + JwtTokenService jwtTokenService = mock(JwtTokenService.class); PlatformIntegrationProperties properties = new PlatformIntegrationProperties(); properties.getFrontend().setCallbackUrl("https://rmb.example.edu/sso-callback"); - PlatformActor actor = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z")); - when(verifier.verify(anyString())).thenReturn(new VerifiedPlatformToken(actor, "fingerprint")); - when(exchangeCodes.issue(101L)).thenReturn("one-time-code"); - MockMvc mvc = MockMvcBuilders.standaloneSetup(new PlatformSsoController(verifier, projection, exchangeCodes, properties)).build(); + VerifiedPlatformToken verified = new VerifiedPlatformToken(101L, "t001", "Teacher", "password", "TEACHER"); + when(verifier.verify(anyString())).thenReturn(verified); + when(jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER"))) + .thenReturn(new JwtTokenService.Token("local-system-jwt", 1800L)); + MockMvc mvc = MockMvcBuilders.standaloneSetup( + new PlatformSsoController(verifier, localAccounts, jwtTokenService, properties)).build(); mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token")) .andExpect(status().isFound()) - .andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?code=one-time-code")) + .andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?token=local-system-jwt")) .andExpect(header().string("Cache-Control", "no-store")) .andExpect(header().string("Referrer-Policy", "no-referrer")); - verify(projection).project(actor); + verify(localAccounts).synchronize(verified); } } diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java index e5f7fd6..412d785 100644 --- a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java @@ -1,22 +1,19 @@ package com.yau.digitalrmb.security; -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.security.application.JwtTokenService; import com.yau.digitalrmb.security.application.RefreshTokenService; +import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.time.Instant; -import java.util.Collections; - import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -26,22 +23,17 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @AutoConfigureMockMvc @ActiveProfiles("test") class CurrentUserAndLogoutTest { - @Autowired - private MockMvc mvc; - @Autowired - private PlatformIdentityProjectionService projectionService; - @Autowired - private JwtTokenService jwtTokenService; - @Autowired - private RefreshTokenService refreshTokenService; + @Autowired private MockMvc mvc; + @Autowired private LocalSsoAccountService localSsoAccountService; + @Autowired private JwtTokenService jwtTokenService; + @Autowired private RefreshTokenService refreshTokenService; private String teacherJwt; private String refreshToken; @BeforeEach void setUp() { - projectionService.project(new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z"))); + localSsoAccountService.synchronize(new VerifiedPlatformToken(101L, "t001", "Teacher", "password", "TEACHER")); teacherJwt = jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")).accessToken(); refreshToken = refreshTokenService.issue(101L); } diff --git a/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java b/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java deleted file mode 100644 index d3c687b..0000000 --- a/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java +++ /dev/null @@ -1,26 +0,0 @@ -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); - } -}