feat: add logout all flow
This commit is contained in:
@@ -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`
|
||||
|
||||
177
docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md
Normal file
177
docs/technical/AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md
Normal file
@@ -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. 文档、任务清单与测试已同步更新
|
||||
@@ -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):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
51
server-rs/crates/api-server/src/logout_all.rs
Normal file
51
server-rs/crates/api-server/src/logout_all.rs
Normal file
@@ -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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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 }),
|
||||
))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<LogoutAllSessionsResult, LogoutError> {
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user