feat: add logout all flow
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user