feat: add current session logout flow
This commit is contained in:
@@ -93,6 +93,17 @@ pub struct RotateRefreshSessionResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionInput {
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogoutCurrentSessionResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordEntryError {
|
||||
InvalidUsername,
|
||||
@@ -111,6 +122,12 @@ pub enum RefreshSessionError {
|
||||
Store(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LogoutError {
|
||||
UserNotFound,
|
||||
Store(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryAuthStore {
|
||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||
@@ -146,6 +163,11 @@ pub struct RefreshSessionService {
|
||||
refresh_session_ttl_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUserService {
|
||||
store: InMemoryAuthStore,
|
||||
}
|
||||
|
||||
impl PasswordEntryService {
|
||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||
Self { store }
|
||||
@@ -319,6 +341,47 @@ impl RefreshSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUserService {
|
||||
pub fn new(store: InMemoryAuthStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
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))
|
||||
.map_err(map_password_error_to_logout_error)
|
||||
}
|
||||
|
||||
pub fn logout_current_session(
|
||||
&self,
|
||||
input: LogoutCurrentSessionInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<LogoutCurrentSessionResult, LogoutError> {
|
||||
if let Some(refresh_token_hash) = input
|
||||
.refresh_token_hash
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
self.store
|
||||
.revoke_session_by_refresh_token_hash(refresh_token_hash, 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(LogoutCurrentSessionResult { user })
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryAuthStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -495,6 +558,58 @@ impl InMemoryAuthStore {
|
||||
|
||||
Ok(updated_session)
|
||||
}
|
||||
|
||||
fn revoke_session_by_refresh_token_hash(
|
||||
&self,
|
||||
refresh_token_hash: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(), RefreshSessionError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
|
||||
let Some(session_id) = state
|
||||
.session_id_by_refresh_token_hash
|
||||
.get(refresh_token_hash)
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(stored) = state.sessions_by_id.get_mut(&session_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if stored.session.revoked_at.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let now_iso = now
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")))?;
|
||||
stored.session.revoked_at = Some(now_iso.clone());
|
||||
stored.session.updated_at = now_iso;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn increment_user_token_version(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<AuthUser>, PasswordEntryError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
|
||||
for stored_user in state.users_by_username.values_mut() {
|
||||
if stored_user.user.id != user_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
stored_user.user.token_version += 1;
|
||||
return Ok(Some(stored_user.user.clone()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -549,6 +664,17 @@ impl fmt::Display for RefreshSessionError {
|
||||
|
||||
impl Error for RefreshSessionError {}
|
||||
|
||||
impl fmt::Display for LogoutError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
|
||||
Self::Store(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for LogoutError {}
|
||||
|
||||
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
|
||||
@@ -561,6 +687,26 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
|
||||
match error {
|
||||
PasswordEntryError::Store(message) => LogoutError::Store(message),
|
||||
PasswordEntryError::InvalidUsername
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
|
||||
match error {
|
||||
RefreshSessionError::Store(message) => LogoutError::Store(message),
|
||||
RefreshSessionError::MissingToken
|
||||
| RefreshSessionError::SessionNotFound
|
||||
| RefreshSessionError::SessionExpired
|
||||
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
|
||||
let username = raw_username.trim().to_string();
|
||||
let valid_length =
|
||||
@@ -603,6 +749,10 @@ mod tests {
|
||||
RefreshSessionService::new(store, 30)
|
||||
}
|
||||
|
||||
fn build_user_service(store: InMemoryAuthStore) -> AuthUserService {
|
||||
AuthUserService::new(store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_password_entry_creates_user() {
|
||||
let service = build_password_service(build_store());
|
||||
@@ -746,4 +896,54 @@ mod tests {
|
||||
|
||||
assert_eq!(error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_current_session_revokes_session_and_increments_token_version() {
|
||||
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".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed")
|
||||
.user;
|
||||
let refresh_token_hash = hash_refresh_session_token("logout-token");
|
||||
refresh_service
|
||||
.create_session(
|
||||
CreateRefreshSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: refresh_token_hash.clone(),
|
||||
issued_by_provider: AuthLoginMethod::Password,
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("session should create");
|
||||
|
||||
let result = user_service
|
||||
.logout_current_session(
|
||||
LogoutCurrentSessionInput {
|
||||
user_id: user.id.clone(),
|
||||
refresh_token_hash: Some(refresh_token_hash.clone()),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("logout should succeed");
|
||||
|
||||
assert_eq!(result.user.token_version, 2);
|
||||
|
||||
let refresh_error = refresh_service
|
||||
.rotate_session(
|
||||
RotateRefreshSessionInput {
|
||||
refresh_token_hash,
|
||||
next_refresh_token_hash: hash_refresh_session_token("logout-token-next"),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect_err("revoked session should fail");
|
||||
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user