2827 lines
100 KiB
Rust
2827 lines
100 KiB
Rust
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);
|
||
}
|
||
}
|