feat: add password entry auth flow
This commit is contained in:
@@ -159,8 +159,10 @@
|
||||
|
||||
### Axum 鉴权服务
|
||||
|
||||
- [ ] 实现密码登录
|
||||
- [ ] 实现账号自动创建 / 幂等登录兼容策略
|
||||
- [x] 实现密码登录
|
||||
交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现账号自动创建 / 幂等登录兼容策略
|
||||
交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 Bearer JWT 校验
|
||||
交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 refresh cookie 读取
|
||||
@@ -210,7 +212,8 @@
|
||||
### 当前接口兼容
|
||||
|
||||
- [ ] 兼容 `/api/auth/login-options`
|
||||
- [ ] 兼容 `/api/auth/entry`
|
||||
- [x] 兼容 `/api/auth/entry`
|
||||
交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 兼容 `/api/auth/me`
|
||||
- [ ] 兼容 `/api/auth/logout`
|
||||
- [ ] 兼容 `/api/auth/logout-all`
|
||||
@@ -229,7 +232,8 @@
|
||||
|
||||
### 阶段验收
|
||||
|
||||
- [ ] 密码登录主链可用
|
||||
- [x] 密码登录主链可用
|
||||
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖自动建号、重复登录复用、错密码 `401`、非法用户名 `400` 与 refresh cookie 写回。
|
||||
- [ ] refresh cookie 主链可用
|
||||
- [ ] 手机验证码主链可用
|
||||
- [ ] 微信登录主链可用
|
||||
|
||||
253
docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md
Normal file
253
docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 密码登录与自动建号落地设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中以下两条任务的首版落地:
|
||||
|
||||
1. `实现密码登录`
|
||||
2. `实现账号自动创建 / 幂等登录兼容策略`
|
||||
|
||||
目标是先把当前 Node 已经稳定运行的 `/api/auth/entry` 语义迁到 Rust 工作区,并冻结:
|
||||
|
||||
1. `api-server` 对外暴露的最小兼容接口。
|
||||
2. `module-auth` 负责的密码登录用例边界。
|
||||
3. 自动建号与并发幂等兼容规则。
|
||||
4. 登录成功后与 JWT、refresh cookie 的衔接方式。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/entry` 主链已经具备如下语义:
|
||||
|
||||
1. 输入 `username + password`。
|
||||
2. 若用户名不存在,则自动创建一个本地账号。
|
||||
3. 若用户名已存在,则校验密码。
|
||||
4. 登录成功后签发 access token。
|
||||
5. 同时创建 refresh session,并把原始 refresh token 写入 HttpOnly cookie。
|
||||
6. 并发创建同一用户名时,后到的请求会回退为“查已存在账号并校验密码”,不因唯一键冲突直接失败。
|
||||
|
||||
这条链路既是当前前端匿名/游客恢复的基础,也是真实 `/api/auth/entry` contract 的既有事实,因此 Rust 首版必须兼容。
|
||||
|
||||
## 3. 设计输入
|
||||
|
||||
本任务直接受以下文档约束:
|
||||
|
||||
1. [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
|
||||
3. [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
|
||||
4. [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
|
||||
5. [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
|
||||
|
||||
关键冻结点:
|
||||
|
||||
1. `password_hash` 当前继续由 `user_account` 承担,不进入 `auth_identity`。
|
||||
2. `sub` 必须是稳定 `user_id`。
|
||||
3. 登录成功后必须继续同时生成 access token 和 refresh session。
|
||||
4. 自动建号兼容必须保留,不能因为迁到 Rust 就删除。
|
||||
|
||||
## 4. 首版落地范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 中的密码登录用例。
|
||||
2. `api-server` 中的 `POST /api/auth/entry`。
|
||||
3. 用户名校验、密码哈希校验与自动建号。
|
||||
4. 登录成功后的 access token 与 refresh cookie 主链打通。
|
||||
|
||||
本阶段明确不包含:
|
||||
|
||||
1. SpacetimeDB 真正的 `user_account` / `refresh_session` reducer 写入。
|
||||
2. `/api/auth/me`、`/api/auth/logout`、`/api/auth/refresh` 的正式业务闭环。
|
||||
3. 手机验证码与微信登录链路。
|
||||
|
||||
## 5. crate 边界
|
||||
|
||||
### 5.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 用户名与密码的领域校验。
|
||||
2. 密码登录主用例。
|
||||
3. 自动建号与并发幂等兼容策略。
|
||||
4. 输出登录成功所需的最小用户快照。
|
||||
|
||||
不负责:
|
||||
|
||||
1. JWT 编解码。
|
||||
2. refresh cookie 解析与写回。
|
||||
3. HTTP 请求解析与响应拼装。
|
||||
|
||||
### 5.2 `platform-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 密码哈希与校验适配。
|
||||
2. JWT 签发与校验。
|
||||
3. refresh cookie 读写适配。
|
||||
|
||||
不负责:
|
||||
|
||||
1. 决定账号是否应当自动创建。
|
||||
2. 决定用户状态是否合法。
|
||||
|
||||
### 5.3 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. 解析 `POST /api/auth/entry` 请求体。
|
||||
2. 调用 `module-auth` 用例。
|
||||
3. 调用 `platform-auth` 签发 token 和 refresh cookie。
|
||||
4. 返回与旧接口兼容的 JSON body。
|
||||
|
||||
## 6. 请求与响应 contract
|
||||
|
||||
### 6.1 请求体
|
||||
|
||||
固定沿用当前 contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 成功响应
|
||||
|
||||
固定沿用当前 contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<access-token>",
|
||||
"user": {
|
||||
"id": "user_xxx",
|
||||
"username": "guest_001",
|
||||
"displayName": "guest_001",
|
||||
"phoneNumberMasked": null,
|
||||
"loginMethod": "password",
|
||||
"bindingStatus": "active",
|
||||
"wechatBound": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同时响应头必须写回 refresh cookie。
|
||||
|
||||
## 7. 用户名与密码规则
|
||||
|
||||
当前阶段继续对齐 Node 基线:
|
||||
|
||||
1. `username` 只允许 `3` 到 `24` 位字母、数字、下划线。
|
||||
2. `password` 长度必须在 `6` 到 `128` 位之间。
|
||||
|
||||
任一校验失败时:
|
||||
|
||||
1. 返回 `400 BAD_REQUEST`
|
||||
2. 错误文案继续保持中文
|
||||
|
||||
## 8. 自动建号与幂等兼容
|
||||
|
||||
### 8.1 自动建号
|
||||
|
||||
当 `username` 不存在时:
|
||||
|
||||
1. 用当前请求里的 `password` 生成密码哈希。
|
||||
2. 创建一条本地账号。
|
||||
3. `display_name = username`
|
||||
4. `login_provider = password`
|
||||
5. `account_status = active`
|
||||
6. `token_version = 1`
|
||||
|
||||
### 8.2 已存在账号
|
||||
|
||||
当 `username` 已存在时:
|
||||
|
||||
1. 校验密码哈希。
|
||||
2. 校验失败返回 `401 UNAUTHORIZED`。
|
||||
3. 校验成功继续登录。
|
||||
|
||||
### 8.3 并发幂等兼容
|
||||
|
||||
若两个请求并发创建同一用户名:
|
||||
|
||||
1. 允许其中一个请求先创建成功。
|
||||
2. 后一个请求若命中唯一键冲突,不直接失败。
|
||||
3. 后一个请求必须重新查询该用户名。
|
||||
4. 若查到账号,则按“已存在账号”路径继续校验密码。
|
||||
|
||||
这保证了当前前端重复调用 `/api/auth/entry` 时可以恢复同一账号,而不是随机失败。
|
||||
|
||||
## 9. 首版存储策略
|
||||
|
||||
当前阶段为了先跑通工程闭环,固定采用:
|
||||
|
||||
1. `module-auth` 内的进程内内存仓储适配器作为临时真相。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是阶段性工程策略,不改变最终 `SpacetimeDB` 作为真相源的目标。
|
||||
2. 当前这样做是为了先把 crate 边界、用例形状、HTTP contract、JWT / refresh cookie 主链稳定下来。
|
||||
3. 后续切到 `SpacetimeDB` 时,应保持 `module-auth` 用例接口不变,只替换仓储实现。
|
||||
|
||||
## 10. 密码哈希策略
|
||||
|
||||
当前阶段继续对齐 Node:
|
||||
|
||||
1. `Argon2id`
|
||||
|
||||
说明:
|
||||
|
||||
1. Rust 侧不再复用 Node 原生库,但哈希语义继续保持同类算法。
|
||||
2. 当前目标是“工程能力闭环”,不是做跨语言哈希值兼容迁移。
|
||||
3. 若未来需要与 Node 历史哈希共存,需单独补兼容文档和迁移策略。
|
||||
|
||||
## 11. 与 JWT / refresh cookie 的衔接
|
||||
|
||||
密码登录成功后:
|
||||
|
||||
1. `module-auth` 返回最小用户领域对象。
|
||||
2. `api-server` 基于该对象构造 `AccessTokenClaimsInput`。
|
||||
3. `platform-auth` 签发 access token。
|
||||
4. `platform-auth` 生成 refresh token 与 `Set-Cookie` 头。
|
||||
5. `api-server` 返回 `token + user`。
|
||||
|
||||
当前阶段固定 claims 值:
|
||||
|
||||
1. `provider = password`
|
||||
2. `roles = ["user"]`
|
||||
3. `binding_status = active`
|
||||
4. `phone_verified = false`
|
||||
5. `display_name = username`
|
||||
|
||||
## 12. 测试策略
|
||||
|
||||
当前阶段至少覆盖:
|
||||
|
||||
1. 首次密码登录自动建号成功。
|
||||
2. 同用户名同密码可重复登录同一账号。
|
||||
3. 同用户名不同密码返回 `401`。
|
||||
4. 非法用户名返回 `400`。
|
||||
5. 登录成功时返回 access token。
|
||||
6. 登录成功时写回 refresh cookie。
|
||||
|
||||
## 13. 完成定义
|
||||
|
||||
满足以下条件时,本任务视为完成:
|
||||
|
||||
1. `module-auth` 不再只是 README,占位被真实 crate 实现替换。
|
||||
2. `POST /api/auth/entry` 可在 Rust 侧独立跑通。
|
||||
3. 自动建号与幂等兼容行为可验证。
|
||||
4. JWT 与 refresh cookie 登录成功主链打通。
|
||||
5. 文档、任务清单与测试同步完成。
|
||||
|
||||
## 14. 后续衔接
|
||||
|
||||
这条任务完成后,下一步顺序固定为:
|
||||
|
||||
1. `me` 查询
|
||||
2. refresh token 轮换
|
||||
3. 会话吊销
|
||||
4. 手机验证码登录
|
||||
|
||||
微信登录继续按“暂缓执行”处理,直到用户重新解锁。
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
|
||||
- [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。
|
||||
- [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。
|
||||
- [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth` 与 `SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。
|
||||
|
||||
60
server-rs/Cargo.lock
generated
60
server-rs/Cargo.lock
generated
@@ -24,6 +24,7 @@ dependencies = [
|
||||
"axum",
|
||||
"dotenvy",
|
||||
"http-body-util",
|
||||
"module-auth",
|
||||
"platform-auth",
|
||||
"platform-oss",
|
||||
"serde",
|
||||
@@ -37,6 +38,18 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -107,12 +120,27 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -512,6 +540,14 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"platform-auth",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -561,6 +597,17 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -587,10 +634,14 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
name = "platform-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"jsonwebtoken",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"time",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -645,6 +696,15 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/api-server",
|
||||
"crates/module-auth",
|
||||
"crates/platform-oss",
|
||||
"crates/platform-auth",
|
||||
"crates/shared-logging",
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
dotenvy = "0.15"
|
||||
module-auth = { path = "../module-auth" }
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
platform-oss = { path = "../platform-oss" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
6. `src/config.rs`
|
||||
7. 基础 `TraceLayer` 挂载
|
||||
8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化
|
||||
9. 接入 `POST /api/auth/entry` 首版密码登录链路
|
||||
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -36,6 +38,8 @@
|
||||
3. [x] 接入统一错误处理中间件
|
||||
4. [x] 接入 response envelope
|
||||
5. [x] 接入 `/healthz`
|
||||
6. [x] 接入 `/api/auth/entry`
|
||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -94,3 +98,4 @@
|
||||
2. 业务逻辑优先通过独立模块 crate 暴露能力,再由主工程组合。
|
||||
3. 外部副作用通过 `platform-auth`、`platform-oss`、`platform-llm` 与各模块 crate 的应用层完成。
|
||||
4. 不把领域规则直接堆在 handler 中。
|
||||
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
||||
|
||||
@@ -123,7 +123,7 @@ mod tests {
|
||||
let request_context = build_request_context(false);
|
||||
let error = ApiErrorPayload {
|
||||
code: "NOT_FOUND",
|
||||
message: "资源不存在",
|
||||
message: "资源不存在".to_string(),
|
||||
details: None,
|
||||
};
|
||||
let body = json_error_body(Some(&request_context), &error).0;
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::{
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
password_entry::password_entry,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
state::AppState,
|
||||
@@ -49,6 +50,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/assets/direct-upload-tickets",
|
||||
post(create_direct_upload_ticket),
|
||||
)
|
||||
.route("/api/auth/entry", post(password_entry))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||
@@ -329,4 +331,179 @@ mod tests {
|
||||
Value::Number(serde_json::Number::from(10))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_creates_user_and_sets_refresh_cookie() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("genarrative_refresh_session="))
|
||||
);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["user"]["username"],
|
||||
Value::String("guest_001".to_string())
|
||||
);
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_reuses_same_user_for_same_credentials() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let first_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first request should succeed");
|
||||
let first_body = first_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first body should collect")
|
||||
.to_bytes();
|
||||
let first_payload: Value =
|
||||
serde_json::from_slice(&first_body).expect("first payload should be json");
|
||||
|
||||
let second_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second request should succeed");
|
||||
let second_body = second_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("second body should collect")
|
||||
.to_bytes();
|
||||
let second_payload: Value =
|
||||
serde_json::from_slice(&second_body).expect("second payload should be json");
|
||||
|
||||
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_wrong_password() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("seed request should succeed");
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret999"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_invalid_username() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "无效用户",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ use crate::{api_response::json_error_body, request_context::RequestContext};
|
||||
pub struct AppError {
|
||||
status_code: StatusCode,
|
||||
code: &'static str,
|
||||
message: &'static str,
|
||||
message: String,
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ApiErrorPayload {
|
||||
pub code: &'static str,
|
||||
pub message: &'static str,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
@@ -30,7 +30,7 @@ impl AppError {
|
||||
Self {
|
||||
status_code,
|
||||
code,
|
||||
message,
|
||||
message: message.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ impl AppError {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||
self.message = message.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
@@ -54,7 +59,7 @@ impl AppError {
|
||||
fn to_payload(&self) -> ApiErrorPayload {
|
||||
ApiErrorPayload {
|
||||
code: self.code,
|
||||
message: self.message,
|
||||
message: self.message.clone(),
|
||||
details: self.details.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod password_entry;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod state;
|
||||
|
||||
132
server-rs/crates/api-server/src/password_entry.rs
Normal file
132
server-rs/crates/api-server/src/password_entry.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::{PasswordEntryError, PasswordEntryInput};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
||||
build_refresh_session_set_cookie, create_refresh_session_token, sign_access_token,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn password_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<PasswordEntryRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
.password_entry_service()
|
||||
.execute(PasswordEntryInput {
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_entry_error)?;
|
||||
|
||||
let refresh_session_token = create_refresh_session_token();
|
||||
let access_claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: result.user.id.clone(),
|
||||
session_id: refresh_session_token.clone(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: result.user.token_version,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some(result.user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
let access_token =
|
||||
sign_access_token(&access_claims, state.auth_jwt_config()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
let refresh_cookie =
|
||||
build_refresh_session_set_cookie(&refresh_session_token, state.refresh_cookie_config());
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
let set_cookie = HeaderValue::from_str(&refresh_cookie).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("refresh cookie 头构造失败:{error}"))
|
||||
})?;
|
||||
headers.insert(SET_COOKIE, set_cookie);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
PasswordEntryResponse {
|
||||
token: access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("用户名只允许 3 到 24 位字母、数字、下划线")
|
||||
.with_details(json!({
|
||||
"field": "username",
|
||||
})),
|
||||
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("密码长度需要在 6 到 128 位之间")
|
||||
.with_details(json!({
|
||||
"field": "password",
|
||||
})),
|
||||
PasswordEntryError::InvalidCredentials => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
|
||||
}
|
||||
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_auth::{InMemoryPasswordUserStore, PasswordEntryService};
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
@@ -16,6 +17,7 @@ pub struct AppState {
|
||||
auth_jwt_config: JwtConfig,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
oss_client: Option<OssClient>,
|
||||
password_entry_service: PasswordEntryService,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -44,12 +46,15 @@ impl AppState {
|
||||
config.refresh_session_ttl_days,
|
||||
)?;
|
||||
let oss_client = build_oss_client(&config)?;
|
||||
let password_entry_service =
|
||||
PasswordEntryService::new(InMemoryPasswordUserStore::default());
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_jwt_config,
|
||||
refresh_cookie_config,
|
||||
oss_client,
|
||||
password_entry_service,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,6 +69,10 @@ impl AppState {
|
||||
pub fn oss_client(&self) -> Option<&OssClient> {
|
||||
self.oss_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn password_entry_service(&self) -> &PasswordEntryService {
|
||||
&self.password_entry_service
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
|
||||
11
server-rs/crates/module-auth/Cargo.toml
Normal file
11
server-rs/crates/module-auth/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "module-auth"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
@@ -1,28 +1,34 @@
|
||||
# module-auth 独立模块 crate 占位说明
|
||||
# module-auth 鉴权模块 crate 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. crate 职责
|
||||
|
||||
`module-auth` 是鉴权与会话模块 crate,后续负责:
|
||||
`module-auth` 是鉴权与会话模块 crate,当前与后续负责:
|
||||
|
||||
1. 用户身份、会话、风控、审计相关领域模型
|
||||
2. 手机验证码、微信登录、密码登录的模块内用例编排
|
||||
3. 与 `crates/api-server` 的鉴权接口装配对接
|
||||
4. 与 `crates/spacetime-module` 的身份表、会话表聚合对接
|
||||
1. 用户身份、会话、风控、审计相关领域模型。
|
||||
2. 手机验证码、微信登录、密码登录的模块内用例编排。
|
||||
3. 与 `crates/api-server` 的鉴权接口装配对接。
|
||||
4. 与 `crates/spacetime-module` 的身份表、会话表聚合对接。
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前阶段已冻结前七张鉴权基础表设计,剩余重点收口在 JWT claims、refresh cookie 与旧接口兼容细节。
|
||||
当前阶段已经冻结前七张鉴权基础表设计,并已完成:
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
1. JWT claims 设计与 `platform-auth` 落地。
|
||||
2. refresh cookie 读取适配。
|
||||
3. `module-auth` 真实 crate 与首版密码登录用例落地。
|
||||
4. 微信登录链路暂缓执行,不进入当前连续实现顺序。
|
||||
|
||||
1. 设计 `user_account`、`auth_identity`、`refresh_session`
|
||||
2. 设计 `auth_audit_log`、`auth_risk_block`
|
||||
3. 设计 `sms_auth_event`、`wechat_auth_state`
|
||||
4. 落地 JWT claims、refresh cookie 与旧接口兼容
|
||||
当前连续实现优先顺序固定为:
|
||||
|
||||
当前已冻结文档:
|
||||
1. 密码登录
|
||||
2. `me` 查询
|
||||
3. refresh token 轮换
|
||||
4. 会话吊销
|
||||
5. 手机验证码登录
|
||||
|
||||
## 3. 当前已冻结文档
|
||||
|
||||
1. [../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
|
||||
@@ -32,9 +38,14 @@
|
||||
6. [../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)
|
||||
7. [../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)
|
||||
8. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
|
||||
9. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
|
||||
10. [../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
|
||||
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
|
||||
|
||||
## 3. 边界约束
|
||||
## 4. 边界约束
|
||||
|
||||
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。
|
||||
2. 短信、微信、JWT、Cookie 等平台适配优先通过 `crates/platform-auth` 承接。
|
||||
2. 短信、微信、JWT、Cookie、密码哈希等平台适配优先通过 `crates/platform-auth` 承接。
|
||||
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
|
||||
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
||||
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||
|
||||
353
server-rs/crates/module-auth/src/lib.rs
Normal file
353
server-rs/crates/module-auth/src/lib.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use platform_auth::{hash_password, verify_password};
|
||||
|
||||
const USERNAME_MIN_LENGTH: usize = 3;
|
||||
const USERNAME_MAX_LENGTH: usize = 24;
|
||||
const PASSWORD_MIN_LENGTH: usize = 6;
|
||||
const PASSWORD_MAX_LENGTH: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AuthLoginMethod {
|
||||
Password,
|
||||
Phone,
|
||||
Wechat,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AuthBindingStatus {
|
||||
Active,
|
||||
PendingBindPhone,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: AuthLoginMethod,
|
||||
pub binding_status: AuthBindingStatus,
|
||||
pub wechat_bound: bool,
|
||||
pub token_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PasswordEntryInput {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PasswordEntryResult {
|
||||
pub user: AuthUser,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordEntryError {
|
||||
InvalidUsername,
|
||||
InvalidPasswordLength,
|
||||
InvalidCredentials,
|
||||
Store(String),
|
||||
PasswordHash(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryPasswordUserStore {
|
||||
inner: Arc<Mutex<InMemoryPasswordUserStoreState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InMemoryPasswordUserStoreState {
|
||||
next_id: u64,
|
||||
users_by_username: HashMap<String, StoredPasswordUser>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredPasswordUser {
|
||||
user: AuthUser,
|
||||
password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PasswordEntryService {
|
||||
store: InMemoryPasswordUserStore,
|
||||
}
|
||||
|
||||
impl PasswordEntryService {
|
||||
pub fn new(store: InMemoryPasswordUserStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: PasswordEntryInput,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
let username = normalize_username(&input.username)?;
|
||||
validate_password(&input.password)?;
|
||||
|
||||
if let Some(existing_user) = self.store.find_by_username(&username)? {
|
||||
let is_valid = verify_password(&existing_user.password_hash, &input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
if !is_valid {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
return Ok(PasswordEntryResult {
|
||||
user: existing_user.user,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
match self
|
||||
.store
|
||||
.create_user(username.clone(), password_hash.clone())
|
||||
{
|
||||
Ok(user) => Ok(PasswordEntryResult {
|
||||
user,
|
||||
created: true,
|
||||
}),
|
||||
Err(CreateUserError::AlreadyExists) => {
|
||||
let existing_user = self.store.find_by_username(&username)?.ok_or_else(|| {
|
||||
PasswordEntryError::Store("唯一键冲突后未能重新读取账号".to_string())
|
||||
})?;
|
||||
let is_valid = verify_password(&existing_user.password_hash, &input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
if !is_valid {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Ok(PasswordEntryResult {
|
||||
user: existing_user.user,
|
||||
created: false,
|
||||
})
|
||||
}
|
||||
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryPasswordUserStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(InMemoryPasswordUserStoreState {
|
||||
next_id: 1,
|
||||
users_by_username: HashMap::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryPasswordUserStore {
|
||||
fn find_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
Ok(state.users_by_username.get(username).cloned())
|
||||
}
|
||||
|
||||
fn create_user(
|
||||
&self,
|
||||
username: String,
|
||||
password_hash: String,
|
||||
) -> Result<AuthUser, CreateUserError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| CreateUserError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
|
||||
if state.users_by_username.contains_key(&username) {
|
||||
return Err(CreateUserError::AlreadyExists);
|
||||
}
|
||||
|
||||
let user_id = format!("user_{:08}", state.next_id);
|
||||
state.next_id += 1;
|
||||
|
||||
let user = AuthUser {
|
||||
id: user_id,
|
||||
username: username.clone(),
|
||||
display_name: username.clone(),
|
||||
phone_number_masked: None,
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
token_version: 1,
|
||||
};
|
||||
state.users_by_username.insert(
|
||||
username,
|
||||
StoredPasswordUser {
|
||||
user: user.clone(),
|
||||
password_hash,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum CreateUserError {
|
||||
AlreadyExists,
|
||||
Store(String),
|
||||
}
|
||||
|
||||
impl AuthLoginMethod {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Password => "password",
|
||||
Self::Phone => "phone",
|
||||
Self::Wechat => "wechat",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthBindingStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::PendingBindPhone => "pending_bind_phone",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PasswordEntryError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
|
||||
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
|
||||
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
|
||||
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PasswordEntryError {}
|
||||
|
||||
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||
let username = raw_username.trim().to_string();
|
||||
let valid_length =
|
||||
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
|
||||
let valid_chars = username
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '_');
|
||||
|
||||
if !valid_length || !valid_chars {
|
||||
return Err(PasswordEntryError::InvalidUsername);
|
||||
}
|
||||
|
||||
Ok(username)
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
||||
let length = password.chars().count();
|
||||
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
|
||||
return Err(PasswordEntryError::InvalidPasswordLength);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_service() -> PasswordEntryService {
|
||||
PasswordEntryService::new(InMemoryPasswordUserStore::default())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_password_entry_creates_user() {
|
||||
let service = build_service();
|
||||
|
||||
let result = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
|
||||
assert!(result.created);
|
||||
assert_eq!(result.user.id, "user_00000001");
|
||||
assert_eq!(result.user.username, "guest_001");
|
||||
assert_eq!(result.user.display_name, "guest_001");
|
||||
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
|
||||
assert_eq!(result.user.binding_status, AuthBindingStatus::Active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_password_entry_reuses_same_user() {
|
||||
let service = build_service();
|
||||
let first = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
|
||||
let second = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("second login should succeed");
|
||||
|
||||
assert!(first.created);
|
||||
assert!(!second.created);
|
||||
assert_eq!(second.user.id, first.user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_password_entry_rejects_wrong_password() {
|
||||
let service = build_service();
|
||||
service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret999".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("wrong password should fail");
|
||||
|
||||
assert_eq!(error, PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_username_returns_bad_request_error() {
|
||||
let service = build_service();
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "坏用户名".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("invalid username should fail");
|
||||
|
||||
assert_eq!(error, PasswordEntryError::InvalidUsername);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
time = { version = "0.3", features = ["std"] }
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::{collections::HashSet, error::Error, fmt};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
@@ -98,6 +101,12 @@ pub enum RefreshCookieError {
|
||||
InvalidConfig(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PasswordHashError {
|
||||
HashFailed(String),
|
||||
VerifyFailed(String),
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn new(
|
||||
issuer: String,
|
||||
@@ -370,6 +379,53 @@ pub fn read_refresh_session_token(
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn hash_password(password: &str) -> Result<String, PasswordHashError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|error| PasswordHashError::HashFailed(format!("密码哈希失败:{error}")))
|
||||
}
|
||||
|
||||
pub async fn verify_password(
|
||||
password_hash: &str,
|
||||
password: &str,
|
||||
) -> Result<bool, PasswordHashError> {
|
||||
let parsed_hash = PasswordHash::new(password_hash)
|
||||
.map_err(|error| PasswordHashError::VerifyFailed(format!("密码哈希格式非法:{error}")))?;
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
pub fn create_refresh_session_token() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfig) -> String {
|
||||
let mut parts = vec![
|
||||
format!(
|
||||
"{}={}",
|
||||
config.cookie_name(),
|
||||
urlencoding::encode(token).into_owned()
|
||||
),
|
||||
format!("Path={}", config.cookie_path()),
|
||||
"HttpOnly".to_string(),
|
||||
format!("SameSite={}", config.cookie_same_site().as_str()),
|
||||
format!(
|
||||
"Max-Age={}",
|
||||
u64::from(config.refresh_session_ttl_days()) * 24 * 60 * 60
|
||||
),
|
||||
];
|
||||
|
||||
if config.cookie_secure() {
|
||||
parts.push("Secure".to_string());
|
||||
}
|
||||
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
impl fmt::Display for JwtError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -391,6 +447,16 @@ impl fmt::Display for RefreshCookieError {
|
||||
|
||||
impl Error for RefreshCookieError {}
|
||||
|
||||
impl fmt::Display for PasswordHashError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::HashFailed(message) | Self::VerifyFailed(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PasswordHashError {}
|
||||
|
||||
fn normalize_required_field(
|
||||
value: String,
|
||||
error_message: &'static str,
|
||||
@@ -560,4 +626,29 @@ mod tests {
|
||||
|
||||
assert!(token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_and_verify_password_round_trip() {
|
||||
let password_hash = hash_password("secret123")
|
||||
.await
|
||||
.expect("password hash should build");
|
||||
|
||||
let is_valid = verify_password(&password_hash, "secret123")
|
||||
.await
|
||||
.expect("password hash should verify");
|
||||
|
||||
assert!(is_valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refresh_session_cookie_respects_config() {
|
||||
let cookie =
|
||||
build_refresh_session_set_cookie("refresh/token=01", &build_refresh_cookie_config());
|
||||
|
||||
assert!(cookie.contains("genarrative_refresh_session=refresh%2Ftoken%3D01"));
|
||||
assert!(cookie.contains("Path=/api/auth"));
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
assert!(cookie.contains("Max-Age=2592000"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user