Compare commits
5 Commits
39eb7a513c
...
60852241c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 60852241c9 | |||
| 584a77e572 | |||
| 70dbefda2b | |||
| c23088539e | |||
| 5675c40119 |
13
.env.example
13
.env.example
@@ -94,6 +94,19 @@ VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
|
||||
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
|
||||
|
||||
# 阿里云 OSS 配置。
|
||||
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
|
||||
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
|
||||
ALIYUN_OSS_BUCKET=""
|
||||
ALIYUN_OSS_ENDPOINT="oss-cn-shanghai.aliyuncs.com"
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=""
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=""
|
||||
# 可选:如已接入 CDN,可填 CDN 域名;未填写时将回退为 bucket 直连域名。
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL=""
|
||||
ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
|
||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
|
||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
|
||||
|
||||
# Optional model name for custom-world scene image generation.
|
||||
DASHSCOPE_IMAGE_MODEL="wan2.7-image"
|
||||
|
||||
|
||||
@@ -40,3 +40,9 @@ LLM_DEBUG_LOG="true"
|
||||
|
||||
# 注意:不要在客户端启用调试日志,避免敏感数据泄露
|
||||
# VITE_LLM_DEBUG_LOG="false"
|
||||
|
||||
ALIYUN_OSS_BUCKET="xushi-dev"
|
||||
ALIYUN_OSS_REGION="oss-cn-beijing"
|
||||
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
|
||||
ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f"
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
|
||||
|
||||
@@ -133,10 +133,11 @@
|
||||
|
||||
1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。
|
||||
2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。
|
||||
3. 再做 `M3`,优先跑通快照、设置、profile。
|
||||
4. 再做 `M4`,把 story action 主循环真正迁走。
|
||||
5. 然后做 `M5`,迁 custom world 与 agent。
|
||||
6. 最后做 `M6 + M7`,收口 assets、部署与切流。
|
||||
3. 当前执行顺序允许前置 `M6` 的 OSS 基础设施与直传票据能力,为后续各阶段复用统一资产入口。
|
||||
4. 再做 `M3`,优先跑通快照、设置、profile。
|
||||
5. 再做 `M4`,把 story action 主循环真正迁走。
|
||||
6. 然后做 `M5`,迁 custom world 与 agent。
|
||||
7. 最后收口 `M6` 余下资产绑定、`M7` 部署与切流。
|
||||
|
||||
## 5. 最终验收清单
|
||||
|
||||
|
||||
@@ -159,16 +159,20 @@
|
||||
|
||||
### Axum 鉴权服务
|
||||
|
||||
- [ ] 实现密码登录
|
||||
- [ ] 实现账号自动创建 / 幂等登录兼容策略
|
||||
- [x] 实现密码登录
|
||||
交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现账号自动创建 / 幂等登录兼容策略
|
||||
交付物:[../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 Bearer JWT 校验
|
||||
交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 实现 refresh cookie 读取
|
||||
交付物:[../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_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/config.rs](../server-rs/crates/api-server/src/config.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 实现 refresh token 轮换
|
||||
- [x] 实现 refresh token 轮换
|
||||
交付物:[../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 实现会话吊销
|
||||
- [ ] 实现全端登出
|
||||
- [ ] 实现 `me` 查询
|
||||
- [x] 实现 `me` 查询
|
||||
交付物:[../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
|
||||
### 手机验证码登录
|
||||
|
||||
@@ -210,11 +214,14 @@
|
||||
### 当前接口兼容
|
||||
|
||||
- [ ] 兼容 `/api/auth/login-options`
|
||||
- [ ] 兼容 `/api/auth/entry`
|
||||
- [ ] 兼容 `/api/auth/me`
|
||||
- [x] 兼容 `/api/auth/entry`
|
||||
交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [x] 兼容 `/api/auth/me`
|
||||
交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 兼容 `/api/auth/logout`
|
||||
- [ ] 兼容 `/api/auth/logout-all`
|
||||
- [ ] 兼容 `/api/auth/refresh`
|
||||
- [x] 兼容 `/api/auth/refresh`
|
||||
交付物:[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 兼容 `/api/auth/sessions`
|
||||
- [ ] 兼容 `/api/auth/sessions/:sessionId/revoke`
|
||||
- [ ] 兼容 `/api/auth/audit-logs`
|
||||
@@ -229,8 +236,10 @@
|
||||
|
||||
### 阶段验收
|
||||
|
||||
- [ ] 密码登录主链可用
|
||||
- [ ] refresh cookie 主链可用
|
||||
- [x] 密码登录主链可用
|
||||
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖自动建号、重复登录复用、错密码 `401`、非法用户名 `400` 与 refresh cookie 写回。
|
||||
- [x] refresh cookie 主链可用
|
||||
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 refresh 成功轮换、旧 token 失效、缺少 cookie `401` 与失败时清理 cookie。
|
||||
- [ ] 手机验证码主链可用
|
||||
- [ ] 微信登录主链可用
|
||||
说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。
|
||||
|
||||
@@ -7,22 +7,32 @@
|
||||
|
||||
## 1. OSS 基础设施
|
||||
|
||||
- [ ] 创建 OSS bucket 方案
|
||||
- [ ] 设计对象键前缀
|
||||
- [ ] 设计 `object_key -> cdn_url` 解析策略
|
||||
- [ ] 设计 public / private 对象访问策略
|
||||
- [x] 创建 OSS bucket 方案
|
||||
- [x] 设计对象键前缀
|
||||
- [x] 设计 `object_key -> cdn_url` 解析策略
|
||||
- [x] 设计 public / private 对象访问策略
|
||||
- [ ] 设计签名 URL 输出策略
|
||||
- [ ] 设计 `x-oss-meta-*` 元数据规范
|
||||
- [x] 设计 `x-oss-meta-*` 元数据规范
|
||||
- [ ] 设计内容 hash / 版本字段规范
|
||||
|
||||
## 2. 上传与对象确认
|
||||
|
||||
- [ ] 实现浏览器 `PostObject` 直传签名接口
|
||||
- [x] 实现浏览器 `PostObject` 直传签名接口
|
||||
- [ ] 实现 STS 临时授权接口
|
||||
- [ ] 实现服务端上传 helper
|
||||
- [ ] 实现上传完成后的对象确认接口
|
||||
- [ ] 实现对象绑定业务实体 reducer
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 自 `2026-04-21` 起,当前重写节奏允许在 `M3/M4/M5` 之前先前置落地 `M6` 的 OSS 基础设施。
|
||||
2. 当前已在 `server-rs/crates/platform-oss` 与 `server-rs/crates/api-server` 落下最小可用链路:
|
||||
- `PostObject` 直传签名能力
|
||||
- `/api/assets/direct-upload-tickets`
|
||||
- 兼容旧 `/generated-*` 前缀的对象键规划
|
||||
- `.env/.env.local` 的 OSS 环境变量加载
|
||||
3. 当前仍未进入 `STS`、服务端上传 helper、对象确认与 `SpacetimeDB` 绑定阶段。
|
||||
|
||||
## 3. 资产任务系统
|
||||
|
||||
- [ ] 设计 `asset_job`
|
||||
|
||||
121
docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md
Normal file
121
docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `/api/auth/me` 查询落地设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中 `实现 me 查询` 任务的首版落地,冻结:
|
||||
|
||||
1. `GET /api/auth/me` 的请求与响应 contract。
|
||||
2. 当前阶段 Bearer JWT 与用户快照读取的衔接方式。
|
||||
3. `availableLoginMethods` 的返回口径。
|
||||
4. JWT 有效但本地用户不存在时的错误处理语义。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/me` 具备以下最小语义:
|
||||
|
||||
1. 必须先通过 Bearer JWT 校验。
|
||||
2. 用 `sub = user_id` 读取当前用户。
|
||||
3. 返回 `user + availableLoginMethods`。
|
||||
4. `availableLoginMethods` 只返回当前对外开启的补充登录方式,不包含 `password`。
|
||||
|
||||
Rust 首版需要保留这条最小 contract,但当前阶段允许继续使用进程内仓储承接用户真相。
|
||||
|
||||
## 3. 当前阶段范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 增加按 `user_id` 查询当前用户能力。
|
||||
2. `api-server` 暴露 `GET /api/auth/me`。
|
||||
3. 返回与当前前端兼容的 `user + availableLoginMethods`。
|
||||
|
||||
本阶段不包含:
|
||||
|
||||
1. `refresh token` 轮换。
|
||||
2. 会话列表、审计、风控等扩展信息。
|
||||
3. `SpacetimeDB` 真正的身份表读取。
|
||||
|
||||
## 4. contract
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
1. 方法:`GET`
|
||||
2. 路径:`/api/auth/me`
|
||||
3. 鉴权:`Authorization: Bearer <token>`
|
||||
|
||||
### 4.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "user_00000001",
|
||||
"username": "guest_001",
|
||||
"displayName": "guest_001",
|
||||
"phoneNumberMasked": null,
|
||||
"loginMethod": "password",
|
||||
"bindingStatus": "active",
|
||||
"wechatBound": false
|
||||
},
|
||||
"availableLoginMethods": []
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前阶段 `user` 字段固定返回当前登录用户快照,不返回 `null`。
|
||||
2. `availableLoginMethods` 只按当前对外配置返回:
|
||||
- `SMS_AUTH_ENABLED=true` 时包含 `phone`
|
||||
- `WECHAT_AUTH_ENABLED=true` 时包含 `wechat`
|
||||
3. `password` 不进入 `availableLoginMethods`,保持和 Node 现状一致。
|
||||
|
||||
## 5. 错误语义
|
||||
|
||||
### 5.1 缺少或非法 Bearer token
|
||||
|
||||
1. 返回 `401 UNAUTHORIZED`
|
||||
|
||||
### 5.2 JWT 有效但用户不存在
|
||||
|
||||
1. 返回 `401 UNAUTHORIZED`
|
||||
2. 语义视为“当前登录态已失效,需要重新登录”
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前阶段不把这种情况返回为 `404`。
|
||||
2. 这样可以与后续 `token_version`、会话吊销和用户禁用策略保持同一类恢复路径。
|
||||
|
||||
## 6. crate 边界
|
||||
|
||||
### 6.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 提供按 `user_id` 查询当前用户快照的能力。
|
||||
2. 继续复用密码登录阶段已经建立的同一份进程内用户真相。
|
||||
|
||||
### 6.2 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. 复用现有 Bearer JWT 中间件拿到 `sub`。
|
||||
2. 调用 `module-auth` 查询用户。
|
||||
3. 组装 `AuthMeResponse`。
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 已登录用户可通过 `/api/auth/me` 取回当前用户。
|
||||
2. 当短信/微信开关开启时,`availableLoginMethods` 返回对应值。
|
||||
3. JWT 有效但用户不存在时返回 `401`。
|
||||
|
||||
## 8. 后续衔接
|
||||
|
||||
这条任务完成后,下一步顺序固定为:
|
||||
|
||||
1. refresh token 轮换
|
||||
2. 会话吊销
|
||||
3. 手机验证码登录
|
||||
|
||||
微信登录继续按“暂缓执行”处理。
|
||||
205
docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md
Normal file
205
docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# `/api/auth/refresh` 轮换落地设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中 `实现 refresh token 轮换` 任务的首版落地,冻结:
|
||||
|
||||
1. `POST /api/auth/refresh` 的请求与响应 contract。
|
||||
2. refresh cookie、服务端 refresh session 与 access token 三者的职责边界。
|
||||
3. “会话 ID 稳定、refresh token 可轮换”的固定语义。
|
||||
4. Rust 首版在未切入 SpacetimeDB 前的临时进程内实现方式。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/refresh` 已具备以下稳定语义:
|
||||
|
||||
1. 从 HttpOnly cookie 中读取原始 refresh token。
|
||||
2. 服务端只按 `refresh_token_hash` 查找当前活跃会话。
|
||||
3. refresh 成功后,不新建第二条会话,而是在原会话上轮换 refresh token。
|
||||
4. 轮换时会更新 `expires_at` 与 `last_seen_at`。
|
||||
5. 成功后返回新的 access token,并写回新的 refresh cookie。
|
||||
6. 失败时会主动清空 refresh cookie,要求前端重新登录。
|
||||
|
||||
Rust 首版必须保留以上语义。
|
||||
|
||||
## 3. 当前阶段范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 增加进程内 refresh session 真相与轮换服务。
|
||||
2. `api-server` 暴露 `POST /api/auth/refresh`。
|
||||
3. 登录成功时创建 refresh session。
|
||||
4. refresh 成功时在原 session 上轮换 refresh token。
|
||||
5. access token 的 `sid` 固定改为稳定 `session_id`,不再直接复用 refresh token。
|
||||
|
||||
本阶段不包含:
|
||||
|
||||
1. `/api/auth/logout`
|
||||
2. `/api/auth/logout-all`
|
||||
3. `/api/auth/sessions`
|
||||
4. `/api/auth/sessions/:sessionId/revoke`
|
||||
5. SpacetimeDB reducer 真正写表
|
||||
|
||||
## 4. contract
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
1. 方法:`POST`
|
||||
2. 路径:`/api/auth/refresh`
|
||||
3. 请求体:空
|
||||
4. 鉴权来源:refresh cookie
|
||||
|
||||
### 4.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<access-token>"
|
||||
}
|
||||
```
|
||||
|
||||
同时响应头必须写回新的 refresh cookie。
|
||||
|
||||
### 4.3 失败响应
|
||||
|
||||
当 refresh token 缺失、会话不存在、会话已过期或用户不存在时:
|
||||
|
||||
1. 返回 `401 UNAUTHORIZED`
|
||||
2. 同时清理 refresh cookie
|
||||
|
||||
## 5. 固定语义
|
||||
|
||||
### 5.1 session_id 与 refresh token 必须拆开
|
||||
|
||||
从本任务开始固定以下规则:
|
||||
|
||||
1. `session_id` 是稳定会话主键。
|
||||
2. refresh token 是可轮换的会话凭证。
|
||||
3. access token 的 `sid` 必须写入 `session_id`。
|
||||
4. refresh 轮换只更新 refresh token,不更改 `session_id`。
|
||||
|
||||
禁止继续把 refresh token 直接塞进 JWT `sid`。
|
||||
|
||||
### 5.2 refresh 是“原会话轮换”
|
||||
|
||||
refresh 成功后:
|
||||
|
||||
1. 保留原 `session_id`
|
||||
2. 生成新的原始 refresh token
|
||||
3. 用新的 `refresh_token_hash` 覆盖旧值
|
||||
4. 更新 `expires_at`
|
||||
5. 更新 `last_seen_at`
|
||||
|
||||
不允许新建第二条 session。
|
||||
|
||||
## 6. crate 边界
|
||||
|
||||
### 6.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 管理 refresh session 进程内真相。
|
||||
2. 提供创建 refresh session 与轮换 refresh session 的用例。
|
||||
3. 提供按 `user_id` 查询用户快照的能力,供 refresh 成功后重新签发 access token。
|
||||
|
||||
不负责:
|
||||
|
||||
1. 生成原始 refresh token。
|
||||
2. 读写 cookie。
|
||||
3. 签发 JWT。
|
||||
|
||||
### 6.2 `platform-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 生成原始 refresh token。
|
||||
2. 对 refresh token 做哈希。
|
||||
3. 构造 refresh cookie 的 `Set-Cookie` 头。
|
||||
4. 从 cookie header 中读取 refresh token。
|
||||
|
||||
### 6.3 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. 从请求 cookie 中提取 refresh token。
|
||||
2. 调用 `module-auth` 执行 refresh session 轮换。
|
||||
3. 根据用户快照与稳定 `session_id` 重新签发 access token。
|
||||
4. refresh 失败时清理 cookie。
|
||||
|
||||
## 7. 进程内存储模型
|
||||
|
||||
当前阶段 `module-auth` 继续使用进程内内存仓储承接 refresh session,字段至少包括:
|
||||
|
||||
1. `session_id`
|
||||
2. `user_id`
|
||||
3. `refresh_token_hash`
|
||||
4. `issued_by_provider`
|
||||
5. `expires_at`
|
||||
6. `created_at`
|
||||
7. `updated_at`
|
||||
8. `last_seen_at`
|
||||
9. `revoked_at`
|
||||
|
||||
说明:
|
||||
|
||||
1. 这只是 SpacetimeDB 正式落地前的阶段性实现。
|
||||
2. 字段命名与语义继续对齐 [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)。
|
||||
|
||||
## 8. 流程
|
||||
|
||||
### 8.1 登录创建 session
|
||||
|
||||
密码登录成功后:
|
||||
|
||||
1. `api-server` 生成原始 refresh token。
|
||||
2. `api-server` 计算 `refresh_token_hash`。
|
||||
3. `module-auth` 创建一条新 session,并返回稳定 `session_id`。
|
||||
4. `api-server` 用该 `session_id` 写入 access token 的 `sid`。
|
||||
5. `api-server` 把原始 refresh token 写回 cookie。
|
||||
|
||||
### 8.2 refresh 轮换 session
|
||||
|
||||
当请求 `POST /api/auth/refresh` 时:
|
||||
|
||||
1. 从 cookie 中读取原始 refresh token。
|
||||
2. 计算 `refresh_token_hash`。
|
||||
3. `module-auth` 查找当前活跃 session。
|
||||
4. 校验 `expires_at > now` 且 `revoked_at == null`。
|
||||
5. 读取该 session 对应用户。
|
||||
6. 生成新的原始 refresh token。
|
||||
7. 用新 hash 更新同一条 session。
|
||||
8. 返回新的 access token 与新的 refresh cookie。
|
||||
|
||||
## 9. 错误语义
|
||||
|
||||
以下情况统一返回 `401`:
|
||||
|
||||
1. 缺少 refresh cookie
|
||||
2. refresh token 命中不到 session
|
||||
3. refresh session 已过期
|
||||
4. refresh session 已吊销
|
||||
5. session 对应用户不存在
|
||||
|
||||
错误文案统一保持中文,并沿用“当前登录态已失效,请重新登录”这类恢复导向语义。
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 登录成功后可用 cookie 调用 `/api/auth/refresh`
|
||||
2. refresh 成功会写回新的 cookie
|
||||
3. refresh 成功返回新的 access token
|
||||
4. refresh 后旧 refresh token 立即失效
|
||||
5. 缺少 cookie 时返回 `401`
|
||||
6. 无效 refresh token 时返回 `401` 且清理 cookie
|
||||
|
||||
## 11. 完成定义
|
||||
|
||||
满足以下条件时,本任务视为完成:
|
||||
|
||||
1. Rust 侧已提供 `POST /api/auth/refresh`。
|
||||
2. access token `sid` 已改为稳定 `session_id`。
|
||||
3. refresh token 轮换成功时不创建新会话。
|
||||
4. refresh 失败时会清理 cookie。
|
||||
5. 文档、任务清单与测试已同步更新。
|
||||
253
docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md
Normal file
253
docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 密码登录与自动建号落地设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中以下两条任务的首版落地:
|
||||
|
||||
1. `实现密码登录`
|
||||
2. `实现账号自动创建 / 幂等登录兼容策略`
|
||||
|
||||
目标是先把当前 Node 已经稳定运行的 `/api/auth/entry` 语义迁到 Rust 工作区,并冻结:
|
||||
|
||||
1. `api-server` 对外暴露的最小兼容接口。
|
||||
2. `module-auth` 负责的密码登录用例边界。
|
||||
3. 自动建号与并发幂等兼容规则。
|
||||
4. 登录成功后与 JWT、refresh cookie 的衔接方式。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/entry` 主链已经具备如下语义:
|
||||
|
||||
1. 输入 `username + password`。
|
||||
2. 若用户名不存在,则自动创建一个本地账号。
|
||||
3. 若用户名已存在,则校验密码。
|
||||
4. 登录成功后签发 access token。
|
||||
5. 同时创建 refresh session,并把原始 refresh token 写入 HttpOnly cookie。
|
||||
6. 并发创建同一用户名时,后到的请求会回退为“查已存在账号并校验密码”,不因唯一键冲突直接失败。
|
||||
|
||||
这条链路既是当前前端匿名/游客恢复的基础,也是真实 `/api/auth/entry` contract 的既有事实,因此 Rust 首版必须兼容。
|
||||
|
||||
## 3. 设计输入
|
||||
|
||||
本任务直接受以下文档约束:
|
||||
|
||||
1. [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
|
||||
3. [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
|
||||
4. [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
|
||||
5. [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
|
||||
|
||||
关键冻结点:
|
||||
|
||||
1. `password_hash` 当前继续由 `user_account` 承担,不进入 `auth_identity`。
|
||||
2. `sub` 必须是稳定 `user_id`。
|
||||
3. 登录成功后必须继续同时生成 access token 和 refresh session。
|
||||
4. 自动建号兼容必须保留,不能因为迁到 Rust 就删除。
|
||||
|
||||
## 4. 首版落地范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 中的密码登录用例。
|
||||
2. `api-server` 中的 `POST /api/auth/entry`。
|
||||
3. 用户名校验、密码哈希校验与自动建号。
|
||||
4. 登录成功后的 access token 与 refresh cookie 主链打通。
|
||||
|
||||
本阶段明确不包含:
|
||||
|
||||
1. SpacetimeDB 真正的 `user_account` / `refresh_session` reducer 写入。
|
||||
2. `/api/auth/me`、`/api/auth/logout`、`/api/auth/refresh` 的正式业务闭环。
|
||||
3. 手机验证码与微信登录链路。
|
||||
|
||||
## 5. crate 边界
|
||||
|
||||
### 5.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 用户名与密码的领域校验。
|
||||
2. 密码登录主用例。
|
||||
3. 自动建号与并发幂等兼容策略。
|
||||
4. 输出登录成功所需的最小用户快照。
|
||||
|
||||
不负责:
|
||||
|
||||
1. JWT 编解码。
|
||||
2. refresh cookie 解析与写回。
|
||||
3. HTTP 请求解析与响应拼装。
|
||||
|
||||
### 5.2 `platform-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 密码哈希与校验适配。
|
||||
2. JWT 签发与校验。
|
||||
3. refresh cookie 读写适配。
|
||||
|
||||
不负责:
|
||||
|
||||
1. 决定账号是否应当自动创建。
|
||||
2. 决定用户状态是否合法。
|
||||
|
||||
### 5.3 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. 解析 `POST /api/auth/entry` 请求体。
|
||||
2. 调用 `module-auth` 用例。
|
||||
3. 调用 `platform-auth` 签发 token 和 refresh cookie。
|
||||
4. 返回与旧接口兼容的 JSON body。
|
||||
|
||||
## 6. 请求与响应 contract
|
||||
|
||||
### 6.1 请求体
|
||||
|
||||
固定沿用当前 contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 成功响应
|
||||
|
||||
固定沿用当前 contract:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<access-token>",
|
||||
"user": {
|
||||
"id": "user_xxx",
|
||||
"username": "guest_001",
|
||||
"displayName": "guest_001",
|
||||
"phoneNumberMasked": null,
|
||||
"loginMethod": "password",
|
||||
"bindingStatus": "active",
|
||||
"wechatBound": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同时响应头必须写回 refresh cookie。
|
||||
|
||||
## 7. 用户名与密码规则
|
||||
|
||||
当前阶段继续对齐 Node 基线:
|
||||
|
||||
1. `username` 只允许 `3` 到 `24` 位字母、数字、下划线。
|
||||
2. `password` 长度必须在 `6` 到 `128` 位之间。
|
||||
|
||||
任一校验失败时:
|
||||
|
||||
1. 返回 `400 BAD_REQUEST`
|
||||
2. 错误文案继续保持中文
|
||||
|
||||
## 8. 自动建号与幂等兼容
|
||||
|
||||
### 8.1 自动建号
|
||||
|
||||
当 `username` 不存在时:
|
||||
|
||||
1. 用当前请求里的 `password` 生成密码哈希。
|
||||
2. 创建一条本地账号。
|
||||
3. `display_name = username`
|
||||
4. `login_provider = password`
|
||||
5. `account_status = active`
|
||||
6. `token_version = 1`
|
||||
|
||||
### 8.2 已存在账号
|
||||
|
||||
当 `username` 已存在时:
|
||||
|
||||
1. 校验密码哈希。
|
||||
2. 校验失败返回 `401 UNAUTHORIZED`。
|
||||
3. 校验成功继续登录。
|
||||
|
||||
### 8.3 并发幂等兼容
|
||||
|
||||
若两个请求并发创建同一用户名:
|
||||
|
||||
1. 允许其中一个请求先创建成功。
|
||||
2. 后一个请求若命中唯一键冲突,不直接失败。
|
||||
3. 后一个请求必须重新查询该用户名。
|
||||
4. 若查到账号,则按“已存在账号”路径继续校验密码。
|
||||
|
||||
这保证了当前前端重复调用 `/api/auth/entry` 时可以恢复同一账号,而不是随机失败。
|
||||
|
||||
## 9. 首版存储策略
|
||||
|
||||
当前阶段为了先跑通工程闭环,固定采用:
|
||||
|
||||
1. `module-auth` 内的进程内内存仓储适配器作为临时真相。
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是阶段性工程策略,不改变最终 `SpacetimeDB` 作为真相源的目标。
|
||||
2. 当前这样做是为了先把 crate 边界、用例形状、HTTP contract、JWT / refresh cookie 主链稳定下来。
|
||||
3. 后续切到 `SpacetimeDB` 时,应保持 `module-auth` 用例接口不变,只替换仓储实现。
|
||||
|
||||
## 10. 密码哈希策略
|
||||
|
||||
当前阶段继续对齐 Node:
|
||||
|
||||
1. `Argon2id`
|
||||
|
||||
说明:
|
||||
|
||||
1. Rust 侧不再复用 Node 原生库,但哈希语义继续保持同类算法。
|
||||
2. 当前目标是“工程能力闭环”,不是做跨语言哈希值兼容迁移。
|
||||
3. 若未来需要与 Node 历史哈希共存,需单独补兼容文档和迁移策略。
|
||||
|
||||
## 11. 与 JWT / refresh cookie 的衔接
|
||||
|
||||
密码登录成功后:
|
||||
|
||||
1. `module-auth` 返回最小用户领域对象。
|
||||
2. `api-server` 基于该对象构造 `AccessTokenClaimsInput`。
|
||||
3. `platform-auth` 签发 access token。
|
||||
4. `platform-auth` 生成 refresh token 与 `Set-Cookie` 头。
|
||||
5. `api-server` 返回 `token + user`。
|
||||
|
||||
当前阶段固定 claims 值:
|
||||
|
||||
1. `provider = password`
|
||||
2. `roles = ["user"]`
|
||||
3. `binding_status = active`
|
||||
4. `phone_verified = false`
|
||||
5. `display_name = username`
|
||||
|
||||
## 12. 测试策略
|
||||
|
||||
当前阶段至少覆盖:
|
||||
|
||||
1. 首次密码登录自动建号成功。
|
||||
2. 同用户名同密码可重复登录同一账号。
|
||||
3. 同用户名不同密码返回 `401`。
|
||||
4. 非法用户名返回 `400`。
|
||||
5. 登录成功时返回 access token。
|
||||
6. 登录成功时写回 refresh cookie。
|
||||
|
||||
## 13. 完成定义
|
||||
|
||||
满足以下条件时,本任务视为完成:
|
||||
|
||||
1. `module-auth` 不再只是 README,占位被真实 crate 实现替换。
|
||||
2. `POST /api/auth/entry` 可在 Rust 侧独立跑通。
|
||||
3. 自动建号与幂等兼容行为可验证。
|
||||
4. JWT 与 refresh cookie 登录成功主链打通。
|
||||
5. 文档、任务清单与测试同步完成。
|
||||
|
||||
## 14. 后续衔接
|
||||
|
||||
这条任务完成后,下一步顺序固定为:
|
||||
|
||||
1. `me` 查询
|
||||
2. refresh token 轮换
|
||||
3. 会话吊销
|
||||
4. 手机验证码登录
|
||||
|
||||
微信登录继续按“暂缓执行”处理,直到用户重新解锁。
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。
|
||||
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
|
||||
- [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。
|
||||
- [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。
|
||||
- [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth` 与 `SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
|
||||
1. `editor` 挂载面在历史系统中真实存在,但已被确认为遗留无用能力。
|
||||
2. `editor` 仅保留为历史基线对照,不纳入本轮 `server-rs` 重写验收。
|
||||
3. 当前执行顺序允许在 `M3 / M4 / M5` 前,先前置 `assets / OSS` 的基础设施接入,以便后续 runtime、custom world、agent 统一复用同一资产入口。
|
||||
|
||||
当前后端内部模块也不能“凭感觉重设计”,而要按现有职责做映射:
|
||||
|
||||
@@ -162,6 +163,11 @@ Aliyun OSS
|
||||
3. Axum 保持当前 story / custom-world-agent 的 SSE 体验。
|
||||
4. SpacetimeDB 先做后端内部真相源。
|
||||
|
||||
补充执行口径:
|
||||
|
||||
1. 虽然总体里程碑仍保留 `M6` 编号,但 `OSS` 的平台适配、浏览器直传票据与旧 `/generated-*` 路径兼容能力允许提前于 `M3 / M4 / M5` 落地。
|
||||
2. 提前落地的目标是先收口统一资产入口,不是提前把全部资产业务状态迁完。
|
||||
|
||||
第二阶段再按模块把只读页改成直接订阅 SpacetimeDB。
|
||||
|
||||
### 5.2 命令与读模型分离
|
||||
@@ -593,6 +599,25 @@ workflow-cache/{workflow_type}/{workflow_id}.json
|
||||
- content-length-range
|
||||
- success_action_status
|
||||
|
||||
当前已落地的最小实现补充:
|
||||
|
||||
1. `server-rs/crates/platform-oss` 已提供 `PostObject` 直传签名能力。
|
||||
2. `server-rs/crates/api-server` 已暴露 `POST /api/assets/direct-upload-tickets`。
|
||||
3. 该接口当前输出:
|
||||
- `objectKey`
|
||||
- `legacyPublicPath`
|
||||
- `publicUrl`
|
||||
- `formFields`
|
||||
- `expiresAt`
|
||||
4. 当前签名链路优先兼容旧公开前缀:
|
||||
- `/generated-character-drafts/*`
|
||||
- `/generated-characters/*`
|
||||
- `/generated-animations/*`
|
||||
- `/generated-custom-world-scenes/*`
|
||||
- `/generated-custom-world-covers/*`
|
||||
- `/generated-qwen-sprites/*`
|
||||
5. `STS`、服务端上传 helper、对象确认与业务绑定仍在后续阶段补齐。
|
||||
|
||||
## 11.3 元数据与标签
|
||||
|
||||
建议所有业务对象写入统一元数据:
|
||||
|
||||
181
server-rs/Cargo.lock
generated
181
server-rs/Cargo.lock
generated
@@ -22,8 +22,11 @@ name = "api-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"dotenvy",
|
||||
"http-body-util",
|
||||
"module-auth",
|
||||
"platform-auth",
|
||||
"platform-oss",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-logging",
|
||||
@@ -35,6 +38,18 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -105,12 +120,36 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
@@ -139,6 +178,25 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -148,6 +206,23 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -208,6 +283,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -255,6 +340,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -446,6 +540,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"platform-auth",
|
||||
"time",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -495,6 +599,17 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -521,10 +636,27 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
name = "platform-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"jsonwebtoken",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"sha2",
|
||||
"time",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platform-oss"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"hmac",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -567,6 +699,15 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -682,6 +823,28 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -738,6 +901,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -946,6 +1115,12 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -987,6 +1162,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/api-server",
|
||||
"crates/module-auth",
|
||||
"crates/platform-oss",
|
||||
"crates/platform-auth",
|
||||
"crates/shared-logging",
|
||||
]
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
1. `crates/spacetime-module` 的表、reducer、view 聚合入口
|
||||
2. `module-auth` 的身份表、JWT 与 refresh cookie 主链
|
||||
3. `platform-oss` 的浏览器直传签名、旧 `/generated-*` 前缀映射与对象 URL 解析能力
|
||||
|
||||
## 3. 已冻结边界
|
||||
|
||||
@@ -66,6 +67,7 @@
|
||||
3. 外部副作用统一收口在 Axum / crate 内应用层 / infra。
|
||||
4. `crates/api-server` 只组合与暴露协议,不直接吞并业务模块实现。
|
||||
5. `crates/spacetime-module` 只负责汇总各模块 crate 的表、reducer、view。
|
||||
6. 当前允许在 `M3 / M4 / M5` 前先行落地 `OSS` 基础设施,但不因此跳过后续资产状态建模与绑定迁移。
|
||||
|
||||
## 4. SpacetimeDB 实施约束
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
dotenvy = "0.15"
|
||||
module-auth = { path = "../module-auth" }
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
platform-oss = { path = "../platform-oss" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared-logging = { path = "../shared-logging" }
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
6. `src/config.rs`
|
||||
7. 基础 `TraceLayer` 挂载
|
||||
8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化
|
||||
9. 接入 `POST /api/auth/entry` 首版密码登录链路
|
||||
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
||||
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
||||
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -36,6 +40,10 @@
|
||||
3. [x] 接入统一错误处理中间件
|
||||
4. [x] 接入 response envelope
|
||||
5. [x] 接入 `/healthz`
|
||||
6. [x] 接入 `/api/auth/entry`
|
||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||
8. [x] 接入 `/api/auth/me`
|
||||
9. [x] 接入 `/api/auth/refresh`
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -94,3 +102,6 @@
|
||||
2. 业务逻辑优先通过独立模块 crate 暴露能力,再由主工程组合。
|
||||
3. 外部副作用通过 `platform-auth`、`platform-oss`、`platform-llm` 与各模块 crate 的应用层完成。
|
||||
4. 不把领域规则直接堆在 handler 中。
|
||||
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
||||
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
||||
7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。
|
||||
|
||||
@@ -27,15 +27,15 @@ pub fn json_success_body<T>(request_context: Option<&RequestContext>, data: T) -
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
if let Some(context) = request_context {
|
||||
if context.wants_envelope() {
|
||||
return Json(json!({
|
||||
"ok": true,
|
||||
"data": data,
|
||||
"error": null,
|
||||
"meta": build_api_response_meta(Some(context)),
|
||||
}));
|
||||
}
|
||||
if let Some(context) = request_context
|
||||
&& context.wants_envelope()
|
||||
{
|
||||
return Json(json!({
|
||||
"ok": true,
|
||||
"data": data,
|
||||
"error": null,
|
||||
"meta": build_api_response_meta(Some(context)),
|
||||
}));
|
||||
}
|
||||
|
||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||
@@ -123,7 +123,7 @@ mod tests {
|
||||
let request_context = build_request_context(false);
|
||||
let error = ApiErrorPayload {
|
||||
code: "NOT_FOUND",
|
||||
message: "资源不存在",
|
||||
message: "资源不存在".to_string(),
|
||||
details: None,
|
||||
};
|
||||
let body = json_error_body(Some(&request_context), &error).0;
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
use axum::{Router, body::Body, extract::Extension, http::Request, middleware, routing::get};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::Extension,
|
||||
http::Request,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||
use tracing::{Level, info_span};
|
||||
|
||||
use crate::{
|
||||
assets::create_direct_upload_ticket,
|
||||
auth::{
|
||||
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||
require_bearer_auth,
|
||||
},
|
||||
auth_me::auth_me,
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
password_entry::password_entry,
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
state::AppState,
|
||||
@@ -37,6 +48,25 @@ pub fn build_router(state: AppState) -> Router {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/refresh",
|
||||
post(refresh_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/direct-upload-tickets",
|
||||
post(create_direct_upload_ticket),
|
||||
)
|
||||
.route("/api/auth/entry", post(password_entry))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||
@@ -317,4 +347,389 @@ mod tests {
|
||||
Value::Number(serde_json::Number::from(10))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_creates_user_and_sets_refresh_cookie() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("genarrative_refresh_session="))
|
||||
);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["user"]["username"],
|
||||
Value::String("guest_001".to_string())
|
||||
);
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_reuses_same_user_for_same_credentials() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let first_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first request should succeed");
|
||||
let first_body = first_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first body should collect")
|
||||
.to_bytes();
|
||||
let first_payload: Value =
|
||||
serde_json::from_slice(&first_body).expect("first payload should be json");
|
||||
|
||||
let second_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second request should succeed");
|
||||
let second_body = second_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("second body should collect")
|
||||
.to_bytes();
|
||||
let second_payload: Value =
|
||||
serde_json::from_slice(&second_body).expect("second payload should be json");
|
||||
|
||||
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_wrong_password() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("seed request should succeed");
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_001",
|
||||
"password": "secret999"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_rejects_invalid_username() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "无效用户",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_me_returns_current_user_and_available_login_methods() {
|
||||
let config = AppConfig {
|
||||
sms_auth_enabled: true,
|
||||
wechat_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_me_query".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("guest_001".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("/api/auth/me")
|
||||
.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["user"]["id"],
|
||||
Value::String("user_00000001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["availableLoginMethods"],
|
||||
serde_json::json!(["phone", "wechat"])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_me_returns_unauthorized_when_user_missing() {
|
||||
let config = AppConfig::default();
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_missing".to_string(),
|
||||
session_id: "sess_missing".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("ghost".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("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_session_rotates_cookie_and_returns_new_access_token() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/entry")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_refresh",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("login request should succeed");
|
||||
let first_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("refresh cookie should exist")
|
||||
.to_string();
|
||||
|
||||
let refresh_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", first_cookie.clone())
|
||||
.body(Body::empty())
|
||||
.expect("refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("refresh request should succeed");
|
||||
|
||||
assert_eq!(refresh_response.status(), StatusCode::OK);
|
||||
let second_cookie = refresh_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("rotated refresh cookie should exist")
|
||||
.to_string();
|
||||
assert_ne!(first_cookie, second_cookie);
|
||||
|
||||
let refresh_body = refresh_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("refresh body should collect")
|
||||
.to_bytes();
|
||||
let refresh_payload: Value =
|
||||
serde_json::from_slice(&refresh_body).expect("refresh payload should be json");
|
||||
assert!(refresh_payload["token"].as_str().is_some());
|
||||
|
||||
let stale_refresh_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.header("cookie", first_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("stale refresh request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("stale refresh request should succeed");
|
||||
|
||||
assert_eq!(stale_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(
|
||||
stale_refresh_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_session_rejects_missing_cookie_and_clears_cookie() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/refresh")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
206
server-rs/crates/api-server/src/assets.rs
Normal file
206
server-rs/crates/api-server/src/assets.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPostObjectRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketRequest {
|
||||
pub legacy_prefix: String,
|
||||
#[serde(default)]
|
||||
pub path_segments: Vec<String>,
|
||||
pub file_name: String,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<OssObjectAccess>,
|
||||
#[serde(default)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub max_size_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub expire_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub success_action_status: Option<u16>,
|
||||
}
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<CreateDirectUploadTicketRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
let legacy_prefix = LegacyAssetPrefix::parse(&payload.legacy_prefix).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"field": "legacyPrefix",
|
||||
"supported": platform_oss::LEGACY_PUBLIC_PREFIXES,
|
||||
}))
|
||||
})?;
|
||||
|
||||
let signed = oss_client
|
||||
.sign_post_object(OssPostObjectRequest {
|
||||
prefix: legacy_prefix,
|
||||
path_segments: payload.path_segments,
|
||||
file_name: payload.file_name,
|
||||
content_type: payload.content_type,
|
||||
access: payload.access.unwrap_or(OssObjectAccess::Public),
|
||||
metadata: payload.metadata,
|
||||
max_size_bytes: payload.max_size_bytes,
|
||||
expire_seconds: payload.expire_seconds,
|
||||
success_action_status: payload.success_action_status,
|
||||
})
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"upload": signed,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/direct-upload-tickets")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"legacyPrefix": "/generated-characters/*",
|
||||
"pathSegments": ["hero", "visual", "asset-01"],
|
||||
"fileName": "master.png"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["error"]["code"],
|
||||
Value::String("SERVICE_UNAVAILABLE".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String("aliyun-oss".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_upload_ticket_returns_signed_payload_when_oss_configured() {
|
||||
let config = AppConfig {
|
||||
oss_bucket: Some("genarrative-assets".to_string()),
|
||||
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
|
||||
oss_access_key_id: Some("test-access-key-id".to_string()),
|
||||
oss_access_key_secret: Some("test-access-key-secret".to_string()),
|
||||
oss_public_base_url: Some("https://cdn.genarrative.local".to_string()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/direct-upload-tickets")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-request-id", "req-oss-ticket")
|
||||
.header("x-genarrative-response-envelope", "1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"legacyPrefix": "/generated-characters/*",
|
||||
"pathSegments": ["hero_001", "visual", "asset_01"],
|
||||
"fileName": "master.png",
|
||||
"contentType": "image/png",
|
||||
"metadata": {
|
||||
"asset-kind": "character-visual"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
payload["data"]["upload"]["objectKey"],
|
||||
Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["upload"]["publicUrl"],
|
||||
Value::String(
|
||||
"https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["data"]["upload"]["formFields"]["OSSAccessKeyId"],
|
||||
Value::String("test-access-key-id".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
75
server-rs/crates/api-server/src/auth_me.rs
Normal file
75
server-rs/crates/api-server/src/auth_me.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeResponse {
|
||||
pub user: AuthMeUserPayload,
|
||||
pub available_login_methods: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn auth_me(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let user = state
|
||||
.password_entry_service()
|
||||
.get_user_by_id(&user_id)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录")
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthMeResponse {
|
||||
user: AuthMeUserPayload {
|
||||
id: user.user.id,
|
||||
username: user.user.username,
|
||||
display_name: user.user.display_name,
|
||||
phone_number_masked: user.user.phone_number_masked,
|
||||
login_method: user.user.login_method.as_str(),
|
||||
binding_status: user.user.binding_status.as_str(),
|
||||
wechat_bound: user.user.wechat_bound,
|
||||
},
|
||||
available_login_methods: build_available_login_methods(&state),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn build_available_login_methods(state: &AppState) -> Vec<&'static str> {
|
||||
let mut methods = Vec::new();
|
||||
if state.config.sms_auth_enabled {
|
||||
methods.push("phone");
|
||||
}
|
||||
if state.config.wechat_auth_enabled {
|
||||
methods.push("wechat");
|
||||
}
|
||||
methods
|
||||
}
|
||||
132
server-rs/crates/api-server/src/auth_session.rs
Normal file
132
server-rs/crates/api-server/src/auth_session.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use axum::http::{
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
header::SET_COOKIE,
|
||||
};
|
||||
use module_auth::{
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, RefreshSessionError,
|
||||
};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
||||
build_refresh_session_clear_cookie, build_refresh_session_set_cookie,
|
||||
create_refresh_session_token, hash_refresh_session_token, sign_access_token,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedAuthSession {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
pub fn create_password_auth_session(
|
||||
state: &AppState,
|
||||
user: &AuthUser,
|
||||
) -> Result<SignedAuthSession, AppError> {
|
||||
let refresh_token = create_refresh_session_token();
|
||||
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
|
||||
let session = state
|
||||
.refresh_session_service()
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash,
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(map_refresh_session_error)?;
|
||||
let access_token = sign_access_token_for_user(state, user, &session.session.session_id)?;
|
||||
|
||||
Ok(SignedAuthSession {
|
||||
access_token,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sign_access_token_for_user(
|
||||
state: &AppState,
|
||||
user: &AuthUser,
|
||||
session_id: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let access_claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
provider: map_auth_provider(&user.login_method),
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
phone_verified: user.phone_number_masked.is_some(),
|
||||
binding_status: map_binding_status(&user.binding_status),
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
|
||||
sign_access_token(&access_claims, state.auth_jwt_config()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_refresh_session_cookie_header(
|
||||
state: &AppState,
|
||||
refresh_token: &str,
|
||||
) -> Result<HeaderValue, AppError> {
|
||||
let refresh_cookie =
|
||||
build_refresh_session_set_cookie(refresh_token, state.refresh_cookie_config());
|
||||
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("refresh cookie 头构造失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_clear_refresh_session_cookie_header(
|
||||
state: &AppState,
|
||||
) -> Result<HeaderValue, AppError> {
|
||||
let refresh_cookie = build_refresh_session_clear_cookie(state.refresh_cookie_config());
|
||||
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("refresh cookie 头构造失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn attach_set_cookie_header(
|
||||
headers: &mut HeaderMap,
|
||||
set_cookie: HeaderValue,
|
||||
) {
|
||||
headers.insert(SET_COOKIE, set_cookie);
|
||||
}
|
||||
|
||||
pub fn map_refresh_session_error(error: RefreshSessionError) -> AppError {
|
||||
match error {
|
||||
RefreshSessionError::MissingToken
|
||||
| RefreshSessionError::SessionNotFound
|
||||
| RefreshSessionError::SessionExpired
|
||||
| RefreshSessionError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
|
||||
}
|
||||
RefreshSessionError::Store(message) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_auth_provider(login_method: &AuthLoginMethod) -> AuthProvider {
|
||||
match login_method {
|
||||
AuthLoginMethod::Password => AuthProvider::Password,
|
||||
AuthLoginMethod::Phone => AuthProvider::Phone,
|
||||
AuthLoginMethod::Wechat => AuthProvider::Wechat,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> BindingStatus {
|
||||
match binding_status {
|
||||
module_auth::AuthBindingStatus::Active => BindingStatus::Active,
|
||||
module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone,
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,16 @@ pub struct AppConfig {
|
||||
pub refresh_cookie_secure: bool,
|
||||
pub refresh_cookie_same_site: String,
|
||||
pub refresh_session_ttl_days: u32,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub wechat_auth_enabled: bool,
|
||||
pub oss_bucket: Option<String>,
|
||||
pub oss_endpoint: Option<String>,
|
||||
pub oss_access_key_id: Option<String>,
|
||||
pub oss_access_key_secret: Option<String>,
|
||||
pub oss_public_base_url: Option<String>,
|
||||
pub oss_post_expire_seconds: u64,
|
||||
pub oss_post_max_size_bytes: u64,
|
||||
pub oss_success_action_status: u16,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -30,6 +40,16 @@ impl Default for AppConfig {
|
||||
refresh_cookie_secure: false,
|
||||
refresh_cookie_same_site: "Lax".to_string(),
|
||||
refresh_session_ttl_days: 30,
|
||||
sms_auth_enabled: false,
|
||||
wechat_auth_enabled: false,
|
||||
oss_bucket: None,
|
||||
oss_endpoint: None,
|
||||
oss_access_key_id: None,
|
||||
oss_access_key_secret: None,
|
||||
oss_public_base_url: None,
|
||||
oss_post_expire_seconds: 10 * 60,
|
||||
oss_post_max_size_bytes: 20 * 1024 * 1024,
|
||||
oss_success_action_status: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,22 +58,22 @@ impl AppConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let mut config = Self::default();
|
||||
|
||||
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") {
|
||||
if !bind_host.trim().is_empty() {
|
||||
config.bind_host = bind_host;
|
||||
}
|
||||
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST")
|
||||
&& !bind_host.trim().is_empty()
|
||||
{
|
||||
config.bind_host = bind_host;
|
||||
}
|
||||
|
||||
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") {
|
||||
if let Ok(parsed_port) = bind_port.parse::<u16>() {
|
||||
config.bind_port = parsed_port;
|
||||
}
|
||||
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT")
|
||||
&& let Ok(parsed_port) = bind_port.parse::<u16>()
|
||||
{
|
||||
config.bind_port = parsed_port;
|
||||
}
|
||||
|
||||
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") {
|
||||
if !log_filter.trim().is_empty() {
|
||||
config.log_filter = log_filter;
|
||||
}
|
||||
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG")
|
||||
&& !log_filter.trim().is_empty()
|
||||
{
|
||||
config.log_filter = log_filter;
|
||||
}
|
||||
|
||||
if let Some(jwt_issuer) =
|
||||
@@ -99,6 +119,38 @@ impl AppConfig {
|
||||
config.refresh_session_ttl_days = refresh_session_ttl_days;
|
||||
}
|
||||
|
||||
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
|
||||
config.sms_auth_enabled = sms_auth_enabled;
|
||||
}
|
||||
|
||||
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
|
||||
config.wechat_auth_enabled = wechat_auth_enabled;
|
||||
}
|
||||
|
||||
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
||||
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
||||
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
|
||||
config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]);
|
||||
config.oss_public_base_url = read_first_non_empty_env(&["ALIYUN_OSS_PUBLIC_BASE_URL"]);
|
||||
|
||||
if let Some(oss_post_expire_seconds) =
|
||||
read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"])
|
||||
{
|
||||
config.oss_post_expire_seconds = oss_post_expire_seconds;
|
||||
}
|
||||
|
||||
if let Some(oss_post_max_size_bytes) =
|
||||
read_first_positive_u64_env(&["ALIYUN_OSS_POST_MAX_SIZE_BYTES"])
|
||||
{
|
||||
config.oss_post_max_size_bytes = oss_post_max_size_bytes;
|
||||
}
|
||||
|
||||
if let Some(oss_success_action_status) =
|
||||
read_first_positive_u16_env(&["ALIYUN_OSS_SUCCESS_ACTION_STATUS"])
|
||||
{
|
||||
config.oss_success_action_status = oss_success_action_status;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
@@ -144,6 +196,22 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_positive_u64(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_positive_u16(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_duration_seconds(raw: &str) -> Option<u64> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
@@ -185,3 +253,21 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_positive_u64(raw: &str) -> Option<u64> {
|
||||
let value = raw.trim().parse::<u64>().ok()?;
|
||||
if value == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
let value = raw.trim().parse::<u16>().ok()?;
|
||||
if value == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(value)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -11,14 +12,15 @@ use crate::{api_response::json_error_body, request_context::RequestContext};
|
||||
pub struct AppError {
|
||||
status_code: StatusCode,
|
||||
code: &'static str,
|
||||
message: &'static str,
|
||||
message: String,
|
||||
details: Option<Value>,
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ApiErrorPayload {
|
||||
pub code: &'static str,
|
||||
pub message: &'static str,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
@@ -30,8 +32,9 @@ impl AppError {
|
||||
Self {
|
||||
status_code,
|
||||
code,
|
||||
message,
|
||||
message: message.to_string(),
|
||||
details: None,
|
||||
headers: HeaderMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,17 +42,33 @@ impl AppError {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||
self.message = message.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self {
|
||||
self.headers.insert(name, value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response {
|
||||
let status_code = self.status_code;
|
||||
let payload = self.to_payload();
|
||||
|
||||
(status_code, json_error_body(request_context, &payload)).into_response()
|
||||
let mut response = (status_code, json_error_body(request_context, &payload)).into_response();
|
||||
response.headers_mut().extend(self.headers);
|
||||
response
|
||||
}
|
||||
|
||||
fn to_payload(&self) -> ApiErrorPayload {
|
||||
ApiErrorPayload {
|
||||
code: self.code,
|
||||
message: self.message,
|
||||
message: self.message.clone(),
|
||||
details: self.details.clone(),
|
||||
}
|
||||
}
|
||||
@@ -70,6 +89,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
|
||||
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
|
||||
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
|
||||
StatusCode::SERVICE_UNAVAILABLE => ("SERVICE_UNAVAILABLE", "服务暂不可用"),
|
||||
_ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"),
|
||||
_ => ("INTERNAL_SERVER_ERROR", "服务器内部错误"),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
mod api_response;
|
||||
mod app;
|
||||
mod assets;
|
||||
mod auth;
|
||||
mod auth_session;
|
||||
mod auth_me;
|
||||
mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod password_entry;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod state;
|
||||
@@ -17,6 +22,10 @@ use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
// 运行本地开发与联调时,优先从仓库根目录的 .env / .env.local 加载变量,避免手工逐项导出 OSS 配置。
|
||||
let _ = dotenvy::from_filename(".env");
|
||||
let _ = dotenvy::from_filename(".env.local");
|
||||
|
||||
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
|
||||
let config = AppConfig::from_env();
|
||||
init_tracing(&config.log_filter)?;
|
||||
@@ -25,7 +34,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
let listener = TcpListener::bind(bind_address).await?;
|
||||
|
||||
let state = AppState::new(config)
|
||||
.map_err(|error| std::io::Error::other(format!("初始化鉴权配置失败:{error}")))?;
|
||||
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
||||
let router = build_router(state);
|
||||
|
||||
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
|
||||
|
||||
107
server-rs/crates/api-server/src/password_entry.rs
Normal file
107
server-rs/crates/api-server/src/password_entry.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::{PasswordEntryError, PasswordEntryInput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryResponse {
|
||||
pub token: String,
|
||||
pub user: PasswordEntryUserPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn password_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<PasswordEntryRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
.password_entry_service()
|
||||
.execute(PasswordEntryInput {
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_entry_error)?;
|
||||
let signed_session = create_password_auth_session(&state, &result.user)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut headers,
|
||||
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
PasswordEntryResponse {
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
display_name: result.user.display_name,
|
||||
phone_number_masked: result.user.phone_number_masked,
|
||||
login_method: result.user.login_method.as_str(),
|
||||
binding_status: result.user.binding_status.as_str(),
|
||||
wechat_bound: result.user.wechat_bound,
|
||||
},
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("用户名只允许 3 到 24 位字母、数字、下划线")
|
||||
.with_details(json!({
|
||||
"field": "username",
|
||||
})),
|
||||
PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("密码长度需要在 6 到 128 位之间")
|
||||
.with_details(json!({
|
||||
"field": "password",
|
||||
})),
|
||||
PasswordEntryError::InvalidCredentials => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
|
||||
}
|
||||
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
84
server-rs/crates/api-server/src/refresh_session.rs
Normal file
84
server-rs/crates/api-server/src/refresh_session.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use axum::{
|
||||
extract::{Extension, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::RefreshSessionToken,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
|
||||
build_refresh_session_cookie_header, map_refresh_session_error, sign_access_token_for_user,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshSessionResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn refresh_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let raw_refresh_token = maybe_refresh_token
|
||||
.map(|token| token.0.token().to_string())
|
||||
.unwrap_or_default();
|
||||
if raw_refresh_token.trim().is_empty() {
|
||||
return Err(map_refresh_error_with_clear_cookie(
|
||||
&state,
|
||||
RefreshSessionError::MissingToken,
|
||||
));
|
||||
}
|
||||
let refresh_token_hash = hash_refresh_session_token(&raw_refresh_token);
|
||||
let next_refresh_token = platform_auth::create_refresh_session_token();
|
||||
let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token);
|
||||
|
||||
let rotated = state
|
||||
.refresh_session_service()
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash,
|
||||
next_refresh_token_hash,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?;
|
||||
let access_token =
|
||||
sign_access_token_for_user(&state, &rotated.user, &rotated.session.session_id)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
&mut headers,
|
||||
build_refresh_session_cookie_header(&state, &next_refresh_token)?,
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
RefreshSessionResponse {
|
||||
token: access_token,
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_refresh_error_with_clear_cookie(state: &AppState, error: RefreshSessionError) -> AppError {
|
||||
let response_error = map_refresh_session_error(error);
|
||||
if let Ok(set_cookie) = build_clear_refresh_session_cookie_header(state) {
|
||||
return response_error.with_header("set-cookie", set_cookie);
|
||||
}
|
||||
|
||||
response_error
|
||||
}
|
||||
@@ -19,12 +19,12 @@ pub async fn propagate_request_id_header(request: Request, next: Next) -> Respon
|
||||
let request_context = request.extensions().get::<RequestContext>().cloned();
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
if let Some(request_id) = request_id {
|
||||
if let Ok(header_value) = HeaderValue::from_str(&request_id) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
|
||||
}
|
||||
if let Some(request_id) = request_id
|
||||
&& let Ok(header_value) = HeaderValue::from_str(&request_id)
|
||||
{
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
|
||||
}
|
||||
|
||||
if let Ok(header_value) = HeaderValue::from_str(API_VERSION) {
|
||||
@@ -37,12 +37,12 @@ pub async fn propagate_request_id_header(request: Request, next: Next) -> Respon
|
||||
.insert(HeaderName::from_static(ROUTE_VERSION_HEADER), header_value);
|
||||
}
|
||||
|
||||
if let Some(request_context) = request_context {
|
||||
if let Ok(header_value) = HeaderValue::from_str(&request_context.elapsed().to_string()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HeaderName::from_static(RESPONSE_TIME_HEADER), header_value);
|
||||
}
|
||||
if let Some(request_context) = request_context
|
||||
&& let Ok(header_value) = HeaderValue::from_str(&request_context.elapsed().to_string())
|
||||
{
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HeaderName::from_static(RESPONSE_TIME_HEADER), header_value);
|
||||
}
|
||||
|
||||
response
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_auth::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
@@ -14,12 +16,16 @@ pub struct AppState {
|
||||
pub config: AppConfig,
|
||||
auth_jwt_config: JwtConfig,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
oss_client: Option<OssClient>,
|
||||
password_entry_service: PasswordEntryService,
|
||||
refresh_session_service: RefreshSessionService,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppStateInitError {
|
||||
Jwt(JwtError),
|
||||
RefreshCookie(RefreshCookieError),
|
||||
Oss(OssError),
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -40,11 +46,19 @@ impl AppState {
|
||||
refresh_cookie_same_site,
|
||||
config.refresh_session_ttl_days,
|
||||
)?;
|
||||
let oss_client = build_oss_client(&config)?;
|
||||
let auth_store = InMemoryAuthStore::default();
|
||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||
let refresh_session_service =
|
||||
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_jwt_config,
|
||||
refresh_cookie_config,
|
||||
oss_client,
|
||||
password_entry_service,
|
||||
refresh_session_service,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +69,18 @@ impl AppState {
|
||||
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
|
||||
&self.refresh_cookie_config
|
||||
}
|
||||
|
||||
pub fn oss_client(&self) -> Option<&OssClient> {
|
||||
self.oss_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn password_entry_service(&self) -> &PasswordEntryService {
|
||||
&self.password_entry_service
|
||||
}
|
||||
|
||||
pub fn refresh_session_service(&self) -> &RefreshSessionService {
|
||||
&self.refresh_session_service
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
@@ -62,6 +88,7 @@ impl fmt::Display for AppStateInitError {
|
||||
match self {
|
||||
Self::Jwt(error) => write!(f, "{error}"),
|
||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||
Self::Oss(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,3 +106,33 @@ impl From<RefreshCookieError> for AppStateInitError {
|
||||
Self::RefreshCookie(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OssError> for AppStateInitError {
|
||||
fn from(value: OssError) -> Self {
|
||||
Self::Oss(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
|
||||
let has_any_oss_field = config.oss_bucket.is_some()
|
||||
|| config.oss_endpoint.is_some()
|
||||
|| config.oss_access_key_id.is_some()
|
||||
|| config.oss_access_key_secret.is_some();
|
||||
|
||||
if !has_any_oss_field {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let oss_config = OssConfig::new(
|
||||
config.oss_bucket.clone().unwrap_or_default(),
|
||||
config.oss_endpoint.clone().unwrap_or_default(),
|
||||
config.oss_access_key_id.clone().unwrap_or_default(),
|
||||
config.oss_access_key_secret.clone().unwrap_or_default(),
|
||||
config.oss_read_expire_seconds,
|
||||
config.oss_post_expire_seconds,
|
||||
config.oss_post_max_size_bytes,
|
||||
config.oss_success_action_status,
|
||||
)?;
|
||||
|
||||
Ok(Some(OssClient::new(oss_config)))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入生成链路、对象确认与兼容接口实现。
|
||||
当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施:
|
||||
|
||||
1. `api-server` 已具备 `POST /api/assets/direct-upload-tickets`
|
||||
2. `platform-oss` 已具备旧 `/generated-*` 前缀兼容的 `PostObject` 签名能力
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
|
||||
|
||||
13
server-rs/crates/module-auth/Cargo.toml
Normal file
13
server-rs/crates/module-auth/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "module-auth"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
@@ -1,28 +1,34 @@
|
||||
# module-auth 独立模块 crate 占位说明
|
||||
# module-auth 鉴权模块 crate 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. crate 职责
|
||||
|
||||
`module-auth` 是鉴权与会话模块 crate,后续负责:
|
||||
`module-auth` 是鉴权与会话模块 crate,当前与后续负责:
|
||||
|
||||
1. 用户身份、会话、风控、审计相关领域模型
|
||||
2. 手机验证码、微信登录、密码登录的模块内用例编排
|
||||
3. 与 `crates/api-server` 的鉴权接口装配对接
|
||||
4. 与 `crates/spacetime-module` 的身份表、会话表聚合对接
|
||||
1. 用户身份、会话、风控、审计相关领域模型。
|
||||
2. 手机验证码、微信登录、密码登录的模块内用例编排。
|
||||
3. 与 `crates/api-server` 的鉴权接口装配对接。
|
||||
4. 与 `crates/spacetime-module` 的身份表、会话表聚合对接。
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前阶段已冻结前七张鉴权基础表设计,剩余重点收口在 JWT claims、refresh cookie 与旧接口兼容细节。
|
||||
当前阶段已经冻结前七张鉴权基础表设计,并已完成:
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
1. JWT claims 设计与 `platform-auth` 落地。
|
||||
2. refresh cookie 读取适配。
|
||||
3. `module-auth` 真实 crate 与首版密码登录用例落地。
|
||||
4. 微信登录链路暂缓执行,不进入当前连续实现顺序。
|
||||
|
||||
1. 设计 `user_account`、`auth_identity`、`refresh_session`
|
||||
2. 设计 `auth_audit_log`、`auth_risk_block`
|
||||
3. 设计 `sms_auth_event`、`wechat_auth_state`
|
||||
4. 落地 JWT claims、refresh cookie 与旧接口兼容
|
||||
当前连续实现优先顺序固定为:
|
||||
|
||||
当前已冻结文档:
|
||||
1. 密码登录
|
||||
2. refresh token 轮换
|
||||
3. `me` 查询
|
||||
4. 会话吊销
|
||||
5. 手机验证码登录
|
||||
|
||||
## 3. 当前已冻结文档
|
||||
|
||||
1. [../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
|
||||
@@ -32,9 +38,17 @@
|
||||
6. [../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)
|
||||
7. [../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)
|
||||
8. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
|
||||
9. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
|
||||
10. [../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
|
||||
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
|
||||
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
|
||||
|
||||
## 3. 边界约束
|
||||
## 4. 边界约束
|
||||
|
||||
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。
|
||||
2. 短信、微信、JWT、Cookie 等平台适配优先通过 `crates/platform-auth` 承接。
|
||||
2. 短信、微信、JWT、Cookie、密码哈希等平台适配优先通过 `crates/platform-auth` 承接。
|
||||
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
|
||||
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
||||
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
||||
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
|
||||
|
||||
749
server-rs/crates/module-auth/src/lib.rs
Normal file
749
server-rs/crates/module-auth/src/lib.rs
Normal file
@@ -0,0 +1,749 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use platform_auth::{hash_password, verify_password};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
const USERNAME_MIN_LENGTH: usize = 3;
|
||||
const USERNAME_MAX_LENGTH: usize = 24;
|
||||
const PASSWORD_MIN_LENGTH: usize = 6;
|
||||
const PASSWORD_MAX_LENGTH: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AuthLoginMethod {
|
||||
Password,
|
||||
Phone,
|
||||
Wechat,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AuthBindingStatus {
|
||||
Active,
|
||||
PendingBindPhone,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: AuthLoginMethod,
|
||||
pub binding_status: AuthBindingStatus,
|
||||
pub wechat_bound: bool,
|
||||
pub token_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthMeResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PasswordEntryInput {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PasswordEntryResult {
|
||||
pub user: AuthUser,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CreateRefreshSessionInput {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub issued_by_provider: AuthLoginMethod,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RefreshSessionRecord {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub issued_by_provider: AuthLoginMethod,
|
||||
pub expires_at: String,
|
||||
pub revoked_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_seen_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CreateRefreshSessionResult {
|
||||
pub session: RefreshSessionRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RotateRefreshSessionInput {
|
||||
pub refresh_token_hash: String,
|
||||
pub next_refresh_token_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RotateRefreshSessionResult {
|
||||
pub session: RefreshSessionRecord,
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordEntryError {
|
||||
InvalidUsername,
|
||||
InvalidPasswordLength,
|
||||
InvalidCredentials,
|
||||
Store(String),
|
||||
PasswordHash(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RefreshSessionError {
|
||||
MissingToken,
|
||||
SessionNotFound,
|
||||
SessionExpired,
|
||||
UserNotFound,
|
||||
Store(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryAuthStore {
|
||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InMemoryAuthStoreState {
|
||||
next_user_id: u64,
|
||||
users_by_username: HashMap<String, StoredPasswordUser>,
|
||||
sessions_by_id: HashMap<String, StoredRefreshSession>,
|
||||
session_id_by_refresh_token_hash: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredPasswordUser {
|
||||
user: AuthUser,
|
||||
password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredRefreshSession {
|
||||
session: RefreshSessionRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PasswordEntryService {
|
||||
store: InMemoryAuthStore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefreshSessionService {
|
||||
store: InMemoryAuthStore,
|
||||
refresh_session_ttl_days: u32,
|
||||
}
|
||||
|
||||
impl PasswordEntryService {
|
||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: PasswordEntryInput,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
let username = normalize_username(&input.username)?;
|
||||
validate_password(&input.password)?;
|
||||
|
||||
if let Some(existing_user) = self.store.find_by_username(&username)? {
|
||||
let is_valid = verify_password(&existing_user.password_hash, &input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
if !is_valid {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
return Ok(PasswordEntryResult {
|
||||
user: existing_user.user,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
match self.store.create_user(username.clone(), password_hash) {
|
||||
Ok(user) => Ok(PasswordEntryResult {
|
||||
user,
|
||||
created: true,
|
||||
}),
|
||||
Err(CreateUserError::AlreadyExists) => {
|
||||
let existing_user = self.store.find_by_username(&username)?.ok_or_else(|| {
|
||||
PasswordEntryError::Store("唯一键冲突后未能重新读取账号".to_string())
|
||||
})?;
|
||||
let is_valid = verify_password(&existing_user.password_hash, &input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
if !is_valid {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Ok(PasswordEntryResult {
|
||||
user: existing_user.user,
|
||||
created: false,
|
||||
})
|
||||
}
|
||||
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<AuthMeResult>, PasswordEntryError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
.map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user }))
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshSessionService {
|
||||
pub fn new(store: InMemoryAuthStore, refresh_session_ttl_days: u32) -> Self {
|
||||
Self {
|
||||
store,
|
||||
refresh_session_ttl_days,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session(
|
||||
&self,
|
||||
input: CreateRefreshSessionInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<CreateRefreshSessionResult, RefreshSessionError> {
|
||||
self.store
|
||||
.find_by_user_id(&input.user_id)
|
||||
.map_err(map_password_store_error)?
|
||||
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||
|
||||
let session_id = format!("usess_{}", Uuid::new_v4().simple());
|
||||
let expires_at = now
|
||||
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
|
||||
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
|
||||
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|
||||
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
|
||||
)?;
|
||||
let expires_at_iso = expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
|
||||
})?;
|
||||
let session = RefreshSessionRecord {
|
||||
session_id,
|
||||
user_id: input.user_id,
|
||||
refresh_token_hash: input.refresh_token_hash,
|
||||
issued_by_provider: input.issued_by_provider,
|
||||
expires_at: expires_at_iso,
|
||||
revoked_at: None,
|
||||
created_at: now_iso.clone(),
|
||||
updated_at: now_iso.clone(),
|
||||
last_seen_at: now_iso,
|
||||
};
|
||||
|
||||
self.store.insert_session(session.clone())?;
|
||||
|
||||
Ok(CreateRefreshSessionResult { session })
|
||||
}
|
||||
|
||||
pub fn rotate_session(
|
||||
&self,
|
||||
input: RotateRefreshSessionInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<RotateRefreshSessionResult, RefreshSessionError> {
|
||||
let refresh_token_hash = input.refresh_token_hash.trim().to_string();
|
||||
if refresh_token_hash.is_empty() {
|
||||
return Err(RefreshSessionError::MissingToken);
|
||||
}
|
||||
|
||||
let session = self
|
||||
.store
|
||||
.find_session_by_refresh_token_hash(&refresh_token_hash)?
|
||||
.ok_or(RefreshSessionError::SessionNotFound)?;
|
||||
|
||||
if session.session.revoked_at.is_some() {
|
||||
return Err(RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
let expires_at = OffsetDateTime::parse(
|
||||
&session.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}")))?;
|
||||
if expires_at <= now {
|
||||
return Err(RefreshSessionError::SessionExpired);
|
||||
}
|
||||
|
||||
let user = self
|
||||
.store
|
||||
.find_by_user_id(&session.session.user_id)
|
||||
.map_err(map_password_store_error)?
|
||||
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||
|
||||
let next_expires_at = now
|
||||
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
|
||||
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
|
||||
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|
||||
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
|
||||
)?;
|
||||
let next_expires_at_iso = next_expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
|
||||
})?;
|
||||
|
||||
let updated_session = self.store.rotate_session(
|
||||
&session.session.session_id,
|
||||
&session.session.refresh_token_hash,
|
||||
input.next_refresh_token_hash,
|
||||
next_expires_at_iso,
|
||||
now_iso.clone(),
|
||||
now_iso,
|
||||
)?;
|
||||
|
||||
Ok(RotateRefreshSessionResult {
|
||||
session: updated_session.session,
|
||||
user: user.user,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryAuthStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(InMemoryAuthStoreState {
|
||||
next_user_id: 1,
|
||||
users_by_username: HashMap::new(),
|
||||
sessions_by_id: HashMap::new(),
|
||||
session_id_by_refresh_token_hash: HashMap::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryAuthStore {
|
||||
fn find_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
Ok(state.users_by_username.get(username).cloned())
|
||||
}
|
||||
|
||||
fn find_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
|
||||
Ok(state
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == user_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn create_user(
|
||||
&self,
|
||||
username: String,
|
||||
password_hash: String,
|
||||
) -> Result<AuthUser, CreateUserError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| CreateUserError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
|
||||
if state.users_by_username.contains_key(&username) {
|
||||
return Err(CreateUserError::AlreadyExists);
|
||||
}
|
||||
|
||||
let user_id = format!("user_{:08}", state.next_user_id);
|
||||
state.next_user_id += 1;
|
||||
|
||||
let user = AuthUser {
|
||||
id: user_id,
|
||||
username: username.clone(),
|
||||
display_name: username.clone(),
|
||||
phone_number_masked: None,
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
token_version: 1,
|
||||
};
|
||||
state.users_by_username.insert(
|
||||
username,
|
||||
StoredPasswordUser {
|
||||
user: user.clone(),
|
||||
password_hash,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn insert_session(
|
||||
&self,
|
||||
session: RefreshSessionRecord,
|
||||
) -> Result<(), RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
|
||||
if state
|
||||
.session_id_by_refresh_token_hash
|
||||
.contains_key(&session.refresh_token_hash)
|
||||
{
|
||||
return Err(RefreshSessionError::Store(
|
||||
"refresh token hash 已存在,无法重复创建会话".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.session_id_by_refresh_token_hash.insert(
|
||||
session.refresh_token_hash.clone(),
|
||||
session.session_id.clone(),
|
||||
);
|
||||
state.sessions_by_id.insert(
|
||||
session.session_id.clone(),
|
||||
StoredRefreshSession { session },
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_session_by_refresh_token_hash(
|
||||
&self,
|
||||
refresh_token_hash: &str,
|
||||
) -> Result<Option<StoredRefreshSession>, RefreshSessionError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(session_id) = state.session_id_by_refresh_token_hash.get(refresh_token_hash) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(state.sessions_by_id.get(session_id).cloned())
|
||||
}
|
||||
|
||||
fn rotate_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
previous_refresh_token_hash: &str,
|
||||
next_refresh_token_hash: String,
|
||||
next_expires_at: String,
|
||||
updated_at: String,
|
||||
last_seen_at: String,
|
||||
) -> Result<StoredRefreshSession, RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
|
||||
if state
|
||||
.session_id_by_refresh_token_hash
|
||||
.contains_key(&next_refresh_token_hash)
|
||||
{
|
||||
return Err(RefreshSessionError::Store(
|
||||
"新 refresh token hash 已存在,无法轮换".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_refresh_token_hash = state
|
||||
.sessions_by_id
|
||||
.get(session_id)
|
||||
.ok_or(RefreshSessionError::SessionNotFound)?
|
||||
.session
|
||||
.refresh_token_hash
|
||||
.clone();
|
||||
if current_refresh_token_hash != previous_refresh_token_hash {
|
||||
return Err(RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
state
|
||||
.session_id_by_refresh_token_hash
|
||||
.remove(previous_refresh_token_hash);
|
||||
let stored = state
|
||||
.sessions_by_id
|
||||
.get_mut(session_id)
|
||||
.ok_or(RefreshSessionError::SessionNotFound)?;
|
||||
stored.session.refresh_token_hash = next_refresh_token_hash.clone();
|
||||
stored.session.expires_at = next_expires_at;
|
||||
stored.session.updated_at = updated_at;
|
||||
stored.session.last_seen_at = last_seen_at;
|
||||
let updated_session = stored.clone();
|
||||
state
|
||||
.session_id_by_refresh_token_hash
|
||||
.insert(next_refresh_token_hash, updated_session.session.session_id.clone());
|
||||
|
||||
Ok(updated_session)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum CreateUserError {
|
||||
AlreadyExists,
|
||||
Store(String),
|
||||
}
|
||||
|
||||
impl AuthLoginMethod {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Password => "password",
|
||||
Self::Phone => "phone",
|
||||
Self::Wechat => "wechat",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthBindingStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::PendingBindPhone => "pending_bind_phone",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PasswordEntryError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
|
||||
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
|
||||
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
|
||||
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PasswordEntryError {}
|
||||
|
||||
impl fmt::Display for RefreshSessionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingToken => f.write_str("缺少刷新会话"),
|
||||
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
|
||||
f.write_str("当前登录态已失效,请重新登录")
|
||||
}
|
||||
Self::Store(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RefreshSessionError {}
|
||||
|
||||
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
|
||||
PasswordEntryError::InvalidUsername
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
| PasswordEntryError::PasswordHash(_) => {
|
||||
RefreshSessionError::Store("用户仓储读取失败".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||
let username = raw_username.trim().to_string();
|
||||
let valid_length =
|
||||
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
|
||||
let valid_chars = username
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '_');
|
||||
|
||||
if !valid_length || !valid_chars {
|
||||
return Err(PasswordEntryError::InvalidUsername);
|
||||
}
|
||||
|
||||
Ok(username)
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
||||
let length = password.chars().count();
|
||||
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
|
||||
return Err(PasswordEntryError::InvalidPasswordLength);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn build_store() -> InMemoryAuthStore {
|
||||
InMemoryAuthStore::default()
|
||||
}
|
||||
|
||||
fn build_password_service(store: InMemoryAuthStore) -> PasswordEntryService {
|
||||
PasswordEntryService::new(store)
|
||||
}
|
||||
|
||||
fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService {
|
||||
RefreshSessionService::new(store, 30)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_password_entry_creates_user() {
|
||||
let service = build_password_service(build_store());
|
||||
|
||||
let result = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
|
||||
assert!(result.created);
|
||||
assert_eq!(result.user.id, "user_00000001");
|
||||
assert_eq!(result.user.username, "guest_001");
|
||||
assert_eq!(result.user.display_name, "guest_001");
|
||||
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
|
||||
assert_eq!(result.user.binding_status, AuthBindingStatus::Active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_password_entry_reuses_same_user() {
|
||||
let store = build_store();
|
||||
let service = build_password_service(store);
|
||||
let first = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
|
||||
let second = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("second login should succeed");
|
||||
|
||||
assert!(first.created);
|
||||
assert!(!second.created);
|
||||
assert_eq!(second.user.id, first.user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_password_entry_rejects_wrong_password() {
|
||||
let service = build_password_service(build_store());
|
||||
service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret999".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("wrong password should fail");
|
||||
|
||||
assert_eq!(error, PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_username_returns_bad_request_error() {
|
||||
let service = build_password_service(build_store());
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "坏用户名".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("invalid username should fail");
|
||||
|
||||
assert_eq!(error, PasswordEntryError::InvalidUsername);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_session_creation_and_rotation_keep_same_session_id() {
|
||||
let store = build_store();
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store);
|
||||
let user = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_002".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed")
|
||||
.user;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let first_token_hash = hash_refresh_session_token("refresh-token-01");
|
||||
let created = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("session should create");
|
||||
|
||||
let rotated = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: first_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("refresh-token-02"),
|
||||
},
|
||||
now + Duration::minutes(10),
|
||||
)
|
||||
.expect("session should rotate");
|
||||
|
||||
assert_eq!(rotated.user.id, user.id);
|
||||
assert_eq!(rotated.session.session_id, created.session.session_id);
|
||||
assert_ne!(
|
||||
rotated.session.refresh_token_hash,
|
||||
created.session.refresh_token_hash
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_session_rejects_unknown_token_hash() {
|
||||
let store = build_store();
|
||||
let refresh_service = build_refresh_service(store);
|
||||
|
||||
let error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: hash_refresh_session_token("missing"),
|
||||
next_refresh_token_hash: hash_refresh_session_token("next"),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect_err("unknown token should fail");
|
||||
|
||||
assert_eq!(error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,14 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
jsonwebtoken = "9"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
time = { version = "0.3", features = ["std"] }
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt"] }
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::{collections::HashSet, error::Error, fmt};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
@@ -98,6 +102,12 @@ pub enum RefreshCookieError {
|
||||
InvalidConfig(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PasswordHashError {
|
||||
HashFailed(String),
|
||||
VerifyFailed(String),
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn new(
|
||||
issuer: String,
|
||||
@@ -370,6 +380,75 @@ pub fn read_refresh_session_token(
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn hash_password(password: &str) -> Result<String, PasswordHashError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|error| PasswordHashError::HashFailed(format!("密码哈希失败:{error}")))
|
||||
}
|
||||
|
||||
pub async fn verify_password(
|
||||
password_hash: &str,
|
||||
password: &str,
|
||||
) -> Result<bool, PasswordHashError> {
|
||||
let parsed_hash = PasswordHash::new(password_hash)
|
||||
.map_err(|error| PasswordHashError::VerifyFailed(format!("密码哈希格式非法:{error}")))?;
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
pub fn create_refresh_session_token() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
pub fn hash_refresh_session_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfig) -> String {
|
||||
let mut parts = vec![
|
||||
format!(
|
||||
"{}={}",
|
||||
config.cookie_name(),
|
||||
urlencoding::encode(token).into_owned()
|
||||
),
|
||||
format!("Path={}", config.cookie_path()),
|
||||
"HttpOnly".to_string(),
|
||||
format!("SameSite={}", config.cookie_same_site().as_str()),
|
||||
format!(
|
||||
"Max-Age={}",
|
||||
u64::from(config.refresh_session_ttl_days()) * 24 * 60 * 60
|
||||
),
|
||||
];
|
||||
|
||||
if config.cookie_secure() {
|
||||
parts.push("Secure".to_string());
|
||||
}
|
||||
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
pub fn build_refresh_session_clear_cookie(config: &RefreshCookieConfig) -> String {
|
||||
let mut parts = vec![
|
||||
format!("{}=", config.cookie_name()),
|
||||
format!("Path={}", config.cookie_path()),
|
||||
"HttpOnly".to_string(),
|
||||
format!("SameSite={}", config.cookie_same_site().as_str()),
|
||||
"Max-Age=0".to_string(),
|
||||
];
|
||||
|
||||
if config.cookie_secure() {
|
||||
parts.push("Secure".to_string());
|
||||
}
|
||||
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
impl fmt::Display for JwtError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -391,6 +470,16 @@ impl fmt::Display for RefreshCookieError {
|
||||
|
||||
impl Error for RefreshCookieError {}
|
||||
|
||||
impl fmt::Display for PasswordHashError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::HashFailed(message) | Self::VerifyFailed(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PasswordHashError {}
|
||||
|
||||
fn normalize_required_field(
|
||||
value: String,
|
||||
error_message: &'static str,
|
||||
@@ -560,4 +649,50 @@ mod tests {
|
||||
|
||||
assert!(token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_and_verify_password_round_trip() {
|
||||
let password_hash = hash_password("secret123")
|
||||
.await
|
||||
.expect("password hash should build");
|
||||
|
||||
let is_valid = verify_password(&password_hash, "secret123")
|
||||
.await
|
||||
.expect("password hash should verify");
|
||||
|
||||
assert!(is_valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refresh_session_cookie_respects_config() {
|
||||
let cookie =
|
||||
build_refresh_session_set_cookie("refresh/token=01", &build_refresh_cookie_config());
|
||||
|
||||
assert!(cookie.contains("genarrative_refresh_session=refresh%2Ftoken%3D01"));
|
||||
assert!(cookie.contains("Path=/api/auth"));
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
assert!(cookie.contains("Max-Age=2592000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_refresh_session_token_matches_sha256_hex() {
|
||||
let hash = hash_refresh_session_token("refresh-token-01");
|
||||
|
||||
assert_eq!(
|
||||
hash,
|
||||
"0b6901f0dcee3f50df4115ecb29214f7740f8173919f94cc1f5eb92ff2481ce8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refresh_session_clear_cookie_respects_config() {
|
||||
let cookie = build_refresh_session_clear_cookie(&build_refresh_cookie_config());
|
||||
|
||||
assert!(cookie.contains("genarrative_refresh_session="));
|
||||
assert!(cookie.contains("Path=/api/auth"));
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
assert!(cookie.contains("Max-Age=0"));
|
||||
}
|
||||
}
|
||||
|
||||
13
server-rs/crates/platform-oss/Cargo.toml
Normal file
13
server-rs/crates/platform-oss/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "platform-oss"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
@@ -1,4 +1,4 @@
|
||||
# platform-oss 平台适配 package 占位说明
|
||||
# platform-oss 平台适配 package 说明
|
||||
|
||||
日期:`2026-04-20`
|
||||
|
||||
@@ -13,14 +13,20 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入 OSS SDK、上传策略与对象读写实现。
|
||||
当前提交已落地最小可用 OSS 基础设施:
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
1. `PostObject` 浏览器直传签名
|
||||
2. 旧 `/generated-*` 公开前缀到 OSS `object_key` 的兼容映射
|
||||
3. `object_key -> publicUrl` 解析
|
||||
4. `x-oss-meta-*` 元数据归一化与大小限制校验
|
||||
5. `content-type`、`content-length-range`、`success_action_status` policy 条件生成
|
||||
|
||||
1. 落地 `PostObject`、STS、服务端上传适配
|
||||
2. 落地对象确认、签名 URL 与 CDN URL 解析适配
|
||||
3. 落地 `x-oss-meta-*` 元数据与对象标签适配
|
||||
4. 对齐旧 `/generated-*` 路径兼容策略
|
||||
当前仍未落地的内容:
|
||||
|
||||
1. `STS` 临时授权
|
||||
2. 服务端上传 helper
|
||||
3. 私有对象签名 URL
|
||||
4. 对象确认与业务绑定
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
|
||||
728
server-rs/crates/platform-oss/src/lib.rs
Normal file
728
server-rs/crates/platform-oss/src/lib.rs
Normal file
@@ -0,0 +1,728 @@
|
||||
use std::{collections::BTreeMap, error::Error, fmt};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha1::Sha1;
|
||||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60;
|
||||
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
|
||||
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
|
||||
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
|
||||
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 6] = [
|
||||
"generated-character-drafts",
|
||||
"generated-characters",
|
||||
"generated-animations",
|
||||
"generated-custom-world-scenes",
|
||||
"generated-custom-world-covers",
|
||||
"generated-qwen-sprites",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OssObjectAccess {
|
||||
Public,
|
||||
Private,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LegacyAssetPrefix {
|
||||
CharacterDrafts,
|
||||
Characters,
|
||||
Animations,
|
||||
CustomWorldScenes,
|
||||
CustomWorldCovers,
|
||||
QwenSprites,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssConfig {
|
||||
bucket: String,
|
||||
endpoint: String,
|
||||
access_key_id: String,
|
||||
access_key_secret: String,
|
||||
public_base_url: Option<String>,
|
||||
default_post_expire_seconds: u64,
|
||||
default_post_max_size_bytes: u64,
|
||||
default_success_action_status: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssPostObjectRequest {
|
||||
pub prefix: LegacyAssetPrefix,
|
||||
pub path_segments: Vec<String>,
|
||||
pub file_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub access: OssObjectAccess,
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
pub max_size_bytes: Option<u64>,
|
||||
pub expire_seconds: Option<u64>,
|
||||
pub success_action_status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct OssPostObjectResponse {
|
||||
#[serde(rename = "signatureVersion")]
|
||||
pub signature_version: &'static str,
|
||||
pub provider: &'static str,
|
||||
pub bucket: String,
|
||||
pub endpoint: String,
|
||||
pub host: String,
|
||||
#[serde(rename = "objectKey")]
|
||||
pub object_key: String,
|
||||
#[serde(rename = "legacyPublicPath")]
|
||||
pub legacy_public_path: String,
|
||||
#[serde(rename = "publicUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub public_url: Option<String>,
|
||||
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
|
||||
pub content_type: Option<String>,
|
||||
pub access: OssObjectAccess,
|
||||
#[serde(rename = "keyPrefix")]
|
||||
pub key_prefix: String,
|
||||
#[serde(rename = "expiresAt")]
|
||||
pub expires_at: String,
|
||||
#[serde(rename = "maxSizeBytes")]
|
||||
pub max_size_bytes: u64,
|
||||
#[serde(rename = "successActionStatus")]
|
||||
pub success_action_status: u16,
|
||||
#[serde(rename = "formFields")]
|
||||
pub form_fields: OssPostObjectFormFields,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct OssPostObjectFormFields {
|
||||
pub key: String,
|
||||
pub policy: String,
|
||||
#[serde(rename = "OSSAccessKeyId")]
|
||||
pub oss_access_key_id: String,
|
||||
#[serde(rename = "Signature")]
|
||||
pub signature: String,
|
||||
#[serde(rename = "success_action_status")]
|
||||
pub success_action_status: String,
|
||||
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OssClient {
|
||||
config: OssConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum OssError {
|
||||
InvalidConfig(String),
|
||||
InvalidRequest(String),
|
||||
SerializePolicy(String),
|
||||
Sign(String),
|
||||
}
|
||||
|
||||
impl LegacyAssetPrefix {
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches('*')
|
||||
.trim_end_matches('/');
|
||||
|
||||
match normalized {
|
||||
"generated-character-drafts" => Some(Self::CharacterDrafts),
|
||||
"generated-characters" => Some(Self::Characters),
|
||||
"generated-animations" => Some(Self::Animations),
|
||||
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
||||
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
||||
"generated-qwen-sprites" => Some(Self::QwenSprites),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CharacterDrafts => "generated-character-drafts",
|
||||
Self::Characters => "generated-characters",
|
||||
Self::Animations => "generated-animations",
|
||||
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
||||
Self::CustomWorldCovers => "generated-custom-world-covers",
|
||||
Self::QwenSprites => "generated-qwen-sprites",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_public_path_prefix(&self) -> String {
|
||||
format!("/{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl OssConfig {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
bucket: String,
|
||||
endpoint: String,
|
||||
access_key_id: String,
|
||||
access_key_secret: String,
|
||||
public_base_url: Option<String>,
|
||||
default_post_expire_seconds: u64,
|
||||
default_post_max_size_bytes: u64,
|
||||
default_success_action_status: u16,
|
||||
) -> Result<Self, OssError> {
|
||||
let bucket = normalize_required_value(bucket, "OSS bucket 不能为空")?;
|
||||
let endpoint = normalize_endpoint(&endpoint)?;
|
||||
let access_key_id = normalize_required_value(access_key_id, "OSS AccessKeyId 不能为空")?;
|
||||
let access_key_secret =
|
||||
normalize_required_value(access_key_secret, "OSS AccessKeySecret 不能为空")?;
|
||||
let public_base_url = normalize_optional_base_url(public_base_url);
|
||||
|
||||
if default_post_expire_seconds == 0 {
|
||||
return Err(OssError::InvalidConfig(
|
||||
"OSS PostObject 签名有效期必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if default_post_max_size_bytes == 0 {
|
||||
return Err(OssError::InvalidConfig(
|
||||
"OSS PostObject 最大上传大小必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !(100..=999).contains(&default_success_action_status) {
|
||||
return Err(OssError::InvalidConfig(
|
||||
"OSS success_action_status 必须是三位 HTTP 状态码".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
bucket,
|
||||
endpoint,
|
||||
access_key_id,
|
||||
access_key_secret,
|
||||
public_base_url,
|
||||
default_post_expire_seconds,
|
||||
default_post_max_size_bytes,
|
||||
default_success_action_status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upload_host(&self) -> String {
|
||||
format!("https://{}.{}", self.bucket, self.endpoint)
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub fn bucket(&self) -> &str {
|
||||
&self.bucket
|
||||
}
|
||||
}
|
||||
|
||||
impl OssClient {
|
||||
pub fn new(config: OssConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn sign_post_object(
|
||||
&self,
|
||||
request: OssPostObjectRequest,
|
||||
) -> Result<OssPostObjectResponse, OssError> {
|
||||
let max_size_bytes = request
|
||||
.max_size_bytes
|
||||
.unwrap_or(self.config.default_post_max_size_bytes);
|
||||
let expire_seconds = request
|
||||
.expire_seconds
|
||||
.unwrap_or(self.config.default_post_expire_seconds);
|
||||
let success_action_status = request
|
||||
.success_action_status
|
||||
.unwrap_or(self.config.default_success_action_status);
|
||||
|
||||
if max_size_bytes == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"maxSizeBytes 必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if expire_seconds == 0 {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"expireSeconds 必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !(100..=999).contains(&success_action_status) {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"successActionStatus 必须是三位 HTTP 状态码".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_segments = request
|
||||
.path_segments
|
||||
.iter()
|
||||
.map(|segment| sanitize_path_segment(segment))
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let file_name = sanitize_file_name(&request.file_name)?;
|
||||
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
||||
let legacy_public_path = format!("/{}", object_key);
|
||||
let content_type = normalize_optional_value(request.content_type);
|
||||
let metadata = normalize_metadata(request.metadata)?;
|
||||
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|
||||
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
|
||||
)?))
|
||||
.ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?;
|
||||
let expires_at = expires_at
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| OssError::SerializePolicy(format!("格式化过期时间失败:{error}")))?;
|
||||
|
||||
let policy_json = build_policy_json(
|
||||
&self.config.bucket,
|
||||
&object_key,
|
||||
&expires_at,
|
||||
max_size_bytes,
|
||||
success_action_status,
|
||||
content_type.as_deref(),
|
||||
&metadata,
|
||||
);
|
||||
let policy = serde_json::to_string(&policy_json)
|
||||
.map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?;
|
||||
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
|
||||
let signature = sign_policy(&self.config.access_key_secret, &encoded_policy)?;
|
||||
|
||||
let public_url = match request.access {
|
||||
OssObjectAccess::Public => Some(self.public_url_for(&object_key)),
|
||||
OssObjectAccess::Private => None,
|
||||
};
|
||||
|
||||
Ok(OssPostObjectResponse {
|
||||
signature_version: "v1",
|
||||
provider: "aliyun-oss",
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
host: self.config.upload_host(),
|
||||
object_key: object_key.clone(),
|
||||
legacy_public_path,
|
||||
public_url,
|
||||
content_type: content_type.clone(),
|
||||
access: request.access,
|
||||
key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
|
||||
expires_at,
|
||||
max_size_bytes,
|
||||
success_action_status,
|
||||
form_fields: OssPostObjectFormFields {
|
||||
key: object_key,
|
||||
policy: encoded_policy,
|
||||
oss_access_key_id: self.config.access_key_id.clone(),
|
||||
signature,
|
||||
success_action_status: success_action_status.to_string(),
|
||||
content_type,
|
||||
metadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn public_url_for(&self, object_key: &str) -> String {
|
||||
let base_url = self
|
||||
.config
|
||||
.public_base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.upload_host());
|
||||
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), object_key)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OssError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidConfig(message)
|
||||
| Self::InvalidRequest(message)
|
||||
| Self::SerializePolicy(message)
|
||||
| Self::Sign(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for OssError {}
|
||||
|
||||
fn build_policy_json(
|
||||
bucket: &str,
|
||||
object_key: &str,
|
||||
expires_at: &str,
|
||||
max_size_bytes: u64,
|
||||
success_action_status: u16,
|
||||
content_type: Option<&str>,
|
||||
metadata: &BTreeMap<String, String>,
|
||||
) -> Value {
|
||||
let mut conditions = vec![
|
||||
json!({ "bucket": bucket }),
|
||||
json!(["eq", "$key", object_key]),
|
||||
json!(["content-length-range", 1, max_size_bytes]),
|
||||
json!([
|
||||
"eq",
|
||||
"$success_action_status",
|
||||
success_action_status.to_string()
|
||||
]),
|
||||
];
|
||||
|
||||
if let Some(content_type) = content_type {
|
||||
conditions.push(json!(["eq", "$content-type", content_type]));
|
||||
}
|
||||
|
||||
for (key, value) in metadata {
|
||||
conditions.push(json!(["eq", format!("${key}"), value]));
|
||||
}
|
||||
|
||||
json!({
|
||||
"expiration": expires_at,
|
||||
"conditions": conditions,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_object_key(
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: &[String],
|
||||
file_name: &str,
|
||||
) -> String {
|
||||
let mut parts = Vec::with_capacity(path_segments.len() + 2);
|
||||
parts.push(prefix.as_str().to_string());
|
||||
parts.extend(path_segments.iter().cloned());
|
||||
parts.push(file_name.to_string());
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String {
|
||||
let mut parts = Vec::with_capacity(path_segments.len() + 1);
|
||||
parts.push(prefix.as_str().to_string());
|
||||
parts.extend(path_segments.iter().cloned());
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
fn normalize_metadata(
|
||||
metadata: BTreeMap<String, String>,
|
||||
) -> Result<BTreeMap<String, String>, OssError> {
|
||||
let mut normalized = BTreeMap::new();
|
||||
|
||||
for (key, value) in metadata {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
if key.is_empty() || value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = normalize_metadata_key(key);
|
||||
normalized.insert(key, value.to_string());
|
||||
}
|
||||
|
||||
let total_bytes = normalized
|
||||
.iter()
|
||||
.map(|(key, value)| key.len() + value.len())
|
||||
.sum::<usize>();
|
||||
|
||||
if total_bytes > DEFAULT_METADATA_TOTAL_BYTES_LIMIT {
|
||||
return Err(OssError::InvalidRequest(format!(
|
||||
"x-oss-meta-* 总大小不能超过 {} 字节",
|
||||
DEFAULT_METADATA_TOTAL_BYTES_LIMIT
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_metadata_key(raw: &str) -> String {
|
||||
let stripped = raw
|
||||
.trim()
|
||||
.trim_start_matches("x-oss-meta-")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let sanitized = stripped
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' => character,
|
||||
'_' | ' ' | '/' | '.' => '-',
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>();
|
||||
let sanitized = collapse_dashes(&sanitized);
|
||||
|
||||
format!(
|
||||
"x-oss-meta-{}",
|
||||
if sanitized.is_empty() {
|
||||
"metadata".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_path_segment(raw: &str) -> String {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
collapse_dashes(&normalized)
|
||||
}
|
||||
|
||||
fn sanitize_file_name(raw: &str) -> Result<String, OssError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(OssError::InvalidRequest("fileName 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let file_name = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed).trim();
|
||||
|
||||
if file_name.is_empty() {
|
||||
return Err(OssError::InvalidRequest("fileName 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let (raw_stem, raw_extension) = match file_name.rsplit_once('.') {
|
||||
Some((stem, extension)) if !stem.trim().is_empty() && !extension.trim().is_empty() => {
|
||||
(stem, Some(extension))
|
||||
}
|
||||
_ => (file_name, None),
|
||||
};
|
||||
|
||||
let stem = raw_stem
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>();
|
||||
let stem = collapse_dashes(&stem);
|
||||
|
||||
if stem.is_empty() {
|
||||
return Err(OssError::InvalidRequest("fileName 主体不合法".to_string()));
|
||||
}
|
||||
|
||||
let extension = raw_extension
|
||||
.map(|extension| {
|
||||
extension
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
})
|
||||
.filter(|extension| !extension.is_empty());
|
||||
|
||||
Ok(match extension {
|
||||
Some(extension) => format!("{stem}.{extension}"),
|
||||
None => stem,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_value(value: String, message: &str) -> Result<String, OssError> {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Err(OssError::InvalidConfig(message.to_string()));
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_base_url(value: Option<String>) -> Option<String> {
|
||||
normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn normalize_endpoint(raw: &str) -> Result<String, OssError> {
|
||||
let endpoint = raw
|
||||
.trim()
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_matches('/')
|
||||
.to_string();
|
||||
|
||||
if endpoint.is_empty() {
|
||||
return Err(OssError::InvalidConfig("OSS endpoint 不能为空".to_string()));
|
||||
}
|
||||
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
fn collapse_dashes(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.fold(
|
||||
(String::new(), false),
|
||||
|(mut output, last_is_dash), character| {
|
||||
let is_dash = character == '-';
|
||||
if is_dash && last_is_dash {
|
||||
return (output, true);
|
||||
}
|
||||
|
||||
output.push(character);
|
||||
(output, is_dash)
|
||||
},
|
||||
)
|
||||
.0
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result<String, OssError> {
|
||||
let mut signer = HmacSha1::new_from_slice(access_key_secret.as_bytes())
|
||||
.map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA1 失败:{error}")))?;
|
||||
signer.update(encoded_policy.as_bytes());
|
||||
|
||||
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_client() -> OssClient {
|
||||
OssClient::new(
|
||||
OssConfig::new(
|
||||
"genarrative-assets".to_string(),
|
||||
"oss-cn-shanghai.aliyuncs.com".to_string(),
|
||||
"test-access-key-id".to_string(),
|
||||
"test-access-key-secret".to_string(),
|
||||
Some("https://cdn.genarrative.local".to_string()),
|
||||
DEFAULT_POST_EXPIRE_SECONDS,
|
||||
DEFAULT_POST_MAX_SIZE_BYTES,
|
||||
DEFAULT_SUCCESS_ACTION_STATUS,
|
||||
)
|
||||
.expect("OSS config should be valid"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_legacy_prefix_accepts_public_style_path() {
|
||||
assert_eq!(
|
||||
LegacyAssetPrefix::parse("/generated-characters/*"),
|
||||
Some(LegacyAssetPrefix::Characters)
|
||||
);
|
||||
assert_eq!(LegacyAssetPrefix::parse("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_post_object_returns_legacy_compatible_key_and_urls() {
|
||||
let client = build_client();
|
||||
let mut metadata = BTreeMap::new();
|
||||
metadata.insert("asset-kind".to_string(), "character-visual".to_string());
|
||||
metadata.insert("origin".to_string(), "browser-upload".to_string());
|
||||
|
||||
let response = client
|
||||
.sign_post_object(OssPostObjectRequest {
|
||||
prefix: LegacyAssetPrefix::Characters,
|
||||
path_segments: vec![
|
||||
"Hero_001".to_string(),
|
||||
"Visual".to_string(),
|
||||
"Asset_01".to_string(),
|
||||
],
|
||||
file_name: "Master.PNG".to_string(),
|
||||
content_type: Some("image/png".to_string()),
|
||||
access: OssObjectAccess::Public,
|
||||
metadata,
|
||||
max_size_bytes: Some(5 * 1024 * 1024),
|
||||
expire_seconds: Some(300),
|
||||
success_action_status: Some(200),
|
||||
})
|
||||
.expect("post object signature should build");
|
||||
|
||||
assert_eq!(
|
||||
response.object_key,
|
||||
"generated-characters/hero_001/visual/asset_01/master.png"
|
||||
);
|
||||
assert_eq!(
|
||||
response.legacy_public_path,
|
||||
"/generated-characters/hero_001/visual/asset_01/master.png"
|
||||
);
|
||||
assert_eq!(
|
||||
response.public_url.as_deref(),
|
||||
Some(
|
||||
"https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
response.form_fields.oss_access_key_id,
|
||||
"test-access-key-id".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
response.form_fields.metadata.get("x-oss-meta-asset-kind"),
|
||||
Some(&"character-visual".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_post_object_embeds_policy_constraints() {
|
||||
let client = build_client();
|
||||
let response = client
|
||||
.sign_post_object(OssPostObjectRequest {
|
||||
prefix: LegacyAssetPrefix::QwenSprites,
|
||||
path_segments: vec!["_drafts".to_string(), "master".to_string()],
|
||||
file_name: "candidate-01.png".to_string(),
|
||||
content_type: Some("image/png".to_string()),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: BTreeMap::new(),
|
||||
max_size_bytes: Some(1024),
|
||||
expire_seconds: Some(60),
|
||||
success_action_status: Some(200),
|
||||
})
|
||||
.expect("post object signature should build");
|
||||
|
||||
let decoded_policy = BASE64_STANDARD
|
||||
.decode(response.form_fields.policy.as_bytes())
|
||||
.expect("policy should be valid base64");
|
||||
let policy: Value =
|
||||
serde_json::from_slice(&decoded_policy).expect("policy should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
policy["conditions"][0]["bucket"],
|
||||
Value::String("genarrative-assets".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
policy["conditions"][1],
|
||||
json!([
|
||||
"eq",
|
||||
"$key",
|
||||
"generated-qwen-sprites/_drafts/master/candidate-01.png"
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
policy["conditions"][2],
|
||||
json!(["content-length-range", 1, 1024])
|
||||
);
|
||||
assert_eq!(
|
||||
policy["conditions"][3],
|
||||
json!(["eq", "$success_action_status", "200"])
|
||||
);
|
||||
assert_eq!(
|
||||
policy["conditions"][4],
|
||||
json!(["eq", "$content-type", "image/png"])
|
||||
);
|
||||
assert!(response.public_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_file_name_rejects_empty_input() {
|
||||
let error = sanitize_file_name(" ").expect_err("empty file name should fail");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
OssError::InvalidRequest("fileName 不能为空".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user