From c23088539e321823a2784818492d4bdbcd55e53b Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 14:50:42 +0800 Subject: [PATCH] feat: add password entry auth flow --- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 12 +- .../PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md | 253 +++++++++++++ docs/technical/README.md | 1 + server-rs/Cargo.lock | 60 +++ server-rs/Cargo.toml | 1 + server-rs/crates/api-server/Cargo.toml | 1 + server-rs/crates/api-server/README.md | 5 + .../crates/api-server/src/api_response.rs | 2 +- server-rs/crates/api-server/src/app.rs | 177 +++++++++ server-rs/crates/api-server/src/http_error.rs | 13 +- server-rs/crates/api-server/src/main.rs | 1 + .../crates/api-server/src/password_entry.rs | 132 +++++++ server-rs/crates/api-server/src/state.rs | 9 + server-rs/crates/module-auth/Cargo.toml | 11 + server-rs/crates/module-auth/README.md | 43 ++- server-rs/crates/module-auth/src/lib.rs | 353 ++++++++++++++++++ server-rs/crates/platform-auth/Cargo.toml | 6 + server-rs/crates/platform-auth/src/lib.rs | 91 +++++ 18 files changed, 1146 insertions(+), 25 deletions(-) create mode 100644 docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md create mode 100644 server-rs/crates/api-server/src/password_entry.rs create mode 100644 server-rs/crates/module-auth/Cargo.toml create mode 100644 server-rs/crates/module-auth/src/lib.rs diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 34d40288..62643350 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -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 主链可用 - [ ] 手机验证码主链可用 - [ ] 微信登录主链可用 diff --git a/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md new file mode 100644 index 00000000..d80ec00a --- /dev/null +++ b/docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md @@ -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": "", + "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. 手机验证码登录 + +微信登录继续按“暂缓执行”处理,直到用户重新解锁。 diff --git a/docs/technical/README.md b/docs/technical/README.md index c0ca22b8..e037396b 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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` 等关键字段。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 245ccc49..2a3c9cce 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -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" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 91b9e57c..c85784fc 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "crates/api-server", + "crates/module-auth", "crates/platform-oss", "crates/platform-auth", "crates/shared-logging", diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 1a9bca9c..ff4d3021 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 1e76bf07..7a5f5300 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -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 写回。 diff --git a/server-rs/crates/api-server/src/api_response.rs b/server-rs/crates/api-server/src/api_response.rs index 7dcdfee2..4a0657f7 100644 --- a/server-rs/crates/api-server/src/api_response.rs +++ b/server-rs/crates/api-server/src/api_response.rs @@ -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; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index be1670d1..c81c59f2 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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); + } } diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 3df80540..a2c91440 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -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, } #[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, } @@ -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) -> 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(), } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 6d1edfa5..adedcd28 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs new file mode 100644 index 00000000..bc7c2d5b --- /dev/null +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -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, + pub login_method: &'static str, + pub binding_status: &'static str, + pub wechat_bound: bool, +} + +pub async fn password_entry( + State(state): State, + Extension(request_context): Extension, + Json(payload): Json, +) -> Result { + 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()) + } + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 865cb98a..67f8de8b 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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, + 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 { diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml new file mode 100644 index 00000000..e9df1e16 --- /dev/null +++ b/server-rs/crates/module-auth/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/module-auth/README.md b/server-rs/crates/module-auth/README.md index 68d5aaba..bbbbccbb 100644 --- a/server-rs/crates/module-auth/README.md +++ b/server-rs/crates/module-auth/README.md @@ -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` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。 diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs new file mode 100644 index 00000000..64231135 --- /dev/null +++ b/server-rs/crates/module-auth/src/lib.rs @@ -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, + 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>, +} + +#[derive(Debug)] +struct InMemoryPasswordUserStoreState { + next_id: u64, + users_by_username: HashMap, +} + +#[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 { + 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, 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 { + 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 { + 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); + } +} diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index 956a8ea5..68ef8899 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 387a6c5f..5923de9a 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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 { + 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 { + 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")); + } }