Files
Genarrative/server-rs/crates/module-auth/src/lib.rs
2026-05-09 01:03:56 +08:00

2827 lines
100 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod application;
mod commands;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use platform_auth::{
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password,
verify_password,
};
use serde::{Deserialize, Serialize};
use shared_kernel::{
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
normalize_optional_string, normalize_required_string, parse_rfc3339,
};
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
#[derive(Clone, Debug)]
pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>,
persistence_path: Option<Arc<PathBuf>>,
}
#[derive(Debug)]
struct InMemoryAuthStoreState {
next_user_id: u64,
users_by_username: HashMap<String, StoredPasswordUser>,
phone_to_user_id: HashMap<String, String>,
sessions_by_id: HashMap<String, StoredRefreshSession>,
session_id_by_refresh_token_hash: HashMap<String, String>,
phone_codes_by_key: HashMap<String, StoredPhoneCode>,
wechat_states_by_token: HashMap<String, StoredWechatAuthState>,
wechat_identity_by_provider_uid: HashMap<String, StoredWechatIdentity>,
user_id_by_provider_union_id: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PersistentAuthStoreSnapshot {
next_user_id: u64,
users_by_username: HashMap<String, StoredPasswordUser>,
phone_to_user_id: HashMap<String, String>,
sessions_by_id: HashMap<String, StoredRefreshSession>,
session_id_by_refresh_token_hash: HashMap<String, String>,
wechat_identity_by_provider_uid: HashMap<String, StoredWechatIdentity>,
user_id_by_provider_union_id: HashMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct StoredPasswordUser {
user: AuthUser,
password_hash: String,
#[serde(default)]
password_login_enabled: bool,
phone_number: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct StoredRefreshSession {
session: RefreshSessionRecord,
}
#[derive(Clone, Debug)]
struct StoredPhoneCode {
phone_number: String,
scene: PhoneAuthScene,
expires_at: String,
last_sent_at: String,
failed_attempts: u32,
provider_out_id: Option<String>,
}
#[derive(Clone, Debug)]
struct StoredWechatAuthState {
state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct StoredWechatIdentity {
user_id: String,
provider_uid: String,
provider_union_id: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct PasswordEntryService {
store: InMemoryAuthStore,
}
#[derive(Clone, Debug)]
pub struct RefreshSessionService {
store: InMemoryAuthStore,
refresh_session_ttl_days: u32,
}
#[derive(Clone, Debug)]
pub struct AuthUserService {
store: InMemoryAuthStore,
}
#[derive(Clone, Debug)]
pub struct PhoneAuthService {
store: InMemoryAuthStore,
sms_provider: SmsAuthProvider,
}
#[derive(Clone, Debug)]
pub struct WechatAuthStateService {
store: InMemoryAuthStore,
state_ttl_minutes: u32,
}
#[derive(Clone, Debug)]
pub struct WechatAuthService {
store: InMemoryAuthStore,
}
impl PasswordEntryService {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
}
pub async fn execute(
&self,
input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> {
validate_password(&input.password)?;
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
else {
return Err(PasswordEntryError::InvalidCredentials);
};
verify_stored_password_user(existing_user, &input.password).await
}
pub async fn execute_with_dev_registration(
&self,
input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> {
validate_password(&input.password)?;
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
if let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
{
return verify_stored_password_user(existing_user, &input.password).await;
}
let password_hash = hash_password(&input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
let user = self.store.create_dev_password_phone_user(
normalized_phone.clone(),
normalized_phone.masked_national_number,
password_hash,
)?;
Ok(PasswordEntryResult {
user: AuthUser {
login_method: AuthLoginMethod::Password,
..user
},
created: true,
})
}
pub fn get_user_by_id(
&self,
user_id: &str,
) -> Result<Option<AuthMeResult>, PasswordEntryError> {
self.store
.find_by_user_id(user_id)
.map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user }))
}
pub fn get_user_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<PublicUserSearchResult>, PasswordEntryError> {
let normalized_public_user_code = normalize_public_user_code(public_user_code)?;
self.store
.find_by_public_user_code(&normalized_public_user_code)
.map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user }))
}
pub fn update_profile(
&self,
input: UpdateProfileInput,
) -> Result<UpdateProfileResult, PasswordEntryError> {
let display_name = match input.display_name {
Some(value) => Some(normalize_profile_display_name(value.as_str())?),
None => None,
};
let avatar_url = match input.avatar_url {
Some(value) => Some(normalize_profile_avatar_url(value.as_str())?),
None => None,
};
if display_name.is_none() && avatar_url.is_none() {
return Err(PasswordEntryError::EmptyProfileUpdate);
}
let user = self
.store
.update_user_profile(&input.user_id, display_name, avatar_url)?
.ok_or(PasswordEntryError::UserNotFound)?;
Ok(UpdateProfileResult { user })
}
pub async fn change_password(
&self,
input: ChangePasswordInput,
) -> Result<ChangePasswordResult, PasswordEntryError> {
validate_password(&input.new_password)?;
let stored_user = self
.store
.find_by_user_id(&input.user_id)?
.ok_or(PasswordEntryError::UserNotFound)?;
if stored_user.password_login_enabled {
let current_password = input
.current_password
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(PasswordEntryError::InvalidCredentials)?;
let is_valid = verify_password(&stored_user.password_hash, current_password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
}
let password_hash = hash_password(&input.new_password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
let user = self
.store
.set_user_password_hash(&input.user_id, password_hash)?
.ok_or(PasswordEntryError::UserNotFound)?;
Ok(ChangePasswordResult { user })
}
}
impl RefreshSessionService {
pub fn new(store: InMemoryAuthStore, refresh_session_ttl_days: u32) -> Self {
Self {
store,
refresh_session_ttl_days,
}
}
pub fn create_session(
&self,
input: CreateRefreshSessionInput,
now: OffsetDateTime,
) -> Result<CreateRefreshSessionResult, RefreshSessionError> {
self.store
.find_by_user_id(&input.user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let session_id = build_prefixed_uuid_id("usess_");
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 = format_rfc3339_with_context(now, "refresh session 时间")?;
let expires_at_iso = format_rfc3339_with_context(expires_at, "refresh session 过期时间")?;
let session = RefreshSessionRecord {
session_id,
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(),
updated_at: now_iso.clone(),
last_seen_at: now_iso,
};
self.store.insert_session(session.clone())?;
Ok(CreateRefreshSessionResult { session })
}
pub fn rotate_session(
&self,
input: RotateRefreshSessionInput,
now: OffsetDateTime,
) -> Result<RotateRefreshSessionResult, RefreshSessionError> {
let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else {
return Err(RefreshSessionError::MissingToken);
};
let session = self
.store
.find_session_by_refresh_token_hash(&refresh_token_hash)?
.ok_or(RefreshSessionError::SessionNotFound)?;
if session.session.revoked_at.is_some() {
return Err(RefreshSessionError::SessionNotFound);
}
let expires_at =
parse_rfc3339_with_context(&session.session.expires_at, "refresh session 过期时间")?;
if expires_at <= now {
return Err(RefreshSessionError::SessionExpired);
}
let user = self
.store
.find_by_user_id(&session.session.user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
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 = format_rfc3339_with_context(now, "refresh session 时间")?;
let next_expires_at_iso =
format_rfc3339_with_context(next_expires_at, "refresh session 过期时间")?;
let updated_session = self.store.rotate_session(
&session.session.session_id,
&session.session.refresh_token_hash,
input.next_refresh_token_hash,
next_expires_at_iso,
now_iso.clone(),
now_iso,
)?;
Ok(RotateRefreshSessionResult {
session: updated_session.session,
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 PhoneAuthService {
pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self {
Self {
store,
sms_provider,
}
}
pub async fn send_code(
&self,
input: SendPhoneCodeInput,
now: OffsetDateTime,
) -> Result<SendPhoneCodeResult, PhoneAuthError> {
let scene = input.scene.clone();
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
info!(
scene = scene.as_str(),
provider = self.sms_provider.kind().as_str(),
phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(),
phone_national_masked = normalized_phone.masked_national_number.as_str(),
"手机号验证码发送准备调用 provider"
);
self.store
.ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?;
let expires_at = now
.checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES))
.ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?;
let expires_at = format_rfc3339(expires_at).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
})?;
let provider_result = self
.sms_provider
.send_code(SmsSendCodeRequest {
national_phone_number,
scene: input.scene.as_str().to_string(),
})
.await
.map_err(map_sms_provider_error_to_phone_error)?;
info!(
scene = scene.as_str(),
provider = self.sms_provider.kind().as_str(),
phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(),
phone_national_masked = normalized_phone.masked_national_number.as_str(),
cooldown_seconds = provider_result.cooldown_seconds,
expires_in_seconds = provider_result.expires_in_seconds,
provider_request_id = provider_result
.provider_request_id
.as_deref()
.unwrap_or("unknown"),
provider_out_id = provider_result
.provider_out_id
.as_deref()
.unwrap_or("unknown"),
"手机号验证码 provider 调用成功,准备写入本地快照"
);
self.store.upsert_phone_code(
StoredPhoneCode {
phone_number: normalized_phone.e164.clone(),
scene,
expires_at,
last_sent_at: format_rfc3339(now).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
})?,
failed_attempts: 0,
provider_out_id: provider_result.provider_out_id.clone(),
},
now,
)?;
Ok(SendPhoneCodeResult {
cooldown_seconds: provider_result.cooldown_seconds,
expires_in_seconds: provider_result.expires_in_seconds,
provider_request_id: provider_result.provider_request_id,
provider_out_id: provider_result.provider_out_id,
provider: self.sms_provider.kind().as_str().to_string(),
scene: input.scene.as_str().to_string(),
phone_number_masked: normalized_phone.masked_national_number,
})
}
pub async fn login(
&self,
input: PhoneLoginInput,
now: OffsetDateTime,
) -> Result<PhoneLoginResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.store.assert_phone_code_active(
&normalized_phone.e164,
&PhoneAuthScene::Login,
now,
)?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id: provider_out_id.clone(),
})
.await
{
Ok(()) => self
.store
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::Login)?,
Err(SmsProviderError::InvalidVerifyCode) => self
.store
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::Login)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
if let Some(user) = self
.store
.find_by_phone_number(&normalized_phone.e164)?
.map(|stored| stored.user)
{
return Ok(PhoneLoginResult {
user: AuthUser {
login_method: AuthLoginMethod::Phone,
..user
},
created: false,
provider: self.sms_provider.kind().as_str().to_string(),
provider_out_id,
phone_number_masked: normalized_phone.masked_national_number,
});
}
let password_hash = hash_password(&build_random_password_seed())
.await
.map_err(|error| PhoneAuthError::PasswordHash(error.to_string()))?;
let created_user = self.store.create_phone_user(
normalized_phone.clone(),
normalized_phone.masked_national_number.clone(),
password_hash,
)?;
Ok(PhoneLoginResult {
user: created_user,
created: true,
provider: self.sms_provider.kind().as_str().to_string(),
provider_out_id,
phone_number_masked: normalized_phone.masked_national_number,
})
}
pub async fn reset_password(
&self,
input: ResetPasswordInput,
now: OffsetDateTime,
) -> Result<ResetPasswordResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?;
validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?;
let provider_out_id = self.store.assert_phone_code_active(
&normalized_phone.e164,
&PhoneAuthScene::ResetPassword,
now,
)?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id: provider_out_id.clone(),
})
.await
{
Ok(()) => self.store.consume_phone_code_success(
&normalized_phone.e164,
&PhoneAuthScene::ResetPassword,
)?,
Err(SmsProviderError::InvalidVerifyCode) => self.store.consume_phone_code_failure(
&normalized_phone.e164,
&PhoneAuthScene::ResetPassword,
)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
self.store
.find_by_phone_number(&normalized_phone.e164)?
.ok_or(PhoneAuthError::UserNotFound)?;
let password_hash = hash_password(&input.new_password)
.await
.map_err(|error| PhoneAuthError::PasswordHash(error.to_string()))?;
let user = self
.store
.set_user_password_by_phone_number(&normalized_phone.e164, password_hash)?;
Ok(ResetPasswordResult {
user,
provider: self.sms_provider.kind().as_str().to_string(),
provider_out_id,
phone_number_masked: normalized_phone.masked_national_number,
})
}
pub async fn bind_wechat_phone(
&self,
input: BindWechatPhoneInput,
now: OffsetDateTime,
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.store.assert_phone_code_active(
&normalized_phone.e164,
&PhoneAuthScene::BindPhone,
now,
)?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id,
})
.await
{
Ok(()) => self
.store
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
Err(SmsProviderError::InvalidVerifyCode) => self
.store
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
let current_user = self
.store
.find_by_user_id(&input.user_id)
.map_err(map_password_error_to_phone_error)?
.ok_or(PhoneAuthError::UserNotFound)?;
if current_user.user.binding_status != AuthBindingStatus::PendingBindPhone {
return Err(PhoneAuthError::UserStateMismatch);
}
if !current_user.user.wechat_bound {
return Err(PhoneAuthError::UserStateMismatch);
}
let (merged_user, activated_new_user) = self
.store
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?;
Ok(BindWechatPhoneResult {
user: merged_user,
activated_new_user,
})
}
}
impl WechatAuthStateService {
pub fn new(store: InMemoryAuthStore, state_ttl_minutes: u32) -> Self {
Self {
store,
state_ttl_minutes,
}
}
pub fn create_state(
&self,
input: CreateWechatAuthStateInput,
now: OffsetDateTime,
) -> Result<CreateWechatAuthStateResult, WechatAuthError> {
let created_at = format_rfc3339(now).map_err(|message| {
WechatAuthError::Store(format!("微信 state 时间格式化失败:{message}"))
})?;
let expires_at = now
.checked_add(Duration::minutes(i64::from(self.state_ttl_minutes)))
.ok_or_else(|| WechatAuthError::Store("微信 state 过期时间计算溢出".to_string()))?;
let expires_at = format_rfc3339(expires_at).map_err(|message| {
WechatAuthError::Store(format!("微信 state 过期时间格式化失败:{message}"))
})?;
let state = WechatAuthStateRecord {
wechat_state_id: build_prefixed_uuid_id("wxstate_"),
state_token: create_wechat_state_token(),
redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(),
scene: input.scene,
request_user_agent: normalize_optional_string(input.request_user_agent),
expires_at,
consumed_at: None,
created_at: created_at.clone(),
updated_at: created_at,
};
self.store.insert_wechat_state(state.clone())?;
Ok(CreateWechatAuthStateResult { state })
}
pub fn consume_state(
&self,
state_token: &str,
now: OffsetDateTime,
) -> Result<ConsumeWechatAuthStateResult, WechatAuthError> {
let consumed = self.store.consume_wechat_state(state_token, now)?;
Ok(ConsumeWechatAuthStateResult {
state: consumed.state,
})
}
}
impl WechatAuthService {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
}
pub async fn resolve_login(
&self,
input: ResolveWechatLoginInput,
) -> Result<ResolveWechatLoginResult, WechatAuthError> {
if input.profile.provider_uid.trim().is_empty()
&& input
.profile
.provider_union_id
.as_ref()
.is_none_or(|value| value.trim().is_empty())
{
return Err(WechatAuthError::MissingProfile);
}
if let Some(user) = self.store.find_by_wechat_identity(
input.profile.provider_uid.trim(),
input.profile.provider_union_id.as_deref(),
)? {
let refreshed_user = self
.store
.refresh_wechat_identity_profile(&user.id, input.profile)?;
return Ok(ResolveWechatLoginResult {
user: refreshed_user,
created: false,
});
}
let password_hash = hash_password(&build_random_password_seed())
.await
.map_err(|error| WechatAuthError::PasswordHash(error.to_string()))?;
let created_user = self
.store
.create_pending_wechat_user(input.profile, password_hash)?;
Ok(ResolveWechatLoginResult {
user: created_user,
created: true,
})
}
}
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 get_user_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<AuthUser>, LogoutError> {
let normalized_public_user_code = normalize_public_user_code(public_user_code)
.map_err(map_password_error_to_logout_error)?;
self.store
.find_by_public_user_code(&normalized_public_user_code)
.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 })
}
// 全端登出需要先吊销该用户全部 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 {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())),
persistence_path: None,
}
}
}
impl Default for InMemoryAuthStoreState {
fn default() -> Self {
Self {
next_user_id: 1,
users_by_username: HashMap::new(),
phone_to_user_id: HashMap::new(),
sessions_by_id: HashMap::new(),
session_id_by_refresh_token_hash: HashMap::new(),
phone_codes_by_key: HashMap::new(),
wechat_states_by_token: HashMap::new(),
wechat_identity_by_provider_uid: HashMap::new(),
user_id_by_provider_union_id: HashMap::new(),
}
}
}
impl InMemoryAuthStoreState {
fn from_persistent_snapshot(snapshot: PersistentAuthStoreSnapshot) -> Self {
Self {
next_user_id: snapshot.next_user_id,
users_by_username: snapshot.users_by_username,
phone_to_user_id: snapshot.phone_to_user_id,
sessions_by_id: snapshot.sessions_by_id,
session_id_by_refresh_token_hash: snapshot.session_id_by_refresh_token_hash,
phone_codes_by_key: HashMap::new(),
wechat_states_by_token: HashMap::new(),
wechat_identity_by_provider_uid: snapshot.wechat_identity_by_provider_uid,
user_id_by_provider_union_id: snapshot.user_id_by_provider_union_id,
}
}
fn to_persistent_snapshot(&self) -> PersistentAuthStoreSnapshot {
PersistentAuthStoreSnapshot {
next_user_id: self.next_user_id,
users_by_username: self.users_by_username.clone(),
phone_to_user_id: self.phone_to_user_id.clone(),
sessions_by_id: self.sessions_by_id.clone(),
session_id_by_refresh_token_hash: self.session_id_by_refresh_token_hash.clone(),
wechat_identity_by_provider_uid: self.wechat_identity_by_provider_uid.clone(),
user_id_by_provider_union_id: self.user_id_by_provider_union_id.clone(),
}
}
}
fn build_temp_persistence_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("auth-store.json");
path.with_file_name(format!("{file_name}.tmp"))
}
impl InMemoryAuthStore {
pub fn from_snapshot_json(snapshot_json: &str) -> Result<Self, String> {
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
.map_err(|error| format!("解析认证快照失败:{error}"))?;
Ok(Self {
inner: Arc::new(Mutex::new(
InMemoryAuthStoreState::from_persistent_snapshot(snapshot),
)),
persistence_path: None,
})
}
pub fn from_persistence_path(path: impl Into<PathBuf>) -> Result<Self, String> {
let path = path.into();
let state = if path.is_file() {
let raw_text =
fs::read_to_string(&path).map_err(|error| format!("读取认证快照失败:{error}"))?;
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(&raw_text)
.map_err(|error| format!("解析认证快照失败:{error}"))?;
InMemoryAuthStoreState::from_persistent_snapshot(snapshot)
} else {
InMemoryAuthStoreState::default()
};
Ok(Self {
inner: Arc::new(Mutex::new(state)),
persistence_path: Some(Arc::new(path)),
})
}
pub fn export_snapshot_json(&self) -> Result<String, String> {
let state = self
.inner
.lock()
.map_err(|_| "认证仓储锁已中毒".to_string())?;
let snapshot = state.to_persistent_snapshot();
serde_json::to_string_pretty(&snapshot)
.map_err(|error| format!("序列化认证快照失败:{error}"))
}
fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> {
let Some(path) = self.persistence_path.as_deref() else {
return Ok(());
};
if let Some(parent_dir) = path.parent() {
fs::create_dir_all(parent_dir).map_err(|error| {
format!(
"创建认证快照目录失败:{},路径:{}",
error,
parent_dir.display()
)
})?;
}
let snapshot = state.to_persistent_snapshot();
let raw_text = serde_json::to_string_pretty(&snapshot)
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
let temp_path = build_temp_persistence_path(path);
fs::write(&temp_path, raw_text)
.map_err(|error| format!("写入认证快照临时文件失败:{error}"))?;
fs::rename(&temp_path, path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
format!("替换认证快照文件失败:{error}")
})
}
fn persist_password_state(
&self,
state: &InMemoryAuthStoreState,
) -> Result<(), PasswordEntryError> {
self.persist_state(state).map_err(PasswordEntryError::Store)
}
fn persist_phone_state(&self, state: &InMemoryAuthStoreState) -> Result<(), PhoneAuthError> {
self.persist_state(state).map_err(PhoneAuthError::Store)
}
fn persist_wechat_state(&self, state: &InMemoryAuthStoreState) -> Result<(), WechatAuthError> {
self.persist_state(state).map_err(WechatAuthError::Store)
}
fn persist_refresh_state(
&self,
state: &InMemoryAuthStoreState,
) -> Result<(), RefreshSessionError> {
self.persist_state(state)
.map_err(RefreshSessionError::Store)
}
fn find_by_user_id(
&self,
user_id: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
.cloned())
}
fn find_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.public_user_code == public_user_code)
.cloned())
}
fn find_by_phone_number(
&self,
phone_number: &str,
) -> Result<Option<StoredPasswordUser>, PhoneAuthError> {
let state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let Some(user_id) = state.phone_to_user_id.get(phone_number) else {
return Ok(None);
};
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == *user_id)
.cloned())
}
fn find_by_phone_number_for_password(
&self,
phone_number: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
let Some(user_id) = state.phone_to_user_id.get(phone_number) else {
return Ok(None);
};
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == *user_id)
.cloned())
}
fn update_user_profile(
&self,
user_id: &str,
display_name: Option<String>,
avatar_url: Option<String>,
) -> 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;
}
if let Some(display_name) = display_name {
stored_user.user.display_name = display_name;
}
if let Some(avatar_url) = avatar_url {
stored_user.user.avatar_url = Some(avatar_url);
}
let next_user = stored_user.user.clone();
self.persist_password_state(&state)?;
return Ok(Some(next_user));
}
Ok(None)
}
fn create_phone_user(
&self,
phone_number: PhoneNumberSnapshot,
display_name: String,
password_hash: String,
) -> Result<AuthUser, PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
if state.phone_to_user_id.contains_key(&phone_number.e164) {
return Err(PhoneAuthError::Store(
"手机号已存在,无法重复创建账号".to_string(),
));
}
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}"))
})?;
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Phone,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
created_at,
};
state
.phone_to_user_id
.insert(phone_number.e164.clone(), user_id);
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
password_login_enabled: false,
phone_number: Some(phone_number.e164),
},
);
self.persist_phone_state(&state)?;
Ok(user)
}
fn create_dev_password_phone_user(
&self,
phone_number: PhoneNumberSnapshot,
display_name: String,
password_hash: String,
) -> Result<AuthUser, PasswordEntryError> {
let mut state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
if state.phone_to_user_id.contains_key(&phone_number.e164) {
return Err(PasswordEntryError::InvalidCredentials);
}
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
})?;
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
created_at,
};
state
.phone_to_user_id
.insert(phone_number.e164.clone(), user_id);
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
password_login_enabled: true,
phone_number: Some(phone_number.e164),
},
);
self.persist_password_state(&state)?;
Ok(user)
}
fn create_pending_wechat_user(
&self,
profile: WechatIdentityProfile,
password_hash: String,
) -> Result<AuthUser, WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
WechatAuthError::Store(format!("用户创建时间格式化失败:{message}"))
})?;
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("wechat", state.next_user_id);
let avatar_url = normalize_optional_string(profile.avatar_url.clone());
let display_name = profile
.display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("微信旅人")
.to_string();
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
avatar_url: avatar_url.clone(),
phone_number_masked: None,
login_method: AuthLoginMethod::Wechat,
binding_status: AuthBindingStatus::PendingBindPhone,
wechat_bound: true,
token_version: 1,
created_at,
};
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
password_login_enabled: false,
phone_number: None,
},
);
let identity = StoredWechatIdentity {
user_id: user_id.clone(),
provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
provider_union_id: normalize_optional_string(profile.provider_union_id),
display_name: normalize_optional_string(profile.display_name),
avatar_url,
};
if let Some(provider_union_id) = identity.provider_union_id.clone() {
state
.user_id_by_provider_union_id
.insert(provider_union_id, user_id.clone());
}
state
.wechat_identity_by_provider_uid
.insert(identity.provider_uid.clone(), identity);
self.persist_wechat_state(&state)?;
Ok(user)
}
fn find_by_wechat_identity(
&self,
provider_uid: &str,
provider_union_id: Option<&str>,
) -> Result<Option<AuthUser>, WechatAuthError> {
let state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
if let Some(provider_union_id) = provider_union_id
.map(str::trim)
.filter(|value| !value.is_empty())
&& let Some(user_id) = state.user_id_by_provider_union_id.get(provider_union_id)
&& let Some(stored) = state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == *user_id)
{
return Ok(Some(stored.user.clone()));
}
let Some(identity) = state
.wechat_identity_by_provider_uid
.get(provider_uid.trim())
else {
return Ok(None);
};
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == identity.user_id)
.map(|stored| stored.user.clone()))
}
fn refresh_wechat_identity_profile(
&self,
user_id: &str,
profile: WechatIdentityProfile,
) -> Result<AuthUser, WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
let next_display_name = normalize_optional_string(profile.display_name);
let next_avatar_url = normalize_optional_string(profile.avatar_url);
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
let next_provider_uid =
normalize_required_string(&profile.provider_uid).unwrap_or_default();
{
let identity = state
.wechat_identity_by_provider_uid
.remove(profile.provider_uid.trim())
.or_else(|| {
state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == user_id)
.cloned()
})
.ok_or(WechatAuthError::MissingWechatIdentity)?;
let mut identity = identity;
// 微信同一 unionid 在不同应用或不同阶段可能回传新的 openid这里要把最新 provider_uid 回写,
// 否则下一次只能按 unionid 命中,随后刷新资料时会因为旧 openid 不存在而丢失 identity。
identity.provider_uid = next_provider_uid.clone();
identity.display_name = next_display_name.clone();
identity.avatar_url = next_avatar_url;
identity.provider_union_id = next_provider_union_id.clone();
state
.wechat_identity_by_provider_uid
.insert(next_provider_uid.clone(), identity);
}
if let Some(provider_union_id) = next_provider_union_id {
state
.user_id_by_provider_union_id
.insert(provider_union_id, user_id.to_string());
}
let next_user = {
let stored_user = state
.users_by_username
.values_mut()
.find(|stored_user| stored_user.user.id == user_id)
.ok_or(WechatAuthError::UserNotFound)?;
if let Some(display_name) = next_display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
&& stored_user.user.binding_status == AuthBindingStatus::PendingBindPhone
{
stored_user.user.display_name = display_name.to_string();
}
stored_user.user.clone()
};
self.persist_wechat_state(&state)?;
Ok(next_user)
}
fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
if state
.session_id_by_refresh_token_hash
.contains_key(&session.refresh_token_hash)
{
return Err(RefreshSessionError::Store(
"refresh token hash 已存在,无法重复创建会话".to_string(),
));
}
state.session_id_by_refresh_token_hash.insert(
session.refresh_token_hash.clone(),
session.session_id.clone(),
);
state
.sessions_by_id
.insert(session.session_id.clone(), StoredRefreshSession { session });
self.persist_refresh_state(&state)?;
Ok(())
}
fn upsert_phone_code(
&self,
code: StoredPhoneCode,
_now: OffsetDateTime,
) -> Result<(), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
// 手机号和业务场景共同决定同一份验证码快照,重复发送时直接覆盖旧值。
let key = build_phone_code_key(&code.phone_number, &code.scene);
state.phone_codes_by_key.insert(key, code);
Ok(())
}
fn ensure_phone_code_not_cooling_down(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
now: OffsetDateTime,
) -> Result<(), PhoneAuthError> {
let state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else {
return Ok(());
};
drop(state);
let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?;
if expires_at <= now {
return Ok(());
}
let last_sent_at = parse_phone_code_time(&stored.last_sent_at, "发送时间")?;
let cooling_until = last_sent_at
.checked_add(Duration::seconds(SMS_CODE_COOLDOWN_SECONDS as i64))
.ok_or_else(|| PhoneAuthError::Store("短信验证码冷却时间计算溢出".to_string()))?;
if cooling_until <= now {
return Ok(());
}
let retry_after_seconds = seconds_until(now, cooling_until);
warn!(
scene = scene.as_str(),
phone_masked = mask_phone_number(phone_number).as_str(),
retry_after_seconds,
"手机号验证码发送命中本地冷却限制"
);
Err(PhoneAuthError::SendCoolingDown {
retry_after_seconds,
})
}
fn assert_phone_code_active(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
now: OffsetDateTime,
) -> Result<Option<String>, PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
let stored = state
.phone_codes_by_key
.get(&key)
.cloned()
.ok_or(PhoneAuthError::VerifyCodeNotFound)?;
let expires_at = OffsetDateTime::parse(
&stored.expires_at,
&time::format_description::well_known::Rfc3339,
)
.map_err(|error| PhoneAuthError::Store(format!("短信验证码过期时间解析失败:{error}")))?;
if expires_at <= now {
state.phone_codes_by_key.remove(&key);
return Err(PhoneAuthError::VerifyCodeExpired);
}
Ok(stored.provider_out_id)
}
fn consume_phone_code_success(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
) -> Result<(), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
state.phone_codes_by_key.remove(&key);
Ok(())
}
fn consume_phone_code_failure(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
) -> Result<(), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else {
return Err(PhoneAuthError::VerifyCodeNotFound);
};
let next_failed_attempts = stored.failed_attempts.saturating_add(1);
if next_failed_attempts >= SMS_CODE_MAX_FAILED_ATTEMPTS {
state.phone_codes_by_key.remove(&key);
return Err(PhoneAuthError::VerifyAttemptsExceeded);
}
if let Some(current) = state.phone_codes_by_key.get_mut(&key) {
current.failed_attempts = next_failed_attempts;
}
Err(PhoneAuthError::InvalidVerifyCode)
}
fn insert_wechat_state(
&self,
state_record: WechatAuthStateRecord,
) -> Result<(), WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?;
if state
.wechat_states_by_token
.contains_key(&state_record.state_token)
{
return Err(WechatAuthError::Store("微信 state 已存在".to_string()));
}
state.wechat_states_by_token.insert(
state_record.state_token.clone(),
StoredWechatAuthState {
state: state_record,
},
);
Ok(())
}
fn consume_wechat_state(
&self,
state_token: &str,
now: OffsetDateTime,
) -> Result<StoredWechatAuthState, WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?;
let stored = state
.wechat_states_by_token
.get(state_token.trim())
.cloned()
.ok_or(WechatAuthError::StateNotFound)?;
if stored.state.consumed_at.is_some() {
return Err(WechatAuthError::StateConsumed);
}
let expires_at = OffsetDateTime::parse(
&stored.state.expires_at,
&time::format_description::well_known::Rfc3339,
)
.map_err(|error| WechatAuthError::Store(format!("微信 state 过期时间解析失败:{error}")))?;
if expires_at <= now {
return Err(WechatAuthError::StateExpired);
}
let now_iso = format_rfc3339(now).map_err(|message| {
WechatAuthError::Store(format!("微信 state 时间格式化失败:{message}"))
})?;
let current = state
.wechat_states_by_token
.get_mut(state_token.trim())
.ok_or(WechatAuthError::StateNotFound)?;
current.state.consumed_at = Some(now_iso.clone());
current.state.updated_at = now_iso;
Ok(current.clone())
}
fn bind_wechat_phone_to_user(
&self,
pending_user_id: &str,
phone_number: PhoneNumberSnapshot,
) -> Result<(AuthUser, bool), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let existing_phone_user_id = state.phone_to_user_id.get(&phone_number.e164).cloned();
if let Some(target_user_id) = existing_phone_user_id
&& target_user_id != pending_user_id
{
let pending_wechat_identity = state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == pending_user_id)
.cloned()
.ok_or(PhoneAuthError::UserStateMismatch)?;
let pending_username = state
.users_by_username
.values()
.find(|stored| stored.user.id == pending_user_id)
.map(|stored| stored.user.username.clone())
.ok_or(PhoneAuthError::UserNotFound)?;
state.users_by_username.remove(&pending_username);
state.wechat_identity_by_provider_uid.insert(
pending_wechat_identity.provider_uid.clone(),
StoredWechatIdentity {
user_id: target_user_id.clone(),
..pending_wechat_identity.clone()
},
);
if let Some(provider_union_id) = pending_wechat_identity.provider_union_id {
state
.user_id_by_provider_union_id
.insert(provider_union_id, target_user_id.clone());
}
let target_user = state
.users_by_username
.values_mut()
.find(|stored| stored.user.id == target_user_id)
.ok_or(PhoneAuthError::UserNotFound)?;
target_user.user.wechat_bound = true;
let next_user = target_user.user.clone();
self.persist_phone_state(&state)?;
return Ok((next_user, false));
}
state
.phone_to_user_id
.insert(phone_number.e164.clone(), pending_user_id.to_string());
let stored_user = state
.users_by_username
.values_mut()
.find(|stored| stored.user.id == pending_user_id)
.ok_or(PhoneAuthError::UserNotFound)?;
stored_user.user.phone_number_masked = Some(phone_number.masked_national_number.clone());
stored_user.user.binding_status = AuthBindingStatus::Active;
stored_user.user.wechat_bound = true;
stored_user.phone_number = Some(phone_number.e164);
let next_user = stored_user.user.clone();
self.persist_phone_state(&state)?;
Ok((next_user, true))
}
fn find_session_by_refresh_token_hash(
&self,
refresh_token_hash: &str,
) -> Result<Option<StoredRefreshSession>, RefreshSessionError> {
let 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)
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,
previous_refresh_token_hash: &str,
next_refresh_token_hash: String,
next_expires_at: String,
updated_at: String,
last_seen_at: String,
) -> Result<StoredRefreshSession, RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
if state
.session_id_by_refresh_token_hash
.contains_key(&next_refresh_token_hash)
{
return Err(RefreshSessionError::Store(
"新 refresh token hash 已存在,无法轮换".to_string(),
));
}
let current_refresh_token_hash = state
.sessions_by_id
.get(session_id)
.ok_or(RefreshSessionError::SessionNotFound)?
.session
.refresh_token_hash
.clone();
if current_refresh_token_hash != previous_refresh_token_hash {
return Err(RefreshSessionError::SessionNotFound);
}
state
.session_id_by_refresh_token_hash
.remove(previous_refresh_token_hash);
let stored = state
.sessions_by_id
.get_mut(session_id)
.ok_or(RefreshSessionError::SessionNotFound)?;
stored.session.refresh_token_hash = next_refresh_token_hash.clone();
stored.session.expires_at = next_expires_at;
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(),
);
self.persist_refresh_state(&state)?;
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;
self.persist_refresh_state(&state)?;
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();
}
self.persist_refresh_state(&state)?;
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;
let next_user = stored_user.user.clone();
self.persist_password_state(&state)?;
return Ok(Some(next_user));
}
Ok(None)
}
fn set_user_password_hash(
&self,
user_id: &str,
password_hash: String,
) -> 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.password_hash = password_hash;
stored_user.password_login_enabled = true;
stored_user.user.token_version += 1;
let next_user = stored_user.user.clone();
self.persist_password_state(&state)?;
return Ok(Some(next_user));
}
Ok(None)
}
fn set_user_password_by_phone_number(
&self,
phone_number: &str,
password_hash: String,
) -> Result<AuthUser, PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let user_id = state
.phone_to_user_id
.get(phone_number)
.cloned()
.ok_or(PhoneAuthError::UserNotFound)?;
for stored_user in state.users_by_username.values_mut() {
if stored_user.user.id != user_id {
continue;
}
stored_user.password_hash = password_hash;
stored_user.password_login_enabled = true;
stored_user.user.token_version += 1;
let next_user = stored_user.user.clone();
self.persist_phone_state(&state)?;
return Ok(next_user);
}
Err(PhoneAuthError::UserNotFound)
}
}
fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError {
match error {
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
SmsProviderError::InvalidConfig(message) => {
PhoneAuthError::SmsProviderInvalidConfig(message)
}
SmsProviderError::Upstream(message) => PhoneAuthError::SmsProviderUpstream(message),
}
}
async fn verify_stored_password_user(
existing_user: StoredPasswordUser,
password: &str,
) -> Result<PasswordEntryResult, PasswordEntryError> {
if !existing_user.password_login_enabled {
return Err(PasswordEntryError::InvalidCredentials);
}
let is_valid = verify_password(&existing_user.password_hash, password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
Ok(PasswordEntryResult {
user: AuthUser {
login_method: AuthLoginMethod::Password,
..existing_user.user
},
created: false,
})
}
fn normalize_profile_display_name(value: &str) -> Result<String, PasswordEntryError> {
let Some(display_name) = normalize_required_string(value) else {
return Err(PasswordEntryError::InvalidDisplayName);
};
let length = display_name.chars().count();
if !(2..=20).contains(&length) {
return Err(PasswordEntryError::InvalidDisplayName);
}
if !display_name.chars().all(|character| {
character == '_'
|| character.is_ascii_alphanumeric()
|| is_common_chinese_character(character)
}) {
return Err(PasswordEntryError::InvalidDisplayName);
}
Ok(display_name)
}
fn normalize_profile_avatar_url(value: &str) -> Result<String, PasswordEntryError> {
let Some(avatar_url) = normalize_required_string(value) else {
return Err(PasswordEntryError::InvalidAvatarDataUrl);
};
if !avatar_url.starts_with("data:image/") || !avatar_url.contains(";base64,") {
return Err(PasswordEntryError::InvalidAvatarDataUrl);
}
Ok(avatar_url)
}
fn is_common_chinese_character(character: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&character)
}
fn build_random_password_seed() -> String {
format!(
"seed_{}_{}",
new_uuid_simple_string(),
new_uuid_simple_string()
)
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value)
}
#[allow(dead_code)]
fn current_auth_user_created_at() -> String {
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| default_auth_user_created_at())
}
#[allow(dead_code)]
fn default_auth_user_created_at() -> String {
"1970-01-01T00:00:00Z".to_string()
}
fn parse_phone_code_time(value: &str, field_label: &str) -> Result<OffsetDateTime, PhoneAuthError> {
parse_rfc3339(value)
.map_err(|error| PhoneAuthError::Store(format!("短信验证码{field_label}解析失败:{error}")))
}
fn seconds_until(now: OffsetDateTime, target: OffsetDateTime) -> u64 {
let seconds = target.unix_timestamp().saturating_sub(now.unix_timestamp());
u64::try_from(seconds.max(1)).unwrap_or(1)
}
fn create_wechat_state_token() -> String {
new_uuid_simple_string()
}
fn format_rfc3339_with_context(
value: OffsetDateTime,
field_label: &str,
) -> Result<String, RefreshSessionError> {
format_shared_rfc3339(value)
.map_err(|error| RefreshSessionError::Store(format!("{field_label}格式化失败:{error}")))
}
fn parse_rfc3339_with_context(
value: &str,
field_label: &str,
) -> Result<OffsetDateTime, RefreshSessionError> {
parse_rfc3339(value)
.map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}")))
}
#[cfg(test)]
mod tests {
use platform_auth::{
DEFAULT_SMS_CASE_AUTH_POLICY, DEFAULT_SMS_CODE_LENGTH, DEFAULT_SMS_CODE_TYPE,
DEFAULT_SMS_COUNTRY_CODE, DEFAULT_SMS_DUPLICATE_POLICY, DEFAULT_SMS_ENDPOINT,
DEFAULT_SMS_INTERVAL_SECONDS, DEFAULT_SMS_MOCK_VERIFY_CODE, DEFAULT_SMS_TEMPLATE_PARAM_KEY,
DEFAULT_SMS_VALID_TIME_SECONDS, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind,
hash_refresh_session_token,
};
use super::*;
fn build_store() -> InMemoryAuthStore {
InMemoryAuthStore::default()
}
fn build_password_service(store: InMemoryAuthStore) -> PasswordEntryService {
PasswordEntryService::new(store)
}
fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService {
let sms_provider = SmsAuthProvider::new(
SmsAuthConfig::new(
SmsAuthProviderKind::Mock,
DEFAULT_SMS_ENDPOINT.to_string(),
None,
None,
String::new(),
String::new(),
DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(),
DEFAULT_SMS_COUNTRY_CODE.to_string(),
None,
DEFAULT_SMS_CODE_LENGTH,
DEFAULT_SMS_CODE_TYPE,
DEFAULT_SMS_VALID_TIME_SECONDS,
DEFAULT_SMS_INTERVAL_SECONDS,
DEFAULT_SMS_DUPLICATE_POLICY,
DEFAULT_SMS_CASE_AUTH_POLICY,
false,
DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
)
.expect("mock sms config should be valid"),
)
.expect("mock sms provider should be valid");
PhoneAuthService::new(store, sms_provider)
}
fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService {
RefreshSessionService::new(store, 30)
}
fn build_user_service(store: InMemoryAuthStore) -> AuthUserService {
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()),
}
}
async fn create_phone_login_user(store: InMemoryAuthStore, phone_number: &str) -> AuthUser {
let phone_service = build_phone_service(store);
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: phone_number.to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
phone_service
.login(
PhoneLoginInput {
phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should create user")
.user
}
#[tokio::test]
async fn password_entry_rejects_unknown_user_without_registration() {
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
phone_number: "13800138000".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("password login must not create user");
assert_eq!(error, PasswordEntryError::InvalidCredentials);
}
#[tokio::test]
async fn password_entry_dev_registration_creates_unknown_phone_user() {
let service = build_password_service(build_store());
let created = service
.execute_with_dev_registration(PasswordEntryInput {
phone_number: "13800138009".to_string(),
password: "secret123".to_string(),
})
.await
.expect("dev registration should create user");
let reused = service
.execute_with_dev_registration(PasswordEntryInput {
phone_number: "13800138009".to_string(),
password: "secret123".to_string(),
})
.await
.expect("same password should reuse created user");
let wrong_password = service
.execute_with_dev_registration(PasswordEntryInput {
phone_number: "13800138009".to_string(),
password: "secret999".to_string(),
})
.await
.expect_err("existing user still requires the right password");
assert!(created.created);
assert_eq!(created.user.login_method, AuthLoginMethod::Password);
assert!(!reused.created);
assert_eq!(created.user.id, reused.user.id);
assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials);
}
#[tokio::test]
async fn phone_user_can_set_password_then_login() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138000").await;
let service = build_password_service(store);
service
.change_password(ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
new_password: "secret123".to_string(),
})
.await
.expect("phone user should set first password");
let result = service
.execute(PasswordEntryInput {
phone_number: "13800138000".to_string(),
password: "secret123".to_string(),
})
.await
.expect("password login should succeed after setting password");
assert!(!result.created);
assert_eq!(result.user.id, user.id);
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
}
#[tokio::test]
async fn password_entry_rejects_wrong_password_after_set() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138001").await;
let service = build_password_service(store);
service
.change_password(ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
new_password: "secret123".to_string(),
})
.await
.expect("password should set");
let error = service
.execute(PasswordEntryInput {
phone_number: "13800138001".to_string(),
password: "secret999".to_string(),
})
.await
.expect_err("wrong password should fail");
assert_eq!(error, PasswordEntryError::InvalidCredentials);
}
#[tokio::test]
async fn reset_password_requires_existing_phone_user() {
let store = build_store();
let phone_service = build_phone_service(store.clone());
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138002".to_string(),
scene: PhoneAuthScene::ResetPassword,
},
now,
)
.await
.expect("reset code should send");
let error = phone_service
.reset_password(
ResetPasswordInput {
phone_number: "13800138002".to_string(),
verify_code: "123456".to_string(),
new_password: "secret123".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect_err("unknown phone must not register by reset");
assert_eq!(error, PhoneAuthError::UserNotFound);
}
#[tokio::test]
async fn persistent_store_restores_user_and_refresh_session_after_restart() {
let store_path = std::env::temp_dir().join(format!(
"genarrative-auth-store-{}.json",
new_uuid_simple_string()
));
let _ = std::fs::remove_file(&store_path);
let store = InMemoryAuthStore::from_persistence_path(store_path.clone())
.expect("persistent store should initialize");
let user = create_phone_login_user(store.clone(), "13800138003").await;
let password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store.clone());
password_service
.change_password(ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
new_password: "secret123".to_string(),
})
.await
.expect("password should set before persistence check");
refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("persist-token-01"),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
OffsetDateTime::now_utc(),
)
.expect("refresh session should be persisted");
drop(store);
let restored_store = InMemoryAuthStore::from_persistence_path(store_path.clone())
.expect("persistent store should restore");
let restored_user = build_password_service(restored_store.clone())
.get_user_by_id(&user.id)
.expect("restored user query should succeed")
.expect("restored user should exist")
.user;
assert_eq!(restored_user.username, user.username);
let rotated = build_refresh_service(restored_store)
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: hash_refresh_session_token("persist-token-01"),
next_refresh_token_hash: hash_refresh_session_token("persist-token-02"),
},
OffsetDateTime::now_utc(),
)
.expect("restored refresh session should rotate");
assert_eq!(rotated.user.id, user.id);
let _ = std::fs::remove_file(&store_path);
}
#[tokio::test]
async fn password_entry_rejects_email_or_username_identifier() {
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
phone_number: "user@example.com".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("email should fail");
assert_eq!(error, PasswordEntryError::InvalidPhoneNumber);
}
#[tokio::test]
async fn phone_send_code_rejects_same_scene_during_cooldown() {
let service = build_phone_service(build_store());
let now = OffsetDateTime::now_utc();
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("first phone code should send");
let error = service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now + Duration::seconds(10),
)
.await
.expect_err("same scene send should be cooled down");
match error {
PhoneAuthError::SendCoolingDown {
retry_after_seconds,
} => assert!((1..=SMS_CODE_COOLDOWN_SECONDS).contains(&retry_after_seconds)),
other => panic!("unexpected phone auth error: {other:?}"),
}
}
#[tokio::test]
async fn phone_send_code_keeps_different_scenes_isolated() {
let service = build_phone_service(build_store());
let now = OffsetDateTime::now_utc();
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("login scene code should send");
let bind_result = service.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now + Duration::seconds(1),
);
assert!(bind_result.await.is_ok());
}
#[tokio::test]
async fn phone_login_expires_code_after_too_many_wrong_attempts() {
let service = build_phone_service(build_store());
let now = OffsetDateTime::now_utc();
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS {
let error = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "000000".to_string(),
},
now + Duration::seconds(i64::from(attempt)),
)
.await
.expect_err("wrong code should fail before limit");
assert_eq!(error, PhoneAuthError::InvalidVerifyCode);
}
let exhausted_error = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "000000".to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS)),
)
.await
.expect_err("fifth wrong code should exhaust the snapshot");
assert_eq!(exhausted_error, PhoneAuthError::VerifyAttemptsExceeded);
let missing_error = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)),
)
.await
.expect_err("exhausted snapshot should be deleted");
assert_eq!(missing_error, PhoneAuthError::VerifyCodeNotFound);
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)),
)
.await
.expect("deleted snapshot should allow a new code");
let login = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)),
)
.await
.expect("new code should login");
assert!(login.created);
assert_eq!(login.user.login_method, AuthLoginMethod::Phone);
}
#[tokio::test]
async fn refresh_session_creation_and_rotation_keep_same_session_id() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138004").await;
let refresh_service = build_refresh_service(store);
let now = OffsetDateTime::now_utc();
let first_token_hash = hash_refresh_session_token("refresh-token-01");
let created = refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: first_token_hash.clone(),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
now,
)
.expect("session should create");
let rotated = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: first_token_hash,
next_refresh_token_hash: hash_refresh_session_token("refresh-token-02"),
},
now + Duration::minutes(10),
)
.expect("session should rotate");
assert_eq!(rotated.user.id, user.id);
assert_eq!(rotated.session.session_id, created.session.session_id);
assert_ne!(
rotated.session.refresh_token_hash,
created.session.refresh_token_hash
);
}
#[tokio::test]
async fn refresh_session_rejects_unknown_token_hash() {
let store = build_store();
let refresh_service = build_refresh_service(store);
let error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: hash_refresh_session_token("missing"),
next_refresh_token_hash: hash_refresh_session_token("next"),
},
OffsetDateTime::now_utc(),
)
.expect_err("unknown token should fail");
assert_eq!(error, RefreshSessionError::SessionNotFound);
}
#[tokio::test]
async fn logout_current_session_revokes_session_and_increments_token_version() {
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");
refresh_service
.create_session(
CreateRefreshSessionInput {
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(),
)
.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);
}
#[tokio::test]
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138006").await;
let refresh_service = build_refresh_service(store.clone());
let user_service = build_user_service(store);
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() {
let store = build_store();
let refresh_service = build_refresh_service(store.clone());
let user = create_phone_login_user(store.clone(), "13800138007").await;
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"
);
}
#[tokio::test]
async fn wechat_login_hits_existing_user_by_union_id_before_openid() {
let store = build_store();
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store);
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should succeed")
.user;
let first_wechat = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-first".to_string(),
provider_union_id: Some("wx-union-shared".to_string()),
display_name: Some("微信旅人甲".to_string()),
avatar_url: None,
},
})
.await
.expect("first wechat login should succeed");
assert!(first_wechat.created);
assert_eq!(
first_wechat.user.binding_status,
AuthBindingStatus::PendingBindPhone
);
let second_wechat = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-second".to_string(),
provider_union_id: Some("wx-union-shared".to_string()),
display_name: Some("微信旅人乙".to_string()),
avatar_url: None,
},
})
.await
.expect("second wechat login should succeed");
assert!(!second_wechat.created);
assert_eq!(second_wechat.user.id, first_wechat.user.id);
assert_ne!(second_wechat.user.id, phone_user.id);
assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat);
}
#[tokio::test]
async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() {
let store = build_store();
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store.clone());
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone login code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should succeed")
.user;
let wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-bind".to_string(),
provider_union_id: Some("wx-union-bind".to_string()),
display_name: Some("待绑定微信用户".to_string()),
avatar_url: None,
},
})
.await
.expect("wechat login should succeed")
.user;
assert_eq!(
wechat_user.binding_status,
AuthBindingStatus::PendingBindPhone
);
assert_ne!(wechat_user.id, phone_user.id);
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now + Duration::seconds(2),
)
.await
.expect("bind phone code should send");
let merged = phone_service
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: wechat_user.id.clone(),
phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(3),
)
.await
.expect("bind phone should succeed");
assert_eq!(merged.user.id, phone_user.id);
assert_eq!(merged.user.binding_status, AuthBindingStatus::Active);
assert!(merged.user.wechat_bound);
let reused_wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-bind".to_string(),
provider_union_id: Some("wx-union-bind".to_string()),
display_name: Some("已归并微信用户".to_string()),
avatar_url: None,
},
})
.await
.expect("wechat login should reuse merged user");
assert!(!reused_wechat_user.created);
assert_eq!(reused_wechat_user.user.id, phone_user.id);
assert!(reused_wechat_user.user.wechat_bound);
}
}