feat: add current session logout flow

This commit is contained in:
2026-04-21 15:36:17 +08:00
parent 60852241c9
commit a83c64133d
11 changed files with 703 additions and 9 deletions

View File

@@ -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 回写。

View File

@@ -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"))
);
}
}

View File

@@ -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()

View File

@@ -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,

View 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 }),
))
}

View File

@@ -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;

View File

@@ -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<OssClient>,
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 {

View File

@@ -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` 复用。

View File

@@ -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<String>,
}
#[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<Mutex<InMemoryAuthStoreState>>,
@@ -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<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 {
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<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)]
@@ -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<String, PasswordEntryError> {
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);
}
}