use std::{ collections::HashMap, error::Error, fmt, 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}; const PASSWORD_MIN_LENGTH: usize = 6; const PASSWORD_MAX_LENGTH: usize = 128; const SMS_CODE_LENGTH: usize = 6; const SMS_CODE_TTL_MINUTES: i64 = 5; const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthLoginMethod { Password, Phone, Wechat, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthBindingStatus { Active, PendingBindPhone, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthUser { pub id: String, pub public_user_code: String, pub username: String, pub display_name: String, pub phone_number_masked: Option, pub login_method: AuthLoginMethod, pub binding_status: AuthBindingStatus, pub wechat_bound: bool, pub token_version: u64, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthMeResult { pub user: AuthUser, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublicUserSearchResult { pub user: AuthUser, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PasswordEntryInput { pub phone_number: String, pub password: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PasswordEntryResult { pub user: AuthUser, pub created: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ChangePasswordInput { pub user_id: String, pub current_password: Option, pub new_password: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ChangePasswordResult { pub user: AuthUser, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResetPasswordInput { pub phone_number: String, pub verify_code: String, pub new_password: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResetPasswordResult { pub user: AuthUser, pub provider: String, pub provider_out_id: Option, pub phone_number_masked: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum PhoneAuthScene { Login, BindPhone, ChangePhone, ResetPassword, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PhoneNumberSnapshot { pub e164: String, pub masked_national_number: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SendPhoneCodeInput { pub phone_number: String, pub scene: PhoneAuthScene, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SendPhoneCodeResult { pub cooldown_seconds: u64, pub expires_in_seconds: u64, pub provider_request_id: Option, pub provider_out_id: Option, pub provider: String, pub scene: String, pub phone_number_masked: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PhoneLoginInput { pub phone_number: String, pub verify_code: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PhoneLoginResult { pub user: AuthUser, pub created: bool, pub provider: String, pub provider_out_id: Option, pub phone_number_masked: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatIdentityProfile { pub provider_uid: String, pub provider_union_id: Option, pub display_name: Option, pub avatar_url: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolveWechatLoginInput { pub profile: WechatIdentityProfile, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolveWechatLoginResult { pub user: AuthUser, pub created: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum WechatAuthScene { Desktop, WechatInApp, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreateWechatAuthStateInput { pub redirect_path: String, pub scene: WechatAuthScene, pub request_user_agent: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatAuthStateRecord { pub wechat_state_id: String, pub state_token: String, pub redirect_path: String, pub scene: WechatAuthScene, pub request_user_agent: Option, pub expires_at: String, pub consumed_at: Option, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreateWechatAuthStateResult { pub state: WechatAuthStateRecord, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ConsumeWechatAuthStateResult { pub state: WechatAuthStateRecord, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatPhoneInput { pub user_id: String, pub phone_number: String, pub verify_code: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatPhoneResult { pub user: AuthUser, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreateRefreshSessionInput { pub user_id: String, pub refresh_token_hash: String, pub issued_by_provider: AuthLoginMethod, pub client_info: RefreshSessionClientInfo, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RefreshSessionClientInfo { pub client_type: String, pub client_runtime: String, pub client_platform: String, pub client_instance_id: Option, pub device_fingerprint: Option, pub device_display_name: String, pub mini_program_app_id: Option, pub mini_program_env: Option, pub user_agent: Option, pub ip: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RefreshSessionRecord { pub session_id: String, pub user_id: String, pub refresh_token_hash: String, pub issued_by_provider: AuthLoginMethod, pub client_info: RefreshSessionClientInfo, pub expires_at: String, pub revoked_at: Option, pub created_at: String, pub updated_at: String, pub last_seen_at: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreateRefreshSessionResult { pub session: RefreshSessionRecord, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RotateRefreshSessionInput { pub refresh_token_hash: String, pub next_refresh_token_hash: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RotateRefreshSessionResult { pub session: RefreshSessionRecord, pub user: AuthUser, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ListActiveRefreshSessionsResult { pub sessions: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutCurrentSessionInput { pub user_id: String, pub refresh_token_hash: Option, } #[derive(Clone, Debug, PartialEq, Eq)] 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, Serialize, Deserialize)] pub struct AuthStoreSnapshotRecord { pub snapshot_json: Option, pub updated_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthStoreSnapshotUpsertInput { pub snapshot_json: String, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthStoreSnapshotProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum PasswordEntryError { InvalidPhoneNumber, InvalidPasswordLength, InvalidPublicUserCode, InvalidCredentials, UserNotFound, Store(String), PasswordHash(String), } #[derive(Clone, Debug, PartialEq, Eq)] pub enum PhoneAuthError { InvalidPhoneNumber, InvalidVerifyCode, VerifyCodeNotFound, VerifyCodeExpired, SendCoolingDown { retry_after_seconds: u64 }, VerifyAttemptsExceeded, UserNotFound, UserStateMismatch, Store(String), PasswordHash(String), } #[derive(Clone, Debug, PartialEq, Eq)] pub enum WechatAuthError { MissingProfile, StateNotFound, StateExpired, StateConsumed, UserNotFound, MissingWechatIdentity, Store(String), PasswordHash(String), } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RefreshSessionError { MissingToken, SessionNotFound, SessionExpired, UserNotFound, Store(String), } #[derive(Clone, Debug, PartialEq, Eq)] pub enum LogoutError { UserNotFound, Store(String), } #[derive(Clone, Debug)] pub struct InMemoryAuthStore { inner: Arc>, persistence_path: Option>, } #[derive(Debug)] struct InMemoryAuthStoreState { next_user_id: u64, users_by_username: HashMap, phone_to_user_id: HashMap, sessions_by_id: HashMap, session_id_by_refresh_token_hash: HashMap, phone_codes_by_key: HashMap, wechat_states_by_token: HashMap, wechat_identity_by_provider_uid: HashMap, user_id_by_provider_union_id: HashMap, } #[derive(Debug, Serialize, Deserialize)] struct PersistentAuthStoreSnapshot { next_user_id: u64, users_by_username: HashMap, phone_to_user_id: HashMap, sessions_by_id: HashMap, session_id_by_refresh_token_hash: HashMap, wechat_identity_by_provider_uid: HashMap, user_id_by_provider_union_id: HashMap, } #[derive(Clone, Debug, Serialize, Deserialize)] struct StoredPasswordUser { user: AuthUser, password_hash: String, #[serde(default)] password_login_enabled: bool, phone_number: Option, } #[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, } #[derive(Clone, Debug)] struct StoredWechatAuthState { state: WechatAuthStateRecord, } #[derive(Clone, Debug, Serialize, Deserialize)] struct StoredWechatIdentity { user_id: String, provider_uid: String, provider_union_id: Option, display_name: Option, avatar_url: Option, } #[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 { 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 fn get_user_by_id( &self, user_id: &str, ) -> Result, 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, 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 async fn change_password( &self, input: ChangePasswordInput, ) -> Result { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 = self .store .bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; Ok(BindWechatPhoneResult { user: merged_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 { 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 { 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 { 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, 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, 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 { 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 { 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 { let snapshot = serde_json::from_str::(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) -> Result { 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::(&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 { 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, 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, 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, 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, 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 create_phone_user( &self, phone_number: PhoneNumberSnapshot, display_name: String, password_hash: String, ) -> Result { 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 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, phone_number_masked: Some(phone_number.masked_national_number.clone()), login_method: AuthLoginMethod::Phone, binding_status: AuthBindingStatus::Active, wechat_bound: false, token_version: 1, }; 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_pending_wechat_user( &self, profile: WechatIdentityProfile, password_hash: String, ) -> Result { let mut state = self .inner .lock() .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; 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 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, phone_number_masked: None, login_method: AuthLoginMethod::Wechat, binding_status: AuthBindingStatus::PendingBindPhone, wechat_bound: true, token_version: 1, }; 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: normalize_optional_string(profile.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, 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 { 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, 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 { 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 { 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); } 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) } fn find_session_by_refresh_token_hash( &self, refresh_token_hash: &str, ) -> Result, 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, 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::>(); 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 { 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, 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, 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 { 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) } } impl AuthLoginMethod { pub fn as_str(&self) -> &'static str { match self { Self::Password => "password", Self::Phone => "phone", Self::Wechat => "wechat", } } } impl AuthBindingStatus { pub fn as_str(&self) -> &'static str { match self { Self::Active => "active", Self::PendingBindPhone => "pending_bind_phone", } } } impl fmt::Display for PasswordEntryError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"), Self::InvalidCredentials => f.write_str("手机号或密码错误"), Self::UserNotFound => f.write_str("用户不存在"), Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), } } } impl Error for PasswordEntryError {} impl fmt::Display for PhoneAuthError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), Self::InvalidVerifyCode => f.write_str("验证码错误"), Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"), Self::VerifyCodeExpired => f.write_str("验证码已过期"), Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"), Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"), Self::UserNotFound => f.write_str("用户不存在"), Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"), Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), } } } impl Error for PhoneAuthError {} impl fmt::Display for WechatAuthError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingProfile => f.write_str("缺少微信身份信息"), Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"), Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"), Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"), Self::UserNotFound => f.write_str("用户不存在"), Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"), Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), } } } impl Error for WechatAuthError {} impl fmt::Display for RefreshSessionError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingToken => f.write_str("缺少刷新会话"), Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => { f.write_str("当前登录态已失效,请重新登录") } Self::Store(message) => f.write_str(message), } } } 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), PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound | PasswordEntryError::PasswordHash(_) => { RefreshSessionError::Store("用户仓储读取失败".to_string()) } } } fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError { match error { PasswordEntryError::Store(message) => PhoneAuthError::Store(message), PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message), PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()), } } fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError { match error { PasswordEntryError::Store(message) => LogoutError::Store(message), PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::UserNotFound | PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()), } } fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError { match error { SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode, SmsProviderError::InvalidConfig(message) | SmsProviderError::Upstream(message) => { PhoneAuthError::Store(message) } } } 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 validate_password(password: &str) -> Result<(), PasswordEntryError> { let length = password.chars().count(); if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) { return Err(PasswordEntryError::InvalidPasswordLength); } Ok(()) } async fn verify_stored_password_user( existing_user: StoredPasswordUser, password: &str, ) -> Result { 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 verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> { let verify_code = verify_code.trim(); if verify_code.len() != SMS_CODE_LENGTH || !verify_code .chars() .all(|character| character.is_ascii_digit()) { return Err(PhoneAuthError::InvalidVerifyCode); } Ok(()) } fn normalize_mainland_china_phone_number( raw_phone_number: &str, ) -> Result { let digits = raw_phone_number .trim() .chars() .filter(|character| character.is_ascii_digit()) .collect::(); if digits.len() != 11 || !digits.starts_with('1') { return Err(PhoneAuthError::InvalidPhoneNumber); } Ok(PhoneNumberSnapshot { e164: format!("+86{digits}"), masked_national_number: mask_phone_number(&digits), }) } fn mask_phone_number(phone_number: &str) -> String { format!("{}****{}", &phone_number[..3], &phone_number[7..11]) } fn build_national_phone_number(e164_phone_number: &str) -> Result { let digits = e164_phone_number.trim().trim_start_matches('+'); if let Some(national) = digits.strip_prefix("86") && national.len() == 11 { return Ok(national.to_string()); } Err(PhoneAuthError::InvalidPhoneNumber) } fn build_random_password_seed() -> String { format!( "seed_{}_{}", new_uuid_simple_string(), new_uuid_simple_string() ) } fn build_system_username(prefix: &str, sequence: u64) -> String { format!("{prefix}_{sequence:08}") } // 公开叙世号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。 fn build_public_user_code(sequence: u64) -> String { format!("SY-{sequence:08}") } pub fn normalize_public_user_code(input: &str) -> Result { let normalized = input .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .collect::() .to_ascii_uppercase(); let digits = normalized.strip_prefix("SY").unwrap_or(&normalized); if digits.is_empty() || digits.len() > 8 || !digits.chars().all(|character| character.is_ascii_digit()) { return Err(PasswordEntryError::InvalidPublicUserCode); } Ok(format!("SY-{digits:0>8}")) } fn format_rfc3339(value: OffsetDateTime) -> Result { format_shared_rfc3339(value) } fn parse_phone_code_time(value: &str, field_label: &str) -> Result { 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 build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String { format!("{}:{}", phone_number.trim(), scene.as_str()) } fn create_wechat_state_token() -> String { new_uuid_simple_string() } fn format_rfc3339_with_context( value: OffsetDateTime, field_label: &str, ) -> Result { format_shared_rfc3339(value) .map_err(|error| RefreshSessionError::Store(format!("{field_label}格式化失败:{error}"))) } fn parse_rfc3339_with_context( value: &str, field_label: &str, ) -> Result { parse_rfc3339(value) .map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}"))) } impl PhoneAuthScene { pub fn as_str(&self) -> &'static str { match self { Self::Login => "login", Self::BindPhone => "bind_phone", Self::ChangePhone => "change_phone", Self::ResetPassword => "reset_password", } } } impl WechatAuthScene { pub fn as_str(&self) -> &'static str { match self { Self::Desktop => "desktop", Self::WechatInApp => "wechat_in_app", } } } #[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 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); } }