use std::collections::HashMap; use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, }; use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput}; use platform_auth::hash_refresh_session_token; use shared_contracts::auth::{ AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse, }; use time::OffsetDateTime; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RefreshSessionToken}, http_error::AppError, request_context::RequestContext, session_client::mask_ip, state::AppState, }; pub async fn auth_sessions( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, maybe_refresh_token: Option>, ) -> Result, AppError> { // 当前设备识别仍然依赖 refresh cookie 命中的原始 token,对旧前端行为保持兼容。 let user_id = authenticated.claims().user_id().to_string(); let current_refresh_token_hash = maybe_refresh_token.and_then(|token| { let token = token.0.token().trim(); if token.is_empty() { return None; } Some(hash_refresh_session_token(token)) }); let sessions = state .refresh_session_service() .list_active_sessions_by_user(&user_id, OffsetDateTime::now_utc()) .map_err(map_refresh_session_list_error)?; let current_session_id = authenticated.claims().session_id().to_string(); let session_groups = group_sessions_by_device_and_ip(sessions.sessions); Ok(json_success_body( Some(&request_context), AuthSessionsResponse { sessions: session_groups .into_iter() .map(|group| { build_session_summary( group, current_refresh_token_hash.as_deref(), ¤t_session_id, ) }) .collect(), }, )) } pub async fn revoke_auth_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(session_id): Path, ) -> Result, AppError> { let session_id = session_id.trim().to_string(); if session_id.is_empty() { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少会话 ID")); } if session_id == authenticated.claims().session_id() { return Err( AppError::from_status(StatusCode::CONFLICT).with_message("当前设备请使用退出登录") ); } let revoke_result = state .refresh_session_service() .revoke_session_by_user_and_session( RevokeRefreshSessionByUserInput { user_id: authenticated.claims().user_id().to_string(), session_id, }, OffsetDateTime::now_utc(), ) .map_err(map_refresh_session_revoke_error)?; if !revoke_result.revoked { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("会话不存在或已失效") ); } state .sync_auth_store_snapshot_to_spacetime() .await .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("同步认证快照失败:{error}")) })?; Ok(json_success_body( Some(&request_context), RevokeAuthSessionResponse { ok: true }, )) } fn group_sessions_by_device_and_ip( sessions: Vec, ) -> Vec> { let mut grouped = HashMap::>::new(); for session in sessions { grouped .entry(build_session_group_key(&session)) .or_default() .push(session); } let mut groups = grouped.into_values().collect::>(); for group in &mut groups { group.sort_by(|left, right| { right .last_seen_at .cmp(&left.last_seen_at) .then_with(|| right.created_at.cmp(&left.created_at)) }); } groups.sort_by(|left, right| { group_latest_last_seen(right) .cmp(group_latest_last_seen(left)) .then_with(|| group_earliest_created(left).cmp(group_earliest_created(right))) }); groups } fn build_session_group_key(session: &RefreshSessionRecord) -> String { let client_info = &session.client_info; let device_key = client_info.device_fingerprint.as_deref().unwrap_or(""); if !device_key.is_empty() { return format!("{}|{}", device_key, client_info.ip.as_deref().unwrap_or("")); } format!( "{}|{}|{}|{}|{}|{}", client_info.client_type, client_info.client_runtime, client_info.client_platform, client_info.device_display_name, client_info.user_agent.as_deref().unwrap_or(""), client_info.ip.as_deref().unwrap_or("") ) } fn build_session_summary( group: Vec, current_refresh_token_hash: Option<&str>, current_session_id: &str, ) -> AuthSessionSummaryPayload { let is_current = group.iter().any(|session| { session.session_id == current_session_id || current_refresh_token_hash.is_some_and(|hash| session.refresh_token_hash == hash) }); let representative = group .iter() .find(|session| is_current && session.session_id == current_session_id) .or_else(|| { group.iter().find(|session| { is_current && current_refresh_token_hash .is_some_and(|hash| session.refresh_token_hash == hash) }) }) .unwrap_or_else(|| group.first().expect("session group should not be empty")); let client_label = representative.client_info.device_display_name.clone(); let session_ids = group .iter() .map(|session| session.session_id.clone()) .collect::>(); let session_count = u32::try_from(session_ids.len()).unwrap_or(u32::MAX); AuthSessionSummaryPayload { session_id: representative.session_id.clone(), session_ids, session_count, client_label, ip_masked: mask_ip(representative.client_info.ip.as_deref()), is_current, created_at: group_earliest_created(&group).to_string(), last_seen_at: group_latest_last_seen(&group).to_string(), expires_at: group_latest_expires_at(&group).to_string(), } } fn group_latest_last_seen(group: &[RefreshSessionRecord]) -> &str { group .iter() .map(|session| session.last_seen_at.as_str()) .max() .unwrap_or("") } fn group_earliest_created(group: &[RefreshSessionRecord]) -> &str { group .iter() .map(|session| session.created_at.as_str()) .min() .unwrap_or("") } fn group_latest_expires_at(group: &[RefreshSessionRecord]) -> &str { group .iter() .map(|session| session.expires_at.as_str()) .max() .unwrap_or("") } fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError { match error { module_auth::RefreshSessionError::UserNotFound => { AppError::from_status(StatusCode::UNAUTHORIZED) .with_message("当前登录态已失效,请重新登录") } module_auth::RefreshSessionError::MissingToken | module_auth::RefreshSessionError::SessionNotFound | module_auth::RefreshSessionError::SessionExpired => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) } module_auth::RefreshSessionError::Store(message) => { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message) } } } fn map_refresh_session_revoke_error(error: module_auth::RefreshSessionError) -> AppError { match error { module_auth::RefreshSessionError::MissingToken | module_auth::RefreshSessionError::SessionNotFound => { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) } module_auth::RefreshSessionError::SessionExpired | module_auth::RefreshSessionError::UserNotFound => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) } module_auth::RefreshSessionError::Store(message) => { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message) } } }