Files
Genarrative/server-rs/crates/api-server/src/auth_sessions.rs
2026-05-28 20:46:21 +08:00

253 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<Json<serde_json::Value>, 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(),
&current_session_id,
)
})
.collect(),
},
))
}
pub async fn revoke_auth_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(session_id): Path<String>,
) -> Result<Json<serde_json::Value>, 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<RefreshSessionRecord>,
) -> Vec<Vec<RefreshSessionRecord>> {
let mut grouped = HashMap::<String, Vec<RefreshSessionRecord>>::new();
for session in sessions {
grouped
.entry(build_session_group_key(&session))
.or_default()
.push(session);
}
let mut groups = grouped.into_values().collect::<Vec<_>>();
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<RefreshSessionRecord>,
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::<Vec<_>>();
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)
}
}
}