feat: add platform auth jwt adapter
This commit is contained in:
@@ -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 可读取用户身份上下文
|
||||
|
||||
|
||||
217
docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md
Normal file
217
docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md
Normal 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 的身份透传验证。
|
||||
@@ -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
245
server-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/api-server",
|
||||
"crates/platform-auth",
|
||||
"crates/shared-logging",
|
||||
]
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
119
server-rs/crates/api-server/src/auth.rs
Normal file
119
server-rs/crates/api-server/src/auth.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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 初始化并开始监听");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
10
server-rs/crates/platform-auth/Cargo.toml
Normal file
10
server-rs/crates/platform-auth/Cargo.toml
Normal 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"] }
|
||||
@@ -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)
|
||||
|
||||
377
server-rs/crates/platform-auth/src/lib.rs
Normal file
377
server-rs/crates/platform-auth/src/lib.rs
Normal 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 至少包含一个角色"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user