From c3c5f1acd7cde9b396de972e97c9b4c3c8376afb Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 16:50:56 +0800 Subject: [PATCH] feat: add logout all flow --- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 6 +- .../AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md | 177 +++++++++++++++ docs/technical/README.md | 2 + server-rs/crates/api-server/src/app.rs | 211 ++++++++++++++++++ server-rs/crates/api-server/src/logout_all.rs | 51 +++++ server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/module-auth/src/lib.rs | 143 ++++++++++++ 7 files changed, 589 insertions(+), 2 deletions(-) create mode 100644 docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md create mode 100644 server-rs/crates/api-server/src/logout_all.rs 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 e6f319a0..267b0f68 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -173,7 +173,8 @@ 交付物:[../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](../docs/technical/MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md)、[../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)、[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs)、[../server-rs/crates/api-server/src/auth_sessions.rs](../server-rs/crates/api-server/src/auth_sessions.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)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../packages/shared/src/contracts/auth.ts](../packages/shared/src/contracts/auth.ts) - [x] 实现会话吊销 交付物:[../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.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/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [ ] 实现全端登出 +- [x] 实现全端登出 + 交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - [x] 实现 `me` 查询 交付物:[../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) @@ -223,7 +224,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) - [x] 兼容 `/api/auth/logout` 交付物:[../server-rs/crates/api-server/src/logout.rs](../server-rs/crates/api-server/src/logout.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [ ] 兼容 `/api/auth/logout-all` +- [x] 兼容 `/api/auth/logout-all` + 交付物:[../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](../docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/logout_all.rs](../server-rs/crates/api-server/src/logout_all.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) - [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) - [x] 兼容 `/api/auth/sessions` diff --git a/docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md new file mode 100644 index 00000000..6b7f650b --- /dev/null +++ b/docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md @@ -0,0 +1,177 @@ +# `/api/auth/logout-all` 全端登出落地设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于指导 `M2` 中 `实现全端登出` 的首版落地,冻结: + +1. `POST /api/auth/logout-all` 的请求与响应 contract +2. 全部 refresh session 吊销与 `token_version` 递增的组合语义 +3. Rust 首版在进程内鉴权真相中的最小实现边界 +4. 与 `/logout`、`/sessions/:sessionId/revoke` 的职责切分 + +## 2. 当前基线 + +当前 Node `/api/auth/logout-all` 已具备以下稳定语义: + +1. 必须先通过 Bearer JWT 校验 +2. 对当前用户执行 `token_version + 1` +3. 吊销该用户全部未吊销 refresh session +4. 响应成功时始终清理 refresh cookie + +因此,Node 的“退出全部设备”同样是两层组合动作: + +1. 会话级:吊销同一账号全部 refresh session +2. 用户级:递增 `token_version`,让全部旧 access token 立即失效 + +Rust 首版必须保留这个语义。 + +## 3. 当前阶段范围 + +本阶段只落以下内容: + +1. `module-auth` 增加按 `user_id` 吊销全部 refresh session 的能力 +2. `api-server` 暴露 `POST /api/auth/logout-all` +3. 成功场景统一清理 refresh cookie + +本阶段明确不包含: + +1. `/api/auth/sessions/:sessionId/revoke` +2. 审计日志正式落表 +3. SpacetimeDB reducer 真正写表 + +## 4. contract + +### 4.1 请求 + +1. 方法:`POST` +2. 路径:`/api/auth/logout-all` +3. 请求体:空 +4. 鉴权: + - Bearer JWT 必填 + - refresh cookie 选填 + +### 4.2 成功响应 + +```json +{ + "ok": true +} +``` + +同时响应头必须写回清理后的 refresh cookie。 + +### 4.3 失败响应 + +以下情况返回 `401 UNAUTHORIZED`: + +1. Bearer JWT 缺失或非法 +2. JWT 对应用户不存在 + +## 5. 固定语义 + +### 5.1 动作顺序 + +`POST /api/auth/logout-all` 固定按以下顺序执行: + +1. 从 Bearer JWT 解析当前用户 +2. 批量吊销当前用户全部 refresh session +3. 对当前用户执行 `token_version + 1` +4. 返回 `ok: true` +5. 始终清理 refresh cookie + +### 5.2 `token_version` 只递增一次 + +无论当前用户存在多少会话: + +1. `logout-all` 只递增一次 `token_version` +2. 不为每条 session 单独递增版本号 + +### 5.3 缺少 refresh cookie 不影响成功 + +`logout-all` 是账号级动作,不依赖当前 refresh cookie 命中: + +1. 即使当前设备没有 refresh cookie,也要允许完成全端登出 +2. 成功响应仍然统一清理 cookie + +## 6. 与其他接口的职责切分 + +### 6.1 `/api/auth/logout` + +负责: + +1. 当前设备退出 +2. 当前 refresh session 尽力吊销 +3. `token_version` 递增一次 + +### 6.2 `/api/auth/logout-all` + +负责: + +1. 全部设备退出 +2. 当前用户全部 refresh session 吊销 +3. `token_version` 递增一次 + +### 6.3 `/api/auth/sessions/:sessionId/revoke` + +后续负责: + +1. 只吊销指定远端设备 refresh session +2. 不递增 `token_version` + +## 7. crate 边界 + +### 7.1 `module-auth` + +负责: + +1. 按 `user_id` 吊销全部 refresh session +2. 递增当前用户 `token_version` +3. 返回最新用户快照 + +### 7.2 `platform-auth` + +负责: + +1. 构造清理 cookie 的 `Set-Cookie` 头 + +### 7.3 `api-server` + +负责: + +1. Bearer JWT 读取与校验 +2. 调用 `module-auth` 执行全端登出 +3. 始终回写清理 cookie + +## 8. 进程内实现策略 + +当前阶段 `module-auth` 继续使用进程内真相,新增以下最小能力: + +1. `revoke_all_sessions_by_user_id` +2. `logout_all_sessions` + +其中: + +1. 批量吊销只改 `revoked_at` +2. 用户版本递增继续直接修改内存用户快照 + +## 9. 测试策略 + +至少覆盖: + +1. 登录两次后调用 `/api/auth/logout-all` 返回 `ok: true` +2. `/logout-all` 成功后清理 refresh cookie +3. `/logout-all` 成功后旧 Bearer token 访问 `/api/auth/me` 返回 `401` +4. `/logout-all` 成功后旧 refresh cookie 调用 `/api/auth/refresh` 返回 `401` +5. 缺少 refresh cookie 时,只要 Bearer token 有效,`/logout-all` 仍返回 `ok: true` + +## 10. 完成定义 + +满足以下条件时,本任务视为完成: + +1. Rust 侧已提供 `POST /api/auth/logout-all` +2. 同一用户全部 refresh session 可被吊销 +3. 用户 `token_version` 会在全端登出时递增 +4. `/logout-all` 总会清理 refresh cookie +5. 文档、任务清单与测试已同步更新 diff --git a/docs/technical/README.md b/docs/technical/README.md index a446e588..030c9211 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。 +- [AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md):`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。 - [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md):`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。 - [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。 - [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md):多端登录会话身份模型设计,冻结浏览器、小程序、微信内 H5 的客户端身份字段、请求头约定与展示名派生规则。 @@ -20,6 +21,7 @@ - [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md):`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。 - [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。 - [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。 +- [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。 - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 - [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 - [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 8c02492c..9bec59bb 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -20,6 +20,7 @@ use crate::{ error_middleware::normalize_error_response, health::health_check, logout::logout, + logout_all::logout_all, password_entry::password_entry, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -88,6 +89,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/auth/logout-all", + post(logout_all).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/assets/direct-upload-tickets", post(create_direct_upload_ticket), @@ -1027,4 +1035,207 @@ mod tests { .is_some_and(|value| value.contains("Max-Age=0")) ); } + + #[tokio::test] + async fn logout_all_clears_cookie_and_invalidates_all_sessions() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let first_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .body(Body::from( + serde_json::json!({ + "username": "guest_logout_all_api", + "password": "secret123" + }) + .to_string(), + )) + .expect("first login request should build"), + ) + .await + .expect("first login should succeed"); + let first_refresh_cookie = first_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("first refresh cookie should exist") + .to_string(); + let first_login_body = first_login_response + .into_body() + .collect() + .await + .expect("first login body should collect") + .to_bytes(); + let first_login_payload: Value = + serde_json::from_slice(&first_login_body).expect("first login payload should be json"); + let first_access_token = first_login_payload["token"] + .as_str() + .expect("first access token should exist") + .to_string(); + + let second_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header("x-client-runtime", "firefox") + .header("x-client-instance-id", "logout-all-instance-002") + .body(Body::from( + serde_json::json!({ + "username": "guest_logout_all_api", + "password": "secret123" + }) + .to_string(), + )) + .expect("second login request should build"), + ) + .await + .expect("second login should succeed"); + let second_refresh_cookie = second_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("second refresh cookie should exist") + .to_string(); + + let logout_all_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/logout-all") + .header("authorization", format!("Bearer {first_access_token}")) + .header("cookie", first_refresh_cookie.clone()) + .body(Body::empty()) + .expect("logout-all request should build"), + ) + .await + .expect("logout-all request should succeed"); + + assert_eq!(logout_all_response.status(), StatusCode::OK); + assert!( + logout_all_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("Max-Age=0")) + ); + + let logout_all_body = logout_all_response + .into_body() + .collect() + .await + .expect("logout-all body should collect") + .to_bytes(); + let logout_all_payload: Value = serde_json::from_slice(&logout_all_body) + .expect("logout-all payload should be json"); + assert_eq!(logout_all_payload["ok"], Value::Bool(true)); + + let me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {first_access_token}")) + .body(Body::empty()) + .expect("me request should build"), + ) + .await + .expect("me request should succeed"); + assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED); + + let first_refresh_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/refresh") + .header("cookie", first_refresh_cookie) + .body(Body::empty()) + .expect("first refresh request should build"), + ) + .await + .expect("first refresh request should succeed"); + assert_eq!(first_refresh_response.status(), StatusCode::UNAUTHORIZED); + + let second_refresh_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/refresh") + .header("cookie", second_refresh_cookie) + .body(Body::empty()) + .expect("second refresh request should build"), + ) + .await + .expect("second refresh request should succeed"); + assert_eq!(second_refresh_response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn logout_all_succeeds_without_refresh_cookie_when_bearer_token_is_valid() { + 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_logout_all_nc", + "password": "secret123" + }) + .to_string(), + )) + .expect("login request should build"), + ) + .await + .expect("login request should succeed"); + let login_body = login_response + .into_body() + .collect() + .await + .expect("login body should collect") + .to_bytes(); + let login_payload: Value = + serde_json::from_slice(&login_body).expect("login payload should be json"); + let access_token = login_payload["token"] + .as_str() + .expect("access token should exist") + .to_string(); + + let logout_all_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/logout-all") + .header("authorization", format!("Bearer {access_token}")) + .body(Body::empty()) + .expect("logout-all request should build"), + ) + .await + .expect("logout-all request should succeed"); + + assert_eq!(logout_all_response.status(), StatusCode::OK); + assert!( + logout_all_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/logout_all.rs b/server-rs/crates/api-server/src/logout_all.rs new file mode 100644 index 00000000..8dbcd445 --- /dev/null +++ b/server-rs/crates/api-server/src/logout_all.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Extension, State}, + http::HeaderMap, + response::IntoResponse, +}; +use module_auth::LogoutAllSessionsInput; +use serde::Serialize; +use time::OffsetDateTime; + +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + auth_session::{ + attach_set_cookie_header, build_clear_refresh_session_cookie_header, map_logout_error, + }, + http_error::AppError, + request_context::RequestContext, + state::AppState, +}; + +#[derive(Debug, Serialize)] +pub struct LogoutAllResponse { + pub ok: bool, +} + +pub async fn logout_all( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result { + state + .auth_user_service() + .logout_all_sessions( + LogoutAllSessionsInput { + user_id: authenticated.claims().user_id().to_string(), + }, + OffsetDateTime::now_utc(), + ) + .map_err(map_logout_error)?; + + let mut headers = HeaderMap::new(); + attach_set_cookie_header( + &mut headers, + build_clear_refresh_session_cookie_header(&state)?, + ); + + Ok(( + headers, + json_success_body(Some(&request_context), LogoutAllResponse { ok: true }), + )) +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 07c4d327..445a5a9c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -10,6 +10,7 @@ mod error_middleware; mod health; mod http_error; mod logout; +mod logout_all; mod password_entry; mod refresh_session; mod request_context; diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 4c50ab69..90fd24ee 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -124,6 +124,15 @@ pub struct LogoutCurrentSessionInput { pub struct LogoutCurrentSessionResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogoutAllSessionsInput { + pub user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogoutAllSessionsResult { + pub user: AuthUser, +} #[derive(Clone, Debug, PartialEq, Eq)] pub enum PasswordEntryError { @@ -423,6 +432,25 @@ impl AuthUserService { Ok(LogoutCurrentSessionResult { user }) } + // 全端登出需要先吊销该用户全部 refresh session,再统一提升 token_version, + // 让所有旧 access token 在下一次鉴权时立即失效。 + pub fn logout_all_sessions( + &self, + input: LogoutAllSessionsInput, + now: OffsetDateTime, + ) -> Result { + self.store + .revoke_all_sessions_by_user_id(&input.user_id, now) + .map_err(map_refresh_error_to_logout_error)?; + + let user = self + .store + .increment_user_token_version(&input.user_id) + .map_err(map_password_error_to_logout_error)? + .ok_or(LogoutError::UserNotFound)?; + + Ok(LogoutAllSessionsResult { user }) + } } impl Default for InMemoryAuthStore { @@ -678,6 +706,35 @@ impl InMemoryAuthStore { Ok(()) } + fn revoke_all_sessions_by_user_id( + &self, + user_id: &str, + now: OffsetDateTime, + ) -> Result<(), RefreshSessionError> { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) + })?; + + for stored in state.sessions_by_id.values_mut() { + if stored.session.user_id != user_id { + continue; + } + if stored.session.revoked_at.is_some() { + continue; + } + + stored.session.revoked_at = Some(now_iso.clone()); + stored.session.updated_at = now_iso.clone(); + } + + Ok(()) + } fn increment_user_token_version( &self, @@ -1052,6 +1109,92 @@ mod tests { .expect_err("revoked session should fail"); assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); } + #[tokio::test] + async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() { + let store = build_store(); + let password_service = build_password_service(store.clone()); + let refresh_service = build_refresh_service(store.clone()); + let user_service = build_user_service(store); + let user = password_service + .execute(PasswordEntryInput { + username: "guest_logout_all".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed") + .user; + let first_refresh_token_hash = hash_refresh_session_token("logout-all-token-01"); + let second_refresh_token_hash = hash_refresh_session_token("logout-all-token-02"); + let now = OffsetDateTime::now_utc(); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: first_refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("first session should create"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: second_refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + now + Duration::seconds(1), + ) + .expect("second session should create"); + + let result = user_service + .logout_all_sessions( + LogoutAllSessionsInput { + user_id: user.id.clone(), + }, + now + Duration::minutes(1), + ) + .expect("logout all should succeed"); + + assert_eq!(result.user.token_version, 2); + assert_eq!( + refresh_service + .list_active_sessions_by_user(&user.id, now + Duration::minutes(2)) + .expect("sessions should list") + .sessions + .len(), + 0 + ); + + let first_refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: first_refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-all-token-03"), + }, + now + Duration::minutes(2), + ) + .expect_err("first revoked session should fail"); + assert_eq!(first_refresh_error, RefreshSessionError::SessionNotFound); + + let second_refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: second_refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-all-token-04"), + }, + now + Duration::minutes(2), + ) + .expect_err("second revoked session should fail"); + assert_eq!(second_refresh_error, RefreshSessionError::SessionNotFound); + } #[tokio::test] async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() {