feat: add platform auth jwt adapter

This commit is contained in:
2026-04-21 13:02:44 +08:00
parent e37163d4d3
commit adaf514a1a
20 changed files with 1220 additions and 44 deletions

View File

@@ -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 可读取用户身份上下文

View File

@@ -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 的身份透传验证。

View File

@@ -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` 的单次消费规则,以及多实例下的清理策略。

245
server-rs/Cargo.lock generated
View File

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

View File

@@ -5,6 +5,7 @@
resolver = "2"
members = [
"crates/api-server",
"crates/platform-auth",
"crates/shared-logging",
]

View File

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

View File

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

View File

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

View File

@@ -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<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let bearer_token = extract_bearer_token(request.headers())?;
let request_id = request
.extensions()
.get::<RequestContext>()
.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<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Json<Value> {
json_success_body(
Some(&request_context),
json!({
"claims": authenticated.claims(),
}),
)
}
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
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);
}
}

View File

@@ -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<String> {
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<u64> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_duration_seconds(&value))
})
}
fn parse_duration_seconds(raw: &str) -> Option<u64> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Ok(seconds) = raw.parse::<u64>() {
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::<u64>().ok()?;
let multiplier = match unit.as_str() {
"s" => 1,
"m" => 60,
"h" => 60 * 60,
"d" => 24 * 60 * 60,
_ => return None,
};
number.checked_mul(multiplier)
}

View File

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

View File

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

View File

@@ -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 初始化并开始监听");

View File

@@ -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,
};

View File

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

View File

@@ -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<Self, JwtError> {
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
}
}

View File

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

View File

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

View File

@@ -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<String>,
pub token_version: u64,
pub phone_verified: bool,
pub binding_status: BindingStatus,
pub display_name: Option<String>,
}
// 直接映射最终 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<String>,
pub ver: u64,
pub phone_verified: bool,
pub binding_status: BindingStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
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<Self, JwtError> {
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<Self, JwtError> {
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<String, JwtError> {
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<AccessTokenClaims, JwtError> {
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::<AccessTokenClaims>(
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<String, JwtError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(JwtError::InvalidClaims(error_message));
}
Ok(value)
}
fn normalize_optional_field(value: Option<String>) -> Option<String> {
value.and_then(|field| {
let field = field.trim().to_string();
if field.is_empty() {
return None;
}
Some(field)
})
}
fn normalize_roles(roles: Vec<String>) -> Result<Vec<String>, JwtError> {
let roles = roles
.into_iter()
.map(|role| role.trim().to_string())
.filter(|role| !role.is_empty())
.collect::<Vec<_>>();
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 至少包含一个角色"));
}
}

View File

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