fix(auth): tighten refresh session revocation

This commit is contained in:
2026-05-13 15:04:37 +08:00
parent b13870f71b
commit 4fecf9c975
36 changed files with 1664 additions and 170 deletions

View File

@@ -1,10 +1,15 @@
use std::collections::HashMap;
use axum::{
Json,
extract::{Extension, State},
extract::{Extension, Path, State},
http::StatusCode,
};
use module_auth::{RefreshSessionRecord, RevokeRefreshSessionByUserInput};
use platform_auth::hash_refresh_session_token;
use shared_contracts::auth::{AuthSessionSummaryPayload, AuthSessionsResponse};
use shared_contracts::auth::{
AuthSessionSummaryPayload, AuthSessionsResponse, RevokeAuthSessionResponse,
};
use time::OffsetDateTime;
use crate::{
@@ -37,41 +42,189 @@ pub async fn auth_sessions(
.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: sessions
.sessions
sessions: session_groups
.into_iter()
.map(|session| {
let is_current = current_refresh_token_hash
.as_ref()
.is_some_and(|hash| session.refresh_token_hash == *hash);
let client_label = session.client_info.device_display_name.clone();
AuthSessionSummaryPayload {
session_id: session.session_id,
client_type: session.client_info.client_type,
client_runtime: session.client_info.client_runtime,
client_platform: session.client_info.client_platform,
client_label,
device_display_name: session.client_info.device_display_name,
mini_program_app_id: session.client_info.mini_program_app_id,
mini_program_env: session.client_info.mini_program_env,
user_agent: session.client_info.user_agent,
ip_masked: mask_ip(session.client_info.ip.as_deref()),
is_current,
created_at: session.created_at,
last_seen_at: session.last_seen_at,
expires_at: session.expires_at,
}
.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_type: representative.client_info.client_type.clone(),
client_runtime: representative.client_info.client_runtime.clone(),
client_platform: representative.client_info.client_platform.clone(),
client_label,
device_display_name: representative.client_info.device_display_name.clone(),
mini_program_app_id: representative.client_info.mini_program_app_id.clone(),
mini_program_env: representative.client_info.mini_program_env.clone(),
user_agent: representative.client_info.user_agent.clone(),
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 => {
@@ -88,3 +241,19 @@ fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> Ap
}
}
}
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)
}
}
}