diff --git a/README.md b/README.md index 2c4009f..255bc10 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ - 主平台单点登录:主平台跳转至 `GET /api/v1/auth/sso?token=...`。后端以主平台教师或学生档案的 `add_time` 动态 HMAC 密钥验证 Token,生成一次性 `code` 并跳转至前端回调地址。 - 独立访问:浏览器访问 `GET /api/v1/auth/cas/login`,跳转学校 CAS;回调校验成功后生成同样的一次性 `code`。 +- 本地账号密码:`POST /api/v1/auth/login` 使用主平台账号和密码直接登录本系统。成功的主平台 SSO/CAS 登录会从主平台只读库读取该账号的密码,并立即以 BCrypt 哈希写入本地;本系统不保存或返回密码明文。 - 会话兑换:`POST /api/v1/auth/session/exchange`,请求体为 `{"code":"..."}`,返回本系统访问 JWT 和刷新令牌。 本系统只接受教师和学生两类身份。平台账号与角色只能通过内部投影写入;没有用户、角色或用户角色的管理接口。 @@ -36,4 +37,4 @@ mvn spring-boot:run -Dspring-boot.run.profiles=local -Dspring-boot.run.arguments 本地 Swagger: -生产环境不启用用户名/密码登录;仅 `local` 与 `test` 配置保留脚手架的 Bootstrap Admin 入口。 +`local` 配置启动时会尝试初始化 `tzs001` 的本地密码哈希;该账号必须存在于主平台且属于教师或学生。密码变更后,用户下一次通过 SSO/CAS 登录会自动刷新本地哈希。 diff --git a/docs/superpowers/specs/2026-08-03-platform-sso-readonly-design.md b/docs/superpowers/specs/2026-08-03-platform-sso-readonly-design.md index 88174e4..6e482a6 100644 --- a/docs/superpowers/specs/2026-08-03-platform-sso-readonly-design.md +++ b/docs/superpowers/specs/2026-08-03-platform-sso-readonly-design.md @@ -2,10 +2,11 @@ ## 1. 目标与边界 -数字人民币系统在现有 `digital-rmb-backend` 中同时实现两个登录入口: +数字人民币系统在现有 `digital-rmb-backend` 中同时实现三个登录入口: - 主平台跳转后的单点登录; - 用户直接访问系统后的学校 CAS 统一认证登录。 +- 使用主平台账号密码的本地直接登录。 主平台不做改造。用户、教师/学生身份及角色均以主平台为唯一来源;数字人民币系统仅维护受控的只读快照,不提供用户、角色或用户角色的人工增删改接口。业务角色只有 `TEACHER` 与 `STUDENT`。 @@ -30,6 +31,7 @@ ``` 主平台 ──携带自定义 Token 跳转──> digital-rmb-backend ──签发本系统 JWT──> 前端 学校 CAS ──CAS Ticket 认证────────> digital-rmb-backend ──签发本系统 JWT──> 前端 +主平台只读库 ──密码明文(瞬时)─────> BCrypt ──> sys_user.password_hash digital-rmb-backend ──SELECT only──────────────────────────> 主平台数据库 ``` @@ -42,7 +44,7 @@ digital-rmb-backend ──SELECT only────────────── 1. 主平台既有跳转链接访问 `GET /api/v1/auth/sso?token=...`; 2. 后端按 Token 中未验签的用户 ID 和身份类型定位主平台候选记录; 3. 后端读取候选教师或学生记录的 `addTime`,按主平台算法完成 HMAC 验签; -4. 后端读取主平台用户状态、教师/学生资料及角色,刷新本地只读快照; +4. 后端读取主平台用户状态、教师/学生资料、角色及密码,刷新本地只读快照,并将密码立即 BCrypt 编码为本地哈希; 5. 后端生成仅一次、短时有效的登录兑换码,重定向到配置的前端回调地址; 6. 前端调用 `POST /api/v1/auth/session/exchange` 兑换本系统 JWT 与刷新令牌。 @@ -52,12 +54,19 @@ digital-rmb-backend ──SELECT only────────────── 2. 后端将浏览器重定向至学校 CAS 登录页,并使用固定的回调地址作为 CAS `service`; 3. CAS 将 `ticket` 回调至 `GET /api/v1/auth/cas/callback`; 4. 后端调用学校 CAS 的 `serviceValidate` 接口验证 Ticket,取得学校账号; -5. 后端以学校账号查询主平台 `core_user`,仅接受教师或学生,再刷新快照; +5. 后端以学校账号查询主平台 `core_user`,仅接受教师或学生,再刷新快照及本地密码哈希; 6. 后端创建登录兑换码,前端兑换本系统 JWT 与刷新令牌。 -用户已在学校 CAS 或主平台登录时,第 2 步无感完成;没有 CAS 会话时,用户在学校认证页输入统一账号密码。本系统不维护第二套用户名和密码。 +用户已在学校 CAS 或主平台登录时,第 2 步无感完成;没有 CAS 会话时,用户在学校认证页输入统一账号密码。 -### 4.3 退出 +### 4.3 本地账号密码登录 + +1. 用户调用 `POST /api/v1/auth/login`,提交主平台账号与密码; +2. 本系统仅以 `sys_user.password_hash` 的 BCrypt 哈希校验密码,成功后签发本系统 JWT; +3. 初次使用前,用户通过主平台 SSO/CAS 成功登录时会初始化本地哈希;`local` 环境还会在启动时为配置账号 `tzs001` 尝试初始化; +4. 密码变更后,下一次成功 SSO/CAS 登录会刷新本地哈希。密码明文绝不进入快照表、JWT、响应、日志或迁移脚本。 + +### 4.4 退出 `POST /api/v1/auth/logout` 只撤销本系统刷新令牌与本地会话;默认不退出学校 CAS 或主平台,避免跨系统连带登出。 @@ -101,13 +110,14 @@ CAS `service` 地址必须固定并使用 HTTPS。Ticket 仅能向配置的 CAS 后端使用独立的主平台只读账号,仅对 `core_user`、`student`、`teacher` 及必要关联表授予 `SELECT`。禁止授予 `INSERT`、`UPDATE`、`DELETE`、`CREATE`、`ALTER` 或 DDL 权限。 -现有脚手架的本地引导管理员密码入口仅保留给开发期;生产环境禁用。教师和学生均走主平台 Token 或 CAS 登录。 +本系统只允许主平台教师和学生使用本地账号密码登录。密码哈希仅由内部主平台凭据投影写入;不提供用户、角色、用户角色或密码的管理接口。主平台数据库账号除身份表外还需对 `core_user.PASSWORD` 保有只读 `SELECT` 权限。 ## 7. 对外接口 - `GET /api/v1/auth/sso?token=...`:主平台单点登录入口; - `GET /api/v1/auth/cas/login`:发起学校 CAS 认证; - `GET /api/v1/auth/cas/callback?ticket=...`:固定 CAS 回调; +- `POST /api/v1/auth/login`:以已初始化的主平台账号密码直接登录; - `POST /api/v1/auth/session/exchange`:以一次性兑换码换取本系统 JWT; - `GET /api/v1/auth/me`:读取当前用户与教师/学生角色; - `POST /api/v1/auth/logout`:撤销本系统会话。 @@ -120,6 +130,7 @@ Swagger 只公开上述认证和当前用户读取接口;不公开用户、角 - 伪造、篡改、过期、重放、身份不匹配或状态异常的 Token 均被拒绝; - CAS 有会话和无会话两种直接登录路径均可完成; - CAS Ticket 验证失败、CAS 返回非教师/学生、主平台查询失败均返回受控错误; +- 本地账号密码仅校验 BCrypt 哈希;密码明文不出现在本系统存储、JWT、响应或日志; - 登录兑换码一次性、短时有效,不能被重放; - 主平台数据库账号仅能 SELECT,任何写操作都失败; - 用户、角色和用户角色的写接口不存在或被拒绝; diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformCredential.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformCredential.java index 69e6d43..60b4bba 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformCredential.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformCredential.java @@ -9,4 +9,9 @@ public record PlatformCredential(PlatformActor actor, String rawPassword) { throw new IllegalArgumentException("rawPassword must not be blank"); } } + + @Override + public String toString() { + return "PlatformCredential[actor=" + actor + ", rawPassword=]"; + } } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java index 0ffbee0..520ea14 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java @@ -56,7 +56,9 @@ public class CasAuthenticationController { String account = ticketValidator.validate(ticket); PlatformActor actor = identityRepository.findBySchoolAccount(account) .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED, "用户无权访问本系统")); - credentialRepository.findCredential(actor.platformUserId()).ifPresent(projectionService::project); + credentialRepository.findCredential(actor.platformUserId()) + .ifPresentOrElse(projectionService::project, + () -> { throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户密码初始化失败"); }); String exchangeCode = exchangeCodeService.issue(actor.platformUserId()); String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) .queryParam("code", exchangeCode).build().encode().toUriString(); diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java index 7285a58..9b5b560 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java @@ -6,6 +6,8 @@ import com.yau.digitalrmb.platformintegration.application.PlatformCredentialRepo 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.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; @@ -38,7 +40,9 @@ public class PlatformSsoController { @GetMapping("/sso") public ResponseEntity loginFromPlatform(@RequestParam("token") String token) { VerifiedPlatformToken verified = tokenVerifier.verify(token); - credentialRepository.findCredential(verified.actor().platformUserId()).ifPresent(projectionService::project); + credentialRepository.findCredential(verified.actor().platformUserId()) + .ifPresentOrElse(projectionService::project, + () -> { throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户密码初始化失败"); }); String exchangeCode = exchangeCodeService.issue(verified.actor().platformUserId()); String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) .queryParam("code", exchangeCode).build().encode().toUriString(); diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java index 959fef6..98c7745 100644 --- a/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java @@ -11,21 +11,15 @@ import java.time.Duration; public class SecurityProperties { private final Jwt jwt = new Jwt(); private final Session session = new Session(); - private final BootstrapAdmin bootstrapAdmin = new BootstrapAdmin(); public Jwt getJwt() { return jwt; } public Session getSession() { return session; } - public BootstrapAdmin getBootstrapAdmin() { return bootstrapAdmin; } @PostConstruct void validate() { if (jwt.secret == null || jwt.secret.length() < 64) { throw new IllegalStateException("security.jwt.secret must contain at least 64 characters"); } - if (bootstrapAdmin.username == null || bootstrapAdmin.username.isBlank() - || bootstrapAdmin.password == null || bootstrapAdmin.password.isBlank()) { - throw new IllegalStateException("bootstrap administrator credentials must not be blank"); - } } public static class Jwt { @@ -37,15 +31,6 @@ public class SecurityProperties { public void setAccessTokenTtl(Duration accessTokenTtl) { this.accessTokenTtl = accessTokenTtl; } } - public static class BootstrapAdmin { - private String username; - private String password; - public String getUsername() { return username; } - public void setUsername(String username) { this.username = username; } - public String getPassword() { return password; } - public void setPassword(String password) { this.password = password; } - } - public static class Session { private Duration exchangeCodeTtl = Duration.ofMinutes(1); private Duration refreshTokenTtl = Duration.ofHours(8); diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index d2e94dd..3187193 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -11,9 +11,6 @@ security: jwt: secret: 0123456789012345678901234567890123456789012345678901234567890123 access-token-ttl: PT30M - bootstrap-admin: - username: admin - password: ChangeMe123! platform-integration: datasource: url: jdbc:h2:mem:platform;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3e0cc16..2352f7e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,9 +13,6 @@ security: jwt: secret: ${DIGITAL_RMB_JWT_SECRET} access-token-ttl: PT30M - bootstrap-admin: - username: ${DIGITAL_RMB_BOOTSTRAP_ADMIN_USERNAME} - password: ${DIGITAL_RMB_BOOTSTRAP_ADMIN_PASSWORD} management: endpoints: web: diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformCredentialTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformCredentialTest.java new file mode 100644 index 0000000..40eb47d --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformCredentialTest.java @@ -0,0 +1,19 @@ +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("").doesNotContain("123qwe"); + } +} diff --git a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java index 7e24ea5..6d41629 100644 --- a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java @@ -37,7 +37,7 @@ class AuthControllerTest { } @Test - void loginIssuesTokenAndTokenProtectsEndpoint() throws Exception { + void localPasswordLoginIssuesTokenAndCanReadCurrentUser() throws Exception { String body = "{\"username\":\"tzs001\",\"password\":\"123qwe\"}"; String response = mvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) @@ -47,10 +47,11 @@ class AuthControllerTest { .andReturn().getResponse().getContentAsString(); String token = JsonPath.read(response, "$.data.accessToken"); - mvc.perform(get("/api/v1/diagnostics/validation") - .param("value", "ok") + mvc.perform(get("/api/v1/auth/me") .header("Authorization", "Bearer " + token)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.account").value("tzs001")) + .andExpect(jsonPath("$.data.roles[0]").value("TEACHER")); } @Test