feat: add multi-device session identity
This commit is contained in:
@@ -16,6 +16,7 @@ use crate::{
|
||||
require_bearer_auth,
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_sessions::auth_sessions,
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
logout::logout,
|
||||
@@ -56,6 +57,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/sessions",
|
||||
get(auth_sessions)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_refresh_session_token,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/refresh",
|
||||
post(refresh_session).route_layer(middleware::from_fn_with_state(
|
||||
@@ -417,6 +430,121 @@ mod tests {
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_sessions_returns_multi_device_session_fields() {
|
||||
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",
|
||||
)
|
||||
.header("x-client-instance-id", "chrome-instance-001")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_sessions_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("first login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("first login should succeed");
|
||||
let first_cookie = first_login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("first cookie should exist")
|
||||
.to_string();
|
||||
let first_body = first_login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("first login body should collect")
|
||||
.to_bytes();
|
||||
let first_payload: Value =
|
||||
serde_json::from_slice(&first_body).expect("first login payload should be json");
|
||||
let access_token = first_payload["token"]
|
||||
.as_str()
|
||||
.expect("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-type", "mini_program")
|
||||
.header("x-client-runtime", "wechat_mini_program")
|
||||
.header("x-client-platform", "android")
|
||||
.header("x-client-instance-id", "mini-instance-001")
|
||||
.header("x-mini-program-app-id", "wx-session-test")
|
||||
.header("x-mini-program-env", "release")
|
||||
.header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "guest_sessions_api",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("second login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("second login should succeed");
|
||||
|
||||
let sessions_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/sessions")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("cookie", first_cookie)
|
||||
.body(Body::empty())
|
||||
.expect("sessions request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("sessions request should succeed");
|
||||
|
||||
assert_eq!(sessions_response.status(), StatusCode::OK);
|
||||
let sessions_body = sessions_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("sessions body should collect")
|
||||
.to_bytes();
|
||||
let sessions_payload: Value =
|
||||
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
|
||||
let sessions = sessions_payload["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions should be array");
|
||||
|
||||
assert_eq!(sessions.len(), 2);
|
||||
assert!(sessions.iter().any(|session| {
|
||||
session["clientType"] == Value::String("web_browser".to_string())
|
||||
&& session["clientRuntime"] == Value::String("chrome".to_string())
|
||||
&& session["clientPlatform"] == Value::String("windows".to_string())
|
||||
&& session["deviceDisplayName"] == Value::String("Windows / Chrome".to_string())
|
||||
&& session["isCurrent"] == Value::Bool(true)
|
||||
}));
|
||||
assert!(sessions.iter().any(|session| {
|
||||
session["clientType"] == Value::String("mini_program".to_string())
|
||||
&& session["clientRuntime"] == Value::String("wechat_mini_program".to_string())
|
||||
&& session["miniProgramAppId"] == Value::String("wx-session-test".to_string())
|
||||
&& session["miniProgramEnv"] == Value::String("release".to_string())
|
||||
&& session["deviceDisplayName"] == Value::String("微信小程序 / Android".to_string())
|
||||
&& session["isCurrent"] == Value::Bool(false)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_reuses_same_user_for_same_credentials() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use axum::http::{
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
header::SET_COOKIE,
|
||||
};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
|
||||
use module_auth::{
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
|
||||
};
|
||||
@@ -13,6 +10,7 @@ use platform_auth::{
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
use crate::{session_client::SessionClientContext};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedAuthSession {
|
||||
@@ -23,6 +21,7 @@ pub struct SignedAuthSession {
|
||||
pub fn create_password_auth_session(
|
||||
state: &AppState,
|
||||
user: &AuthUser,
|
||||
session_client: &SessionClientContext,
|
||||
) -> Result<SignedAuthSession, AppError> {
|
||||
let refresh_token = create_refresh_session_token();
|
||||
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
|
||||
@@ -33,6 +32,7 @@ pub fn create_password_auth_session(
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash,
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: session_client.to_refresh_session_client_info(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
@@ -95,10 +95,7 @@ pub fn build_clear_refresh_session_cookie_header(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn attach_set_cookie_header(
|
||||
headers: &mut HeaderMap,
|
||||
set_cookie: HeaderValue,
|
||||
) {
|
||||
pub fn attach_set_cookie_header(headers: &mut HeaderMap, set_cookie: HeaderValue) {
|
||||
headers.insert(SET_COOKIE, set_cookie);
|
||||
}
|
||||
|
||||
|
||||
113
server-rs/crates/api-server/src/auth_sessions.rs
Normal file
113
server-rs/crates/api-server/src/auth_sessions.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::Serialize;
|
||||
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,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionsResponse {
|
||||
pub sessions: Vec<AuthSessionSummaryPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthSessionSummaryPayload {
|
||||
pub session_id: String,
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_label: String,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_masked: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at: String,
|
||||
pub last_seen_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthSessionsResponse {
|
||||
sessions: sessions
|
||||
.sessions
|
||||
.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,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@ mod api_response;
|
||||
mod app;
|
||||
mod assets;
|
||||
mod auth;
|
||||
mod auth_session;
|
||||
mod auth_me;
|
||||
mod auth_sessions;
|
||||
mod auth_session;
|
||||
mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
@@ -13,6 +14,7 @@ mod password_entry;
|
||||
mod refresh_session;
|
||||
mod request_context;
|
||||
mod response_headers;
|
||||
mod session_client;
|
||||
mod state;
|
||||
|
||||
use shared_logging::init_tracing;
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -47,6 +48,7 @@ pub struct PasswordEntryUserPayload {
|
||||
pub async fn password_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PasswordEntryRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let result = state
|
||||
@@ -57,7 +59,8 @@ pub async fn password_entry(
|
||||
})
|
||||
.await
|
||||
.map_err(map_password_entry_error)?;
|
||||
let signed_session = create_password_auth_session(&state, &result.user)?;
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
attach_set_cookie_header(
|
||||
|
||||
433
server-rs/crates/api-server/src/session_client.rs
Normal file
433
server-rs/crates/api-server/src/session_client.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
use axum::http::HeaderMap;
|
||||
use module_auth::RefreshSessionClientInfo;
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
|
||||
const X_CLIENT_TYPE_HEADER: &str = "x-client-type";
|
||||
const X_CLIENT_RUNTIME_HEADER: &str = "x-client-runtime";
|
||||
const X_CLIENT_PLATFORM_HEADER: &str = "x-client-platform";
|
||||
const X_CLIENT_INSTANCE_ID_HEADER: &str = "x-client-instance-id";
|
||||
const X_MINI_PROGRAM_APP_ID_HEADER: &str = "x-mini-program-app-id";
|
||||
const X_MINI_PROGRAM_ENV_HEADER: &str = "x-mini-program-env";
|
||||
const USER_AGENT_HEADER: &str = "user-agent";
|
||||
const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for";
|
||||
const X_REAL_IP_HEADER: &str = "x-real-ip";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SessionClientContext {
|
||||
// 统一保存登录时采集到的客户端身份快照,后续直接写入 refresh_session。
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_instance_id: Option<String>,
|
||||
pub device_fingerprint: Option<String>,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionClientContext {
|
||||
pub fn to_refresh_session_client_info(&self) -> RefreshSessionClientInfo {
|
||||
RefreshSessionClientInfo {
|
||||
client_type: self.client_type.clone(),
|
||||
client_runtime: self.client_runtime.clone(),
|
||||
client_platform: self.client_platform.clone(),
|
||||
client_instance_id: self.client_instance_id.clone(),
|
||||
device_fingerprint: self.device_fingerprint.clone(),
|
||||
device_display_name: self.device_display_name.clone(),
|
||||
mini_program_app_id: self.mini_program_app_id.clone(),
|
||||
mini_program_env: self.mini_program_env.clone(),
|
||||
user_agent: self.user_agent.clone(),
|
||||
ip: self.ip.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientContext {
|
||||
// 显式头优先,UA 自动识别兜底,避免前端没有完全补头时整条登录链路不可用。
|
||||
let user_agent = header_value(headers, USER_AGENT_HEADER);
|
||||
let ua_lower = user_agent
|
||||
.as_ref()
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let explicit_client_type = normalize_client_type(header_value(headers, X_CLIENT_TYPE_HEADER));
|
||||
let explicit_client_runtime =
|
||||
normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower);
|
||||
let explicit_client_platform =
|
||||
normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower);
|
||||
let client_instance_id = normalize_optional_string(header_value(
|
||||
headers,
|
||||
X_CLIENT_INSTANCE_ID_HEADER,
|
||||
));
|
||||
let mini_program_app_id =
|
||||
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER));
|
||||
let mini_program_env =
|
||||
normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER));
|
||||
|
||||
let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower);
|
||||
let inferred_runtime =
|
||||
infer_client_runtime(explicit_client_runtime.as_deref(), &inferred_client_type, &ua_lower);
|
||||
let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower);
|
||||
let ip = resolve_ip(headers);
|
||||
let device_display_name =
|
||||
build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform);
|
||||
let device_fingerprint =
|
||||
build_device_fingerprint(&inferred_client_type, &inferred_runtime, &inferred_platform, client_instance_id.as_deref(), user_agent.as_deref());
|
||||
|
||||
SessionClientContext {
|
||||
client_type: inferred_client_type,
|
||||
client_runtime: inferred_runtime,
|
||||
client_platform: inferred_platform,
|
||||
client_instance_id,
|
||||
device_fingerprint,
|
||||
device_display_name,
|
||||
mini_program_app_id,
|
||||
mini_program_env,
|
||||
user_agent: normalize_optional_string(user_agent),
|
||||
ip,
|
||||
}
|
||||
}
|
||||
|
||||
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_client_type(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"web_browser" | "wechat_h5" | "mini_program" | "native_app" | "desktop_app"
|
||||
| "unknown" => Some(normalized),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_runtime(value: Option<String>, ua_lower: &str) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"chrome"
|
||||
| "edge"
|
||||
| "safari"
|
||||
| "firefox"
|
||||
| "wechat_embedded_browser"
|
||||
| "wechat_mini_program"
|
||||
| "alipay_mini_program"
|
||||
| "douyin_mini_program"
|
||||
| "unknown" => Some(normalized),
|
||||
_ => infer_runtime_from_user_agent(ua_lower),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_platform(value: Option<String>, ua_lower: &str) -> Option<String> {
|
||||
value.and_then(|raw| {
|
||||
let normalized = raw.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"windows" | "macos" | "linux" | "ios" | "android" | "unknown" => Some(normalized),
|
||||
_ => infer_platform_from_user_agent(ua_lower),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String {
|
||||
if let Some(client_type) = explicit_type {
|
||||
return client_type.to_string();
|
||||
}
|
||||
|
||||
if ua_lower.contains("micromessenger") {
|
||||
return "wechat_h5".to_string();
|
||||
}
|
||||
|
||||
"web_browser".to_string()
|
||||
}
|
||||
|
||||
fn infer_client_runtime(explicit_runtime: Option<&str>, client_type: &str, ua_lower: &str) -> String {
|
||||
if client_type == "mini_program" {
|
||||
if let Some(runtime) = explicit_runtime {
|
||||
return runtime.to_string();
|
||||
}
|
||||
if ua_lower.contains("alipayclient") {
|
||||
return "alipay_mini_program".to_string();
|
||||
}
|
||||
if ua_lower.contains("toutiaomicroapp") || ua_lower.contains("douyin") {
|
||||
return "douyin_mini_program".to_string();
|
||||
}
|
||||
return "wechat_mini_program".to_string();
|
||||
}
|
||||
|
||||
if client_type == "wechat_h5" {
|
||||
return "wechat_embedded_browser".to_string();
|
||||
}
|
||||
|
||||
explicit_runtime
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| infer_runtime_from_user_agent(ua_lower))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn infer_client_platform(explicit_platform: Option<&str>, ua_lower: &str) -> String {
|
||||
explicit_platform
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| infer_platform_from_user_agent(ua_lower))
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn infer_runtime_from_user_agent(ua_lower: &str) -> Option<String> {
|
||||
if ua_lower.contains("edg/") {
|
||||
return Some("edge".to_string());
|
||||
}
|
||||
if ua_lower.contains("firefox/") {
|
||||
return Some("firefox".to_string());
|
||||
}
|
||||
if ua_lower.contains("chrome/") || ua_lower.contains("crios/") {
|
||||
return Some("chrome".to_string());
|
||||
}
|
||||
if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/") {
|
||||
return Some("safari".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn infer_platform_from_user_agent(ua_lower: &str) -> Option<String> {
|
||||
if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ios") {
|
||||
return Some("ios".to_string());
|
||||
}
|
||||
if ua_lower.contains("android") {
|
||||
return Some("android".to_string());
|
||||
}
|
||||
if ua_lower.contains("windows") {
|
||||
return Some("windows".to_string());
|
||||
}
|
||||
if ua_lower.contains("mac os") || ua_lower.contains("macintosh") {
|
||||
return Some("macos".to_string());
|
||||
}
|
||||
if ua_lower.contains("linux") {
|
||||
return Some("linux".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn build_device_display_name(client_type: &str, client_runtime: &str, client_platform: &str) -> String {
|
||||
// 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。
|
||||
if client_type == "mini_program" {
|
||||
return format!(
|
||||
"{} / {}",
|
||||
map_runtime_display(client_runtime),
|
||||
map_platform_display(client_platform)
|
||||
);
|
||||
}
|
||||
if client_type == "wechat_h5" {
|
||||
return format!("微信内网页 / {}", map_platform_display(client_platform));
|
||||
}
|
||||
if client_type == "unknown" {
|
||||
return "未知设备".to_string();
|
||||
}
|
||||
|
||||
format!(
|
||||
"{} / {}",
|
||||
map_platform_display(client_platform),
|
||||
map_runtime_display(client_runtime)
|
||||
)
|
||||
}
|
||||
|
||||
fn map_runtime_display(runtime: &str) -> &'static str {
|
||||
match runtime {
|
||||
"chrome" => "Chrome",
|
||||
"edge" => "Edge",
|
||||
"safari" => "Safari",
|
||||
"firefox" => "Firefox",
|
||||
"wechat_embedded_browser" => "微信内网页",
|
||||
"wechat_mini_program" => "微信小程序",
|
||||
"alipay_mini_program" => "支付宝小程序",
|
||||
"douyin_mini_program" => "抖音小程序",
|
||||
_ => "未知客户端",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_platform_display(platform: &str) -> &'static str {
|
||||
match platform {
|
||||
"windows" => "Windows",
|
||||
"macos" => "macOS",
|
||||
"linux" => "Linux",
|
||||
"ios" => "iPhone",
|
||||
"android" => "Android",
|
||||
_ => "未知设备",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_device_fingerprint(
|
||||
client_type: &str,
|
||||
client_runtime: &str,
|
||||
client_platform: &str,
|
||||
client_instance_id: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Option<String> {
|
||||
// 这里的指纹只用于会话聚类与展示,不参与任何鉴权决策。
|
||||
let seed = if let Some(instance_id) = client_instance_id {
|
||||
format!("{client_type}|{client_runtime}|{client_platform}|{instance_id}")
|
||||
} else if let Some(user_agent) = user_agent {
|
||||
format!(
|
||||
"{client_type}|{client_runtime}|{client_platform}|{}",
|
||||
normalize_user_agent(user_agent)
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(hash_refresh_session_token(&seed))
|
||||
}
|
||||
|
||||
fn normalize_user_agent(user_agent: &str) -> String {
|
||||
user_agent
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn resolve_ip(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(forwarded) = header_value(headers, X_FORWARDED_FOR_HEADER) {
|
||||
let ip = forwarded
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
if ip.is_some() {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
normalize_optional_string(header_value(headers, X_REAL_IP_HEADER))
|
||||
}
|
||||
|
||||
pub fn mask_ip(ip: Option<&str>) -> Option<String> {
|
||||
// 会话列表只返回脱敏后的 IP,避免把完整地址直接暴露给前端。
|
||||
let ip = ip?.trim();
|
||||
if ip.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if ip.contains(':') {
|
||||
let parts = ip.split(':').filter(|part| !part.is_empty()).collect::<Vec<_>>();
|
||||
if parts.len() <= 2 {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
return Some(format!("{}:{}::*", parts[0], parts[1]));
|
||||
}
|
||||
|
||||
let parts = ip.split('.').collect::<Vec<_>>();
|
||||
if parts.len() != 4 {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
|
||||
Some(format!("{}.{}.*.*", parts[0], parts[1]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
use super::{mask_ip, resolve_session_client_context};
|
||||
|
||||
#[test]
|
||||
fn resolve_session_client_context_detects_wechat_h5_from_user_agent() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit Safari MicroMessenger",
|
||||
),
|
||||
);
|
||||
|
||||
let context = resolve_session_client_context(&headers);
|
||||
|
||||
assert_eq!(context.client_type, "wechat_h5");
|
||||
assert_eq!(context.client_runtime, "wechat_embedded_browser");
|
||||
assert_eq!(context.client_platform, "ios");
|
||||
assert_eq!(context.device_display_name, "微信内网页 / iPhone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_session_client_context_prefers_explicit_mini_program_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-client-type", HeaderValue::from_static("mini_program"));
|
||||
headers.insert(
|
||||
"x-client-runtime",
|
||||
HeaderValue::from_static("wechat_mini_program"),
|
||||
);
|
||||
headers.insert("x-client-platform", HeaderValue::from_static("android"));
|
||||
headers.insert(
|
||||
"x-client-instance-id",
|
||||
HeaderValue::from_static("mini-instance-001"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-mini-program-app-id",
|
||||
HeaderValue::from_static("wx1234567890"),
|
||||
);
|
||||
headers.insert("x-mini-program-env", HeaderValue::from_static("release"));
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("Mozilla/5.0 Chrome/123.0 MicroMessenger"),
|
||||
);
|
||||
|
||||
let context = resolve_session_client_context(&headers);
|
||||
|
||||
assert_eq!(context.client_type, "mini_program");
|
||||
assert_eq!(context.client_runtime, "wechat_mini_program");
|
||||
assert_eq!(context.client_platform, "android");
|
||||
assert_eq!(context.mini_program_app_id.as_deref(), Some("wx1234567890"));
|
||||
assert_eq!(context.mini_program_env.as_deref(), Some("release"));
|
||||
assert_eq!(context.device_display_name, "微信小程序 / Android");
|
||||
assert!(context.device_fingerprint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_session_client_context_distinguishes_web_browser_runtime() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36",
|
||||
),
|
||||
);
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.11"));
|
||||
|
||||
let context = resolve_session_client_context(&headers);
|
||||
|
||||
assert_eq!(context.client_type, "web_browser");
|
||||
assert_eq!(context.client_runtime, "chrome");
|
||||
assert_eq!(context.client_platform, "windows");
|
||||
assert_eq!(context.ip.as_deref(), Some("203.0.113.11"));
|
||||
assert_eq!(context.device_display_name, "Windows / Chrome");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_ip_returns_masked_ipv4_and_ipv6() {
|
||||
assert_eq!(mask_ip(Some("203.0.113.11")).as_deref(), Some("203.0.*.*"));
|
||||
assert_eq!(
|
||||
mask_ip(Some("2408:8000:abcd:1234::1")).as_deref(),
|
||||
Some("2408:8000::*")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,21 @@ pub struct CreateRefreshSessionInput {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub issued_by_provider: AuthLoginMethod,
|
||||
pub client_info: RefreshSessionClientInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RefreshSessionClientInfo {
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
pub client_instance_id: Option<String>,
|
||||
pub device_fingerprint: Option<String>,
|
||||
pub device_display_name: String,
|
||||
pub mini_program_app_id: Option<String>,
|
||||
pub mini_program_env: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -69,6 +84,7 @@ pub struct RefreshSessionRecord {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub issued_by_provider: AuthLoginMethod,
|
||||
pub client_info: RefreshSessionClientInfo,
|
||||
pub expires_at: String,
|
||||
pub revoked_at: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -93,6 +109,11 @@ pub struct RotateRefreshSessionResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ListActiveRefreshSessionsResult {
|
||||
pub sessions: Vec<RefreshSessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionInput {
|
||||
pub user_id: String,
|
||||
@@ -253,10 +274,14 @@ impl RefreshSessionService {
|
||||
let session_id = format!("usess_{}", Uuid::new_v4().simple());
|
||||
let expires_at = now
|
||||
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
|
||||
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
|
||||
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|
||||
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
|
||||
)?;
|
||||
.ok_or_else(|| {
|
||||
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
|
||||
})?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
|
||||
})?;
|
||||
let expires_at_iso = expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
@@ -267,6 +292,7 @@ impl RefreshSessionService {
|
||||
user_id: input.user_id,
|
||||
refresh_token_hash: input.refresh_token_hash,
|
||||
issued_by_provider: input.issued_by_provider,
|
||||
client_info: input.client_info,
|
||||
expires_at: expires_at_iso,
|
||||
revoked_at: None,
|
||||
created_at: now_iso.clone(),
|
||||
@@ -302,7 +328,9 @@ impl RefreshSessionService {
|
||||
&session.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}")))?;
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}"))
|
||||
})?;
|
||||
if expires_at <= now {
|
||||
return Err(RefreshSessionError::SessionExpired);
|
||||
}
|
||||
@@ -315,10 +343,14 @@ impl RefreshSessionService {
|
||||
|
||||
let next_expires_at = now
|
||||
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
|
||||
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
|
||||
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|
||||
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
|
||||
)?;
|
||||
.ok_or_else(|| {
|
||||
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
|
||||
})?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}"))
|
||||
})?;
|
||||
let next_expires_at_iso = next_expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
@@ -339,6 +371,20 @@ impl RefreshSessionService {
|
||||
user: user.user,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_active_sessions_by_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<ListActiveRefreshSessionsResult, RefreshSessionError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
.map_err(map_password_store_error)?
|
||||
.ok_or(RefreshSessionError::UserNotFound)?;
|
||||
|
||||
let sessions = self.store.list_active_sessions_by_user(user_id, now)?;
|
||||
Ok(ListActiveRefreshSessionsResult { sessions })
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUserService {
|
||||
@@ -346,10 +392,7 @@ impl AuthUserService {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<AuthUser>, LogoutError> {
|
||||
pub fn get_user_by_id(&self, user_id: &str) -> Result<Option<AuthUser>, LogoutError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
.map(|maybe_user| maybe_user.map(|stored| stored.user))
|
||||
@@ -461,10 +504,7 @@ impl InMemoryAuthStore {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn insert_session(
|
||||
&self,
|
||||
session: RefreshSessionRecord,
|
||||
) -> Result<(), RefreshSessionError> {
|
||||
fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -483,10 +523,9 @@ impl InMemoryAuthStore {
|
||||
session.refresh_token_hash.clone(),
|
||||
session.session_id.clone(),
|
||||
);
|
||||
state.sessions_by_id.insert(
|
||||
session.session_id.clone(),
|
||||
StoredRefreshSession { session },
|
||||
);
|
||||
state
|
||||
.sessions_by_id
|
||||
.insert(session.session_id.clone(), StoredRefreshSession { session });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -499,13 +538,60 @@ impl InMemoryAuthStore {
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(session_id) = state.session_id_by_refresh_token_hash.get(refresh_token_hash) else {
|
||||
let Some(session_id) = state
|
||||
.session_id_by_refresh_token_hash
|
||||
.get(refresh_token_hash)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(state.sessions_by_id.get(session_id).cloned())
|
||||
}
|
||||
|
||||
fn list_active_sessions_by_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<Vec<RefreshSessionRecord>, RefreshSessionError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let now_unix = now.unix_timestamp();
|
||||
|
||||
let mut sessions = state
|
||||
.sessions_by_id
|
||||
.values()
|
||||
.filter_map(|stored| {
|
||||
if stored.session.user_id != user_id {
|
||||
return None;
|
||||
}
|
||||
if stored.session.revoked_at.is_some() {
|
||||
return None;
|
||||
}
|
||||
let expires_at = OffsetDateTime::parse(
|
||||
&stored.session.expires_at,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.ok()?;
|
||||
if expires_at.unix_timestamp() <= now_unix {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(stored.session.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sessions.sort_by(|left, right| {
|
||||
right
|
||||
.last_seen_at
|
||||
.cmp(&left.last_seen_at)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
});
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn rotate_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -552,9 +638,10 @@ impl InMemoryAuthStore {
|
||||
stored.session.updated_at = updated_at;
|
||||
stored.session.last_seen_at = last_seen_at;
|
||||
let updated_session = stored.clone();
|
||||
state
|
||||
.session_id_by_refresh_token_hash
|
||||
.insert(next_refresh_token_hash, updated_session.session.session_id.clone());
|
||||
state.session_id_by_refresh_token_hash.insert(
|
||||
next_refresh_token_hash,
|
||||
updated_session.session.session_id.clone(),
|
||||
);
|
||||
|
||||
Ok(updated_session)
|
||||
}
|
||||
@@ -583,7 +670,9 @@ impl InMemoryAuthStore {
|
||||
}
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")))?;
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?;
|
||||
stored.session.revoked_at = Some(now_iso.clone());
|
||||
stored.session.updated_at = now_iso;
|
||||
|
||||
@@ -753,6 +842,21 @@ mod tests {
|
||||
AuthUserService::new(store)
|
||||
}
|
||||
|
||||
fn build_client_info() -> RefreshSessionClientInfo {
|
||||
RefreshSessionClientInfo {
|
||||
client_type: "web_browser".to_string(),
|
||||
client_runtime: "chrome".to_string(),
|
||||
client_platform: "windows".to_string(),
|
||||
client_instance_id: Some("client-instance-001".to_string()),
|
||||
device_fingerprint: Some("device-fingerprint-001".to_string()),
|
||||
device_display_name: "Windows / Chrome".to_string(),
|
||||
mini_program_app_id: None,
|
||||
mini_program_env: None,
|
||||
user_agent: Some("Mozilla/5.0".to_string()),
|
||||
ip: Some("203.0.113.10".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_password_entry_creates_user() {
|
||||
let service = build_password_service(build_store());
|
||||
@@ -856,6 +960,7 @@ mod tests {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
@@ -918,6 +1023,7 @@ mod tests {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
@@ -946,4 +1052,82 @@ mod tests {
|
||||
.expect_err("revoked session should fail");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() {
|
||||
let store = build_store();
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let user = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_sessions".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed")
|
||||
.user;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
let active_session = refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: hash_refresh_session_token("sessions-active"),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("active session should create");
|
||||
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: hash_refresh_session_token("sessions-revoked"),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "edge".to_string(),
|
||||
device_display_name: "Windows / Edge".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now - Duration::minutes(5),
|
||||
)
|
||||
.expect("revoked session should create");
|
||||
store
|
||||
.revoke_session_by_refresh_token_hash(
|
||||
&hash_refresh_session_token("sessions-revoked"),
|
||||
now - Duration::minutes(1),
|
||||
)
|
||||
.expect("revoked session should revoke");
|
||||
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: hash_refresh_session_token("sessions-expired"),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: RefreshSessionClientInfo {
|
||||
client_runtime: "firefox".to_string(),
|
||||
device_display_name: "Windows / Firefox".to_string(),
|
||||
..build_client_info()
|
||||
},
|
||||
},
|
||||
now - Duration::days(40),
|
||||
)
|
||||
.expect("expired session should create");
|
||||
|
||||
let listed = refresh_service
|
||||
.list_active_sessions_by_user(&user.id, now)
|
||||
.expect("sessions should list");
|
||||
|
||||
assert_eq!(listed.sessions.len(), 1);
|
||||
assert_eq!(listed.sessions[0].session_id, active_session.session.session_id);
|
||||
assert_eq!(listed.sessions[0].client_info.client_runtime, "chrome");
|
||||
assert_eq!(
|
||||
listed.sessions[0].client_info.device_display_name,
|
||||
"Windows / Chrome"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user