feat: add current session logout flow
This commit is contained in:
@@ -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)
|
交付物:[../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 轮换
|
- [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)
|
交付物:[../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` 查询
|
- [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)
|
交付物:[../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)
|
交付物:[../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`
|
- [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)
|
交付物:[../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`
|
- [ ] 兼容 `/api/auth/logout-all`
|
||||||
- [x] 兼容 `/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)
|
交付物:[../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)
|
||||||
|
|||||||
209
docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md
Normal file
209
docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md
Normal file
@@ -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. 文档、任务清单与测试已同步更新
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
||||||
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
||||||
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
|
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
|
||||||
|
13. 接入 `POST /api/auth/logout` 当前设备退出链路
|
||||||
|
|
||||||
后续与本 crate 直接相关的任务包括:
|
后续与本 crate 直接相关的任务包括:
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||||
8. [x] 接入 `/api/auth/me`
|
8. [x] 接入 `/api/auth/me`
|
||||||
9. [x] 接入 `/api/auth/refresh`
|
9. [x] 接入 `/api/auth/refresh`
|
||||||
|
10. [x] 接入 `/api/auth/logout`
|
||||||
|
|
||||||
当前 tracing 约定:
|
当前 tracing 约定:
|
||||||
|
|
||||||
@@ -105,3 +107,4 @@
|
|||||||
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
||||||
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
||||||
7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。
|
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 回写。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
|
|||||||
use tracing::{Level, info_span};
|
use tracing::{Level, info_span};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
assets::create_direct_upload_ticket,
|
assets::{create_direct_upload_ticket, get_asset_read_url},
|
||||||
auth::{
|
auth::{
|
||||||
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
@@ -18,6 +18,7 @@ use crate::{
|
|||||||
auth_me::auth_me,
|
auth_me::auth_me,
|
||||||
error_middleware::normalize_error_response,
|
error_middleware::normalize_error_response,
|
||||||
health::health_check,
|
health::health_check,
|
||||||
|
logout::logout,
|
||||||
password_entry::password_entry,
|
password_entry::password_entry,
|
||||||
refresh_session::refresh_session,
|
refresh_session::refresh_session,
|
||||||
request_context::{attach_request_context, resolve_request_id},
|
request_context::{attach_request_context, resolve_request_id},
|
||||||
@@ -62,10 +63,23 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
attach_refresh_session_token,
|
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(
|
.route(
|
||||||
"/api/assets/direct-upload-tickets",
|
"/api/assets/direct-upload-tickets",
|
||||||
post(create_direct_upload_ticket),
|
post(create_direct_upload_ticket),
|
||||||
)
|
)
|
||||||
|
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||||
.route("/api/auth/entry", post(password_entry))
|
.route("/api/auth/entry", post(password_entry))
|
||||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||||
.layer(middleware::from_fn(normalize_error_response))
|
.layer(middleware::from_fn(normalize_error_response))
|
||||||
@@ -226,13 +240,21 @@ mod tests {
|
|||||||
async fn internal_auth_claims_returns_verified_claims() {
|
async fn internal_auth_claims_returns_verified_claims() {
|
||||||
let config = AppConfig::default();
|
let config = AppConfig::default();
|
||||||
let state = AppState::new(config.clone()).expect("state should build");
|
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(
|
let claims = AccessTokenClaims::from_input(
|
||||||
AccessTokenClaimsInput {
|
AccessTokenClaimsInput {
|
||||||
user_id: "usr_auth_debug".to_string(),
|
user_id: "user_00000001".to_string(),
|
||||||
session_id: "sess_auth_debug".to_string(),
|
session_id: "sess_auth_debug".to_string(),
|
||||||
provider: AuthProvider::Password,
|
provider: AuthProvider::Password,
|
||||||
roles: vec!["user".to_string()],
|
roles: vec!["user".to_string()],
|
||||||
token_version: 7,
|
token_version: 1,
|
||||||
phone_verified: true,
|
phone_verified: true,
|
||||||
binding_status: BindingStatus::Active,
|
binding_status: BindingStatus::Active,
|
||||||
display_name: Some("测试用户".to_string()),
|
display_name: Some("测试用户".to_string()),
|
||||||
@@ -268,7 +290,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["claims"]["sub"],
|
payload["claims"]["sub"],
|
||||||
Value::String("usr_auth_debug".to_string())
|
Value::String("user_00000001".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["claims"]["sid"],
|
payload["claims"]["sid"],
|
||||||
@@ -276,7 +298,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["claims"]["ver"],
|
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"))
|
.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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,36 @@ pub async fn require_bearer_auth(
|
|||||||
);
|
);
|
||||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
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
|
request
|
||||||
.extensions_mut()
|
.extensions_mut()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use axum::http::{
|
|||||||
header::SET_COOKIE,
|
header::SET_COOKIE,
|
||||||
};
|
};
|
||||||
use module_auth::{
|
use module_auth::{
|
||||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, RefreshSessionError,
|
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
|
||||||
};
|
};
|
||||||
use platform_auth::{
|
use platform_auth::{
|
||||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
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 {
|
fn map_auth_provider(login_method: &AuthLoginMethod) -> AuthProvider {
|
||||||
match login_method {
|
match login_method {
|
||||||
AuthLoginMethod::Password => AuthProvider::Password,
|
AuthLoginMethod::Password => AuthProvider::Password,
|
||||||
|
|||||||
63
server-rs/crates/api-server/src/logout.rs
Normal file
63
server-rs/crates/api-server/src/logout.rs
Normal file
@@ -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<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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 }),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ mod config;
|
|||||||
mod error_middleware;
|
mod error_middleware;
|
||||||
mod health;
|
mod health;
|
||||||
mod http_error;
|
mod http_error;
|
||||||
|
mod logout;
|
||||||
mod password_entry;
|
mod password_entry;
|
||||||
mod refresh_session;
|
mod refresh_session;
|
||||||
mod request_context;
|
mod request_context;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{error::Error, fmt};
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
use module_auth::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
use module_auth::{AuthUserService, InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
|
||||||
use platform_auth::{
|
use platform_auth::{
|
||||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||||
};
|
};
|
||||||
@@ -19,6 +19,7 @@ pub struct AppState {
|
|||||||
oss_client: Option<OssClient>,
|
oss_client: Option<OssClient>,
|
||||||
password_entry_service: PasswordEntryService,
|
password_entry_service: PasswordEntryService,
|
||||||
refresh_session_service: RefreshSessionService,
|
refresh_session_service: RefreshSessionService,
|
||||||
|
auth_user_service: AuthUserService,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -49,6 +50,7 @@ impl AppState {
|
|||||||
let oss_client = build_oss_client(&config)?;
|
let oss_client = build_oss_client(&config)?;
|
||||||
let auth_store = InMemoryAuthStore::default();
|
let auth_store = InMemoryAuthStore::default();
|
||||||
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
let password_entry_service = PasswordEntryService::new(auth_store.clone());
|
||||||
|
let auth_user_service = AuthUserService::new(auth_store.clone());
|
||||||
let refresh_session_service =
|
let refresh_session_service =
|
||||||
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ impl AppState {
|
|||||||
oss_client,
|
oss_client,
|
||||||
password_entry_service,
|
password_entry_service,
|
||||||
refresh_session_service,
|
refresh_session_service,
|
||||||
|
auth_user_service,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +84,10 @@ impl AppState {
|
|||||||
pub fn refresh_session_service(&self) -> &RefreshSessionService {
|
pub fn refresh_session_service(&self) -> &RefreshSessionService {
|
||||||
&self.refresh_session_service
|
&self.refresh_session_service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn auth_user_service(&self) -> &AuthUserService {
|
||||||
|
&self.auth_user_service
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for AppStateInitError {
|
impl fmt::Display for AppStateInitError {
|
||||||
|
|||||||
@@ -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)
|
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)
|
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)
|
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. 边界约束
|
## 4. 边界约束
|
||||||
|
|
||||||
@@ -52,3 +53,4 @@
|
|||||||
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||||
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
||||||
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
|
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
|
||||||
|
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ pub struct RotateRefreshSessionResult {
|
|||||||
pub user: AuthUser,
|
pub user: AuthUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LogoutCurrentSessionInput {
|
||||||
|
pub user_id: String,
|
||||||
|
pub refresh_token_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LogoutCurrentSessionResult {
|
||||||
|
pub user: AuthUser,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum PasswordEntryError {
|
pub enum PasswordEntryError {
|
||||||
InvalidUsername,
|
InvalidUsername,
|
||||||
@@ -111,6 +122,12 @@ pub enum RefreshSessionError {
|
|||||||
Store(String),
|
Store(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum LogoutError {
|
||||||
|
UserNotFound,
|
||||||
|
Store(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct InMemoryAuthStore {
|
pub struct InMemoryAuthStore {
|
||||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||||
@@ -146,6 +163,11 @@ pub struct RefreshSessionService {
|
|||||||
refresh_session_ttl_days: u32,
|
refresh_session_ttl_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AuthUserService {
|
||||||
|
store: InMemoryAuthStore,
|
||||||
|
}
|
||||||
|
|
||||||
impl PasswordEntryService {
|
impl PasswordEntryService {
|
||||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||||
Self { store }
|
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<Option<AuthUser>, 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<LogoutCurrentSessionResult, LogoutError> {
|
||||||
|
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 {
|
impl Default for InMemoryAuthStore {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -495,6 +558,58 @@ impl InMemoryAuthStore {
|
|||||||
|
|
||||||
Ok(updated_session)
|
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<Option<AuthUser>, 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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
@@ -549,6 +664,17 @@ impl fmt::Display for RefreshSessionError {
|
|||||||
|
|
||||||
impl Error 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 {
|
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||||
match error {
|
match error {
|
||||||
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
|
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<String, PasswordEntryError> {
|
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||||
let username = raw_username.trim().to_string();
|
let username = raw_username.trim().to_string();
|
||||||
let valid_length =
|
let valid_length =
|
||||||
@@ -603,6 +749,10 @@ mod tests {
|
|||||||
RefreshSessionService::new(store, 30)
|
RefreshSessionService::new(store, 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_user_service(store: InMemoryAuthStore) -> AuthUserService {
|
||||||
|
AuthUserService::new(store)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn first_password_entry_creates_user() {
|
async fn first_password_entry_creates_user() {
|
||||||
let service = build_password_service(build_store());
|
let service = build_password_service(build_store());
|
||||||
@@ -746,4 +896,54 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(error, RefreshSessionError::SessionNotFound);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user