diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 25233171..e2804968 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -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。 - [ ] 手机验证码主链可用 - [ ] 微信登录主链可用 说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。 diff --git a/docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md b/docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md new file mode 100644 index 00000000..77fc7c54 --- /dev/null +++ b/docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md @@ -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": "" +} +``` + +同时响应头必须写回新的 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. 文档、任务清单与测试已同步更新。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 2a3c9cce..497d1e37 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -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" diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 1be32051..d017a892 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -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 重签。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 03c80afc..8b298524 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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")) + ); + } } diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs new file mode 100644 index 00000000..3af7d229 --- /dev/null +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -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 { + 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 { + 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 { + 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 { + 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, + } +} diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index a2c91440..88853a9b 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -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, + 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 { diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index b43c6bcb..dfe2ed12 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index bc7c2d5b..e61ac7a3 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -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, diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs new file mode 100644 index 00000000..cb88fb81 --- /dev/null +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -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, + Extension(request_context): Extension, + maybe_refresh_token: Option>, +) -> Result { + 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 +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 67f8de8b..4b9d7757 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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, 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, 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, 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, diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index e9df1e16..45366aea 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/module-auth/README.md b/server-rs/crates/module-auth/README.md index 51cabdd9..6c48fa0b 100644 --- a/server-rs/crates/module-auth/README.md +++ b/server-rs/crates/module-auth/README.md @@ -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` 复用。 diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index a9f77c0d..db0681e7 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -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, + 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>, +pub struct InMemoryAuthStore { + inner: Arc>, } #[derive(Debug)] -struct InMemoryPasswordUserStoreState { - next_id: u64, +struct InMemoryAuthStoreState { + next_user_id: u64, users_by_username: HashMap, + sessions_by_id: HashMap, + session_id_by_refresh_token_hash: HashMap, } #[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 { + 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 { + 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, 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, 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, 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 { + 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 { 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); + } } diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index 68ef8899..fe739ef0 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 5923de9a..6785ef5f 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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")); + } }