feat: align local accounts with pevc

master
chenyuan 4 weeks ago
parent 3a410e286f
commit 031a8a3998

@ -4,7 +4,7 @@
- 主平台单点登录:主平台跳转至 `GET /api/v1/auth/sso?token=...`。后端以主平台教师或学生档案的 `add_time` 动态 HMAC 密钥验证 Token生成一次性 `code` 并跳转至前端回调地址。 - 主平台单点登录:主平台跳转至 `GET /api/v1/auth/sso?token=...`。后端以主平台教师或学生档案的 `add_time` 动态 HMAC 密钥验证 Token生成一次性 `code` 并跳转至前端回调地址。
- 独立访问:浏览器访问 `GET /api/v1/auth/cas/login`,跳转学校 CAS回调校验成功后生成同样的一次性 `code` - 独立访问:浏览器访问 `GET /api/v1/auth/cas/login`,跳转学校 CAS回调校验成功后生成同样的一次性 `code`
- 本地账号密码:`POST /api/v1/auth/login` 使用主平台账号和密码直接登录本系统。成功的主平台 SSO/CAS 登录会从主平台只读库读取该账号的密码,并立即以 BCrypt 哈希写入本地;本系统不保存或返回密码明文 - 本地账号密码:`POST /api/v1/auth/login` 仅校验本系统保存的 BCrypt 密码哈希。数据库迁移内置学生账号 `tzs001`SSO/CAS 仅同步身份与角色,不读取主平台密码
- 会话兑换:`POST /api/v1/auth/session/exchange`,请求体为 `{"code":"..."}`,返回本系统访问 JWT 和刷新令牌。 - 会话兑换:`POST /api/v1/auth/session/exchange`,请求体为 `{"code":"..."}`,返回本系统访问 JWT 和刷新令牌。
本系统只接受教师和学生两类身份。平台账号与角色只能通过内部投影写入;没有用户、角色或用户角色的管理接口。 本系统只接受教师和学生两类身份。平台账号与角色只能通过内部投影写入;没有用户、角色或用户角色的管理接口。
@ -37,4 +37,4 @@ mvn spring-boot:run -Dspring-boot.run.profiles=local -Dspring-boot.run.arguments
本地 Swagger<http://localhost:8081/swagger-ui/index.html> 本地 Swagger<http://localhost:8081/swagger-ui/index.html>
`local` 配置启动时会尝试初始化 `tzs001` 的本地密码哈希;该账号必须存在于主平台且属于教师或学生。密码变更后,用户下一次通过 SSO/CAS 登录会自动刷新本地哈希 首次启动时Flyway V3 迁移会创建或更新 `tzs001`(平台用户 ID `487`、`STUDENT`)。迁移文件只保存 BCrypt 哈希,不含密码明文。新 SSO/CAS 用户会获得随机 BCrypt 密码;如需为其开通本地密码登录,必须通过后续受控的本地账号初始化流程完成

@ -31,7 +31,6 @@
``` ```
主平台 ──携带自定义 Token 跳转──> digital-rmb-backend ──签发本系统 JWT──> 前端 主平台 ──携带自定义 Token 跳转──> digital-rmb-backend ──签发本系统 JWT──> 前端
学校 CAS ──CAS Ticket 认证────────> digital-rmb-backend ──签发本系统 JWT──> 前端 学校 CAS ──CAS Ticket 认证────────> digital-rmb-backend ──签发本系统 JWT──> 前端
主平台只读库 ──密码明文(瞬时)─────> BCrypt ──> sys_user.password_hash
digital-rmb-backend ──SELECT only──────────────────────────> 主平台数据库 digital-rmb-backend ──SELECT only──────────────────────────> 主平台数据库
``` ```
@ -44,7 +43,7 @@ digital-rmb-backend ──SELECT only──────────────
1. 主平台既有跳转链接访问 `GET /api/v1/auth/sso?token=...` 1. 主平台既有跳转链接访问 `GET /api/v1/auth/sso?token=...`
2. 后端按 Token 中未验签的用户 ID 和身份类型定位主平台候选记录; 2. 后端按 Token 中未验签的用户 ID 和身份类型定位主平台候选记录;
3. 后端读取候选教师或学生记录的 `addTime`,按主平台算法完成 HMAC 验签; 3. 后端读取候选教师或学生记录的 `addTime`,按主平台算法完成 HMAC 验签;
4. 后端读取主平台用户状态、教师/学生资料、角色及密码,刷新本地只读快照,并将密码立即 BCrypt 编码为本地哈希 4. 后端读取主平台用户状态、教师/学生资料及角色,刷新本地只读快照;首次投影的本地用户生成随机 BCrypt 密码
5. 后端生成仅一次、短时有效的登录兑换码,重定向到配置的前端回调地址; 5. 后端生成仅一次、短时有效的登录兑换码,重定向到配置的前端回调地址;
6. 前端调用 `POST /api/v1/auth/session/exchange` 兑换本系统 JWT 与刷新令牌。 6. 前端调用 `POST /api/v1/auth/session/exchange` 兑换本系统 JWT 与刷新令牌。
@ -54,17 +53,17 @@ digital-rmb-backend ──SELECT only──────────────
2. 后端将浏览器重定向至学校 CAS 登录页,并使用固定的回调地址作为 CAS `service` 2. 后端将浏览器重定向至学校 CAS 登录页,并使用固定的回调地址作为 CAS `service`
3. CAS 将 `ticket` 回调至 `GET /api/v1/auth/cas/callback` 3. CAS 将 `ticket` 回调至 `GET /api/v1/auth/cas/callback`
4. 后端调用学校 CAS 的 `serviceValidate` 接口验证 Ticket取得学校账号 4. 后端调用学校 CAS 的 `serviceValidate` 接口验证 Ticket取得学校账号
5. 后端以学校账号查询主平台 `core_user`,仅接受教师或学生,再刷新快照及本地密码哈希 5. 后端以学校账号查询主平台 `core_user`,仅接受教师或学生,再刷新快照;
6. 后端创建登录兑换码,前端兑换本系统 JWT 与刷新令牌。 6. 后端创建登录兑换码,前端兑换本系统 JWT 与刷新令牌。
用户已在学校 CAS 或主平台登录时,第 2 步无感完成;没有 CAS 会话时,用户在学校认证页输入统一账号密码。 用户已在学校 CAS 或主平台登录时,第 2 步无感完成;没有 CAS 会话时,用户在学校认证页输入统一账号密码。
### 4.3 本地账号密码登录 ### 4.3 本地账号密码登录
1. 用户调用 `POST /api/v1/auth/login`,提交主平台账号与密码; 1. 用户调用 `POST /api/v1/auth/login`,提交已由本系统初始化的账号与密码;
2. 本系统仅以 `sys_user.password_hash` 的 BCrypt 哈希校验密码,成功后签发本系统 JWT 2. 本系统仅以 `sys_user.password_hash` 的 BCrypt 哈希校验密码,成功后签发本系统 JWT
3. 初次使用前,用户通过主平台 SSO/CAS 成功登录时会初始化本地哈希;`local` 环境还会在启动时为配置账号 `tzs001` 尝试初始化 3. Flyway V3 迁移内置 `tzs001`(平台用户 ID `487`、`STUDENT`)的 BCrypt 哈希、快照和角色关联
4. 密码变更后,下一次成功 SSO/CAS 登录会刷新本地哈希。密码明文绝不进入快照表、JWT、响应、日志或迁移脚本。 4. SSO/CAS 自动建号仅生成随机 BCrypt 密码,不读取主平台密码。密码明文绝不进入快照表、JWT、响应、日志或迁移脚本。
### 4.4 退出 ### 4.4 退出
@ -110,14 +109,14 @@ CAS `service` 地址必须固定并使用 HTTPS。Ticket 仅能向配置的 CAS
后端使用独立的主平台只读账号,仅对 `core_user`、`student`、`teacher` 及必要关联表授予 `SELECT`。禁止授予 `INSERT`、`UPDATE`、`DELETE`、`CREATE`、`ALTER` 或 DDL 权限。 后端使用独立的主平台只读账号,仅对 `core_user`、`student`、`teacher` 及必要关联表授予 `SELECT`。禁止授予 `INSERT`、`UPDATE`、`DELETE`、`CREATE`、`ALTER` 或 DDL 权限。
本系统只允许主平台教师和学生使用本地账号密码登录。密码哈希仅由内部主平台凭据投影写入;不提供用户、角色、用户角色或密码的管理接口。主平台数据库账号除身份表外还需对 `core_user.PASSWORD` 保有只读 `SELECT` 权限 本系统只允许教师和学生使用本地账号密码登录。内置账号通过 Flyway 迁移写入 BCrypt 哈希SSO/CAS 自动建号使用随机 BCrypt 密码,不提供用户、角色、用户角色或密码的管理接口。主平台数据库账号只需读取身份和角色所需字段,不读取 `core_user.PASSWORD`
## 7. 对外接口 ## 7. 对外接口
- `GET /api/v1/auth/sso?token=...`:主平台单点登录入口; - `GET /api/v1/auth/sso?token=...`:主平台单点登录入口;
- `GET /api/v1/auth/cas/login`:发起学校 CAS 认证; - `GET /api/v1/auth/cas/login`:发起学校 CAS 认证;
- `GET /api/v1/auth/cas/callback?ticket=...`:固定 CAS 回调; - `GET /api/v1/auth/cas/callback?ticket=...`:固定 CAS 回调;
- `POST /api/v1/auth/login`:以已初始化的主平台账号密码直接登录; - `POST /api/v1/auth/login`:以本系统已初始化的账号密码直接登录;
- `POST /api/v1/auth/session/exchange`:以一次性兑换码换取本系统 JWT - `POST /api/v1/auth/session/exchange`:以一次性兑换码换取本系统 JWT
- `GET /api/v1/auth/me`:读取当前用户与教师/学生角色; - `GET /api/v1/auth/me`:读取当前用户与教师/学生角色;
- `POST /api/v1/auth/logout`:撤销本系统会话。 - `POST /api/v1/auth/logout`:撤销本系统会话。

@ -1,36 +0,0 @@
package com.yau.digitalrmb.identity.application;
import com.yau.digitalrmb.platformintegration.application.PlatformCredentialRepository;
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("local")
public class PlatformCredentialInitializer implements ApplicationRunner {
private final PlatformIntegrationProperties.LocalLogin properties;
private final PlatformIdentityRepository identityRepository;
private final PlatformCredentialRepository credentialRepository;
private final PlatformIdentityProjectionService projectionService;
public PlatformCredentialInitializer(PlatformIntegrationProperties properties,
PlatformIdentityRepository identityRepository,
PlatformCredentialRepository credentialRepository,
PlatformIdentityProjectionService projectionService) {
this.properties = properties.getLocalLogin();
this.identityRepository = identityRepository;
this.credentialRepository = credentialRepository;
this.projectionService = projectionService;
}
@Override
public void run(ApplicationArguments args) {
properties.getInitialAccounts().stream().filter(account -> account != null && !account.isBlank())
.forEach(account -> identityRepository.findBySchoolAccount(account)
.flatMap(actor -> credentialRepository.findCredential(actor.platformUserId()))
.ifPresent(projectionService::project));
}
}

@ -5,7 +5,6 @@ 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.PlatformUserSnapshotMapper;
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper; import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor; import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformCredential;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -13,11 +12,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.UUID;
@Service @Service
public class PlatformIdentityProjectionService { public class PlatformIdentityProjectionService {
private static final String EXTERNAL_SSO_ONLY_PASSWORD = "{noop}EXTERNAL_SSO_ONLY";
private final UserMapper userMapper; private final UserMapper userMapper;
private final PlatformUserSnapshotMapper snapshotMapper; private final PlatformUserSnapshotMapper snapshotMapper;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@ -40,21 +38,13 @@ public class PlatformIdentityProjectionService {
projectRole(actor); projectRole(actor);
} }
@Transactional
public void project(PlatformCredential credential) {
project(credential.actor());
UserEntity user = userMapper.selectById(credential.actor().platformUserId());
user.setPasswordHash(passwordEncoder.encode(credential.rawPassword()));
userMapper.updateById(user);
}
private void projectUser(PlatformActor actor) { private void projectUser(PlatformActor actor) {
UserEntity user = userMapper.selectById(actor.platformUserId()); UserEntity user = userMapper.selectById(actor.platformUserId());
if (user == null) { if (user == null) {
user = new UserEntity(); user = new UserEntity();
user.setId(actor.platformUserId()); user.setId(actor.platformUserId());
user.setUsername(actor.account()); user.setUsername(actor.account());
user.setPasswordHash(EXTERNAL_SSO_ONLY_PASSWORD); user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
user.setEnabled(true); user.setEnabled(true);
userMapper.insert(user); userMapper.insert(user);
return; return;

@ -1,9 +0,0 @@
package com.yau.digitalrmb.platformintegration.application;
import com.yau.digitalrmb.platformintegration.domain.PlatformCredential;
import java.util.Optional;
public interface PlatformCredentialRepository {
Optional<PlatformCredential> findCredential(long platformUserId);
}

@ -10,7 +10,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import java.time.Duration; import java.time.Duration;
import java.util.List;
@Getter @Getter
@Setter @Setter
@ -27,8 +26,6 @@ public class PlatformIntegrationProperties {
private Frontend frontend = new Frontend(); private Frontend frontend = new Frontend();
@Valid @Valid
private Sync sync = new Sync(); private Sync sync = new Sync();
@Valid
private LocalLogin localLogin = new LocalLogin();
@Getter @Getter
@Setter @Setter
@ -83,9 +80,4 @@ public class PlatformIntegrationProperties {
private Duration fixedDelay = Duration.ofMinutes(15); private Duration fixedDelay = Duration.ofMinutes(15);
} }
@Getter
@Setter
public static class LocalLogin {
private List<String> initialAccounts = List.of("tzs001");
}
} }

@ -1,17 +0,0 @@
package com.yau.digitalrmb.platformintegration.domain;
import java.util.Objects;
public record PlatformCredential(PlatformActor actor, String rawPassword) {
public PlatformCredential {
Objects.requireNonNull(actor, "actor must not be null");
if (rawPassword == null || rawPassword.isBlank()) {
throw new IllegalArgumentException("rawPassword must not be blank");
}
}
@Override
public String toString() {
return "PlatformCredential[actor=" + actor + ", rawPassword=<redacted>]";
}
}

@ -1,9 +1,7 @@
package com.yau.digitalrmb.platformintegration.infrastructure; package com.yau.digitalrmb.platformintegration.infrastructure;
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
import com.yau.digitalrmb.platformintegration.application.PlatformCredentialRepository;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor; import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformCredential;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole; import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
@ -18,7 +16,7 @@ import java.util.ArrayList;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository, PlatformCredentialRepository { public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository {
private static final String TEACHER_QUERY = """ private static final String TEACHER_QUERY = """
SELECT cu.ID, cu.CODE, cu.NAME, t.teacher_id AS profile_id, t.add_time AS signing_time SELECT cu.ID, cu.CODE, cu.NAME, t.teacher_id AS profile_id, t.add_time AS signing_time
FROM core_user cu JOIN teacher t ON t.user_id = cu.ID FROM core_user cu JOIN teacher t ON t.user_id = cu.ID
@ -48,18 +46,6 @@ public class JdbcPlatformIdentityRepository implements PlatformIdentityRepositor
return findBy("cu.CODE = :value", schoolAccount); return findBy("cu.CODE = :value", schoolAccount);
} }
@Override
public Optional<PlatformCredential> findCredential(long platformUserId) {
return findByPlatformUserId(platformUserId).flatMap(actor -> jdbcClient.sql("""
SELECT PASSWORD FROM core_user
WHERE ID = :userId AND PASSWORD IS NOT NULL AND PASSWORD <> ''
""")
.param("userId", platformUserId)
.query(String.class)
.optional()
.map(password -> new PlatformCredential(actor, password)));
}
@Override @Override
public List<PlatformActor> findChangedSince(Instant watermark) { public List<PlatformActor> findChangedSince(Instant watermark) {
String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)"; String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)";

@ -3,7 +3,6 @@ package com.yau.digitalrmb.platformintegration.interfaces;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.application.CasTicketValidator; import com.yau.digitalrmb.platformintegration.application.CasTicketValidator;
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
import com.yau.digitalrmb.platformintegration.application.PlatformCredentialRepository;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor; import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.security.application.LoginExchangeCodeService; import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
@ -23,20 +22,17 @@ public class CasAuthenticationController {
private final CasTicketValidator ticketValidator; private final CasTicketValidator ticketValidator;
private final PlatformIdentityRepository identityRepository; private final PlatformIdentityRepository identityRepository;
private final PlatformIdentityProjectionService projectionService; private final PlatformIdentityProjectionService projectionService;
private final PlatformCredentialRepository credentialRepository;
private final LoginExchangeCodeService exchangeCodeService; private final LoginExchangeCodeService exchangeCodeService;
private final PlatformIntegrationProperties.Cas cas; private final PlatformIntegrationProperties.Cas cas;
private final PlatformIntegrationProperties.Frontend frontend; private final PlatformIntegrationProperties.Frontend frontend;
public CasAuthenticationController(CasTicketValidator ticketValidator, public CasAuthenticationController(CasTicketValidator ticketValidator,
PlatformIdentityRepository identityRepository, PlatformIdentityRepository identityRepository,
PlatformCredentialRepository credentialRepository,
PlatformIdentityProjectionService projectionService, PlatformIdentityProjectionService projectionService,
LoginExchangeCodeService exchangeCodeService, LoginExchangeCodeService exchangeCodeService,
PlatformIntegrationProperties properties) { PlatformIntegrationProperties properties) {
this.ticketValidator = ticketValidator; this.ticketValidator = ticketValidator;
this.identityRepository = identityRepository; this.identityRepository = identityRepository;
this.credentialRepository = credentialRepository;
this.projectionService = projectionService; this.projectionService = projectionService;
this.exchangeCodeService = exchangeCodeService; this.exchangeCodeService = exchangeCodeService;
this.cas = properties.getCas(); this.cas = properties.getCas();
@ -56,9 +52,7 @@ public class CasAuthenticationController {
String account = ticketValidator.validate(ticket); String account = ticketValidator.validate(ticket);
PlatformActor actor = identityRepository.findBySchoolAccount(account) PlatformActor actor = identityRepository.findBySchoolAccount(account)
.orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED, "用户无权访问本系统")); .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED, "用户无权访问本系统"));
credentialRepository.findCredential(actor.platformUserId()) projectionService.project(actor);
.ifPresentOrElse(projectionService::project,
() -> { throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户密码初始化失败"); });
String exchangeCode = exchangeCodeService.issue(actor.platformUserId()); String exchangeCode = exchangeCodeService.issue(actor.platformUserId());
String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl())
.queryParam("code", exchangeCode).build().encode().toUriString(); .queryParam("code", exchangeCode).build().encode().toUriString();

@ -2,12 +2,9 @@ package com.yau.digitalrmb.platformintegration.interfaces;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier;
import com.yau.digitalrmb.platformintegration.application.PlatformCredentialRepository;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.security.application.LoginExchangeCodeService; 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.HttpHeaders;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -21,17 +18,14 @@ import org.springframework.web.util.UriComponentsBuilder;
public class PlatformSsoController { public class PlatformSsoController {
private final PlatformTokenVerifier tokenVerifier; private final PlatformTokenVerifier tokenVerifier;
private final PlatformIdentityProjectionService projectionService; private final PlatformIdentityProjectionService projectionService;
private final PlatformCredentialRepository credentialRepository;
private final LoginExchangeCodeService exchangeCodeService; private final LoginExchangeCodeService exchangeCodeService;
private final PlatformIntegrationProperties.Frontend frontend; private final PlatformIntegrationProperties.Frontend frontend;
public PlatformSsoController(PlatformTokenVerifier tokenVerifier, public PlatformSsoController(PlatformTokenVerifier tokenVerifier,
PlatformCredentialRepository credentialRepository,
PlatformIdentityProjectionService projectionService, PlatformIdentityProjectionService projectionService,
LoginExchangeCodeService exchangeCodeService, LoginExchangeCodeService exchangeCodeService,
PlatformIntegrationProperties properties) { PlatformIntegrationProperties properties) {
this.tokenVerifier = tokenVerifier; this.tokenVerifier = tokenVerifier;
this.credentialRepository = credentialRepository;
this.projectionService = projectionService; this.projectionService = projectionService;
this.exchangeCodeService = exchangeCodeService; this.exchangeCodeService = exchangeCodeService;
this.frontend = properties.getFrontend(); this.frontend = properties.getFrontend();
@ -40,9 +34,7 @@ public class PlatformSsoController {
@GetMapping("/sso") @GetMapping("/sso")
public ResponseEntity<Void> loginFromPlatform(@RequestParam("token") String token) { public ResponseEntity<Void> loginFromPlatform(@RequestParam("token") String token) {
VerifiedPlatformToken verified = tokenVerifier.verify(token); VerifiedPlatformToken verified = tokenVerifier.verify(token);
credentialRepository.findCredential(verified.actor().platformUserId()) projectionService.project(verified.actor());
.ifPresentOrElse(projectionService::project,
() -> { throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户密码初始化失败"); });
String exchangeCode = exchangeCodeService.issue(verified.actor().platformUserId()); String exchangeCode = exchangeCodeService.issue(verified.actor().platformUserId());
String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl())
.queryParam("code", exchangeCode).build().encode().toUriString(); .queryParam("code", exchangeCode).build().encode().toUriString();

@ -0,0 +1,12 @@
INSERT INTO sys_user (id, username, password_hash, enabled, created_at, updated_at, created_by, updated_by, deleted)
VALUES (487, 'tzs001', '$2a$10$ufcw5KFHtOmLzxAsV4C.MuIDOErMlw0iw5J5hc8OMzaTx0u9QYxG6', TRUE,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'SYSTEM', 'SYSTEM', FALSE)
ON DUPLICATE KEY UPDATE username = VALUES(username), password_hash = VALUES(password_hash), enabled = TRUE,
updated_at = CURRENT_TIMESTAMP, updated_by = 'SYSTEM', deleted = FALSE;
INSERT INTO platform_user_snapshot (platform_user_id, account, display_name, role_key, source_updated_at, synced_at)
VALUES (487, 'tzs001', 'tzs001', 'STUDENT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE account = VALUES(account), display_name = VALUES(display_name), role_key = VALUES(role_key),
source_updated_at = CURRENT_TIMESTAMP, synced_at = CURRENT_TIMESTAMP;
INSERT IGNORE INTO sys_user_role (user_id, role_id) VALUES (487, 1002);

@ -1,41 +0,0 @@
package com.yau.digitalrmb.identity;
import com.yau.digitalrmb.identity.application.PlatformCredentialInitializer;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.application.PlatformCredentialRepository;
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.platformintegration.domain.PlatformCredential;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.junit.jupiter.api.Test;
import org.springframework.boot.DefaultApplicationArguments;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PlatformCredentialInitializerTest {
@Test
void initializesOnlyConfiguredPlatformAccount() throws Exception {
PlatformIdentityRepository identities = mock(PlatformIdentityRepository.class);
PlatformCredentialRepository credentials = mock(PlatformCredentialRepository.class);
PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class);
PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
properties.getLocalLogin().setInitialAccounts(List.of("tzs001"));
PlatformActor actor = new PlatformActor(301L, 3L, "tzs001", "教师", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z"));
PlatformCredential credential = new PlatformCredential(actor, "123qwe");
when(identities.findBySchoolAccount("tzs001")).thenReturn(Optional.of(actor));
when(credentials.findCredential(301L)).thenReturn(Optional.of(credential));
new PlatformCredentialInitializer(properties, identities, credentials, projection)
.run(new DefaultApplicationArguments());
verify(projection).project(credential);
}
}

@ -2,7 +2,6 @@ package com.yau.digitalrmb.identity;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor; import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformCredential;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole; import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -47,15 +46,15 @@ class PlatformIdentityProjectionServiceTest {
} }
@Test @Test
void credentialProjectionStoresOnlyBcryptPasswordHash() { void ssoProjectionCreatesUserWithRandomBcryptPassword() {
PlatformActor actor = new PlatformActor(301L, 3L, "tzs001", "教师", PlatformRole.TEACHER, PlatformActor actor = new PlatformActor(302L, 3L, "sso-user", "教师", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z")); Instant.parse("2026-01-01T00:00:00Z"));
projectionService.project(new PlatformCredential(actor, "123qwe")); projectionService.project(actor);
String passwordHash = new JdbcTemplate(dataSource) String passwordHash = new JdbcTemplate(dataSource)
.queryForObject("SELECT password_hash FROM sys_user WHERE id = 301", String.class); .queryForObject("SELECT password_hash FROM sys_user WHERE id = 302", String.class);
assertThat(passwordHash).startsWith("$2"); assertThat(passwordHash).startsWith("$2");
assertThat(passwordEncoder.matches("123qwe", passwordHash)).isTrue(); assertThat(passwordEncoder.matches("EXTERNAL_SSO_ONLY", passwordHash)).isFalse();
} }
} }

@ -1,19 +0,0 @@
package com.yau.digitalrmb.platformintegration.domain;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformCredentialTest {
@Test
void stringRepresentationRedactsRawPassword() {
PlatformActor actor = new PlatformActor(301L, 3L, "tzs001", "教师", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z"));
PlatformCredential credential = new PlatformCredential(actor, "123qwe");
assertThat(credential.toString()).contains("<redacted>").doesNotContain("123qwe");
}
}

@ -1,7 +1,6 @@
package com.yau.digitalrmb.platformintegration.infrastructure; package com.yau.digitalrmb.platformintegration.infrastructure;
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
import com.yau.digitalrmb.platformintegration.domain.PlatformCredential;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor; import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole; import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.h2.jdbcx.JdbcDataSource; import org.h2.jdbcx.JdbcDataSource;
@ -54,19 +53,8 @@ class JdbcPlatformIdentityRepositoryTest {
} }
@Test @Test
void resolvesCredentialOnlyForEnabledSupportedPlatformUsers() throws Exception {
execute("INSERT INTO core_user(ID, CODE, NAME, PASSWORD, STATE, JOB_TYPE1, DEL_FLAG) VALUES (301, 'tzs001', '教师', '123qwe', 'S1', 'JT_S_02', 0)");
execute("INSERT INTO teacher(teacher_id, user_id, teacher_status, add_time) VALUES (3, 301, 1, '2026-01-01 00:00:00')");
PlatformCredential credential = repository.findCredential(301L).orElseThrow();
assertThat(credential.actor().account()).isEqualTo("tzs001");
assertThat(credential.rawPassword()).isEqualTo("123qwe");
assertThat(repository.findCredential(999L)).isEmpty();
}
private void createSchema() throws Exception { private void createSchema() throws Exception {
execute("CREATE TABLE core_user(ID BIGINT PRIMARY KEY, CODE VARCHAR(64), NAME VARCHAR(64), PASSWORD VARCHAR(128), STATE VARCHAR(16), JOB_TYPE1 VARCHAR(16), DEL_FLAG INT)"); 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 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)"); execute("CREATE TABLE student(student_id BIGINT PRIMARY KEY, user_id BIGINT, student_status INT, add_time TIMESTAMP)");
} }

@ -2,11 +2,9 @@ package com.yau.digitalrmb.platformintegration.interfaces;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier;
import com.yau.digitalrmb.platformintegration.application.PlatformCredentialRepository;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor; import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformCredential;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole; import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import com.yau.digitalrmb.security.application.LoginExchangeCodeService; import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -27,7 +25,6 @@ class PlatformSsoControllerTest {
@Test @Test
void ssoRedirectDoesNotLeakIncomingToken() throws Exception { void ssoRedirectDoesNotLeakIncomingToken() throws Exception {
PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class); PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class);
PlatformCredentialRepository credentials = mock(PlatformCredentialRepository.class);
PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class); PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class);
LoginExchangeCodeService exchangeCodes = mock(LoginExchangeCodeService.class); LoginExchangeCodeService exchangeCodes = mock(LoginExchangeCodeService.class);
PlatformIntegrationProperties properties = new PlatformIntegrationProperties(); PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
@ -35,15 +32,14 @@ class PlatformSsoControllerTest {
PlatformActor actor = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, PlatformActor actor = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z")); Instant.parse("2026-01-01T00:00:00Z"));
when(verifier.verify(anyString())).thenReturn(new VerifiedPlatformToken(actor, "fingerprint")); when(verifier.verify(anyString())).thenReturn(new VerifiedPlatformToken(actor, "fingerprint"));
when(credentials.findCredential(101L)).thenReturn(java.util.Optional.of(new PlatformCredential(actor, "123qwe")));
when(exchangeCodes.issue(101L)).thenReturn("one-time-code"); when(exchangeCodes.issue(101L)).thenReturn("one-time-code");
MockMvc mvc = MockMvcBuilders.standaloneSetup(new PlatformSsoController(verifier, credentials, projection, exchangeCodes, properties)).build(); MockMvc mvc = MockMvcBuilders.standaloneSetup(new PlatformSsoController(verifier, projection, exchangeCodes, properties)).build();
mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token")) mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token"))
.andExpect(status().isFound()) .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?code=one-time-code"))
.andExpect(header().string("Cache-Control", "no-store")) .andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("Referrer-Policy", "no-referrer")); .andExpect(header().string("Referrer-Policy", "no-referrer"));
verify(projection).project(new PlatformCredential(actor, "123qwe")); verify(projection).project(actor);
} }
} }

@ -1,15 +1,10 @@
package com.yau.digitalrmb.security; package com.yau.digitalrmb.security;
import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.JsonPath;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformCredential;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@ -26,16 +21,6 @@ class AuthControllerTest {
@Autowired @Autowired
private MockMvc mvc; private MockMvc mvc;
@Autowired
private PlatformIdentityProjectionService projectionService;
@BeforeEach
void setUp() {
PlatformActor actor = new PlatformActor(301L, 3L, "tzs001", "教师", PlatformRole.TEACHER,
java.time.Instant.parse("2026-01-01T00:00:00Z"));
projectionService.project(new PlatformCredential(actor, "123qwe"));
}
@Test @Test
void localPasswordLoginIssuesTokenAndCanReadCurrentUser() throws Exception { void localPasswordLoginIssuesTokenAndCanReadCurrentUser() throws Exception {
String body = "{\"username\":\"tzs001\",\"password\":\"123qwe\"}"; String body = "{\"username\":\"tzs001\",\"password\":\"123qwe\"}";
@ -51,7 +36,7 @@ class AuthControllerTest {
.header("Authorization", "Bearer " + token)) .header("Authorization", "Bearer " + token))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.account").value("tzs001")) .andExpect(jsonPath("$.data.account").value("tzs001"))
.andExpect(jsonPath("$.data.roles[0]").value("TEACHER")); .andExpect(jsonPath("$.data.roles[0]").value("STUDENT"));
} }
@Test @Test

Loading…
Cancel
Save