merge: local token sso without platform datasource
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);
|
||||
}
|
||||
@ -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,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,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, "登录兑换码无效或已过期");
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,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…
Reference in New Issue