feat: add logout all flow

This commit is contained in:
2026-04-21 16:50:56 +08:00
parent 78dcad1222
commit c3c5f1acd7
7 changed files with 589 additions and 2 deletions

View File

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

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

View File

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

View File

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