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)
|
||||
- [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)
|
||||
- [ ] 实现会话吊销
|
||||
- [ ] 实现全端登出
|
||||
- [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)
|
||||
- [ ] 兼容 `/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`
|
||||
@@ -236,7 +238,8 @@
|
||||
|
||||
- [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 写回。
|
||||
- [ ] 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"
|
||||
dependencies = [
|
||||
"platform-auth",
|
||||
"time",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -638,6 +640,7 @@ dependencies = [
|
||||
"jsonwebtoken",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"sha2",
|
||||
"time",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
@@ -831,6 +834,17 @@ dependencies = [
|
||||
"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"
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
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 直接相关的任务包括:
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
6. [x] 接入 `/api/auth/entry`
|
||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||
8. [x] 接入 `/api/auth/me`
|
||||
9. [x] 接入 `/api/auth/refresh`
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -102,3 +104,4 @@
|
||||
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 重签。
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::{
|
||||
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,
|
||||
@@ -54,6 +55,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
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),
|
||||
@@ -616,4 +624,112 @@ mod tests {
|
||||
|
||||
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::{
|
||||
http::{HeaderMap, HeaderValue},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -13,6 +14,7 @@ pub struct AppError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
details: Option<Value>,
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -32,6 +34,7 @@ impl AppError {
|
||||
code,
|
||||
message: message.to_string(),
|
||||
details: None,
|
||||
headers: HeaderMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +52,17 @@ impl AppError {
|
||||
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 {
|
||||
|
||||
@@ -2,12 +2,14 @@ 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;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
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_json::json;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -57,45 +57,20 @@ pub async fn password_entry(
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_entry_error)?;
|
||||
|
||||
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 signed_session = create_password_auth_session(&state, &result.user)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
let set_cookie = HeaderValue::from_str(&refresh_cookie).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(format!("refresh cookie 头构造失败:{error}"))
|
||||
})?;
|
||||
headers.insert(SET_COOKIE, set_cookie);
|
||||
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: access_token,
|
||||
token: signed_session.access_token,
|
||||
user: PasswordEntryUserPayload {
|
||||
id: result.user.id,
|
||||
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 module_auth::{InMemoryPasswordUserStore, PasswordEntryService};
|
||||
use module_auth::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||
use platform_auth::{
|
||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
};
|
||||
@@ -18,6 +18,7 @@ pub struct AppState {
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
oss_client: Option<OssClient>,
|
||||
password_entry_service: PasswordEntryService,
|
||||
refresh_session_service: RefreshSessionService,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -46,8 +47,10 @@ impl AppState {
|
||||
config.refresh_session_ttl_days,
|
||||
)?;
|
||||
let oss_client = build_oss_client(&config)?;
|
||||
let password_entry_service =
|
||||
PasswordEntryService::new(InMemoryPasswordUserStore::default());
|
||||
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,
|
||||
@@ -55,6 +58,7 @@ impl AppState {
|
||||
refresh_cookie_config,
|
||||
oss_client,
|
||||
password_entry_service,
|
||||
refresh_session_service,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +77,10 @@ impl AppState {
|
||||
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 {
|
||||
@@ -109,8 +117,7 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
|
||||
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()
|
||||
|| config.oss_public_base_url.is_some();
|
||||
|| config.oss_access_key_secret.is_some();
|
||||
|
||||
if !has_any_oss_field {
|
||||
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_access_key_id.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_max_size_bytes,
|
||||
config.oss_success_action_status,
|
||||
|
||||
@@ -6,6 +6,8 @@ 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"] }
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
当前连续实现优先顺序固定为:
|
||||
|
||||
1. 密码登录
|
||||
2. `me` 查询
|
||||
3. refresh token 轮换
|
||||
2. refresh token 轮换
|
||||
3. `me` 查询
|
||||
4. 会话吊销
|
||||
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)
|
||||
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)
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
@@ -50,3 +51,4 @@
|
||||
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
||||
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||
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 time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
const USERNAME_MIN_LENGTH: usize = 3;
|
||||
const USERNAME_MAX_LENGTH: usize = 24;
|
||||
@@ -37,6 +39,11 @@ pub struct AuthUser {
|
||||
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,
|
||||
@@ -50,7 +57,39 @@ pub struct PasswordEntryResult {
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -63,15 +102,26 @@ pub enum PasswordEntryError {
|
||||
PasswordHash(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RefreshSessionError {
|
||||
MissingToken,
|
||||
SessionNotFound,
|
||||
SessionExpired,
|
||||
UserNotFound,
|
||||
Store(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryPasswordUserStore {
|
||||
inner: Arc<Mutex<InMemoryPasswordUserStoreState>>,
|
||||
pub struct InMemoryAuthStore {
|
||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InMemoryPasswordUserStoreState {
|
||||
next_id: u64,
|
||||
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)]
|
||||
@@ -80,13 +130,24 @@ struct StoredPasswordUser {
|
||||
password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredRefreshSession {
|
||||
session: RefreshSessionRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PasswordEntryService {
|
||||
store: InMemoryPasswordUserStore,
|
||||
store: InMemoryAuthStore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefreshSessionService {
|
||||
store: InMemoryAuthStore,
|
||||
refresh_session_ttl_days: u32,
|
||||
}
|
||||
|
||||
impl PasswordEntryService {
|
||||
pub fn new(store: InMemoryPasswordUserStore) -> Self {
|
||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
@@ -114,10 +175,7 @@ impl PasswordEntryService {
|
||||
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.clone())
|
||||
{
|
||||
match self.store.create_user(username.clone(), password_hash) {
|
||||
Ok(user) => Ok(PasswordEntryResult {
|
||||
user,
|
||||
created: true,
|
||||
@@ -141,9 +199,7 @@ impl PasswordEntryService {
|
||||
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordEntryService {
|
||||
pub fn get_user_by_id(
|
||||
&self,
|
||||
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 {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(InMemoryPasswordUserStoreState {
|
||||
next_id: 1,
|
||||
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 InMemoryPasswordUserStore {
|
||||
impl InMemoryAuthStore {
|
||||
fn find_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
@@ -177,6 +344,22 @@ impl InMemoryPasswordUserStore {
|
||||
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,
|
||||
@@ -191,8 +374,8 @@ impl InMemoryPasswordUserStore {
|
||||
return Err(CreateUserError::AlreadyExists);
|
||||
}
|
||||
|
||||
let user_id = format!("user_{:08}", state.next_id);
|
||||
state.next_id += 1;
|
||||
let user_id = format!("user_{:08}", state.next_user_id);
|
||||
state.next_user_id += 1;
|
||||
|
||||
let user = AuthUser {
|
||||
id: user_id,
|
||||
@@ -215,20 +398,102 @@ impl InMemoryPasswordUserStore {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn find_by_user_id(
|
||||
fn insert_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
||||
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(|_| 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
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == user_id)
|
||||
.cloned())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +535,32 @@ impl fmt::Display 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> {
|
||||
let username = raw_username.trim().to_string();
|
||||
let valid_length =
|
||||
@@ -296,15 +587,25 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn build_service() -> PasswordEntryService {
|
||||
PasswordEntryService::new(InMemoryPasswordUserStore::default())
|
||||
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_service();
|
||||
let service = build_password_service(build_store());
|
||||
|
||||
let result = service
|
||||
.execute(PasswordEntryInput {
|
||||
@@ -324,7 +625,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
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
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
@@ -348,7 +650,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_password_entry_rejects_wrong_password() {
|
||||
let service = build_service();
|
||||
let service = build_password_service(build_store());
|
||||
service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
@@ -370,7 +672,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_username_returns_bad_request_error() {
|
||||
let service = build_service();
|
||||
let service = build_password_service(build_store());
|
||||
|
||||
let error = service
|
||||
.execute(PasswordEntryInput {
|
||||
@@ -382,4 +684,66 @@ mod tests {
|
||||
|
||||
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]
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
jsonwebtoken = "9"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -6,6 +6,7 @@ use jsonwebtoken::{
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -403,6 +404,12 @@ 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!(
|
||||
@@ -426,6 +433,22 @@ pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfi
|
||||
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 {
|
||||
@@ -651,4 +674,25 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user