feat: finalize local password authentication

master
chenyuan 4 weeks ago
parent 6edd870a32
commit 2751d73bc0

@ -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<http://localhost:8081/swagger-ui/index.html>
生产环境不启用用户名/密码登录;仅 `local``test` 配置保留脚手架的 Bootstrap Admin 入口
`local` 配置启动时会尝试初始化 `tzs001` 的本地密码哈希;该账号必须存在于主平台且属于教师或学生。密码变更后,用户下一次通过 SSO/CAS 登录会自动刷新本地哈希

@ -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任何写操作都失败
- 用户、角色和用户角色的写接口不存在或被拒绝;

@ -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=<redacted>]";
}
}

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

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

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

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

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

@ -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("<redacted>").doesNotContain("123qwe");
}
}

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

Loading…
Cancel
Save