修复多端登录互相顶号

单设备退出只撤销当前 refresh session,不再提升账号级 token_version

认证中间件和 refresh 接口在本进程未命中会话时按需刷新 SpacetimeDB 认证工作集

补充多端登录与跨进程会话补水回归测试

同步项目文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-07 20:54:35 +08:00
parent a5143fa0cb
commit cc84656a1f
9 changed files with 463 additions and 55 deletions

View File

@@ -111,6 +111,12 @@ pub struct LogoutCurrentSessionResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefreshAuthStoreSnapshotResult {
pub user_count: usize,
pub session_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsResult {
pub user: AuthUser,

View File

@@ -863,6 +863,12 @@ impl AuthUserService {
input: LogoutCurrentSessionInput,
now: OffsetDateTime,
) -> Result<LogoutCurrentSessionResult, LogoutError> {
let user = self
.store
.find_by_user_id(&input.user_id)
.map_err(map_password_error_to_logout_error)?
.ok_or(LogoutError::UserNotFound)?
.user;
let revoked_by_hash = if let Some(refresh_token_hash) = input
.refresh_token_hash
.as_ref()
@@ -889,12 +895,6 @@ impl AuthUserService {
.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 })
}
@@ -989,6 +989,16 @@ impl InMemoryAuthStoreState {
}
}
fn apply_persistent_state(&mut self, next_state: Self) {
self.next_user_id = next_state.next_user_id;
self.users_by_username = next_state.users_by_username;
self.phone_to_user_id = next_state.phone_to_user_id;
self.sessions_by_id = next_state.sessions_by_id;
self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash;
self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid;
self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id;
}
fn to_persistent_snapshot(&self) -> PersistentAuthStoreSnapshot {
PersistentAuthStoreSnapshot {
next_user_id: self.next_user_id,
@@ -1013,6 +1023,26 @@ impl InMemoryAuthStore {
})
}
pub fn refresh_from_snapshot_json(
&self,
snapshot_json: &str,
) -> Result<RefreshAuthStoreSnapshotResult, String> {
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
.map_err(|error| format!("解析认证快照失败:{error}"))?;
let next_state = InMemoryAuthStoreState::from_persistent_snapshot(snapshot);
let result = RefreshAuthStoreSnapshotResult {
user_count: next_state.users_by_username.len(),
session_count: next_state.sessions_by_id.len(),
};
let mut state = self
.inner
.lock()
.map_err(|_| "认证仓储锁已中毒".to_string())?;
state.apply_persistent_state(next_state);
Ok(result)
}
pub fn export_snapshot_json(&self) -> Result<String, String> {
let state = self
.inner
@@ -2857,6 +2887,68 @@ mod tests {
assert_eq!(rotated.user.id, user.id);
}
#[tokio::test]
async fn refresh_from_snapshot_json_merges_session_created_by_another_process() {
let source_store = InMemoryAuthStore::default();
let user = create_phone_login_user(source_store.clone(), "13800138033").await;
let source_refresh_service = build_refresh_service(source_store.clone());
let source_session = source_refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("remote-process-token"),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
OffsetDateTime::now_utc(),
)
.expect("source session should create");
let snapshot_json = source_store
.export_snapshot_json()
.expect("source snapshot should export");
let local_store = InMemoryAuthStore::default();
let local_phone_service = build_phone_service(local_store.clone());
let local_now = OffsetDateTime::now_utc();
local_phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138034".to_string(),
scene: PhoneAuthScene::Login,
},
local_now,
)
.await
.expect("local transient phone code should send");
let refreshed = local_store
.refresh_from_snapshot_json(&snapshot_json)
.expect("local store should refresh");
assert_eq!(refreshed.user_count, 1);
assert_eq!(refreshed.session_count, 1);
assert!(
build_refresh_service(local_store)
.is_session_active_for_user(
&user.id,
&source_session.session.session_id,
OffsetDateTime::now_utc() + Duration::minutes(1)
)
.expect("refreshed session active check should succeed")
);
assert!(matches!(
local_phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138034".to_string(),
scene: PhoneAuthScene::Login,
},
local_now + Duration::seconds(5),
)
.await,
Err(PhoneAuthError::SendCoolingDown { .. })
));
}
#[tokio::test]
async fn snapshot_json_drops_orphan_phone_index_before_phone_login() {
let snapshot = PersistentAuthStoreSnapshot {
@@ -3124,12 +3216,13 @@ mod tests {
}
#[tokio::test]
async fn logout_current_session_revokes_session_and_increments_token_version() {
async fn logout_current_session_revokes_only_current_session_without_token_version_bump() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138005").await;
let refresh_service = build_refresh_service(store.clone());
let user_service = build_user_service(store);
let refresh_token_hash = hash_refresh_session_token("logout-token");
let other_refresh_token_hash = hash_refresh_session_token("logout-token-other");
refresh_service
.create_session(
CreateRefreshSessionInput {
@@ -3141,6 +3234,21 @@ mod tests {
OffsetDateTime::now_utc(),
)
.expect("session should create");
let other_session = refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: other_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()
},
},
OffsetDateTime::now_utc() + Duration::seconds(1),
)
.expect("other session should create");
let result = user_service
.logout_current_session(
@@ -3153,7 +3261,7 @@ mod tests {
)
.expect("logout should succeed");
assert_eq!(result.user.token_version, 2);
assert_eq!(result.user.token_version, user.token_version);
let refresh_error = refresh_service
.rotate_session(
@@ -3165,6 +3273,25 @@ mod tests {
)
.expect_err("revoked session should fail");
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
assert!(
refresh_service
.is_session_active_for_user(
&user.id,
&other_session.session.session_id,
OffsetDateTime::now_utc() + Duration::minutes(2)
)
.expect("other session active check should succeed")
);
let rotated_other = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: other_refresh_token_hash,
next_refresh_token_hash: hash_refresh_session_token("logout-token-other-next"),
},
OffsetDateTime::now_utc() + Duration::minutes(2),
)
.expect("other session should still rotate");
assert_eq!(rotated_other.user.id, user.id);
}
#[tokio::test]
@@ -3286,7 +3413,7 @@ mod tests {
)
.expect("logout should succeed");
assert_eq!(result.user.token_version, user.token_version + 1);
assert_eq!(result.user.token_version, user.token_version);
assert!(
!refresh_service
.is_session_active_for_user(