feat: add multi-device session identity
This commit is contained in:
@@ -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