e0e3348f79
切断运行中从 SpacetimeDB 整包快照回灌内存的路径 清空旧 auth_store_snapshot 并仅保留一次性迁移 修复手机号字段只在 AuthUser 中时重复创建账号 同步认证排障与后端数据契约文档
253 lines
8.6 KiB
Rust
253 lines
8.6 KiB
Rust
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(),
|
|
¤t_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_tables_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)
|
|
}
|
|
}
|
|
}
|