feat: add refresh token rotation flow
This commit is contained in:
@@ -167,7 +167,8 @@
|
|||||||
交付物:[../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)
|
交付物:[../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 读取
|
- [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)
|
交付物:[../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)
|
||||||
- [ ] 实现会话吊销
|
- [ ] 实现会话吊销
|
||||||
- [ ] 实现全端登出
|
- [ ] 实现全端登出
|
||||||
- [x] 实现 `me` 查询
|
- [x] 实现 `me` 查询
|
||||||
@@ -219,7 +220,8 @@
|
|||||||
交付物:[../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)
|
交付物:[../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`
|
||||||
- [ ] 兼容 `/api/auth/logout-all`
|
- [ ] 兼容 `/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`
|
||||||
- [ ] 兼容 `/api/auth/sessions/:sessionId/revoke`
|
- [ ] 兼容 `/api/auth/sessions/:sessionId/revoke`
|
||||||
- [ ] 兼容 `/api/auth/audit-logs`
|
- [ ] 兼容 `/api/auth/audit-logs`
|
||||||
@@ -236,7 +238,8 @@
|
|||||||
|
|
||||||
- [x] 密码登录主链可用
|
- [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 写回。
|
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖自动建号、重复登录复用、错密码 `401`、非法用户名 `400` 与 refresh cookie 写回。
|
||||||
- [ ] refresh cookie 主链可用
|
- [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。
|
||||||
- [ ] 手机验证码主链可用
|
- [ ] 手机验证码主链可用
|
||||||
- [ ] 微信登录主链可用
|
- [ ] 微信登录主链可用
|
||||||
说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。
|
说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。
|
||||||
|
|||||||
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. 文档、任务清单与测试已同步更新。
|
||||||
14
server-rs/Cargo.lock
generated
14
server-rs/Cargo.lock
generated
@@ -545,7 +545,9 @@ name = "module-auth"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"platform-auth",
|
"platform-auth",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -638,6 +640,7 @@ dependencies = [
|
|||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sha2",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
@@ -831,6 +834,17 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
9. 接入 `POST /api/auth/entry` 首版密码登录链路
|
9. 接入 `POST /api/auth/entry` 首版密码登录链路
|
||||||
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
||||||
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
||||||
|
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
|
||||||
|
|
||||||
后续与本 crate 直接相关的任务包括:
|
后续与本 crate 直接相关的任务包括:
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
6. [x] 接入 `/api/auth/entry`
|
6. [x] 接入 `/api/auth/entry`
|
||||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||||
8. [x] 接入 `/api/auth/me`
|
8. [x] 接入 `/api/auth/me`
|
||||||
|
9. [x] 接入 `/api/auth/refresh`
|
||||||
|
|
||||||
当前 tracing 约定:
|
当前 tracing 约定:
|
||||||
|
|
||||||
@@ -102,3 +104,4 @@
|
|||||||
4. 不把领域规则直接堆在 handler 中。
|
4. 不把领域规则直接堆在 handler 中。
|
||||||
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
||||||
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
||||||
|
7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use crate::{
|
|||||||
error_middleware::normalize_error_response,
|
error_middleware::normalize_error_response,
|
||||||
health::health_check,
|
health::health_check,
|
||||||
password_entry::password_entry,
|
password_entry::password_entry,
|
||||||
|
refresh_session::refresh_session,
|
||||||
request_context::{attach_request_context, resolve_request_id},
|
request_context::{attach_request_context, resolve_request_id},
|
||||||
response_headers::propagate_request_id_header,
|
response_headers::propagate_request_id_header,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@@ -54,6 +55,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
post(refresh_session).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
attach_refresh_session_token,
|
||||||
|
)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/assets/direct-upload-tickets",
|
"/api/assets/direct-upload-tickets",
|
||||||
post(create_direct_upload_ticket),
|
post(create_direct_upload_ticket),
|
||||||
@@ -616,4 +624,112 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
|
http::{HeaderMap, HeaderValue},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -13,6 +14,7 @@ pub struct AppError {
|
|||||||
code: &'static str,
|
code: &'static str,
|
||||||
message: String,
|
message: String,
|
||||||
details: Option<Value>,
|
details: Option<Value>,
|
||||||
|
headers: HeaderMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -32,6 +34,7 @@ impl AppError {
|
|||||||
code,
|
code,
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
details: None,
|
details: None,
|
||||||
|
headers: HeaderMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +52,17 @@ impl AppError {
|
|||||||
self
|
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 {
|
pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response {
|
||||||
let status_code = self.status_code;
|
let status_code = self.status_code;
|
||||||
let payload = self.to_payload();
|
let payload = self.to_payload();
|
||||||
|
let mut response = (status_code, json_error_body(request_context, &payload)).into_response();
|
||||||
(status_code, json_error_body(request_context, &payload)).into_response()
|
response.headers_mut().extend(self.headers);
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_payload(&self) -> ApiErrorPayload {
|
fn to_payload(&self) -> ApiErrorPayload {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ mod api_response;
|
|||||||
mod app;
|
mod app;
|
||||||
mod assets;
|
mod assets;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod auth_session;
|
||||||
mod auth_me;
|
mod auth_me;
|
||||||
mod config;
|
mod config;
|
||||||
mod error_middleware;
|
mod error_middleware;
|
||||||
mod health;
|
mod health;
|
||||||
mod http_error;
|
mod http_error;
|
||||||
mod password_entry;
|
mod password_entry;
|
||||||
|
mod refresh_session;
|
||||||
mod request_context;
|
mod request_context;
|
||||||
mod response_headers;
|
mod response_headers;
|
||||||
mod state;
|
mod state;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Extension, State},
|
extract::{Extension, State},
|
||||||
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE},
|
http::{HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use module_auth::{PasswordEntryError, PasswordEntryInput};
|
use module_auth::{PasswordEntryError, PasswordEntryInput};
|
||||||
use platform_auth::{
|
|
||||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
|
||||||
build_refresh_session_set_cookie, create_refresh_session_token, sign_access_token,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::OffsetDateTime;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
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,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,45 +57,20 @@ pub async fn password_entry(
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(map_password_entry_error)?;
|
.map_err(map_password_entry_error)?;
|
||||||
|
let signed_session = create_password_auth_session(&state, &result.user)?;
|
||||||
let refresh_session_token = create_refresh_session_token();
|
|
||||||
let access_claims = AccessTokenClaims::from_input(
|
|
||||||
AccessTokenClaimsInput {
|
|
||||||
user_id: result.user.id.clone(),
|
|
||||||
session_id: refresh_session_token.clone(),
|
|
||||||
provider: AuthProvider::Password,
|
|
||||||
roles: vec!["user".to_string()],
|
|
||||||
token_version: result.user.token_version,
|
|
||||||
phone_verified: false,
|
|
||||||
binding_status: BindingStatus::Active,
|
|
||||||
display_name: Some(result.user.display_name.clone()),
|
|
||||||
},
|
|
||||||
state.auth_jwt_config(),
|
|
||||||
OffsetDateTime::now_utc(),
|
|
||||||
)
|
|
||||||
.map_err(|error| {
|
|
||||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
|
||||||
})?;
|
|
||||||
let access_token =
|
|
||||||
sign_access_token(&access_claims, state.auth_jwt_config()).map_err(|error| {
|
|
||||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
|
||||||
})?;
|
|
||||||
let refresh_cookie =
|
|
||||||
build_refresh_session_set_cookie(&refresh_session_token, state.refresh_cookie_config());
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
let set_cookie = HeaderValue::from_str(&refresh_cookie).map_err(|error| {
|
attach_set_cookie_header(
|
||||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
&mut headers,
|
||||||
.with_message(format!("refresh cookie 头构造失败:{error}"))
|
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
|
||||||
})?;
|
);
|
||||||
headers.insert(SET_COOKIE, set_cookie);
|
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
headers,
|
headers,
|
||||||
json_success_body(
|
json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
PasswordEntryResponse {
|
PasswordEntryResponse {
|
||||||
token: access_token,
|
token: signed_session.access_token,
|
||||||
user: PasswordEntryUserPayload {
|
user: PasswordEntryUserPayload {
|
||||||
id: result.user.id,
|
id: result.user.id,
|
||||||
username: result.user.username,
|
username: result.user.username,
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{error::Error, fmt};
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
use module_auth::{InMemoryPasswordUserStore, PasswordEntryService};
|
use module_auth::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||||
use platform_auth::{
|
use platform_auth::{
|
||||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||||
};
|
};
|
||||||
@@ -18,6 +18,7 @@ pub struct AppState {
|
|||||||
refresh_cookie_config: RefreshCookieConfig,
|
refresh_cookie_config: RefreshCookieConfig,
|
||||||
oss_client: Option<OssClient>,
|
oss_client: Option<OssClient>,
|
||||||
password_entry_service: PasswordEntryService,
|
password_entry_service: PasswordEntryService,
|
||||||
|
refresh_session_service: RefreshSessionService,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -46,8 +47,10 @@ impl AppState {
|
|||||||
config.refresh_session_ttl_days,
|
config.refresh_session_ttl_days,
|
||||||
)?;
|
)?;
|
||||||
let oss_client = build_oss_client(&config)?;
|
let oss_client = build_oss_client(&config)?;
|
||||||
let password_entry_service =
|
let auth_store = InMemoryAuthStore::default();
|
||||||
PasswordEntryService::new(InMemoryPasswordUserStore::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 {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
@@ -55,6 +58,7 @@ impl AppState {
|
|||||||
refresh_cookie_config,
|
refresh_cookie_config,
|
||||||
oss_client,
|
oss_client,
|
||||||
password_entry_service,
|
password_entry_service,
|
||||||
|
refresh_session_service,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +77,10 @@ impl AppState {
|
|||||||
pub fn password_entry_service(&self) -> &PasswordEntryService {
|
pub fn password_entry_service(&self) -> &PasswordEntryService {
|
||||||
&self.password_entry_service
|
&self.password_entry_service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn refresh_session_service(&self) -> &RefreshSessionService {
|
||||||
|
&self.refresh_session_service
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for AppStateInitError {
|
impl fmt::Display for AppStateInitError {
|
||||||
@@ -109,8 +117,7 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
|
|||||||
let has_any_oss_field = config.oss_bucket.is_some()
|
let has_any_oss_field = config.oss_bucket.is_some()
|
||||||
|| config.oss_endpoint.is_some()
|
|| config.oss_endpoint.is_some()
|
||||||
|| config.oss_access_key_id.is_some()
|
|| config.oss_access_key_id.is_some()
|
||||||
|| config.oss_access_key_secret.is_some()
|
|| config.oss_access_key_secret.is_some();
|
||||||
|| config.oss_public_base_url.is_some();
|
|
||||||
|
|
||||||
if !has_any_oss_field {
|
if !has_any_oss_field {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -121,7 +128,7 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
|
|||||||
config.oss_endpoint.clone().unwrap_or_default(),
|
config.oss_endpoint.clone().unwrap_or_default(),
|
||||||
config.oss_access_key_id.clone().unwrap_or_default(),
|
config.oss_access_key_id.clone().unwrap_or_default(),
|
||||||
config.oss_access_key_secret.clone().unwrap_or_default(),
|
config.oss_access_key_secret.clone().unwrap_or_default(),
|
||||||
config.oss_public_base_url.clone(),
|
config.oss_read_expire_seconds,
|
||||||
config.oss_post_expire_seconds,
|
config.oss_post_expire_seconds,
|
||||||
config.oss_post_max_size_bytes,
|
config.oss_post_max_size_bytes,
|
||||||
config.oss_success_action_status,
|
config.oss_success_action_status,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
platform-auth = { path = "../platform-auth" }
|
platform-auth = { path = "../platform-auth" }
|
||||||
|
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["macros", "rt"] }
|
tokio = { version = "1", features = ["macros", "rt"] }
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
当前连续实现优先顺序固定为:
|
当前连续实现优先顺序固定为:
|
||||||
|
|
||||||
1. 密码登录
|
1. 密码登录
|
||||||
2. `me` 查询
|
2. refresh token 轮换
|
||||||
3. refresh token 轮换
|
3. `me` 查询
|
||||||
4. 会话吊销
|
4. 会话吊销
|
||||||
5. 手机验证码登录
|
5. 手机验证码登录
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
9. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_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)
|
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)
|
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)
|
||||||
|
|
||||||
## 4. 边界约束
|
## 4. 边界约束
|
||||||
|
|
||||||
@@ -50,3 +51,4 @@
|
|||||||
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
||||||
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||||
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
||||||
|
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use platform_auth::{hash_password, verify_password};
|
use platform_auth::{hash_password, verify_password};
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
const USERNAME_MIN_LENGTH: usize = 3;
|
const USERNAME_MIN_LENGTH: usize = 3;
|
||||||
const USERNAME_MAX_LENGTH: usize = 24;
|
const USERNAME_MAX_LENGTH: usize = 24;
|
||||||
@@ -37,6 +39,11 @@ pub struct AuthUser {
|
|||||||
pub token_version: u64,
|
pub token_version: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AuthMeResult {
|
||||||
|
pub user: AuthUser,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PasswordEntryInput {
|
pub struct PasswordEntryInput {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -50,7 +57,39 @@ pub struct PasswordEntryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct AuthMeResult {
|
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,
|
pub user: AuthUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,15 +102,26 @@ pub enum PasswordEntryError {
|
|||||||
PasswordHash(String),
|
PasswordHash(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RefreshSessionError {
|
||||||
|
MissingToken,
|
||||||
|
SessionNotFound,
|
||||||
|
SessionExpired,
|
||||||
|
UserNotFound,
|
||||||
|
Store(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct InMemoryPasswordUserStore {
|
pub struct InMemoryAuthStore {
|
||||||
inner: Arc<Mutex<InMemoryPasswordUserStoreState>>,
|
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct InMemoryPasswordUserStoreState {
|
struct InMemoryAuthStoreState {
|
||||||
next_id: u64,
|
next_user_id: u64,
|
||||||
users_by_username: HashMap<String, StoredPasswordUser>,
|
users_by_username: HashMap<String, StoredPasswordUser>,
|
||||||
|
sessions_by_id: HashMap<String, StoredRefreshSession>,
|
||||||
|
session_id_by_refresh_token_hash: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -80,13 +130,24 @@ struct StoredPasswordUser {
|
|||||||
password_hash: String,
|
password_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct StoredRefreshSession {
|
||||||
|
session: RefreshSessionRecord,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PasswordEntryService {
|
pub struct PasswordEntryService {
|
||||||
store: InMemoryPasswordUserStore,
|
store: InMemoryAuthStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RefreshSessionService {
|
||||||
|
store: InMemoryAuthStore,
|
||||||
|
refresh_session_ttl_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PasswordEntryService {
|
impl PasswordEntryService {
|
||||||
pub fn new(store: InMemoryPasswordUserStore) -> Self {
|
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||||
Self { store }
|
Self { store }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +175,7 @@ impl PasswordEntryService {
|
|||||||
let password_hash = hash_password(&input.password)
|
let password_hash = hash_password(&input.password)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||||
match self
|
match self.store.create_user(username.clone(), password_hash) {
|
||||||
.store
|
|
||||||
.create_user(username.clone(), password_hash.clone())
|
|
||||||
{
|
|
||||||
Ok(user) => Ok(PasswordEntryResult {
|
Ok(user) => Ok(PasswordEntryResult {
|
||||||
user,
|
user,
|
||||||
created: true,
|
created: true,
|
||||||
@@ -141,9 +199,7 @@ impl PasswordEntryService {
|
|||||||
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
|
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl PasswordEntryService {
|
|
||||||
pub fn get_user_by_id(
|
pub fn get_user_by_id(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -154,18 +210,129 @@ impl PasswordEntryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for InMemoryPasswordUserStore {
|
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 {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Mutex::new(InMemoryPasswordUserStoreState {
|
inner: Arc::new(Mutex::new(InMemoryAuthStoreState {
|
||||||
next_id: 1,
|
next_user_id: 1,
|
||||||
users_by_username: HashMap::new(),
|
users_by_username: HashMap::new(),
|
||||||
|
sessions_by_id: HashMap::new(),
|
||||||
|
session_id_by_refresh_token_hash: HashMap::new(),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InMemoryPasswordUserStore {
|
impl InMemoryAuthStore {
|
||||||
fn find_by_username(
|
fn find_by_username(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
@@ -177,6 +344,22 @@ impl InMemoryPasswordUserStore {
|
|||||||
Ok(state.users_by_username.get(username).cloned())
|
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(
|
fn create_user(
|
||||||
&self,
|
&self,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -191,8 +374,8 @@ impl InMemoryPasswordUserStore {
|
|||||||
return Err(CreateUserError::AlreadyExists);
|
return Err(CreateUserError::AlreadyExists);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_id = format!("user_{:08}", state.next_id);
|
let user_id = format!("user_{:08}", state.next_user_id);
|
||||||
state.next_id += 1;
|
state.next_user_id += 1;
|
||||||
|
|
||||||
let user = AuthUser {
|
let user = AuthUser {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
@@ -215,20 +398,102 @@ impl InMemoryPasswordUserStore {
|
|||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_by_user_id(
|
fn insert_session(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
session: RefreshSessionRecord,
|
||||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
) -> 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
|
let state = self
|
||||||
.inner
|
.inner
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
.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
|
Ok(state.sessions_by_id.get(session_id).cloned())
|
||||||
.users_by_username
|
}
|
||||||
.values()
|
|
||||||
.find(|stored_user| stored_user.user.id == user_id)
|
fn rotate_session(
|
||||||
.cloned())
|
&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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +535,32 @@ impl fmt::Display for PasswordEntryError {
|
|||||||
|
|
||||||
impl Error for PasswordEntryError {}
|
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> {
|
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||||
let username = raw_username.trim().to_string();
|
let username = raw_username.trim().to_string();
|
||||||
let valid_length =
|
let valid_length =
|
||||||
@@ -296,15 +587,25 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use platform_auth::hash_refresh_session_token;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn build_service() -> PasswordEntryService {
|
fn build_store() -> InMemoryAuthStore {
|
||||||
PasswordEntryService::new(InMemoryPasswordUserStore::default())
|
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]
|
#[tokio::test]
|
||||||
async fn first_password_entry_creates_user() {
|
async fn first_password_entry_creates_user() {
|
||||||
let service = build_service();
|
let service = build_password_service(build_store());
|
||||||
|
|
||||||
let result = service
|
let result = service
|
||||||
.execute(PasswordEntryInput {
|
.execute(PasswordEntryInput {
|
||||||
@@ -324,7 +625,8 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repeated_password_entry_reuses_same_user() {
|
async fn repeated_password_entry_reuses_same_user() {
|
||||||
let service = build_service();
|
let store = build_store();
|
||||||
|
let service = build_password_service(store);
|
||||||
let first = service
|
let first = service
|
||||||
.execute(PasswordEntryInput {
|
.execute(PasswordEntryInput {
|
||||||
username: "guest_001".to_string(),
|
username: "guest_001".to_string(),
|
||||||
@@ -348,7 +650,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repeated_password_entry_rejects_wrong_password() {
|
async fn repeated_password_entry_rejects_wrong_password() {
|
||||||
let service = build_service();
|
let service = build_password_service(build_store());
|
||||||
service
|
service
|
||||||
.execute(PasswordEntryInput {
|
.execute(PasswordEntryInput {
|
||||||
username: "guest_001".to_string(),
|
username: "guest_001".to_string(),
|
||||||
@@ -370,7 +672,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invalid_username_returns_bad_request_error() {
|
async fn invalid_username_returns_bad_request_error() {
|
||||||
let service = build_service();
|
let service = build_password_service(build_store());
|
||||||
|
|
||||||
let error = service
|
let error = service
|
||||||
.execute(PasswordEntryInput {
|
.execute(PasswordEntryInput {
|
||||||
@@ -382,4 +684,66 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(error, PasswordEntryError::InvalidUsername);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
sha2 = "0.10"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use jsonwebtoken::{
|
|||||||
};
|
};
|
||||||
use rand_core::OsRng;
|
use rand_core::OsRng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -403,6 +404,12 @@ pub fn create_refresh_session_token() -> String {
|
|||||||
Uuid::new_v4().simple().to_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 {
|
pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfig) -> String {
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!(
|
format!(
|
||||||
@@ -426,6 +433,22 @@ pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfi
|
|||||||
parts.join("; ")
|
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 {
|
impl fmt::Display for JwtError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -651,4 +674,25 @@ mod tests {
|
|||||||
assert!(cookie.contains("SameSite=Lax"));
|
assert!(cookie.contains("SameSite=Lax"));
|
||||||
assert!(cookie.contains("Max-Age=2592000"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user