feat: add logout all flow
This commit is contained in:
@@ -124,6 +124,15 @@ pub struct LogoutCurrentSessionInput {
|
||||
pub struct LogoutCurrentSessionResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutAllSessionsInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutAllSessionsResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordEntryError {
|
||||
@@ -423,6 +432,25 @@ impl AuthUserService {
|
||||
|
||||
Ok(LogoutCurrentSessionResult { user })
|
||||
}
|
||||
// 全端登出需要先吊销该用户全部 refresh session,再统一提升 token_version,
|
||||
// 让所有旧 access token 在下一次鉴权时立即失效。
|
||||
pub fn logout_all_sessions(
|
||||
&self,
|
||||
input: LogoutAllSessionsInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<LogoutAllSessionsResult, LogoutError> {
|
||||
self.store
|
||||
.revoke_all_sessions_by_user_id(&input.user_id, now)
|
||||
.map_err(map_refresh_error_to_logout_error)?;
|
||||
|
||||
let user = self
|
||||
.store
|
||||
.increment_user_token_version(&input.user_id)
|
||||
.map_err(map_password_error_to_logout_error)?
|
||||
.ok_or(LogoutError::UserNotFound)?;
|
||||
|
||||
Ok(LogoutAllSessionsResult { user })
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryAuthStore {
|
||||
@@ -678,6 +706,35 @@ impl InMemoryAuthStore {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn revoke_all_sessions_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(), RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
|
||||
})?;
|
||||
|
||||
for stored in state.sessions_by_id.values_mut() {
|
||||
if stored.session.user_id != user_id {
|
||||
continue;
|
||||
}
|
||||
if stored.session.revoked_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
stored.session.revoked_at = Some(now_iso.clone());
|
||||
stored.session.updated_at = now_iso.clone();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn increment_user_token_version(
|
||||
&self,
|
||||
@@ -1052,6 +1109,92 @@ mod tests {
|
||||
.expect_err("revoked session should fail");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
|
||||
let store = build_store();
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
let user_service = build_user_service(store);
|
||||
let user = password_service
|
||||
.execute(PasswordEntryInput {
|
||||
username: "guest_logout_all".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed")
|
||||
.user;
|
||||
let first_refresh_token_hash = hash_refresh_session_token("logout-all-token-01");
|
||||
let second_refresh_token_hash = hash_refresh_session_token("logout-all-token-02");
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: first_refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
client_info: build_client_info(),
|
||||
},
|
||||
now,
|
||||
)
|
||||
.expect("first session should create");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: second_refresh_token_hash.clone(),
|
||||
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::seconds(1),
|
||||
)
|
||||
.expect("second session should create");
|
||||
|
||||
let result = user_service
|
||||
.logout_all_sessions(
|
||||
LogoutAllSessionsInput {
|
||||
user_id: user.id.clone(),
|
||||
},
|
||||
now + Duration::minutes(1),
|
||||
)
|
||||
.expect("logout all should succeed");
|
||||
|
||||
assert_eq!(result.user.token_version, 2);
|
||||
assert_eq!(
|
||||
refresh_service
|
||||
.list_active_sessions_by_user(&user.id, now + Duration::minutes(2))
|
||||
.expect("sessions should list")
|
||||
.sessions
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
|
||||
let first_refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: first_refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-all-token-03"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("first revoked session should fail");
|
||||
assert_eq!(first_refresh_error, RefreshSessionError::SessionNotFound);
|
||||
|
||||
let second_refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash: second_refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-all-token-04"),
|
||||
},
|
||||
now + Duration::minutes(2),
|
||||
)
|
||||
.expect_err("second revoked session should fail");
|
||||
assert_eq!(second_refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() {
|
||||
|
||||
Reference in New Issue
Block a user