From adaf514a1a3737248027abb5ff71f17a62ba54e3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 13:02:44 +0800 Subject: [PATCH] feat: add platform auth jwt adapter --- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 8 +- ...FORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md | 217 ++++++++++ docs/technical/README.md | 1 + server-rs/Cargo.lock | 245 +++++++++++- server-rs/Cargo.toml | 1 + server-rs/crates/api-server/Cargo.toml | 1 + .../crates/api-server/src/api_response.rs | 4 +- server-rs/crates/api-server/src/app.rs | 95 ++++- server-rs/crates/api-server/src/auth.rs | 119 ++++++ server-rs/crates/api-server/src/config.rs | 71 ++++ .../crates/api-server/src/error_middleware.rs | 2 +- server-rs/crates/api-server/src/health.rs | 4 +- server-rs/crates/api-server/src/main.rs | 4 +- .../crates/api-server/src/request_context.rs | 2 +- .../crates/api-server/src/response_headers.rs | 4 +- server-rs/crates/api-server/src/state.rs | 20 +- server-rs/crates/platform-auth/Cargo.toml | 10 + server-rs/crates/platform-auth/README.md | 77 +++- server-rs/crates/platform-auth/src/lib.rs | 377 ++++++++++++++++++ server-rs/crates/shared-logging/src/lib.rs | 2 +- 20 files changed, 1220 insertions(+), 44 deletions(-) create mode 100644 docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md create mode 100644 server-rs/crates/api-server/src/auth.rs create mode 100644 server-rs/crates/platform-auth/Cargo.toml create mode 100644 server-rs/crates/platform-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 42949a09..df287fd7 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -77,7 +77,7 @@ 交付物:[../server-rs/crates/shared-contracts/README.md](../server-rs/crates/shared-contracts/README.md) - [x] 创建 `crates/shared-kernel` 交付物:[../server-rs/crates/shared-kernel/README.md](../server-rs/crates/shared-kernel/README.md) -- [ ] 创建 `crates/shared-logging` +- [x] 创建 `crates/shared-logging` 交付物:[../server-rs/crates/shared-logging/README.md](../server-rs/crates/shared-logging/README.md) - [x] 创建 `crates/platform-auth` 交付物:[../server-rs/crates/platform-auth/README.md](../server-rs/crates/platform-auth/README.md) @@ -161,7 +161,8 @@ - [ ] 实现密码登录 - [ ] 实现账号自动创建 / 幂等登录兼容策略 -- [ ] 实现 Bearer JWT 校验 +- [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) - [ ] 实现 refresh cookie 读取 - [ ] 实现 refresh token 轮换 - [ ] 实现会话吊销 @@ -195,7 +196,8 @@ 交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) - [x] 确认 `iss/sub/sid/provider/roles` 字段 交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) -- [ ] 让 Axum 自身可校验 JWT +- [x] 让 Axum 自身可校验 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/README.md](../server-rs/crates/platform-auth/README.md)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs) - [ ] 让 SpacetimeDB 可识别 Axum 签发的身份令牌 - [ ] 验证 reducer / view 可读取用户身份上下文 diff --git a/docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md b/docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md new file mode 100644 index 00000000..655777db --- /dev/null +++ b/docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md @@ -0,0 +1,217 @@ +# platform-auth JWT 适配设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于指导 `platform-auth` 首个真实能力落地,目标是完成: + +1. `platform-auth` crate 的 JWT claims 结构。 +2. access token 的签发与校验适配。 +3. `api-server` 的最小 Bearer JWT 校验入口。 + +这一步只解决“Axum 自身能稳定签发并校验 JWT”的基础问题,不提前把 refresh cookie、短信和微信 OAuth 一起耦合进来。 + +## 2. 当前落地范围 + +本阶段只包含以下实现: + +1. `JwtConfig` +2. `AccessTokenClaimsInput` +3. `AccessTokenClaims` +4. `sign_access_token(...)` +5. `verify_access_token(...)` +6. `api-server` 的 Bearer 鉴权中间件 +7. `/_internal/auth/claims` 内部调试路由 + +本阶段明确不包含: + +1. refresh cookie 的生成、解析、轮换和吊销。 +2. 从 `module-auth` / `SpacetimeDB` 真相表读取 `token_version` 并做在线比对。 +3. 短信 provider 与微信 OAuth 平台适配。 +4. SpacetimeDB 模块对 Axum 签发 JWT 的消费代码。 + +## 3. 设计输入 + +本实现直接受以下文档约束: + +1. [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) +2. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) + +关键冻结点: + +1. `iss/sub/sid/provider/roles/ver/phone_verified/binding_status` 是当前 access token 的固定字段。 +2. `sub` 是稳定 `user_id`。 +3. `sid` 是会话 ID,不是单次 token ID。 +4. `roles` 当前至少包含 `user`。 +5. 不允许把手机号、openid、风控状态、refresh token hash 放进 JWT。 + +## 4. crate 边界 + +### 4.1 `platform-auth` + +负责: + +1. 组织 JWT 结构。 +2. 执行签名与验签。 +3. 执行基础 claims 完整性校验。 + +不负责: + +1. 用户是否存在。 +2. `token_version` 是否仍是数据库最新值。 +3. refresh session 是否已被吊销。 +4. provider 之外的业务规则判断。 + +### 4.2 `api-server` + +负责: + +1. 从 `Authorization` 头提取 Bearer token。 +2. 调用 `platform-auth` 校验。 +3. 把已校验 claims 写入请求上下文。 +4. 在本阶段提供最小内部验收入口 `/_internal/auth/claims`。 + +不负责: + +1. 自己再实现一套 JWT 编解码逻辑。 +2. 把 claims 结构拆散成多个重复 helper。 + +## 5. 配置口径 + +当前阶段 `api-server` 读取并传入 `platform-auth` 的配置如下: + +| 配置项 | 环境变量 | 默认值 | 说明 | +| --- | --- | --- | --- | +| issuer | `GENARRATIVE_JWT_ISSUER` | `https://auth.genarrative.local` | OIDC 风格发行者标识。 | +| secret | `GENARRATIVE_JWT_SECRET` | `genarrative-dev-secret` | 当前阶段沿用对称签名密钥。 | +| access token TTL | `GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS` | `7200` | access token 有效期,单位秒。 | + +兼容回退: + +1. `issuer` 可回退读取 `JWT_ISSUER`。 +2. `secret` 可回退读取 `JWT_SECRET`。 +3. TTL 可回退读取 `JWT_EXPIRES_IN`,支持: + - 纯秒值,例如 `900` + - `s/m/h/d` 后缀,例如 `30m`、`2h` + +## 6. 算法选择 + +当前阶段固定采用: + +1. `alg = HS256` + +理由: + +1. 与当前 Node 基线兼容,迁移阻力最低。 +2. 先把 claims、配置口径和 Bearer 主链稳定下来。 +3. 若未来升级到非对称签名,应作为独立任务处理,而不是夹带进当前重写链路。 + +## 7. Rust 结构设计 + +### 7.1 `AccessTokenClaimsInput` + +用途: + +1. 作为业务层输入。 +2. 不承载 `iat/exp/iss` 这种平台计算字段。 + +字段: + +1. `user_id` +2. `session_id` +3. `provider` +4. `roles` +5. `token_version` +6. `phone_verified` +7. `binding_status` +8. `display_name` + +### 7.2 `AccessTokenClaims` + +用途: + +1. 对应最终 JWT payload。 +2. 直接用于签名与验签结果输出。 + +字段: + +1. `iss` +2. `sub` +3. `sid` +4. `provider` +5. `roles` +6. `ver` +7. `phone_verified` +8. `binding_status` +9. `display_name` +10. `iat` +11. `exp` + +## 8. 校验规则 + +### 8.1 签发前校验 + +1. `issuer`、`secret` 不能为空。 +2. TTL 必须大于 `0`。 +3. `sub` 不能为空。 +4. `sid` 不能为空。 +5. `roles` 不能为空数组。 +6. `exp` 必须晚于 `iat`。 + +### 8.2 验签时校验 + +1. 算法必须是 `HS256`。 +2. 签名必须正确。 +3. `iss` 必须匹配当前配置。 +4. `exp/iat/iss/sub` 必须存在。 +5. 反序列化后的 `sid/provider/roles/ver/phone_verified/binding_status` 必须完整。 + +当前阶段不做的校验: + +1. `ver` 与数据库最新 token version 比对。 +2. `sid` 与 `refresh_session` 活跃状态比对。 +3. `roles` 的细粒度授权判断。 + +## 9. api-server 最小接线 + +本阶段 `api-server` 接线规则如下: + +1. `AppConfig` 增加 JWT 相关配置。 +2. `AppState` 在启动时构造唯一一份 `JwtConfig`。 +3. `require_bearer_auth` 中间件从请求头读取 Bearer token。 +4. 验签成功后把 claims 以 `AuthenticatedAccessToken` 写入 request extensions。 +5. 内部路由 `/_internal/auth/claims` 用于返回当前已校验 claims,作为阶段验收与调试入口。 + +说明: + +1. 这个内部路由不是最终对外 contract。 +2. 它的存在是为了在 `module-auth` 与正式 `/api/auth/me` 落地前,先把 Bearer 主链单独跑通。 + +## 10. 测试策略 + +当前阶段要求至少覆盖: + +1. `platform-auth` 的 JWT 签发与验签回环。 +2. issuer 不匹配时拒绝。 +3. 空角色拒绝。 +4. `api-server` 在无 Bearer token 时返回 `401`。 +5. `api-server` 在合法 Bearer token 下返回 claims。 + +## 11. 完成定义 + +当以下条件满足时,本任务视为完成: + +1. Rust workspace 中存在真实可编译的 `platform-auth` crate。 +2. `api-server` 已能使用 `platform-auth` 校验 Bearer JWT。 +3. 工作区测试与编译可通过。 +4. 任务清单已同步更新。 + +## 12. 后续衔接 + +下一阶段继续衔接: + +1. refresh cookie 读取与轮换。 +2. `module-auth` 会话真相与 `token_version` 在线校验。 +3. `/api/auth/me`、`/api/auth/refresh` 等正式接口。 +4. SpacetimeDB 对 Axum JWT 的身份透传验证。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 20557fed..d0a48fde 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [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` 等关键字段。 - [RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](./RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md):Rust 工作区统一日志模块 `shared-logging` 的职责边界、API、输出风格与 `api-server` 迁移规则。 - [SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md):`M2` 第七张微信 OAuth 状态表 `wechat_auth_state` 的字段、过期/消费语义、`wechat/start` 与 `wechat/callback` 的单次消费规则,以及多实例下的清理策略。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 6c025adf..9785f547 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -23,6 +23,7 @@ version = "0.1.0" dependencies = [ "axum", "http-body-util", + "platform-auth", "serde", "serde_json", "shared-logging", @@ -40,6 +41,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.9" @@ -92,6 +99,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" @@ -110,6 +123,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -131,6 +154,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" @@ -179,6 +208,19 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -327,6 +369,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -386,7 +443,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -395,7 +452,17 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", ] [[package]] @@ -404,12 +471,40 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -422,6 +517,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "platform-auth" +version = "0.1.0" +dependencies = [ + "jsonwebtoken", + "serde", + "time", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -479,6 +583,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -579,6 +697,24 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -598,7 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -618,6 +754,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -669,7 +825,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -801,13 +957,19 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "uuid" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -927,6 +1089,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -936,6 +1107,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 8c643b69..d0fd77c7 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "crates/api-server", + "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 0fea339a..080be28b 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] axum = "0.8" +platform-auth = { path = "../platform-auth" } serde = { version = "1", features = ["derive"] } serde_json = "1" shared-logging = { path = "../shared-logging" } diff --git a/server-rs/crates/api-server/src/api_response.rs b/server-rs/crates/api-server/src/api_response.rs index 534648f1..accf6d4e 100644 --- a/server-rs/crates/api-server/src/api_response.rs +++ b/server-rs/crates/api-server/src/api_response.rs @@ -1,7 +1,7 @@ use axum::Json; use serde::Serialize; -use serde_json::{json, Value}; -use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use serde_json::{Value, json}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{http_error::ApiErrorPayload, request_context::RequestContext}; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 514fd776..062a894c 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1,8 +1,9 @@ -use axum::{body::Body, extract::Extension, http::Request, middleware, routing::get, Router}; +use axum::{Router, body::Body, extract::Extension, http::Request, middleware, routing::get}; use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer}; -use tracing::{info_span, Level}; +use tracing::{Level, info_span}; use crate::{ + auth::{inspect_auth_claims, require_bearer_auth}, error_middleware::normalize_error_response, health::health_check, request_context::{attach_request_context, resolve_request_id}, @@ -19,6 +20,13 @@ pub fn build_router(state: AppState) -> Router { health_check(Extension(request_context)).await }), ) + .route( + "/_internal/auth/claims", + get(inspect_auth_claims).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 @@ -53,7 +61,11 @@ mod tests { http::{Request, StatusCode}, }; use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; use serde_json::Value; + use time::OffsetDateTime; use tower::ServiceExt; use crate::{config::AppConfig, state::AppState}; @@ -62,7 +74,7 @@ mod tests { #[tokio::test] async fn healthz_returns_legacy_compatible_payload_and_headers() { - let app = build_router(AppState::new(AppConfig::default())); + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( @@ -117,7 +129,7 @@ mod tests { #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { - let app = build_router(AppState::new(AppConfig::default())); + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( @@ -152,4 +164,79 @@ mod tests { Value::String("req-health-envelope".to_string()) ); } + + #[tokio::test] + async fn internal_auth_claims_rejects_missing_bearer_token() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .uri("/_internal/auth/claims") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn internal_auth_claims_returns_verified_claims() { + let config = AppConfig::default(); + let state = AppState::new(config.clone()).expect("state should build"); + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "usr_auth_debug".to_string(), + session_id: "sess_auth_debug".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 7, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("测试用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/_internal/auth/claims") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + 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["claims"]["sub"], + Value::String("usr_auth_debug".to_string()) + ); + assert_eq!( + payload["claims"]["sid"], + Value::String("sess_auth_debug".to_string()) + ); + assert_eq!( + payload["claims"]["ver"], + Value::Number(serde_json::Number::from(7)) + ); + } } diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs new file mode 100644 index 00000000..c987d9b5 --- /dev/null +++ b/server-rs/crates/api-server/src/auth.rs @@ -0,0 +1,119 @@ +use axum::{ + Json, + extract::{Extension, Request, State}, + http::{HeaderMap, StatusCode, header::AUTHORIZATION}, + middleware::Next, + response::Response, +}; +use platform_auth::{AccessTokenClaims, verify_access_token}; +use serde_json::{Value, json}; +use tracing::warn; + +use crate::{ + api_response::json_success_body, http_error::AppError, request_context::RequestContext, + state::AppState, +}; + +// 统一把已校验的 claims 写入 request extensions,避免后续 handler 再次重复解析 Bearer token。 +#[derive(Clone, Debug)] +pub struct AuthenticatedAccessToken { + claims: AccessTokenClaims, +} + +impl AuthenticatedAccessToken { + pub fn new(claims: AccessTokenClaims) -> Self { + Self { claims } + } + + pub fn claims(&self) -> &AccessTokenClaims { + &self.claims + } +} + +pub async fn require_bearer_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let bearer_token = extract_bearer_token(request.headers())?; + let request_id = request + .extensions() + .get::() + .map(|context| context.request_id().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let claims = verify_access_token(&bearer_token, state.auth_jwt_config()).map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 校验失败" + ); + AppError::from_status(StatusCode::UNAUTHORIZED) + })?; + + request + .extensions_mut() + .insert(AuthenticatedAccessToken::new(claims)); + + Ok(next.run(request).await) +} + +pub async fn inspect_auth_claims( + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Json { + json_success_body( + Some(&request_context), + json!({ + "claims": authenticated.claims(), + }), + ) +} + +fn extract_bearer_token(headers: &HeaderMap) -> Result { + let authorization = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?; + + let token = authorization + .strip_prefix("Bearer ") + .or_else(|| authorization.strip_prefix("bearer ")) + .map(str::trim) + .filter(|token| !token.is_empty()) + .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?; + + Ok(token.to_string()) +} + +#[cfg(test)] +mod tests { + use super::extract_bearer_token; + use axum::{ + http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION}, + response::IntoResponse, + }; + + #[test] + fn extract_bearer_token_accepts_standard_header() { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer token-value"), + ); + + let token = extract_bearer_token(&headers).expect("bearer token should be extracted"); + + assert_eq!(token, "token-value"); + } + + #[test] + fn extract_bearer_token_rejects_missing_scheme() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic abc")); + + let error = extract_bearer_token(&headers).expect_err("basic auth should be rejected"); + + assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index df637ad9..303349c6 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -6,6 +6,9 @@ pub struct AppConfig { pub bind_host: String, pub bind_port: u16, pub log_filter: String, + pub jwt_issuer: String, + pub jwt_secret: String, + pub jwt_access_token_ttl_seconds: u64, } impl Default for AppConfig { @@ -14,6 +17,9 @@ impl Default for AppConfig { bind_host: "127.0.0.1".to_string(), bind_port: 3000, log_filter: "info,tower_http=info".to_string(), + jwt_issuer: "https://auth.genarrative.local".to_string(), + jwt_secret: "genarrative-dev-secret".to_string(), + jwt_access_token_ttl_seconds: 2 * 60 * 60, } } } @@ -40,6 +46,25 @@ impl AppConfig { } } + if let Some(jwt_issuer) = + read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"]) + { + config.jwt_issuer = jwt_issuer; + } + + if let Some(jwt_secret) = + read_first_non_empty_env(&["GENARRATIVE_JWT_SECRET", "JWT_SECRET"]) + { + config.jwt_secret = jwt_secret; + } + + if let Some(ttl_seconds) = read_first_duration_seconds_env(&[ + "GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS", + "JWT_EXPIRES_IN", + ]) { + config.jwt_access_token_ttl_seconds = ttl_seconds; + } + config } @@ -50,3 +75,49 @@ impl AppConfig { .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000))) } } + +fn read_first_non_empty_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key).ok().and_then(|value| { + let value = value.trim().to_string(); + if value.is_empty() { + return None; + } + + Some(value) + }) + }) +} + +fn read_first_duration_seconds_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_duration_seconds(&value)) + }) +} + +fn parse_duration_seconds(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + + if let Ok(seconds) = raw.parse::() { + return Some(seconds); + } + + let (number, unit) = raw.split_at(raw.len().checked_sub(1)?); + let unit = unit.to_ascii_lowercase(); + let number = number.trim().parse::().ok()?; + + let multiplier = match unit.as_str() { + "s" => 1, + "m" => 60, + "h" => 60 * 60, + "d" => 24 * 60 * 60, + _ => return None, + }; + + number.checked_mul(multiplier) +} diff --git a/server-rs/crates/api-server/src/error_middleware.rs b/server-rs/crates/api-server/src/error_middleware.rs index 0af5bf51..e673cec7 100644 --- a/server-rs/crates/api-server/src/error_middleware.rs +++ b/server-rs/crates/api-server/src/error_middleware.rs @@ -3,7 +3,7 @@ use tracing::{error, warn}; use crate::{ http_error::AppError, - request_context::{resolve_request_id, RequestContext}, + request_context::{RequestContext, resolve_request_id}, }; pub async fn normalize_error_response(request: Request, next: Next) -> Response { diff --git a/server-rs/crates/api-server/src/health.rs b/server-rs/crates/api-server/src/health.rs index 8ab27ab5..aff5ecb7 100644 --- a/server-rs/crates/api-server/src/health.rs +++ b/server-rs/crates/api-server/src/health.rs @@ -1,5 +1,5 @@ -use axum::{extract::Extension, Json}; -use serde_json::{json, Value}; +use axum::{Json, extract::Extension}; +use serde_json::{Value, json}; use crate::{api_response::json_success_body, request_context::RequestContext}; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 39a1b794..f791ded8 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -1,5 +1,6 @@ mod api_response; mod app; +mod auth; mod config; mod error_middleware; mod health; @@ -23,7 +24,8 @@ async fn main() -> Result<(), std::io::Error> { let bind_address = config.bind_socket_addr(); let listener = TcpListener::bind(bind_address).await?; - let state = AppState::new(config); + let state = AppState::new(config) + .map_err(|error| std::io::Error::other(format!("初始化鉴权配置失败:{error}")))?; let router = build_router(state); info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听"); diff --git a/server-rs/crates/api-server/src/request_context.rs b/server-rs/crates/api-server/src/request_context.rs index 353f4cd9..9fcaaaf8 100644 --- a/server-rs/crates/api-server/src/request_context.rs +++ b/server-rs/crates/api-server/src/request_context.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; use axum::{ extract::Request, - http::{header::HeaderName, HeaderValue, Request as HttpRequest}, + http::{HeaderValue, Request as HttpRequest, header::HeaderName}, middleware::Next, response::Response, }; diff --git a/server-rs/crates/api-server/src/response_headers.rs b/server-rs/crates/api-server/src/response_headers.rs index 1e41a9ec..b4b647c9 100644 --- a/server-rs/crates/api-server/src/response_headers.rs +++ b/server-rs/crates/api-server/src/response_headers.rs @@ -1,13 +1,13 @@ use axum::{ extract::Request, - http::{header::HeaderName, HeaderValue}, + http::{HeaderValue, header::HeaderName}, middleware::Next, response::Response, }; use crate::{ api_response::API_VERSION, - request_context::{resolve_request_id, RequestContext, X_REQUEST_ID_HEADER}, + request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id}, }; pub const API_VERSION_HEADER: &str = "x-api-version"; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 1d55662c..5b51bdd3 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,3 +1,5 @@ +use platform_auth::{JwtConfig, JwtError}; + use crate::config::AppConfig; // 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。 @@ -6,10 +8,24 @@ pub struct AppState { // 配置会在后续中间件、路由和平台适配接入时逐步消费。 #[allow(dead_code)] pub config: AppConfig, + auth_jwt_config: JwtConfig, } impl AppState { - pub fn new(config: AppConfig) -> Self { - Self { config } + pub fn new(config: AppConfig) -> Result { + let auth_jwt_config = JwtConfig::new( + config.jwt_issuer.clone(), + config.jwt_secret.clone(), + config.jwt_access_token_ttl_seconds, + )?; + + Ok(Self { + config, + auth_jwt_config, + }) + } + + pub fn auth_jwt_config(&self) -> &JwtConfig { + &self.auth_jwt_config } } diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml new file mode 100644 index 00000000..30216789 --- /dev/null +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "platform-auth" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +jsonwebtoken = "9" +serde = { version = "1", features = ["derive"] } +time = { version = "0.3", features = ["std"] } diff --git a/server-rs/crates/platform-auth/README.md b/server-rs/crates/platform-auth/README.md index 73879025..c7fdd973 100644 --- a/server-rs/crates/platform-auth/README.md +++ b/server-rs/crates/platform-auth/README.md @@ -1,34 +1,71 @@ -# platform-auth 平台适配 crate 占位说明 +# platform-auth 鉴权平台适配 crate 说明 -日期:`2026-04-20` +日期:`2026-04-21` ## 1. crate 职责 -`platform-auth` 是鉴权平台适配 crate,后续负责: +`platform-auth` 是 Rust 工作区中的鉴权平台适配 crate,当前与后续负责: -1. JWT 签发与校验适配 -2. refresh cookie 读写与轮换适配 -3. 手机验证码发送与校验适配 -4. 微信 OAuth 相关平台适配 -5. 供 `module-auth` 与 `crates/api-server` 复用的鉴权基础设施能力 +1. Access token JWT 的 claims 结构、签发与校验适配。 +2. refresh cookie 的读写、签名与轮换适配。 +3. 手机验证码发送、校验与外部 provider 适配。 +4. 微信 OAuth start / callback 的平台调用适配。 +5. 供 `module-auth` 与 `crates/api-server` 复用的鉴权基础设施能力。 -## 2. 当前阶段说明 +## 2. 当前阶段已落地内容 -当前提交仅完成目录占位,不提前进入 JWT、Cookie、短信与微信平台实现。 +本阶段已经完成 JWT 基础能力首版落地: -后续与本 crate 直接相关的任务包括: +1. 新增 `JwtConfig`,统一管理 `issuer`、`secret` 与 access token TTL。 +2. 新增 `AccessTokenClaimsInput` 与 `AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。 +3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。 +4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。 +5. 增加单元测试,覆盖基本签发/校验、issuer 不匹配与空角色拒绝。 -1. 落地 JWT claims、签发与校验适配 -2. 落地 refresh cookie 读取、写入与轮换适配 -3. 落地短信发送、校验与风控适配 -4. 落地微信 OAuth start / callback 适配 +当前阶段仍未进入: -当前优先冻结依据: +1. refresh cookie 读写与轮换。 +2. 短信 provider 适配。 +3. 微信 OAuth 适配。 +4. `module-auth` 领域规则与数据库真相读取。 -1. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) +## 3. 本阶段 API -## 3. 边界约束 +当前开放给工作区其它 crate 的最小 API: + +1. `JwtConfig::new(...)` +2. `AccessTokenClaims::from_input(...)` +3. `sign_access_token(...)` +4. `verify_access_token(...)` +5. `AuthProvider` +6. `BindingStatus` + +## 4. 配置口径 + +当前 `api-server` 接入时采用以下环境变量口径: + +1. `GENARRATIVE_JWT_ISSUER` + 默认值:`https://auth.genarrative.local` +2. `GENARRATIVE_JWT_SECRET` + 默认值:`genarrative-dev-secret` +3. `GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS` + 默认值:`7200` +4. 兼容读取旧变量:`JWT_ISSUER`、`JWT_SECRET`、`JWT_EXPIRES_IN` + +说明: + +1. `JWT_EXPIRES_IN` 当前兼容 `2h`、`30m`、`900` 这类简单时长格式。 +2. 当前阶段保持 `HS256`,优先保证与旧 Node 方案迁移平滑。 + +## 5. 边界约束 1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。 -2. 鉴权状态最终由 `module-auth` 与 `crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。 -3. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。 +2. `sub` 必须是稳定 `user_id`,`sid` 必须是会话 ID,不能退化为一次 token 的随机 ID。 +3. 不允许把手机号、openid、refresh token hash、风控状态等敏感或高频变化字段塞进 JWT。 +4. 鉴权状态最终由 `module-auth` 与 `crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。 +5. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。 + +## 6. 关联文档 + +1. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md) +2. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md) diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs new file mode 100644 index 00000000..da2198de --- /dev/null +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -0,0 +1,377 @@ +use std::{collections::HashSet, error::Error, fmt}; + +use jsonwebtoken::{ + Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind, +}; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; + +pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256; +pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60; + +// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthProvider { + Password, + Phone, + Wechat, +} + +// 绑定状态只保留当前 JWT 需要透传的最小快照,不把完整账号状态枚举直接泄漏到 token 中。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BindingStatus { + Active, + PendingBindPhone, +} + +// 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AccessTokenClaimsInput { + pub user_id: String, + pub session_id: String, + pub provider: AuthProvider, + pub roles: Vec, + pub token_version: u64, + pub phone_verified: bool, + pub binding_status: BindingStatus, + pub display_name: Option, +} + +// 直接映射最终 JWT payload,字段名与文档冻结口径保持一致。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccessTokenClaims { + pub iss: String, + pub sub: String, + pub sid: String, + pub provider: AuthProvider, + pub roles: Vec, + pub ver: u64, + pub phone_verified: bool, + pub binding_status: BindingStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub iat: u64, + pub exp: u64, +} + +// 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JwtConfig { + issuer: String, + secret: String, + access_token_ttl_seconds: u64, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum JwtError { + InvalidConfig(&'static str), + InvalidClaims(&'static str), + SignFailed(String), + VerifyFailed(String), +} + +impl JwtConfig { + pub fn new( + issuer: String, + secret: String, + access_token_ttl_seconds: u64, + ) -> Result { + let issuer = issuer.trim().to_string(); + let secret = secret.trim().to_string(); + + if issuer.is_empty() { + return Err(JwtError::InvalidConfig("JWT issuer 不能为空")); + } + + if secret.is_empty() { + return Err(JwtError::InvalidConfig("JWT secret 不能为空")); + } + + if access_token_ttl_seconds == 0 { + return Err(JwtError::InvalidConfig( + "JWT access token 过期时间必须大于 0", + )); + } + + Ok(Self { + issuer, + secret, + access_token_ttl_seconds, + }) + } + + pub fn issuer(&self) -> &str { + &self.issuer + } + + pub fn access_token_ttl_seconds(&self) -> u64 { + self.access_token_ttl_seconds + } +} + +impl AccessTokenClaims { + pub fn from_input( + input: AccessTokenClaimsInput, + config: &JwtConfig, + issued_at: OffsetDateTime, + ) -> Result { + let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?; + let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?; + let roles = normalize_roles(input.roles)?; + let display_name = normalize_optional_field(input.display_name); + + let issued_at_unix = issued_at.unix_timestamp(); + if issued_at_unix < 0 { + return Err(JwtError::InvalidClaims("JWT iat 不能早于 Unix epoch")); + } + + let expires_at = issued_at + .checked_add(Duration::seconds( + i64::try_from(config.access_token_ttl_seconds()).map_err(|_| { + JwtError::InvalidConfig("JWT access token 过期时间超出 i64 上限") + })?, + )) + .ok_or(JwtError::InvalidConfig("JWT 过期时间计算溢出"))?; + + let expires_at_unix = expires_at.unix_timestamp(); + if expires_at_unix <= issued_at_unix { + return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat")); + } + + let claims = Self { + iss: config.issuer().to_string(), + sub: user_id, + sid: session_id, + provider: input.provider, + roles, + ver: input.token_version, + phone_verified: input.phone_verified, + binding_status: input.binding_status, + display_name, + iat: issued_at_unix as u64, + exp: expires_at_unix as u64, + }; + + claims.validate_for_config(config)?; + Ok(claims) + } + + pub fn user_id(&self) -> &str { + &self.sub + } + + pub fn session_id(&self) -> &str { + &self.sid + } + + pub fn token_version(&self) -> u64 { + self.ver + } + + pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> { + if self.iss.trim() != config.issuer() { + return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致")); + } + + normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?; + normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?; + normalize_roles(self.roles.clone())?; + + if self.exp <= self.iat { + return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat")); + } + + Ok(()) + } +} + +pub fn sign_access_token( + claims: &AccessTokenClaims, + config: &JwtConfig, +) -> Result { + claims.validate_for_config(config)?; + + let header = Header { + alg: ACCESS_TOKEN_ALGORITHM, + typ: Some("JWT".to_string()), + ..Header::default() + }; + + encode( + &header, + claims, + &EncodingKey::from_secret(config.secret.as_bytes()), + ) + .map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}"))) +} + +pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result { + let token = token.trim(); + if token.is_empty() { + return Err(JwtError::VerifyFailed("JWT 不能为空".to_string())); + } + + let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM); + validation.required_spec_claims = HashSet::from([ + "exp".to_string(), + "iat".to_string(), + "iss".to_string(), + "sub".to_string(), + ]); + validation.set_issuer(&[config.issuer()]); + + let decoded = decode::( + token, + &DecodingKey::from_secret(config.secret.as_bytes()), + &validation, + ) + .map_err(map_verify_error)?; + + decoded.claims.validate_for_config(config)?; + Ok(decoded.claims) +} + +impl fmt::Display for JwtError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConfig(message) | Self::InvalidClaims(message) => f.write_str(message), + Self::SignFailed(message) | Self::VerifyFailed(message) => f.write_str(message), + } + } +} + +impl Error for JwtError {} + +fn normalize_required_field( + value: String, + error_message: &'static str, +) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(JwtError::InvalidClaims(error_message)); + } + + Ok(value) +} + +fn normalize_optional_field(value: Option) -> Option { + value.and_then(|field| { + let field = field.trim().to_string(); + if field.is_empty() { + return None; + } + + Some(field) + }) +} + +fn normalize_roles(roles: Vec) -> Result, JwtError> { + let roles = roles + .into_iter() + .map(|role| role.trim().to_string()) + .filter(|role| !role.is_empty()) + .collect::>(); + + if roles.is_empty() { + return Err(JwtError::InvalidClaims("JWT roles 至少包含一个角色")); + } + + Ok(roles) +} + +fn map_verify_error(error: jsonwebtoken::errors::Error) -> JwtError { + let message = match error.kind() { + ErrorKind::ExpiredSignature => "JWT 已过期".to_string(), + ErrorKind::InvalidIssuer => "JWT 发行者不匹配".to_string(), + ErrorKind::InvalidSignature => "JWT 签名无效".to_string(), + ErrorKind::InvalidAlgorithm => "JWT 算法不匹配".to_string(), + ErrorKind::InvalidToken => "JWT 非法".to_string(), + ErrorKind::ImmatureSignature => "JWT 尚未生效".to_string(), + ErrorKind::MissingRequiredClaim(claim) => format!("JWT 缺少必填字段:{claim}"), + _ => format!("JWT 校验失败:{error}"), + }; + + JwtError::VerifyFailed(message) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_jwt_config() -> JwtConfig { + JwtConfig::new( + "https://auth.genarrative.local".to_string(), + "genarrative-dev-secret".to_string(), + DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + ) + .expect("jwt config should be valid") + } + + fn build_claims_input() -> AccessTokenClaimsInput { + AccessTokenClaimsInput { + user_id: "usr_123".to_string(), + session_id: "sess_456".to_string(), + provider: AuthProvider::Wechat, + roles: vec!["user".to_string()], + token_version: 3, + phone_verified: false, + binding_status: BindingStatus::PendingBindPhone, + display_name: Some("微信旅人".to_string()), + } + } + + #[test] + fn round_trip_sign_and_verify_access_token() { + let config = build_jwt_config(); + let claims = + AccessTokenClaims::from_input(build_claims_input(), &config, OffsetDateTime::now_utc()) + .expect("claims should build"); + + let token = sign_access_token(&claims, &config).expect("token should sign"); + let verified = verify_access_token(&token, &config).expect("token should verify"); + + assert_eq!(verified, claims); + assert_eq!(verified.user_id(), "usr_123"); + assert_eq!(verified.session_id(), "sess_456"); + assert_eq!(verified.token_version(), 3); + } + + #[test] + fn verify_rejects_invalid_issuer() { + let config = build_jwt_config(); + let claims = + AccessTokenClaims::from_input(build_claims_input(), &config, OffsetDateTime::now_utc()) + .expect("claims should build"); + let token = sign_access_token(&claims, &config).expect("token should sign"); + let other_config = JwtConfig::new( + "https://auth.other.local".to_string(), + "genarrative-dev-secret".to_string(), + DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + ) + .expect("other config should be valid"); + + let error = verify_access_token(&token, &other_config).expect_err("issuer should mismatch"); + + assert_eq!( + error, + JwtError::VerifyFailed("JWT 发行者不匹配".to_string()) + ); + } + + #[test] + fn build_claims_rejects_empty_roles() { + let error = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + roles: Vec::new(), + ..build_claims_input() + }, + &build_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect_err("empty roles should be rejected"); + + assert_eq!(error, JwtError::InvalidClaims("JWT roles 至少包含一个角色")); + } +} diff --git a/server-rs/crates/shared-logging/src/lib.rs b/server-rs/crates/shared-logging/src/lib.rs index 6908f60b..8d8ab925 100644 --- a/server-rs/crates/shared-logging/src/lib.rs +++ b/server-rs/crates/shared-logging/src/lib.rs @@ -1,6 +1,6 @@ use std::io; -use tracing_subscriber::{fmt, EnvFilter}; +use tracing_subscriber::{EnvFilter, fmt}; // 统一解析工作区日志过滤器,优先环境变量,其次回落到调用方传入的默认值。 pub fn resolve_env_filter(default_filter: &str) -> EnvFilter {