merge: local token sso without platform datasource

master
chenyuan 4 weeks ago
commit f0e10a5567

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

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

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

@ -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<String> 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 票据无效");
}
}

@ -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<PlatformActor> findByPlatformUserId(long platformUserId);
Optional<PlatformActor> findBySchoolAccount(String schoolAccount);
List<PlatformActor> findChangedSince(Instant watermark);
}

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

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

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

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

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

@ -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<PlatformActor> findByPlatformUserId(long platformUserId) {
return findBy("cu.ID = :value", platformUserId);
}
@Override
public Optional<PlatformActor> findBySchoolAccount(String schoolAccount) {
return findBy("cu.CODE = :value", schoolAccount);
}
@Override
public List<PlatformActor> findChangedSince(Instant watermark) {
String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)";
Timestamp since = Timestamp.from(watermark);
List<PlatformActor> actors = new ArrayList<PlatformActor>(
queryAll(String.format(TEACHER_QUERY, predicate), since, PlatformRole.TEACHER));
actors.addAll(queryAll(String.format(STUDENT_QUERY, predicate), since, PlatformRole.STUDENT));
return actors;
}
private Optional<PlatformActor> findBy(String predicate, Object value) {
Optional<PlatformActor> 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<PlatformActor> query(String sql, Object value, PlatformRole role) {
List<PlatformActor> actors = jdbcTemplate.query(sql,
new MapSqlParameterSource("value", value), rowMapper(role));
if (actors.size() > 1) {
throw new IncorrectResultSizeDataAccessException(1, actors.size());
}
return actors.isEmpty() ? Optional.<PlatformActor>empty() : Optional.of(actors.get(0));
}
private List<PlatformActor> queryAll(String sql, Timestamp watermark, PlatformRole role) {
return jdbcTemplate.query(sql, new MapSqlParameterSource("watermark", watermark), rowMapper(role));
}
private RowMapper<PlatformActor> rowMapper(final PlatformRole role) {
return new RowMapper<PlatformActor>() {
@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());
}
}

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

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

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

@ -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<Long> platformUserIds = jdbcTemplate.query(
"SELECT platform_user_id FROM auth_login_exchange_code WHERE code_hash = ?",
(resultSet, rowNum) -> resultSet.getLong(1), hash);
if (platformUserIds.isEmpty()) {
throw invalidCode();
}
int consumed = jdbcTemplate.update(
"UPDATE auth_login_exchange_code SET consumed_at = CURRENT_TIMESTAMP "
+ "WHERE code_hash = ? AND consumed_at IS NULL AND expires_at > CURRENT_TIMESTAMP", hash);
if (consumed != 1) {
throw invalidCode();
}
return platformUserIds.get(0);
}
static String hash(String value) {
try {
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, "登录兑换码无效或已过期");
}
}

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

@ -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())

@ -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<SessionResponse> 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<CurrentUserResponse> 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<Void> 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) {

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

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

@ -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

@ -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

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

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

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

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

@ -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 = "<cas:serviceResponse xmlns:cas=\"http://www.yale.edu/tp/cas\">"
+ "<cas:authenticationSuccess><cas:user>t001</cas:user></cas:authenticationSuccess>"
+ "</cas:serviceResponse>";
String xxe = "<!DOCTYPE serviceResponse [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>"
+ "<serviceResponse><authenticationSuccess><user>&xxe;</user></authenticationSuccess></serviceResponse>";
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);
}
}

@ -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<PlatformActor> findByPlatformUserId(long platformUserId) {
return platformUserId == 101L ? Optional.of(teacher) : Optional.empty();
}
@Override
public Optional<PlatformActor> findBySchoolAccount(String schoolAccount) {
return Optional.empty();
}
@Override
public List<PlatformActor> 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<String, Object> incomplete = new HashMap<String, Object>();
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<String, Object> claims = new HashMap<String, Object>();
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<String, Object> claims, Instant expiresAt) throws Exception {
claims.put("exp", expiresAt.getEpochSecond());
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> headerClaims = new HashMap<String, Object>();
headerClaims.put("alg", "HS256");
headerClaims.put("typ", "JWT");
Map<String, Object> payloadClaims = new HashMap<String, Object>();
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<String, Object> header = new HashMap<String, Object>(); 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); }
}

@ -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 {
}
}

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

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

@ -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 {
}
}

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

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

@ -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);
}
}
Loading…
Cancel
Save