From a83c64133d3e8ba1140dae8f2c59833c79153ff2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 15:36:17 +0800 Subject: [PATCH] feat: add current session logout flow --- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 6 +- ...OGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md | 209 ++++++++++++++++++ server-rs/crates/api-server/README.md | 3 + server-rs/crates/api-server/src/app.rs | 177 ++++++++++++++- server-rs/crates/api-server/src/auth.rs | 30 +++ .../crates/api-server/src/auth_session.rs | 12 +- server-rs/crates/api-server/src/logout.rs | 63 ++++++ server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/api-server/src/state.rs | 9 +- server-rs/crates/module-auth/README.md | 2 + server-rs/crates/module-auth/src/lib.rs | 200 +++++++++++++++++ 11 files changed, 703 insertions(+), 9 deletions(-) create mode 100644 docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md create mode 100644 server-rs/crates/api-server/src/logout.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 e2804968..926f1d61 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -169,7 +169,8 @@ 交付物:[../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) - [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] 实现会话吊销 + 交付物:[../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] 实现 `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) @@ -218,7 +219,8 @@ 交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - [x] 兼容 `/api/auth/me` 交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [ ] 兼容 `/api/auth/logout` +- [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/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) diff --git a/docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md new file mode 100644 index 00000000..0d325fd5 --- /dev/null +++ b/docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md @@ -0,0 +1,209 @@ +# `/api/auth/logout` 当前会话吊销落地设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于指导 `M2` 中 `实现会话吊销` 任务的第一段首版落地,冻结: + +1. `POST /api/auth/logout` 的请求与响应 contract。 +2. 当前设备退出时 refresh session 吊销与 `token_version` 递增的组合语义。 +3. Rust 首版在进程内鉴权真相中的最小实现边界。 +4. 与后续 `logout-all`、`sessions/:sessionId/revoke` 的职责切分。 + +## 2. 当前基线 + +当前 Node `/api/auth/logout` 已具备以下稳定语义: + +1. 必须先通过 Bearer JWT 校验。 +2. 从 cookie 中读取当前 refresh token,并尝试吊销对应 refresh session。 +3. 无论当前 refresh session 是否已存在,只要用户存在,仍继续执行“退出当前设备”。 +4. 对当前用户执行 `token_version + 1`,使当前 access token 全局失效。 +5. 响应成功时始终清理 refresh cookie。 + +因此,Node 的“退出当前设备”实际是两层组合动作: + +1. 设备级:吊销当前 refresh session +2. 用户级:递增 `token_version`,让当前 access token 立即失效 + +Rust 首版必须保留这个语义。 + +## 3. 当前阶段范围 + +本阶段只落以下内容: + +1. `module-auth` 增加当前 refresh session 吊销能力。 +2. `module-auth` 增加用户 `token_version` 递增能力。 +3. `api-server` 暴露 `POST /api/auth/logout`。 +4. 成功或已失效场景统一清理 refresh cookie。 + +本阶段明确不包含: + +1. `/api/auth/logout-all` +2. `/api/auth/sessions` +3. `/api/auth/sessions/:sessionId/revoke` +4. 审计日志与风控日志正式落表 +5. SpacetimeDB reducer 真正写表 + +## 4. contract + +### 4.1 请求 + +1. 方法:`POST` +2. 路径:`/api/auth/logout` +3. 请求体:空 +4. 鉴权: + - Bearer JWT 必填 + - refresh cookie 选填但应尽量提供 + +### 4.2 成功响应 + +```json +{ + "ok": true +} +``` + +同时响应头必须写回清理后的 refresh cookie。 + +### 4.3 失败响应 + +以下情况返回 `401 UNAUTHORIZED`: + +1. Bearer JWT 缺失或非法 +2. JWT 对应用户不存在 + +说明: + +1. 当前 refresh cookie 缺失本身不构成 `/logout` 失败。 +2. 因为当前设备可能已经没有 refresh cookie,但 access token 仍应允许执行显式退出。 + +## 5. 固定语义 + +### 5.1 当前设备退出的动作顺序 + +`POST /api/auth/logout` 固定按以下顺序执行: + +1. 从 Bearer JWT 解析当前用户。 +2. 尝试按当前 refresh cookie 吊销 refresh session。 +3. 对当前用户执行 `token_version + 1`。 +4. 返回 `ok: true`。 +5. 始终清理 refresh cookie。 + +### 5.2 refresh session 吊销是“尽力而为” + +当 refresh cookie 缺失、refresh token 无法命中 session、session 已吊销时: + +1. 不把这些情况视为 `/logout` 失败。 +2. 继续执行用户级 `token_version` 递增。 + +原因: + +1. 当前设备退出的主目标是让“现在这份 access token”立刻失效。 +2. refresh session 丢失不应该阻断显式退出。 + +### 5.3 `token_version` 必须递增 + +当前阶段固定规则: + +1. `/logout` 必须递增 `user.token_version` +2. 后续 Bearer JWT 校验必须比对当前用户最新 `token_version` + +说明: + +1. 如果不递增,当前 access token 直到自然过期前仍可继续访问。 +2. 这与 Node 当前行为不一致,也会让“退出登录”在用户感知上失真。 + +## 6. 与其他接口的职责切分 + +### 6.1 `/api/auth/logout` + +负责: + +1. 当前设备退出 +2. 当前 access token 立即失效 +3. 当前 refresh session 尽力吊销 + +### 6.2 `/api/auth/logout-all` + +后续负责: + +1. 吊销同一用户全部 refresh session +2. 递增一次 `token_version` + +### 6.3 `/api/auth/sessions/:sessionId/revoke` + +后续负责: + +1. 只吊销指定远端设备 refresh session +2. 不递增 `token_version` + +## 7. crate 边界 + +### 7.1 `module-auth` + +负责: + +1. 按 refresh token hash 吊销当前 session。 +2. 递增当前用户 `token_version`。 +3. 返回退出后最新用户快照,供后续 access token 校验使用。 + +### 7.2 `platform-auth` + +负责: + +1. refresh token 哈希 +2. 构造清理 cookie 的 `Set-Cookie` 头 + +### 7.3 `api-server` + +负责: + +1. Bearer JWT 与 refresh cookie 的读取 +2. 调用 `module-auth` 组合执行当前设备退出 +3. 始终回写清理 cookie + +## 8. 进程内实现策略 + +当前阶段 `module-auth` 继续使用进程内真相,新增以下最小能力: + +1. `revoke_session_by_refresh_token_hash` +2. `increment_user_token_version` + +其中: + +1. session 吊销要写入 `revoked_at` +2. 用户版本递增要直接修改内存中用户快照 + +## 9. Bearer JWT 校验补强 + +为了让 `/logout` 后“旧 access token 立即失效”真正成立,当前阶段需要补一条约束: + +1. Bearer JWT 校验通过签名后,还必须比对 claims 里的 `ver` +2. 若 `claims.ver != 当前用户 token_version`,返回 `401` + +说明: + +1. 这是当前 Rust 鉴权链路必须补上的一致性校验。 +2. 否则 `logout` 虽然递增了用户版本,但旧 JWT 仍能继续访问。 + +## 10. 测试策略 + +至少覆盖: + +1. 登录成功后调用 `/api/auth/logout` 返回 `ok: true` +2. `/logout` 成功后会清理 refresh cookie +3. `/logout` 成功后旧 Bearer token 再访问 `/api/auth/me` 返回 `401` +4. refresh cookie 缺失时,只要 Bearer token 有效,`/logout` 仍返回 `ok: true` +5. 用户不存在时 `/logout` 返回 `401` + +## 11. 完成定义 + +满足以下条件时,本任务视为完成: + +1. Rust 侧已提供 `POST /api/auth/logout` +2. 当前 refresh session 可按 cookie 对应关系被吊销 +3. 用户 `token_version` 会在退出时递增 +4. Bearer JWT 已补充版本比对 +5. `/logout` 总会清理 refresh cookie +6. 文档、任务清单与测试已同步更新 diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index d017a892..9a290480 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -32,6 +32,7 @@ 10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口 11. 接入 `GET /api/auth/me` 当前用户查询链路 12. 接入 `POST /api/auth/refresh` refresh token 轮换链路 +13. 接入 `POST /api/auth/logout` 当前设备退出链路 后续与本 crate 直接相关的任务包括: @@ -44,6 +45,7 @@ 7. [x] 接入 `/api/assets/direct-upload-tickets` 8. [x] 接入 `/api/auth/me` 9. [x] 接入 `/api/auth/refresh` +10. [x] 接入 `/api/auth/logout` 当前 tracing 约定: @@ -105,3 +107,4 @@ 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 重签。 +8. 当前 `/api/auth/logout` 复用 `module-auth` 的当前会话吊销与用户版本递增能力,`api-server` 负责 Bearer JWT、refresh cookie 读取与清理 cookie 回写。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 8b298524..f85dc770 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -10,7 +10,7 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T use tracing::{Level, info_span}; use crate::{ - assets::create_direct_upload_ticket, + assets::{create_direct_upload_ticket, get_asset_read_url}, auth::{ attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie, require_bearer_auth, @@ -18,6 +18,7 @@ use crate::{ auth_me::auth_me, error_middleware::normalize_error_response, health::health_check, + logout::logout, password_entry::password_entry, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -62,10 +63,23 @@ pub fn build_router(state: AppState) -> Router { attach_refresh_session_token, )), ) + .route( + "/api/auth/logout", + post(logout) + .route_layer(middleware::from_fn_with_state( + state.clone(), + attach_refresh_session_token, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/assets/direct-upload-tickets", post(create_direct_upload_ticket), ) + .route("/api/assets/read-url", get(get_asset_read_url)) .route("/api/auth/entry", post(password_entry)) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) @@ -226,13 +240,21 @@ mod tests { async fn internal_auth_claims_returns_verified_claims() { let config = AppConfig::default(); let state = AppState::new(config.clone()).expect("state should build"); + state + .password_entry_service() + .execute(module_auth::PasswordEntryInput { + username: "guest_auth_debug".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "usr_auth_debug".to_string(), + user_id: "user_00000001".to_string(), session_id: "sess_auth_debug".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 7, + token_version: 1, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("测试用户".to_string()), @@ -268,7 +290,7 @@ mod tests { assert_eq!( payload["claims"]["sub"], - Value::String("usr_auth_debug".to_string()) + Value::String("user_00000001".to_string()) ); assert_eq!( payload["claims"]["sid"], @@ -276,7 +298,7 @@ mod tests { ); assert_eq!( payload["claims"]["ver"], - Value::Number(serde_json::Number::from(7)) + Value::Number(serde_json::Number::from(1)) ); } @@ -732,4 +754,149 @@ mod tests { .is_some_and(|value| value.contains("Max-Age=0")) ); } + + #[tokio::test] + async fn logout_clears_cookie_and_invalidates_current_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_logout_api", + "password": "secret123" + }) + .to_string(), + )) + .expect("login request should build"), + ) + .await + .expect("login request should succeed"); + let refresh_cookie = login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("refresh cookie should exist") + .to_string(); + 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("token should exist") + .to_string(); + + let logout_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header("authorization", format!("Bearer {access_token}")) + .header("cookie", refresh_cookie) + .body(Body::empty()) + .expect("logout request should build"), + ) + .await + .expect("logout request should succeed"); + + assert_eq!(logout_response.status(), StatusCode::OK); + assert!( + logout_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("Max-Age=0")) + ); + + let logout_body = logout_response + .into_body() + .collect() + .await + .expect("logout body should collect") + .to_bytes(); + let logout_payload: Value = + serde_json::from_slice(&logout_body).expect("logout payload should be json"); + assert_eq!(logout_payload["ok"], Value::Bool(true)); + + let me_response = app + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {access_token}")) + .body(Body::empty()) + .expect("me request should build"), + ) + .await + .expect("me request should succeed"); + + assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn logout_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_no_cookie", + "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("token should exist") + .to_string(); + + let logout_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header("authorization", format!("Bearer {access_token}")) + .body(Body::empty()) + .expect("logout request should build"), + ) + .await + .expect("logout request should succeed"); + + assert_eq!(logout_response.status(), StatusCode::OK); + assert!( + logout_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.rs b/server-rs/crates/api-server/src/auth.rs index 272bb928..df2c3eac 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -67,6 +67,36 @@ pub async fn require_bearer_auth( ); AppError::from_status(StatusCode::UNAUTHORIZED) })?; + let current_user = state + .auth_user_service() + .get_user_by_id(claims.user_id()) + .map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 用户快照读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + .ok_or_else(|| { + warn!( + %request_id, + user_id = %claims.user_id(), + "Bearer JWT 对应用户不存在" + ); + AppError::from_status(StatusCode::UNAUTHORIZED) + })?; + if current_user.token_version != claims.token_version() { + warn!( + %request_id, + user_id = %claims.user_id(), + token_version = claims.token_version(), + current_token_version = current_user.token_version, + "Bearer JWT 版本已失效" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录")); + } request .extensions_mut() diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs index 3af7d229..0bacc6b6 100644 --- a/server-rs/crates/api-server/src/auth_session.rs +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -3,7 +3,7 @@ use axum::http::{ header::SET_COOKIE, }; use module_auth::{ - AuthLoginMethod, AuthUser, CreateRefreshSessionInput, RefreshSessionError, + AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError, }; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, @@ -116,6 +116,16 @@ pub fn map_refresh_session_error(error: RefreshSessionError) -> AppError { } } +pub fn map_logout_error(error: LogoutError) -> AppError { + match error { + LogoutError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录"), + LogoutError::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, diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs new file mode 100644 index 00000000..c4fef132 --- /dev/null +++ b/server-rs/crates/api-server/src/logout.rs @@ -0,0 +1,63 @@ +use axum::{ + extract::{Extension, State}, + http::HeaderMap, + response::IntoResponse, +}; +use module_auth::LogoutCurrentSessionInput; +use platform_auth::hash_refresh_session_token; +use serde::Serialize; +use time::OffsetDateTime; + +use crate::{ + api_response::json_success_body, + auth::{AuthenticatedAccessToken, RefreshSessionToken}, + 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 LogoutResponse { + pub ok: bool, +} + +pub async fn logout( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + maybe_refresh_token: Option>, +) -> Result { + let refresh_token_hash = maybe_refresh_token.and_then(|token| { + let token = token.0.token().trim().to_string(); + if token.is_empty() { + return None; + } + + Some(hash_refresh_session_token(&token)) + }); + + state + .auth_user_service() + .logout_current_session( + LogoutCurrentSessionInput { + user_id: authenticated.claims().user_id().to_string(), + refresh_token_hash, + }, + 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), LogoutResponse { ok: true }), + )) +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index dfe2ed12..205987f7 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -8,6 +8,7 @@ mod config; mod error_middleware; mod health; mod http_error; +mod logout; mod password_entry; mod refresh_session; mod request_context; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 4b9d7757..b161e2f6 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::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService}; +use module_auth::{AuthUserService, InMemoryAuthStore, PasswordEntryService, RefreshSessionService}; use platform_auth::{ JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, }; @@ -19,6 +19,7 @@ pub struct AppState { oss_client: Option, password_entry_service: PasswordEntryService, refresh_session_service: RefreshSessionService, + auth_user_service: AuthUserService, } #[derive(Debug)] @@ -49,6 +50,7 @@ impl AppState { let oss_client = build_oss_client(&config)?; let auth_store = InMemoryAuthStore::default(); let password_entry_service = PasswordEntryService::new(auth_store.clone()); + let auth_user_service = AuthUserService::new(auth_store.clone()); let refresh_session_service = RefreshSessionService::new(auth_store, config.refresh_session_ttl_days); @@ -59,6 +61,7 @@ impl AppState { oss_client, password_entry_service, refresh_session_service, + auth_user_service, }) } @@ -81,6 +84,10 @@ impl AppState { pub fn refresh_session_service(&self) -> &RefreshSessionService { &self.refresh_session_service } + + pub fn auth_user_service(&self) -> &AuthUserService { + &self.auth_user_service + } } impl fmt::Display for AppStateInitError { diff --git a/server-rs/crates/module-auth/README.md b/server-rs/crates/module-auth/README.md index 6c48fa0b..25f38310 100644 --- a/server-rs/crates/module-auth/README.md +++ b/server-rs/crates/module-auth/README.md @@ -42,6 +42,7 @@ 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) +13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md) ## 4. 边界约束 @@ -52,3 +53,4 @@ 5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。 6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。 7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。 +8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。 diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index db0681e7..39d4c467 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -93,6 +93,17 @@ pub struct RotateRefreshSessionResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogoutCurrentSessionInput { + pub user_id: String, + pub refresh_token_hash: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogoutCurrentSessionResult { + pub user: AuthUser, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum PasswordEntryError { InvalidUsername, @@ -111,6 +122,12 @@ pub enum RefreshSessionError { Store(String), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LogoutError { + UserNotFound, + Store(String), +} + #[derive(Clone, Debug)] pub struct InMemoryAuthStore { inner: Arc>, @@ -146,6 +163,11 @@ pub struct RefreshSessionService { refresh_session_ttl_days: u32, } +#[derive(Clone, Debug)] +pub struct AuthUserService { + store: InMemoryAuthStore, +} + impl PasswordEntryService { pub fn new(store: InMemoryAuthStore) -> Self { Self { store } @@ -319,6 +341,47 @@ impl RefreshSessionService { } } +impl AuthUserService { + pub fn new(store: InMemoryAuthStore) -> Self { + Self { store } + } + + pub fn get_user_by_id( + &self, + user_id: &str, + ) -> Result, LogoutError> { + self.store + .find_by_user_id(user_id) + .map(|maybe_user| maybe_user.map(|stored| stored.user)) + .map_err(map_password_error_to_logout_error) + } + + pub fn logout_current_session( + &self, + input: LogoutCurrentSessionInput, + now: OffsetDateTime, + ) -> Result { + if let Some(refresh_token_hash) = input + .refresh_token_hash + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + self.store + .revoke_session_by_refresh_token_hash(refresh_token_hash, 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(LogoutCurrentSessionResult { user }) + } +} + impl Default for InMemoryAuthStore { fn default() -> Self { Self { @@ -495,6 +558,58 @@ impl InMemoryAuthStore { Ok(updated_session) } + + fn revoke_session_by_refresh_token_hash( + &self, + refresh_token_hash: &str, + now: OffsetDateTime, + ) -> Result<(), RefreshSessionError> { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let Some(session_id) = state + .session_id_by_refresh_token_hash + .get(refresh_token_hash) + .cloned() + else { + return Ok(()); + }; + let Some(stored) = state.sessions_by_id.get_mut(&session_id) else { + return Ok(()); + }; + if stored.session.revoked_at.is_some() { + return Ok(()); + } + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")))?; + stored.session.revoked_at = Some(now_iso.clone()); + stored.session.updated_at = now_iso; + + Ok(()) + } + + fn increment_user_token_version( + &self, + user_id: &str, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + stored_user.user.token_version += 1; + return Ok(Some(stored_user.user.clone())); + } + + Ok(None) + } } #[derive(Debug, PartialEq, Eq)] @@ -549,6 +664,17 @@ impl fmt::Display for RefreshSessionError { impl Error for RefreshSessionError {} +impl fmt::Display for LogoutError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"), + Self::Store(message) => f.write_str(message), + } + } +} + +impl Error for LogoutError {} + fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError { match error { PasswordEntryError::Store(message) => RefreshSessionError::Store(message), @@ -561,6 +687,26 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError { } } +fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError { + match error { + PasswordEntryError::Store(message) => LogoutError::Store(message), + PasswordEntryError::InvalidUsername + | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidCredentials + | PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()), + } +} + +fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError { + match error { + RefreshSessionError::Store(message) => LogoutError::Store(message), + RefreshSessionError::MissingToken + | RefreshSessionError::SessionNotFound + | RefreshSessionError::SessionExpired + | RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()), + } +} + fn normalize_username(raw_username: &str) -> Result { let username = raw_username.trim().to_string(); let valid_length = @@ -603,6 +749,10 @@ mod tests { RefreshSessionService::new(store, 30) } + fn build_user_service(store: InMemoryAuthStore) -> AuthUserService { + AuthUserService::new(store) + } + #[tokio::test] async fn first_password_entry_creates_user() { let service = build_password_service(build_store()); @@ -746,4 +896,54 @@ mod tests { assert_eq!(error, RefreshSessionError::SessionNotFound); } + + #[tokio::test] + async fn logout_current_session_revokes_session_and_increments_token_version() { + 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".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("seed login should succeed") + .user; + let refresh_token_hash = hash_refresh_session_token("logout-token"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + }, + OffsetDateTime::now_utc(), + ) + .expect("session should create"); + + let result = user_service + .logout_current_session( + LogoutCurrentSessionInput { + user_id: user.id.clone(), + refresh_token_hash: Some(refresh_token_hash.clone()), + }, + OffsetDateTime::now_utc(), + ) + .expect("logout should succeed"); + + assert_eq!(result.user.token_version, 2); + + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-token-next"), + }, + OffsetDateTime::now_utc(), + ) + .expect_err("revoked session should fail"); + assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + } }