Files
Genarrative/server-rs/crates/module-auth/src/lib.rs
T
kdletters 458371a73d
Project CI / Repository checks (push) Successful in 4m11s
Project CI / Frontend tests (push) Successful in 8m38s
Project CI / Backend tests (push) Successful in 10m42s
Project CI / Native shell tests (push) Successful in 32m11s
将退款 outbox 主路径迁入 SpacetimeDB (#204)
## 变更内容

- 新增 `profile_wallet_refund_outbox` 表与 enqueue/process procedure,退款主路径进入 SpacetimeDB。
- 外部生成失败事务、inline 资产失败和跨节点 worker 统一使用库内 outbox,按 ledger 幂等并在事务内完成退款与删除。
- SpacetimeDB 完全不可达时才写本机 emergency spool,恢复时重新入库;兼容旧 spool 文件并保留 attempt 追踪。
- 更新 SpacetimeDB migration、生成 bindings、架构文档、运维恢复说明和项目决策记录。

## 验证

- `cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`
- api-server / spacetime-client / spacetime-module / module-runtime 定向测试
- `npm run check:spacetime-schema`
- `npm run check:spacetime-runtime-access`
- `npm run check:server-rs-ddd`
- `npm run check:encoding`
- `git diff --check`

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/204
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-27 22:04:50 +08:00

5099 lines
187 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod application;
mod commands;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;
use std::{
collections::{HashMap, HashSet},
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
};
use platform_auth::{
SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password,
verify_password,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
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 DEFAULT_PHONE_VERIFY_CODE_SALT: &str = "genarrative-phone-verify-code-v1";
const PHONE_CODE_RESERVATION_MARKER: &str = "__genarrative_phone_code_reservation__";
const MAX_ACTIVE_WECHAT_AUTH_STATES: usize = 1024;
const REFRESH_SESSION_STALE_RETENTION: Duration = Duration::days(1);
const MAX_REFRESH_SESSIONS: usize = 8_192;
const MAX_PHONE_CODES: usize = 4_096;
#[derive(Clone, Debug)]
pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>,
revision: Arc<AtomicU64>,
}
#[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(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,
verify_code_hash: String,
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>,
session_key: Option<String>,
}
fn hydrate_private_auth_fields(
state: &InMemoryAuthStoreState,
stored_user: &StoredPasswordUser,
) -> StoredPasswordUser {
let mut hydrated = stored_user.clone();
if hydrated.user.phone_number.is_none() {
hydrated.user.phone_number = hydrated.phone_number.clone();
}
let hydrated_wechat_identity = state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == hydrated.user.id);
if hydrated.user.wechat_display_name.is_none() {
hydrated.user.wechat_display_name = hydrated_wechat_identity
.and_then(|identity| normalize_optional_string(identity.display_name.clone()));
}
if hydrated.user.wechat_account.is_none() {
hydrated.user.wechat_account =
hydrated_wechat_identity.map(|identity| identity.provider_uid.clone());
}
hydrated
}
fn stored_user_phone_number(stored_user: &StoredPasswordUser) -> Option<&str> {
stored_user
.phone_number
.as_deref()
.or(stored_user.user.phone_number.as_deref())
}
fn parse_auth_login_method(value: &str) -> AuthLoginMethod {
match value {
"password" | "Password" => AuthLoginMethod::Password,
"wechat" | "Wechat" => AuthLoginMethod::Wechat,
_ => AuthLoginMethod::Phone,
}
}
fn parse_auth_binding_status(value: &str) -> AuthBindingStatus {
match value {
"pending_bind_phone" | "PendingBindPhone" => AuthBindingStatus::PendingBindPhone,
_ => AuthBindingStatus::Active,
}
}
fn parse_phone_auth_scene(value: &str) -> Option<PhoneAuthScene> {
match value.trim() {
"login" => Some(PhoneAuthScene::Login),
"bind_phone" => Some(PhoneAuthScene::BindPhone),
"change_phone" => Some(PhoneAuthScene::ChangePhone),
"reset_password" => Some(PhoneAuthScene::ResetPassword),
_ => None,
}
}
fn parse_wechat_auth_scene(value: &str) -> Option<WechatAuthScene> {
match value.trim() {
"desktop" => Some(WechatAuthScene::Desktop),
"wechat_in_app" => Some(WechatAuthScene::WechatInApp),
_ => None,
}
}
fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 {
public_user_code
.trim()
.strip_prefix("SY-")
.and_then(|value| value.parse::<u64>().ok())
.map(|sequence| sequence.saturating_add(1))
.unwrap_or(1)
}
fn auth_identity_id(provider: &str, provider_uid: &str) -> String {
format!(
"authi_{}_{}",
sanitize_identity_component(provider),
sanitize_identity_component(provider_uid)
)
}
fn sanitize_identity_component(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_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,
verify_code_salt: String,
}
#[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)?;
validate_mainland_china_country_code(input.country_code.as_deref())
.map_err(map_phone_number_error_to_password_error)?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)
.map_err(map_phone_number_error_to_password_error)?;
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)?;
validate_mainland_china_country_code(input.country_code.as_deref())
.map_err(map_phone_number_error_to_password_error)?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)
.map_err(map_phone_number_error_to_password_error)?;
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> {
self.change_password_internal(input, None).await
}
pub async fn change_password_and_revoke_all_sessions(
&self,
input: ChangePasswordInput,
now: OffsetDateTime,
) -> Result<ChangePasswordResult, PasswordEntryError> {
self.change_password_internal(input, Some(now)).await
}
async fn change_password_internal(
&self,
input: ChangePasswordInput,
revoke_all_sessions_at: Option<OffsetDateTime>,
) -> 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, revoke_all_sessions_at)?
.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.prune_stale_sessions(now)?;
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> {
self.store.prune_stale_sessions(now)?;
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.prune_stale_sessions(now)?;
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 })
}
pub fn revoke_session_by_user_and_session(
&self,
input: RevokeRefreshSessionByUserInput,
now: OffsetDateTime,
) -> Result<RevokeRefreshSessionResult, RefreshSessionError> {
self.store.prune_stale_sessions(now)?;
self.store
.find_by_user_id(&input.user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let Some(session_id) = normalize_required_string(&input.session_id) else {
return Err(RefreshSessionError::SessionNotFound);
};
let revoked =
self.store
.revoke_session_by_user_and_session_id(&input.user_id, &session_id, now)?;
Ok(RevokeRefreshSessionResult {
session_id,
revoked,
})
}
pub fn is_session_active_for_user(
&self,
user_id: &str,
session_id: &str,
now: OffsetDateTime,
) -> Result<bool, RefreshSessionError> {
self.store.prune_stale_sessions(now)?;
self.store
.is_session_active_for_user(user_id, session_id.trim(), now)
}
}
impl PhoneAuthService {
pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self {
Self::new_with_verify_code_salt(store, sms_provider, DEFAULT_PHONE_VERIFY_CODE_SALT)
}
/// 使用部署级稳定盐值构造服务,确保验证码投影恢复到另一节点后仍可校验。
pub fn new_with_verify_code_salt(
store: InMemoryAuthStore,
sms_provider: SmsAuthProvider,
verify_code_salt: impl Into<String>,
) -> Self {
Self {
store,
sms_provider,
verify_code_salt: verify_code_salt.into(),
}
}
pub async fn send_code(
&self,
input: SendPhoneCodeInput,
now: OffsetDateTime,
) -> Result<SendPhoneCodeResult, PhoneAuthError> {
self.send_code_inner(input, now, true).await
}
/// 在 provider 调用前由 api-server 先同步该占位记录,以便 SpacetimeDB 的
/// projection CAS 原子占用跨节点冷却窗口。
pub fn reserve_code_send(
&self,
input: &SendPhoneCodeInput,
now: OffsetDateTime,
) -> Result<(), PhoneAuthError> {
self.store.prune_expired_phone_codes(now)?;
let scene = input.scene.clone();
validate_mainland_china_country_code(input.country_code.as_deref())?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?;
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 last_sent_at = format_rfc3339(now).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
})?;
let verify_code_hash = hash_phone_verify_code(
&self.verify_code_salt,
&normalized_phone.e164,
&scene,
PHONE_CODE_RESERVATION_MARKER,
);
self.store.upsert_phone_code(
StoredPhoneCode {
phone_number: normalized_phone.e164,
scene,
verify_code_hash,
expires_at,
last_sent_at,
failed_attempts: 0,
provider_out_id: None,
},
now,
)
}
/// 仅供完成权威占用后的 provider 调用使用;占用已由 projection CAS 校验。
pub async fn send_code_after_authoritative_reservation(
&self,
input: SendPhoneCodeInput,
now: OffsetDateTime,
) -> Result<SendPhoneCodeResult, PhoneAuthError> {
self.send_code_inner(input, now, false).await
}
async fn send_code_inner(
&self,
input: SendPhoneCodeInput,
now: OffsetDateTime,
check_local_cooldown: bool,
) -> Result<SendPhoneCodeResult, PhoneAuthError> {
self.store.prune_expired_phone_codes(now)?;
let scene = input.scene.clone();
validate_mainland_china_country_code(input.country_code.as_deref())?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?;
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
let verify_code = self.generate_phone_verify_code();
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"
);
if check_local_cooldown {
self.store
.ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?;
}
self.store
.ensure_phone_code_capacity(&normalized_phone.e164, &scene)?;
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 verify_code_hash = hash_phone_verify_code(
&self.verify_code_salt,
&normalized_phone.e164,
&scene,
&verify_code,
);
let provider_result = self
.sms_provider
.send_code(SmsSendCodeRequest {
national_phone_number,
scene: input.scene.as_str().to_string(),
verify_code,
})
.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,
verify_code_hash,
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> {
validate_mainland_china_country_code(input.country_code.as_deref())?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?;
verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.verify_phone_code(
&normalized_phone.e164,
&PhoneAuthScene::Login,
&input.verify_code,
now,
)?;
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> {
validate_mainland_china_country_code(input.country_code.as_deref())?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_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.verify_phone_code(
&normalized_phone.e164,
&PhoneAuthScene::ResetPassword,
&input.verify_code,
now,
)?;
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> {
validate_mainland_china_country_code(input.country_code.as_deref())?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?;
verify_sms_code_format(&input.verify_code)?;
self.verify_phone_code(
&normalized_phone.e164,
&PhoneAuthScene::BindPhone,
&input.verify_code,
now,
)?;
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,
input.wechat_display_name,
)?;
Ok(BindWechatPhoneResult {
user: merged_user,
activated_new_user,
})
}
fn verify_phone_code(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
verify_code: &str,
now: OffsetDateTime,
) -> Result<Option<String>, PhoneAuthError> {
let stored = self.store.get_active_phone_code(phone_number, scene, now)?;
let expected_hash =
hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code);
if stored.verify_code_hash != expected_hash {
self.store.consume_phone_code_failure(phone_number, scene)?;
return Err(PhoneAuthError::InvalidVerifyCode);
}
self.store.consume_phone_code_success(phone_number, scene)?;
Ok(stored.provider_out_id)
}
fn generate_phone_verify_code(&self) -> String {
match self.sms_provider.kind() {
SmsAuthProviderKind::Mock => self
.sms_provider
.mock_verify_code()
.map(str::to_string)
.unwrap_or_else(|| "123456".to_string()),
SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(),
}
}
pub async fn bind_wechat_verified_phone(
&self,
input: BindWechatVerifiedPhoneInput,
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
validate_mainland_china_country_code(Some(&input.country_code))?;
let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?;
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,
input.wechat_display_name,
)?;
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> {
self.store.prune_wechat_states(now)?;
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),
bind_user_id: normalize_optional_string(input.bind_user_id),
expires_at,
consumed_at: None,
created_at: created_at.clone(),
updated_at: created_at,
};
self.store.insert_wechat_state(state.clone(), now)?;
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,
})
}
pub fn bind_identity_to_user(
&self,
input: BindWechatIdentityInput,
) -> Result<AuthUser, WechatAuthError> {
if input.profile.provider_uid.trim().is_empty() {
return Err(WechatAuthError::MissingProfile);
}
self.store
.bind_wechat_identity_to_user(&input.user_id, input.profile)
}
pub fn get_identity_by_user_id(
&self,
user_id: &str,
) -> Result<Option<WechatIdentityRecord>, WechatAuthError> {
self.store.get_wechat_identity_by_user_id(user_id)
}
}
impl AuthUserService {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
}
pub fn ensure_orphan_work_owner_user(
&self,
user_id: &str,
username: &str,
display_name: &str,
public_user_code: &str,
) -> Result<AuthUser, PasswordEntryError> {
self.store
.ensure_orphan_work_owner_user(user_id, username, display_name, public_user_code)
}
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> {
let user = self
.store
.find_by_user_id(&input.user_id)
.map_err(map_password_error_to_logout_error)?
.ok_or(LogoutError::UserNotFound)?
.user;
let revoked_by_hash = if let Some(refresh_token_hash) = input
.refresh_token_hash
.as_ref()
.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)?;
true
} else {
false
};
if !revoked_by_hash
&& let Some(session_id) = input
.session_id
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
self.store
.revoke_session_by_user_and_session_id(&input.user_id, session_id, now)
.map_err(map_refresh_error_to_logout_error)?;
}
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())),
revision: Arc::new(AtomicU64::new(0)),
}
}
}
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_projection_view(view: AuthStoreProjectionView) -> Result<Self, String> {
let existing_user_ids = view
.users
.iter()
.map(|user| user.user_id.clone())
.collect::<HashSet<_>>();
let mut users_by_username = HashMap::new();
let mut phone_to_user_id = HashMap::new();
let mut sessions_by_id = HashMap::new();
let mut session_id_by_refresh_token_hash = HashMap::new();
let mut wechat_identity_by_provider_uid = HashMap::new();
let mut user_id_by_provider_union_id = HashMap::new();
let mut phone_number_by_user_id = HashMap::new();
let mut phone_codes_by_key = HashMap::new();
let mut wechat_states_by_token = HashMap::new();
for user in &view.users {
if let Some(phone_number) = normalize_optional_string(user.phone_number_e164.clone()) {
phone_to_user_id.insert(phone_number.clone(), user.user_id.clone());
phone_number_by_user_id.insert(user.user_id.clone(), phone_number);
}
}
for identity in view.identities {
if !existing_user_ids.contains(&identity.user_id) {
continue;
}
match identity.provider.as_str() {
"phone" => {
if let Some(phone_number) =
normalize_optional_string(Some(identity.provider_uid.clone()))
{
phone_to_user_id.insert(phone_number.clone(), identity.user_id.clone());
phone_number_by_user_id.insert(identity.user_id, phone_number);
}
}
"wechat" => {
if let Some(provider_union_id) = identity.provider_union_id.clone() {
user_id_by_provider_union_id
.insert(provider_union_id, identity.user_id.clone());
}
wechat_identity_by_provider_uid.insert(
identity.provider_uid.clone(),
StoredWechatIdentity {
user_id: identity.user_id,
provider_uid: identity.provider_uid,
provider_union_id: identity.provider_union_id,
display_name: None,
avatar_url: None,
session_key: None,
},
);
}
_ => {}
}
}
let now = OffsetDateTime::now_utc();
let mut retained_refresh_session_count = 0_usize;
for session in view.refresh_sessions {
if !existing_user_ids.contains(&session.user_id) {
continue;
}
if should_prune_refresh_session_fields(
&session.expires_at,
session.revoked_at.as_deref(),
now,
) {
continue;
}
retained_refresh_session_count += 1;
if retained_refresh_session_count > MAX_REFRESH_SESSIONS {
return Err(format!(
"认证投影中的 refresh session 数量超过内存上限(最多 {MAX_REFRESH_SESSIONS} 条)"
));
}
let client_info =
serde_json::from_str::<RefreshSessionClientInfo>(&session.client_info_json)
.map_err(|error| format!("解析 refresh session 客户端信息失败:{error}"))?;
session_id_by_refresh_token_hash.insert(
session.refresh_token_hash.clone(),
session.session_id.clone(),
);
sessions_by_id.insert(
session.session_id.clone(),
StoredRefreshSession {
session: RefreshSessionRecord {
session_id: session.session_id,
user_id: session.user_id,
refresh_token_hash: session.refresh_token_hash,
issued_by_provider: parse_auth_login_method(&session.issued_by_provider),
client_info,
expires_at: session.expires_at,
revoked_at: session.revoked_at,
created_at: session.created_at,
updated_at: session.updated_at,
last_seen_at: session.last_seen_at,
},
},
);
}
for phone_code in view.phone_codes {
let scene = parse_phone_auth_scene(&phone_code.scene)
.ok_or_else(|| format!("未知短信验证码场景:{}", phone_code.scene))?;
let key = build_phone_code_key(&phone_code.phone_number, &scene);
phone_codes_by_key.insert(
key,
StoredPhoneCode {
phone_number: phone_code.phone_number,
scene,
verify_code_hash: phone_code.verify_code_hash,
expires_at: phone_code.expires_at,
last_sent_at: phone_code.last_sent_at,
failed_attempts: phone_code.failed_attempts,
provider_out_id: phone_code.provider_out_id,
},
);
}
for wechat_state in view.wechat_states {
let scene = parse_wechat_auth_scene(&wechat_state.scene)
.ok_or_else(|| format!("未知微信授权 state 场景:{}", wechat_state.scene))?;
wechat_states_by_token.insert(
wechat_state.state_token.clone(),
StoredWechatAuthState {
state: WechatAuthStateRecord {
wechat_state_id: wechat_state.wechat_state_id,
state_token: wechat_state.state_token,
redirect_path: wechat_state.redirect_path,
scene,
request_user_agent: wechat_state.request_user_agent,
bind_user_id: wechat_state.bind_user_id,
expires_at: wechat_state.expires_at,
consumed_at: wechat_state.consumed_at,
created_at: wechat_state.created_at,
updated_at: wechat_state.updated_at,
},
},
);
}
for user in view.users {
let wechat_identity = wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == user.user_id);
let wechat_account = wechat_identity.map(|identity| identity.provider_uid.clone());
let phone_number = user
.phone_number_e164
.clone()
.or_else(|| phone_number_by_user_id.remove(&user.user_id));
users_by_username.insert(
user.username.clone(),
StoredPasswordUser {
user: AuthUser {
id: user.user_id,
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
phone_number: phone_number.clone(),
phone_number_masked: user.phone_number_masked,
login_method: parse_auth_login_method(&user.login_method),
binding_status: parse_auth_binding_status(&user.binding_status),
wechat_bound: user.wechat_bound,
wechat_display_name: None,
wechat_account,
token_version: user.token_version,
created_at: user.created_at,
},
password_hash: user.password_hash,
password_login_enabled: user.password_login_enabled,
phone_number,
},
);
}
let next_user_id = users_by_username
.values()
.map(|stored| next_sequence_from_public_user_code(&stored.user.public_user_code))
.max()
.unwrap_or(1);
Ok(Self {
next_user_id,
users_by_username,
phone_to_user_id,
sessions_by_id,
session_id_by_refresh_token_hash,
phone_codes_by_key,
wechat_states_by_token,
wechat_identity_by_provider_uid,
user_id_by_provider_union_id,
})
}
fn apply_persistent_state(&mut self, next_state: Self) {
self.next_user_id = next_state.next_user_id;
self.users_by_username = next_state.users_by_username;
self.phone_to_user_id = next_state.phone_to_user_id;
self.sessions_by_id = next_state.sessions_by_id;
self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash;
self.phone_codes_by_key = next_state.phone_codes_by_key;
self.wechat_states_by_token = next_state.wechat_states_by_token;
self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid;
self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id;
}
}
fn prune_expired_short_lived_state(
state: &mut InMemoryAuthStoreState,
now: OffsetDateTime,
) -> bool {
let phone_code_count = state.phone_codes_by_key.len();
state.phone_codes_by_key.retain(|_, code| {
parse_rfc3339(&code.expires_at)
.map(|expires_at| expires_at > now)
.unwrap_or(true)
});
let wechat_state_count = state.wechat_states_by_token.len();
state.wechat_states_by_token.retain(|_, stored| {
parse_rfc3339(&stored.state.expires_at)
.map(|expires_at| expires_at > now)
.unwrap_or(true)
});
phone_code_count != state.phone_codes_by_key.len()
|| wechat_state_count != state.wechat_states_by_token.len()
}
impl InMemoryAuthStore {
pub fn from_projection_view(view: AuthStoreProjectionView) -> Result<Self, String> {
Ok(Self {
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::from_projection_view(
view,
)?)),
revision: Arc::new(AtomicU64::new(0)),
})
}
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
pub fn refresh_from_projection_view(
&self,
view: AuthStoreProjectionView,
) -> Result<(), String> {
let next_state = InMemoryAuthStoreState::from_projection_view(view)?;
let mut state = self
.inner
.lock()
.map_err(|_| "认证仓储锁已中毒".to_string())?;
state.apply_persistent_state(next_state);
self.revision.fetch_add(1, Ordering::Release);
Ok(())
}
pub fn refresh_from_projection_view_if_revision(
&self,
view: AuthStoreProjectionView,
expected_revision: u64,
) -> Result<bool, String> {
let next_state = InMemoryAuthStoreState::from_projection_view(view)?;
let mut state = self
.inner
.lock()
.map_err(|_| "认证仓储锁已中毒".to_string())?;
if self.revision.load(Ordering::Acquire) != expected_revision {
return Ok(false);
}
state.apply_persistent_state(next_state);
self.revision.fetch_add(1, Ordering::Release);
Ok(true)
}
pub fn export_projection_view(
&self,
updated_at_micros: i64,
) -> Result<AuthStoreProjectionView, String> {
self.prune_stale_sessions(OffsetDateTime::now_utc())
.map_err(|error| error.to_string())?;
let mut state = self
.inner
.lock()
.map_err(|_| "认证仓储锁已中毒".to_string())?;
let pruned = prune_expired_short_lived_state(&mut state, OffsetDateTime::now_utc());
if pruned {
self.revision.fetch_add(1, Ordering::Release);
}
let users = state
.users_by_username
.values()
.map(|stored| AuthStoreProjectionUser {
user_id: stored.user.id.clone(),
public_user_code: stored.user.public_user_code.clone(),
username: stored.user.username.clone(),
display_name: stored.user.display_name.clone(),
avatar_url: stored.user.avatar_url.clone(),
phone_number_masked: stored.user.phone_number_masked.clone(),
phone_number_e164: stored_user_phone_number(stored).map(str::to_string),
login_method: stored.user.login_method.as_str().to_string(),
binding_status: stored.user.binding_status.as_str().to_string(),
wechat_bound: stored.user.wechat_bound,
password_hash: stored.password_hash.clone(),
password_login_enabled: stored.password_login_enabled,
token_version: stored.user.token_version,
created_at: stored.user.created_at.clone(),
})
.collect();
let mut identities = Vec::new();
for stored in state.users_by_username.values() {
if let Some(phone_number) = stored_user_phone_number(stored) {
identities.push(AuthStoreProjectionIdentity {
identity_id: auth_identity_id("phone", phone_number),
user_id: stored.user.id.clone(),
provider: "phone".to_string(),
provider_uid: phone_number.to_string(),
provider_union_id: None,
});
}
}
identities.extend(
state
.wechat_identity_by_provider_uid
.values()
.map(|identity| AuthStoreProjectionIdentity {
identity_id: auth_identity_id("wechat", &identity.provider_uid),
user_id: identity.user_id.clone(),
provider: "wechat".to_string(),
provider_uid: identity.provider_uid.clone(),
provider_union_id: identity.provider_union_id.clone(),
}),
);
let refresh_sessions = state
.sessions_by_id
.values()
.map(|stored| {
let client_info_json = serde_json::to_string(&stored.session.client_info)
.map_err(|error| format!("序列化 refresh session 客户端信息失败:{error}"))?;
Ok(AuthStoreProjectionRefreshSession {
session_id: stored.session.session_id.clone(),
user_id: stored.session.user_id.clone(),
refresh_token_hash: stored.session.refresh_token_hash.clone(),
issued_by_provider: stored.session.issued_by_provider.as_str().to_string(),
client_info_json,
expires_at: stored.session.expires_at.clone(),
revoked_at: stored.session.revoked_at.clone(),
created_at: stored.session.created_at.clone(),
updated_at: stored.session.updated_at.clone(),
last_seen_at: stored.session.last_seen_at.clone(),
})
})
.collect::<Result<Vec<_>, String>>()?;
let phone_codes = state
.phone_codes_by_key
.values()
.map(|stored| AuthStoreProjectionPhoneCode {
phone_number: stored.phone_number.clone(),
scene: stored.scene.as_str().to_string(),
verify_code_hash: stored.verify_code_hash.clone(),
expires_at: stored.expires_at.clone(),
last_sent_at: stored.last_sent_at.clone(),
failed_attempts: stored.failed_attempts,
provider_out_id: stored.provider_out_id.clone(),
})
.collect();
let wechat_states = state
.wechat_states_by_token
.values()
.map(|stored| AuthStoreProjectionWechatState {
wechat_state_id: stored.state.wechat_state_id.clone(),
state_token: stored.state.state_token.clone(),
redirect_path: stored.state.redirect_path.clone(),
scene: stored.state.scene.as_str().to_string(),
request_user_agent: stored.state.request_user_agent.clone(),
bind_user_id: stored.state.bind_user_id.clone(),
expires_at: stored.state.expires_at.clone(),
consumed_at: stored.state.consumed_at.clone(),
created_at: stored.state.created_at.clone(),
updated_at: stored.state.updated_at.clone(),
})
.collect();
Ok(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros,
users,
identities,
refresh_sessions,
phone_codes,
wechat_states,
})
}
pub fn export_projection_view_with_revision(
&self,
updated_at_micros: i64,
) -> Result<(AuthStoreProjectionView, u64), String> {
for _ in 0..3 {
let before = self.revision.load(Ordering::Acquire);
let view = self.export_projection_view(updated_at_micros)?;
let after = self.revision.load(Ordering::Acquire);
if before == after {
return Ok((view, after));
}
}
Err("认证工作集在导出期间持续发生变化".to_string())
}
fn prune_stale_sessions(&self, now: OffsetDateTime) -> Result<(), RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
let stale_session_ids = state
.sessions_by_id
.iter()
.filter(|(_, stored)| should_prune_refresh_session(&stored.session, now))
.map(|(session_id, _)| session_id.clone())
.collect::<Vec<_>>();
if stale_session_ids.is_empty() {
return Ok(());
}
for session_id in stale_session_ids {
let Some(stored) = state.sessions_by_id.remove(&session_id) else {
continue;
};
if state
.session_id_by_refresh_token_hash
.get(&stored.session.refresh_token_hash)
.is_some_and(|mapped_id| mapped_id == &session_id)
{
state
.session_id_by_refresh_token_hash
.remove(&stored.session.refresh_token_hash);
}
}
self.persist_refresh_state(&state)
}
fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> {
let _ = state;
self.revision.fetch_add(1, Ordering::Release);
Ok(())
}
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 resolve_phone_user_locked(
state: &mut InMemoryAuthStoreState,
phone_number: &str,
) -> Option<StoredPasswordUser> {
if let Some(user_id) = state.phone_to_user_id.get(phone_number).cloned() {
if let Some(stored_user) = state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
.cloned()
{
return Some(stored_user);
}
state.phone_to_user_id.remove(phone_number);
}
let Some(stored_user) = state
.users_by_username
.values()
.find(|stored_user| stored_user_phone_number(stored_user) == Some(phone_number))
.cloned()
else {
return None;
};
state
.phone_to_user_id
.insert(phone_number.to_string(), stored_user.user.id.clone());
Some(stored_user)
}
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)
.map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
}
fn ensure_orphan_work_owner_user(
&self,
user_id: &str,
username: &str,
display_name: &str,
public_user_code: &str,
) -> Result<AuthUser, PasswordEntryError> {
let user_id = normalize_required_string(user_id)
.ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string()))?;
let username = normalize_required_string(username)
.ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string()))?;
let display_name = normalize_required_string(display_name)
.ok_or_else(|| PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string()))?;
let public_user_code = normalize_required_string(public_user_code)
.ok_or_else(|| PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string()))?;
let mut state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
if let Some(stored) = state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
{
return Ok(stored.user.clone());
}
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
})?;
let user = AuthUser {
id: user_id,
public_user_code,
username: username.clone(),
display_name,
avatar_url: None,
phone_number: None,
phone_number_masked: None,
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
wechat_display_name: None,
wechat_account: None,
token_version: 1,
created_at,
};
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash: String::new(),
password_login_enabled: false,
phone_number: None,
},
);
self.persist_password_state(&state)?;
Ok(user)
}
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)
.map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
}
fn find_by_phone_number(
&self,
phone_number: &str,
) -> Result<Option<StoredPasswordUser>, PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
Ok(Self::resolve_phone_user_locked(&mut state, phone_number)
.map(|stored_user| hydrate_private_auth_fields(&state, &stored_user)))
}
fn find_by_phone_number_for_password(
&self,
phone_number: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let mut state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(Self::resolve_phone_user_locked(&mut state, phone_number)
.map(|stored_user| hydrate_private_auth_fields(&state, &stored_user)))
}
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 Self::resolve_phone_user_locked(&mut state, &phone_number.e164).is_some() {
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 = build_prefixed_uuid_id("user_");
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: Some(phone_number.e164.clone()),
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Phone,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
wechat_display_name: None,
wechat_account: None,
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 Self::resolve_phone_user_locked(&mut state, &phone_number.e164).is_some() {
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 = build_prefixed_uuid_id("user_");
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: Some(phone_number.e164.clone()),
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
wechat_display_name: None,
wechat_account: None,
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 = build_prefixed_uuid_id("user_");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
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 wechat_display_name = normalize_optional_string(profile.display_name.clone());
let username = build_wechat_username(&display_name, &profile.provider_uid);
let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default();
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
avatar_url: avatar_url.clone(),
phone_number: None,
phone_number_masked: None,
login_method: AuthLoginMethod::Wechat,
binding_status: AuthBindingStatus::PendingBindPhone,
wechat_bound: true,
wechat_display_name,
wechat_account: Some(provider_uid.clone()),
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,
provider_union_id: normalize_optional_string(profile.provider_union_id),
display_name: normalize_optional_string(profile.display_name),
avatar_url,
session_key: normalize_optional_string(profile.session_key),
};
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(hydrate_private_auth_fields(&state, stored).user));
}
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| hydrate_private_auth_fields(&state, stored).user))
}
fn get_wechat_identity_by_user_id(
&self,
user_id: &str,
) -> Result<Option<WechatIdentityRecord>, WechatAuthError> {
let state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
let Some(identity) = state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == user_id.trim())
else {
return Ok(None);
};
Ok(Some(WechatIdentityRecord {
user_id: identity.user_id.clone(),
provider_uid: identity.provider_uid.clone(),
provider_union_id: identity.provider_union_id.clone(),
session_key: identity.session_key.clone(),
}))
}
fn bind_wechat_identity_to_user(
&self,
user_id: &str,
profile: WechatIdentityProfile,
) -> Result<AuthUser, WechatAuthError> {
let user_id = normalize_required_string(user_id).ok_or(WechatAuthError::UserNotFound)?;
let provider_uid = normalize_required_string(&profile.provider_uid)
.ok_or(WechatAuthError::MissingProfile)?;
let provider_union_id = normalize_optional_string(profile.provider_union_id);
let display_name = normalize_optional_string(profile.display_name);
let avatar_url = normalize_optional_string(profile.avatar_url);
let session_key = normalize_optional_string(profile.session_key);
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("鐢ㄦ埛浠撳偍閿佸凡涓瘨".to_string()))?;
if !state
.users_by_username
.values()
.any(|stored_user| stored_user.user.id == user_id)
{
return Err(WechatAuthError::UserNotFound);
}
if let Some(existing) = state.wechat_identity_by_provider_uid.get(&provider_uid)
&& existing.user_id != user_id
{
return Err(WechatAuthError::WechatIdentityAlreadyBound);
}
if let Some(provider_union_id) = provider_union_id.as_deref()
&& let Some(existing_user_id) =
state.user_id_by_provider_union_id.get(provider_union_id)
&& existing_user_id != &user_id
{
return Err(WechatAuthError::WechatIdentityAlreadyBound);
}
if let Some(existing_identity) = state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == user_id)
.cloned()
{
state
.wechat_identity_by_provider_uid
.remove(&existing_identity.provider_uid);
if let Some(existing_union_id) = existing_identity.provider_union_id
&& provider_union_id.as_deref() != Some(existing_union_id.as_str())
{
state
.user_id_by_provider_union_id
.remove(&existing_union_id);
}
}
state.wechat_identity_by_provider_uid.insert(
provider_uid.clone(),
StoredWechatIdentity {
user_id: user_id.clone(),
provider_uid: provider_uid.clone(),
provider_union_id: provider_union_id.clone(),
display_name: display_name.clone(),
avatar_url: avatar_url.clone(),
session_key,
},
);
if let Some(provider_union_id) = provider_union_id {
state
.user_id_by_provider_union_id
.insert(provider_union_id, user_id.clone());
}
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)?;
stored_user.user.wechat_bound = true;
stored_user.user.wechat_account = Some(provider_uid);
if let Some(display_name) = display_name {
stored_user.user.wechat_display_name = Some(display_name);
}
if stored_user.user.avatar_url.is_none()
&& let Some(avatar_url) = avatar_url
{
stored_user.user.avatar_url = Some(avatar_url);
}
stored_user.user.clone()
};
self.persist_wechat_state(&state)?;
Ok(next_user)
}
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_session_key = normalize_optional_string(profile.session_key);
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.clone();
identity.provider_union_id = next_provider_union_id.clone();
if next_session_key.is_some() {
identity.session_key = next_session_key.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() {
stored_user.user.display_name = display_name.to_string();
}
stored_user.user.wechat_account = Some(next_provider_uid.clone());
if let Some(display_name) = next_display_name.clone() {
stored_user.user.wechat_display_name = Some(display_name);
}
if let Some(avatar_url) = next_avatar_url.clone() {
stored_user.user.avatar_url = Some(avatar_url);
}
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(),
));
}
if state.sessions_by_id.len() >= MAX_REFRESH_SESSIONS {
return Err(RefreshSessionError::Store(
"refresh session 内存容量已达到上限".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);
if !state.phone_codes_by_key.contains_key(&key)
&& state.phone_codes_by_key.len() >= MAX_PHONE_CODES
{
return Err(PhoneAuthError::Store(
"短信验证码内存容量已达到上限,请稍后重试".to_string(),
));
}
state.phone_codes_by_key.insert(key, code);
self.persist_phone_state(&state)?;
Ok(())
}
fn prune_expired_phone_codes(&self, now: OffsetDateTime) -> Result<(), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let expired_keys = state
.phone_codes_by_key
.iter()
.filter_map(|(key, stored)| {
OffsetDateTime::parse(
&stored.expires_at,
&time::format_description::well_known::Rfc3339,
)
.ok()
.filter(|expires_at| *expires_at <= now)
.map(|_| key.clone())
})
.collect::<Vec<_>>();
for key in expired_keys {
state.phone_codes_by_key.remove(&key);
}
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 ensure_phone_code_capacity(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
) -> Result<(), PhoneAuthError> {
let state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
if state.phone_codes_by_key.contains_key(&key)
|| state.phone_codes_by_key.len() < MAX_PHONE_CODES
{
return Ok(());
}
Err(PhoneAuthError::Store(
"短信验证码内存容量已达到上限,请稍后重试".to_string(),
))
}
fn get_active_phone_code(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
now: OffsetDateTime,
) -> Result<StoredPhoneCode, 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)
}
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);
self.persist_phone_state(&state)?;
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);
self.persist_phone_state(&state)?;
return Err(PhoneAuthError::VerifyAttemptsExceeded);
}
if let Some(current) = state.phone_codes_by_key.get_mut(&key) {
current.failed_attempts = next_failed_attempts;
}
self.persist_phone_state(&state)?;
Err(PhoneAuthError::InvalidVerifyCode)
}
fn insert_wechat_state(
&self,
state_record: WechatAuthStateRecord,
now: OffsetDateTime,
) -> Result<(), WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?;
prune_expired_short_lived_state(&mut state, now);
if state
.wechat_states_by_token
.contains_key(&state_record.state_token)
{
return Err(WechatAuthError::Store("微信 state 已存在".to_string()));
}
if state.wechat_states_by_token.len() >= MAX_ACTIVE_WECHAT_AUTH_STATES {
return Err(WechatAuthError::Store(
"微信登录请求过多,请稍后重试".to_string(),
));
}
state.wechat_states_by_token.insert(
state_record.state_token.clone(),
StoredWechatAuthState {
state: state_record,
},
);
self.persist_wechat_state(&state)?;
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;
let consumed = current.clone();
state.wechat_states_by_token.remove(state_token.trim());
self.persist_wechat_state(&state)?;
Ok(consumed)
}
fn bind_wechat_phone_to_user(
&self,
pending_user_id: &str,
phone_number: PhoneNumberSnapshot,
wechat_display_name: Option<String>,
) -> Result<(AuthUser, bool), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let submitted_wechat_display_name = normalize_optional_string(wechat_display_name);
let existing_phone_user_id =
Self::resolve_phone_user_locked(&mut state, &phone_number.e164)
.map(|stored_user| stored_user.user.id);
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_wechat_account = pending_wechat_identity.provider_uid.clone();
let pending_user = state
.users_by_username
.values()
.find(|stored| stored.user.id == pending_user_id)
.cloned()
.ok_or(PhoneAuthError::UserNotFound)?;
let pending_username = pending_user.user.username.clone();
let pending_wechat_display_name = submitted_wechat_display_name
.clone()
.or_else(|| normalize_optional_string(pending_wechat_identity.display_name.clone()))
.or_else(|| {
normalize_optional_string(pending_user.user.wechat_display_name.clone())
});
let pending_wechat_avatar_url =
normalize_optional_string(pending_wechat_identity.avatar_url.clone())
.or_else(|| normalize_optional_string(pending_user.user.avatar_url.clone()));
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(),
display_name: pending_wechat_display_name.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;
target_user.user.wechat_account = Some(pending_wechat_account);
target_user.user.wechat_display_name = pending_wechat_display_name.clone();
if let Some(display_name) = pending_wechat_display_name {
target_user.user.display_name = display_name;
}
if target_user.user.avatar_url.is_none() {
target_user.user.avatar_url = pending_wechat_avatar_url;
}
if target_user.user.phone_number.is_none() {
target_user.user.phone_number = target_user.phone_number.clone();
}
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 bound_wechat_account = state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == pending_user_id)
.map(|identity| identity.provider_uid.clone());
let bound_wechat_display_name = submitted_wechat_display_name.clone().or_else(|| {
state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == pending_user_id)
.and_then(|identity| normalize_optional_string(identity.display_name.clone()))
.or_else(|| {
state
.users_by_username
.values()
.find(|stored| stored.user.id == pending_user_id)
.and_then(|stored| {
normalize_optional_string(stored.user.wechat_display_name.clone())
})
})
});
if let Some(display_name) = bound_wechat_display_name.clone()
&& let Some(identity) = state
.wechat_identity_by_provider_uid
.values_mut()
.find(|identity| identity.user_id == pending_user_id)
{
identity.display_name = Some(display_name);
}
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 = Some(phone_number.e164.clone());
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;
if stored_user.user.wechat_account.is_none() {
stored_user.user.wechat_account = bound_wechat_account;
}
if let Some(display_name) = bound_wechat_display_name {
stored_user.user.display_name = display_name.clone();
stored_user.user.wechat_display_name = Some(display_name);
}
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 is_session_active_for_user(
&self,
user_id: &str,
session_id: &str,
now: OffsetDateTime,
) -> Result<bool, RefreshSessionError> {
if session_id.trim().is_empty() {
return Ok(false);
}
let state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
let Some(stored) = state.sessions_by_id.get(session_id) else {
return Ok(false);
};
if stored.session.user_id != user_id || stored.session.revoked_at.is_some() {
return Ok(false);
}
let expires_at = OffsetDateTime::parse(
&stored.session.expires_at,
&time::format_description::well_known::Rfc3339,
)
.map_err(|error| RefreshSessionError::Store(format!("会话过期时间解析失败:{error}")))?;
Ok(expires_at > now)
}
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 prune_wechat_states(&self, now: OffsetDateTime) -> Result<(), WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?;
let stale_tokens = state
.wechat_states_by_token
.iter()
.filter_map(|(token, stored)| {
if stored.state.consumed_at.is_some() {
return Some(token.clone());
}
OffsetDateTime::parse(
&stored.state.expires_at,
&time::format_description::well_known::Rfc3339,
)
.ok()
.filter(|expires_at| *expires_at <= now)
.map(|_| token.clone())
})
.collect::<Vec<_>>();
for token in stale_tokens {
state.wechat_states_by_token.remove(&token);
}
Ok(())
}
fn revoke_session_by_user_and_session_id(
&self,
user_id: &str,
session_id: &str,
now: OffsetDateTime,
) -> Result<bool, RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
let Some(stored) = state.sessions_by_id.get_mut(session_id) else {
return Ok(false);
};
if stored.session.user_id != user_id {
return Ok(false);
}
if stored.session.revoked_at.is_some() {
return Ok(false);
}
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(true)
}
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,
revoke_all_sessions_at: Option<OffsetDateTime>,
) -> Result<Option<AuthUser>, PasswordEntryError> {
let mut state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
let revoke_all_sessions_at = match revoke_all_sessions_at {
Some(now) => Some(
now.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
PasswordEntryError::Store(format!("会话吊销时间格式化失败:{error}"))
})?,
),
None => None,
};
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();
if let Some(now_iso) = revoke_all_sessions_at.as_ref() {
for stored_session in state.sessions_by_id.values_mut() {
if stored_session.session.user_id != user_id
|| stored_session.session.revoked_at.is_some()
{
continue;
}
stored_session.session.revoked_at = Some(now_iso.clone());
stored_session.session.updated_at = now_iso.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 = Self::resolve_phone_user_locked(&mut state, phone_number)
.map(|stored_user| stored_user.user.id)
.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 should_prune_refresh_session(session: &RefreshSessionRecord, now: OffsetDateTime) -> bool {
should_prune_refresh_session_fields(&session.expires_at, session.revoked_at.as_deref(), now)
}
fn should_prune_refresh_session_fields(
expires_at: &str,
revoked_at: Option<&str>,
now: OffsetDateTime,
) -> bool {
let stale_before = now.saturating_sub(REFRESH_SESSION_STALE_RETENTION);
if let Some(revoked_at) = revoked_at {
return OffsetDateTime::parse(revoked_at, &time::format_description::well_known::Rfc3339)
.is_ok_and(|timestamp| timestamp <= stale_before);
}
OffsetDateTime::parse(expires_at, &time::format_description::well_known::Rfc3339)
.is_ok_and(|timestamp| timestamp <= stale_before)
}
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),
}
}
fn map_phone_number_error_to_password_error(error: PhoneAuthError) -> PasswordEntryError {
match error {
PhoneAuthError::UnsupportedPhoneCountryCode => {
PasswordEntryError::UnsupportedPhoneCountryCode
}
_ => PasswordEntryError::InvalidPhoneNumber,
}
}
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 generate_random_phone_verify_code() -> String {
let digest = Sha256::digest(new_uuid_simple_string().as_bytes());
let mut digits = digest
.iter()
.take(SMS_CODE_LENGTH)
.map(|byte| char::from(b'0' + (*byte % 10)))
.collect::<String>();
while digits.len() < SMS_CODE_LENGTH {
digits.push('0');
}
digits
}
fn hash_phone_verify_code(
salt: &str,
phone_number: &str,
scene: &PhoneAuthScene,
verify_code: &str,
) -> String {
let content = format!(
"{}:{}:{}:{}",
salt,
phone_number.trim(),
scene.as_str(),
verify_code.trim()
);
let digest = Sha256::digest(content.as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
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::*;
#[test]
fn build_wechat_username_uses_display_name_and_provider_uid() {
assert_eq!(
build_wechat_username("小明", "wx-openid-123"),
"小明_wx-openid-123"
);
assert_eq!(
build_wechat_username(" ", "wx-openid-123"),
"微信旅人_wx-openid-123"
);
}
#[test]
fn mainland_china_phone_normalization_accepts_pure_phone_number() {
let national = normalize_mainland_china_phone_number("13800138000")
.expect("national phone should normalize");
assert_eq!(national.e164, "+8613800138000");
}
#[test]
fn mainland_china_country_code_defaults_to_china_and_accepts_explicit_86() {
validate_mainland_china_country_code(None).expect("missing country code should default");
validate_mainland_china_country_code(Some(" 86 "))
.expect("explicit mainland China country code should pass");
}
#[test]
fn mainland_china_country_code_rejects_foreign_or_non_wechat_format() {
for country_code in ["1", "+86", ""] {
let error = validate_mainland_china_country_code(Some(country_code))
.expect_err("unsupported country code should fail");
assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode);
assert_eq!(error.to_string(), "仅支持中国大陆手机号(+86");
}
}
fn build_store() -> InMemoryAuthStore {
InMemoryAuthStore::default()
}
fn empty_projection_store() -> InMemoryAuthStore {
InMemoryAuthStore::from_projection_view(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 0,
users: vec![],
identities: vec![],
refresh_sessions: vec![],
phone_codes: vec![],
wechat_states: vec![],
})
.expect("projection should restore")
}
fn projection_user(
user_id: &str,
username: &str,
phone_number: Option<&str>,
) -> AuthStoreProjectionUser {
AuthStoreProjectionUser {
user_id: user_id.to_string(),
public_user_code: "SY-00000009".to_string(),
username: username.to_string(),
display_name: "138****8009".to_string(),
avatar_url: None,
phone_number_masked: Some("138****8009".to_string()),
phone_number_e164: phone_number.map(str::to_string),
login_method: "phone".to_string(),
binding_status: "active".to_string(),
wechat_bound: false,
password_hash: "hash-existing".to_string(),
password_login_enabled: false,
token_version: 1,
created_at: "2026-06-30T00:00:00Z".to_string(),
}
}
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 {
country_code: None,
pure_phone_number: phone_number.to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_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 {
country_code: None,
pure_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 {
country_code: None,
pure_phone_number: "13800138009".to_string(),
password: "secret123".to_string(),
})
.await
.expect("dev registration should create user");
let reused = service
.execute_with_dev_registration(PasswordEntryInput {
country_code: None,
pure_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 {
country_code: None,
pure_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 {
country_code: None,
pure_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 change_password_and_revoke_all_sessions_revokes_every_refresh_session() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138030").await;
let password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store.clone());
let now = OffsetDateTime::now_utc();
let first_password_user = password_service
.change_password(ChangePasswordInput {
user_id: user.id.clone(),
current_password: None,
new_password: "secret123".to_string(),
})
.await
.expect("first password should set")
.user;
let first_token_hash = hash_refresh_session_token("change-password-token-01");
let second_token_hash = hash_refresh_session_token("change-password-token-02");
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("first session should create");
refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: second_token_hash.clone(),
issued_by_provider: AuthLoginMethod::Password,
client_info: RefreshSessionClientInfo {
client_runtime: "safari".to_string(),
device_display_name: "iOS / Safari".to_string(),
..build_client_info()
},
},
now + Duration::seconds(1),
)
.expect("second session should create");
let changed_user = password_service
.change_password_and_revoke_all_sessions(
ChangePasswordInput {
user_id: user.id.clone(),
current_password: Some("secret123".to_string()),
new_password: "secret456".to_string(),
},
now + Duration::minutes(1),
)
.await
.expect("password change should revoke all sessions")
.user;
assert_eq!(
changed_user.token_version,
first_password_user.token_version + 1
);
assert!(
refresh_service
.list_active_sessions_by_user(&user.id, now + Duration::minutes(2))
.expect("active sessions should list")
.sessions
.is_empty()
);
for (token_hash, next_hash) in [
(
first_token_hash,
hash_refresh_session_token("change-password-token-01-next"),
),
(
second_token_hash,
hash_refresh_session_token("change-password-token-02-next"),
),
] {
let refresh_error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: token_hash,
next_refresh_token_hash: next_hash,
},
now + Duration::minutes(2),
)
.expect_err("revoked session should not rotate");
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
}
assert_eq!(
password_service
.execute(PasswordEntryInput {
country_code: None,
pure_phone_number: "13800138030".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("old password should fail"),
PasswordEntryError::InvalidCredentials
);
let login = password_service
.execute(PasswordEntryInput {
country_code: None,
pure_phone_number: "13800138030".to_string(),
password: "secret456".to_string(),
})
.await
.expect("new password should login");
assert_eq!(login.user.id, user.id);
}
#[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 {
country_code: None,
pure_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 {
country_code: None,
pure_phone_number: "13800138002".to_string(),
scene: PhoneAuthScene::ResetPassword,
},
now,
)
.await
.expect("reset code should send");
let error = phone_service
.reset_password(
ResetPasswordInput {
country_code: None,
pure_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 dev_password_registration_after_empty_projection_restore() {
let service = build_password_service(empty_projection_store());
let created = service
.execute_with_dev_registration(PasswordEntryInput {
country_code: None,
pure_phone_number: "13800138004".to_string(),
password: "secret123".to_string(),
})
.await
.expect("orphan phone index should not block dev registration");
assert!(created.created);
assert_eq!(
created.user.phone_number_masked.as_deref(),
Some("138****8004")
);
}
#[tokio::test]
async fn phone_login_after_empty_projection_restore() {
let phone_service = build_phone_service(empty_projection_store());
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138005".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let created = phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_phone_number: "13800138005".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("orphan phone index should not turn login into duplicate create");
assert!(created.created);
assert_eq!(
created.user.phone_number_masked.as_deref(),
Some("138****8005")
);
}
#[tokio::test]
async fn phone_login_reuses_user_restored_from_projection() {
let phone_service = build_phone_service(
InMemoryAuthStore::from_projection_view(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 1,
users: vec![projection_user(
"user_existing_phone",
"phone_existing",
Some("+8613800138009"),
)],
identities: vec![],
refresh_sessions: vec![],
phone_codes: vec![],
wechat_states: vec![],
})
.expect("projection should restore"),
);
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138009".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let reused = phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_phone_number: "13800138009".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should reuse existing user");
assert!(!reused.created);
assert_eq!(reused.user.id, "user_existing_phone");
}
#[tokio::test]
async fn projection_restores_user_and_refresh_session_after_roundtrip() {
let store = InMemoryAuthStore::default();
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");
let projection = store
.export_projection_view(1)
.expect("projection export should succeed");
let restored_store =
InMemoryAuthStore::from_projection_view(projection).expect("projection 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);
}
#[tokio::test]
async fn projection_roundtrip_preserves_phone_code_and_wechat_state() {
let store = InMemoryAuthStore::default();
let phone_service = build_phone_service(store.clone());
let wechat_state_service = WechatAuthStateService::new(store.clone(), 5);
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138040".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send before projection export");
let created_state = wechat_state_service
.create_state(
CreateWechatAuthStateInput {
redirect_path: "/studio".to_string(),
scene: WechatAuthScene::Desktop,
request_user_agent: Some("test-agent".to_string()),
bind_user_id: None,
},
now,
)
.expect("wechat state should be created before projection export");
let projection = store
.export_projection_view(1)
.expect("projection export should include short-lived auth state");
assert_eq!(projection.phone_codes.len(), 1);
assert_eq!(projection.wechat_states.len(), 1);
let restored_store = InMemoryAuthStore::from_projection_view(projection)
.expect("projection should restore short-lived auth state");
let restored_phone_service = build_phone_service(restored_store.clone());
let login = restored_phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_phone_number: "13800138040".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("restored phone code should verify");
assert!(login.created);
let consumed_state = WechatAuthStateService::new(restored_store, 5)
.consume_state(&created_state.state.state_token, now + Duration::seconds(1))
.expect("restored wechat state should be consumable");
assert_eq!(consumed_state.state.redirect_path, "/studio");
}
#[test]
fn wechat_state_creation_is_bounded_before_projection_sync() {
let store = InMemoryAuthStore::default();
let service = WechatAuthStateService::new(store, 5);
let now = OffsetDateTime::now_utc();
for index in 0..MAX_ACTIVE_WECHAT_AUTH_STATES {
service
.create_state(
CreateWechatAuthStateInput {
redirect_path: format!("/studio?attempt={index}"),
scene: WechatAuthScene::Desktop,
request_user_agent: None,
bind_user_id: None,
},
now,
)
.expect("active wechat state should fit within the projection budget");
}
let error = service
.create_state(
CreateWechatAuthStateInput {
redirect_path: "/studio".to_string(),
scene: WechatAuthScene::Desktop,
request_user_agent: None,
bind_user_id: None,
},
now,
)
.expect_err("wechat state creation must reject an unbounded projection");
assert!(matches!(error, WechatAuthError::Store(message) if message.contains("请求过多")));
}
#[tokio::test]
async fn refresh_from_projection_view_merges_session_created_by_another_process() {
let source_store = InMemoryAuthStore::default();
let user = create_phone_login_user(source_store.clone(), "13800138033").await;
let source_refresh_service = build_refresh_service(source_store.clone());
let source_session = source_refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("remote-process-token"),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
OffsetDateTime::now_utc(),
)
.expect("source session should create");
let projection = source_store
.export_projection_view(1)
.expect("source projection should export");
let local_store = InMemoryAuthStore::default();
let local_phone_service = build_phone_service(local_store.clone());
let local_now = OffsetDateTime::now_utc();
local_phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138034".to_string(),
scene: PhoneAuthScene::Login,
},
local_now,
)
.await
.expect("local transient phone code should send");
local_store
.refresh_from_projection_view(projection)
.expect("local store should refresh");
assert!(
build_refresh_service(local_store)
.is_session_active_for_user(
&user.id,
&source_session.session.session_id,
OffsetDateTime::now_utc() + Duration::minutes(1)
)
.expect("refreshed session active check should succeed")
);
// 刷新到数据库正式投影后,短期认证状态也以数据库快照为准;本地未同步的验证码
// 不得继续留在工作集里,避免消费已被其他节点清理的验证码。
local_phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138034".to_string(),
scene: PhoneAuthScene::Login,
},
local_now + Duration::seconds(5),
)
.await
.expect("phone code should be resendable after authoritative refresh");
}
#[test]
fn conditional_projection_refresh_rejects_stale_revision() {
let store = InMemoryAuthStore::default();
let projection = AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 1,
users: vec![],
identities: vec![],
refresh_sessions: vec![],
phone_codes: vec![],
wechat_states: vec![],
};
assert_eq!(store.revision(), 0);
store
.refresh_from_projection_view(projection.clone())
.expect("initial projection refresh should succeed");
assert_eq!(store.revision(), 1);
assert!(
!store
.refresh_from_projection_view_if_revision(projection, 0)
.expect("stale projection refresh should be checked without error")
);
assert_eq!(store.revision(), 1);
}
#[tokio::test]
async fn empty_projection_restore_does_not_block_phone_login() {
let phone_service = build_phone_service(empty_projection_store());
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138032".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let result = phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_phone_number: "13800138032".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("orphan phone index should not block phone login");
assert!(result.created);
assert_eq!(
result.user.phone_number_masked.as_deref(),
Some("138****8032")
);
}
#[tokio::test]
async fn password_entry_rejects_email_or_username_identifier() {
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
country_code: None,
pure_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 password_entry_rejects_foreign_country_code() {
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
country_code: Some("1".to_string()),
pure_phone_number: "12025550123".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("foreign phone should fail");
assert_eq!(error, PasswordEntryError::UnsupportedPhoneCountryCode);
}
#[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 {
country_code: None,
pure_phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("first phone code should send");
let error = service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_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 authoritative_phone_code_reservation_blocks_duplicate_provider_send() {
let service = build_phone_service(build_store());
let input = SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138001".to_string(),
scene: PhoneAuthScene::Login,
};
let now = OffsetDateTime::now_utc();
service
.reserve_code_send(&input, now)
.expect("authoritative reservation should be representable locally");
let duplicate = service
.reserve_code_send(&input, now + Duration::seconds(1))
.expect_err("a second reservation must observe the local cooldown");
assert!(matches!(duplicate, PhoneAuthError::SendCoolingDown { .. }));
service
.send_code_after_authoritative_reservation(input, now)
.await
.expect("provider send should replace the reservation with the real code");
}
#[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 {
country_code: None,
pure_phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("login scene code should send");
let bind_result = service.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now + Duration::seconds(1),
);
assert!(bind_result.await.is_ok());
}
#[test]
fn random_phone_verify_code_is_six_digits() {
let code = generate_random_phone_verify_code();
assert_eq!(code.len(), SMS_CODE_LENGTH);
assert!(code.chars().all(|character| character.is_ascii_digit()));
}
#[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 {
country_code: None,
pure_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 {
country_code: None,
pure_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 {
country_code: None,
pure_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 {
country_code: None,
pure_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 {
country_code: None,
pure_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 {
country_code: None,
pure_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_only_current_session_without_token_version_bump() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138005").await;
let refresh_service = build_refresh_service(store.clone());
let user_service = build_user_service(store);
let refresh_token_hash = hash_refresh_session_token("logout-token");
let other_refresh_token_hash = hash_refresh_session_token("logout-token-other");
refresh_service
.create_session(
CreateRefreshSessionInput {
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 other_session = refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: other_refresh_token_hash.clone(),
issued_by_provider: AuthLoginMethod::Password,
client_info: RefreshSessionClientInfo {
client_runtime: "firefox".to_string(),
device_display_name: "Windows / Firefox".to_string(),
..build_client_info()
},
},
OffsetDateTime::now_utc() + Duration::seconds(1),
)
.expect("other session should create");
let result = user_service
.logout_current_session(
LogoutCurrentSessionInput {
user_id: user.id.clone(),
refresh_token_hash: Some(refresh_token_hash.clone()),
session_id: None,
},
OffsetDateTime::now_utc(),
)
.expect("logout should succeed");
assert_eq!(result.user.token_version, user.token_version);
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);
assert!(
refresh_service
.is_session_active_for_user(
&user.id,
&other_session.session.session_id,
OffsetDateTime::now_utc() + Duration::minutes(2)
)
.expect("other session active check should succeed")
);
let rotated_other = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: other_refresh_token_hash,
next_refresh_token_hash: hash_refresh_session_token("logout-token-other-next"),
},
OffsetDateTime::now_utc() + Duration::minutes(2),
)
.expect("other session should still rotate");
assert_eq!(rotated_other.user.id, user.id);
}
#[tokio::test]
async fn revoke_session_by_user_and_session_revokes_only_target_without_token_bump() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138028").await;
let refresh_service = build_refresh_service(store.clone());
let now = OffsetDateTime::now_utc();
let first_token_hash = hash_refresh_session_token("revoke-target-token");
let second_token_hash = hash_refresh_session_token("revoke-current-token");
let target = 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("target session should create");
let current = refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: second_token_hash,
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("current session should create");
let revoke = refresh_service
.revoke_session_by_user_and_session(
RevokeRefreshSessionByUserInput {
user_id: user.id.clone(),
session_id: target.session.session_id.clone(),
},
now + Duration::minutes(1),
)
.expect("target session should revoke");
assert!(revoke.revoked);
assert_eq!(revoke.session_id, target.session.session_id);
assert!(
!refresh_service
.is_session_active_for_user(
&user.id,
&target.session.session_id,
now + Duration::minutes(2)
)
.expect("target active check should succeed")
);
assert!(
refresh_service
.is_session_active_for_user(
&user.id,
&current.session.session_id,
now + Duration::minutes(2)
)
.expect("current active check should succeed")
);
assert_eq!(
store
.find_by_user_id(&user.id)
.expect("user lookup should succeed")
.expect("user should exist")
.user
.token_version,
user.token_version
);
let refresh_error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: first_token_hash,
next_refresh_token_hash: hash_refresh_session_token("revoke-target-next"),
},
now + Duration::minutes(2),
)
.expect_err("revoked target should not rotate");
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
}
#[tokio::test]
async fn logout_current_session_uses_session_id_when_refresh_cookie_missing() {
let store = build_store();
let user = create_phone_login_user(store.clone(), "13800138029").await;
let refresh_service = build_refresh_service(store.clone());
let user_service = build_user_service(store);
let now = OffsetDateTime::now_utc();
let refresh_token_hash = hash_refresh_session_token("logout-sid-token");
let session = 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(),
},
now,
)
.expect("session should create");
let result = user_service
.logout_current_session(
LogoutCurrentSessionInput {
user_id: user.id.clone(),
refresh_token_hash: None,
session_id: Some(session.session.session_id.clone()),
},
now + Duration::minutes(1),
)
.expect("logout should succeed");
assert_eq!(result.user.token_version, user.token_version);
assert!(
!refresh_service
.is_session_active_for_user(
&user.id,
&session.session.session_id,
now + Duration::minutes(2)
)
.expect("session active check should succeed")
);
let refresh_error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash,
next_refresh_token_hash: hash_refresh_session_token("logout-sid-next"),
},
now + Duration::minutes(2),
)
.expect_err("sid-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 stale_refresh_sessions_are_pruned_from_both_indexes() {
let store = build_store();
let refresh_service = build_refresh_service(store.clone());
let user = create_phone_login_user(store.clone(), "13800138008").await;
let now = OffsetDateTime::now_utc();
refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("stale-revoked"),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
now - Duration::days(2),
)
.expect("stale session should create");
store
.revoke_session_by_refresh_token_hash(
&hash_refresh_session_token("stale-revoked"),
now - Duration::days(2),
)
.expect("stale session should revoke");
refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("recent-revoked"),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
now,
)
.expect("recent session should create");
store
.revoke_session_by_refresh_token_hash(
&hash_refresh_session_token("recent-revoked"),
now,
)
.expect("recent session should revoke");
let projection = store
.export_projection_view(now.unix_timestamp())
.expect("projection export should prune stale sessions");
assert_eq!(projection.refresh_sessions.len(), 1);
assert_eq!(
projection.refresh_sessions[0].refresh_token_hash,
hash_refresh_session_token("recent-revoked")
);
let stale_error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: hash_refresh_session_token("stale-revoked"),
next_refresh_token_hash: hash_refresh_session_token("stale-next"),
},
now,
)
.expect_err("pruned session should no longer be indexed");
assert_eq!(stale_error, RefreshSessionError::SessionNotFound);
}
#[test]
fn projection_restore_rejects_too_many_retained_refresh_sessions() {
let client_info_json =
serde_json::to_string(&build_client_info()).expect("client info should serialize");
let refresh_sessions = (0..=MAX_REFRESH_SESSIONS)
.map(|index| AuthStoreProjectionRefreshSession {
session_id: format!("session-{index}"),
user_id: "user_projection_cap".to_string(),
refresh_token_hash: format!("hash-{index}"),
issued_by_provider: "password".to_string(),
client_info_json: client_info_json.clone(),
expires_at: "2999-01-01T00:00:00Z".to_string(),
revoked_at: None,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
last_seen_at: "2026-01-01T00:00:00Z".to_string(),
})
.collect();
let error = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 1,
users: vec![projection_user(
"user_projection_cap",
"projection_cap",
None,
)],
identities: vec![],
refresh_sessions,
phone_codes: vec![],
wechat_states: vec![],
})
.expect_err("projection restore must enforce the refresh session cap");
assert!(error.contains("refresh session"));
assert!(error.contains(&MAX_REFRESH_SESSIONS.to_string()));
}
#[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 {
country_code: None,
pure_phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_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,
session_key: None,
},
})
.await
.expect("first wechat login should succeed");
assert!(first_wechat.created);
assert_eq!(
first_wechat.user.binding_status,
AuthBindingStatus::PendingBindPhone
);
assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first");
assert_eq!(
first_wechat.user.wechat_display_name.as_deref(),
Some("微信旅人甲")
);
assert!(first_wechat.user.id.starts_with("user_"));
assert!(!first_wechat.user.id.ends_with("00000001"));
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,
session_key: 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);
assert_eq!(second_wechat.user.username, first_wechat.user.username);
assert_eq!(
second_wechat.user.wechat_display_name.as_deref(),
Some("微信旅人乙")
);
}
#[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 {
country_code: None,
pure_phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone login code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_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,
session_key: None,
},
})
.await
.expect("wechat login should succeed")
.user;
assert_eq!(
wechat_user.binding_status,
AuthBindingStatus::PendingBindPhone
);
assert_eq!(
wechat_user.wechat_display_name.as_deref(),
Some("待绑定微信用户")
);
assert_ne!(wechat_user.id, phone_user.id);
phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_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(),
country_code: None,
pure_phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
wechat_display_name: None,
},
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);
assert_eq!(
merged.user.wechat_display_name.as_deref(),
Some("待绑定微信用户")
);
assert_eq!(merged.user.display_name, "待绑定微信用户");
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,
session_key: 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);
assert_eq!(
reused_wechat_user.user.wechat_display_name.as_deref(),
Some("已归并微信用户")
);
assert_eq!(reused_wechat_user.user.display_name, "已归并微信用户");
}
#[tokio::test]
async fn bind_wechat_verified_phone_rejects_foreign_country_before_account_merge() {
let store = build_store();
let phone_service = build_phone_service(store.clone());
let wechat_user = WechatAuthService::new(store.clone())
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-foreign-phone".to_string(),
provider_union_id: None,
display_name: Some("境外手机号用户".to_string()),
avatar_url: None,
session_key: None,
},
})
.await
.expect("wechat login should succeed")
.user;
let error = phone_service
.bind_wechat_verified_phone(BindWechatVerifiedPhoneInput {
user_id: wechat_user.id.clone(),
country_code: "1".to_string(),
pure_phone_number: "12025550123".to_string(),
wechat_display_name: None,
})
.await
.expect_err("foreign country code must fail before account merge");
assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode);
let unchanged_user = store
.find_by_user_id(&wechat_user.id)
.expect("user lookup should succeed")
.expect("pending wechat user should remain");
assert_eq!(
unchanged_user.user.binding_status,
AuthBindingStatus::PendingBindPhone
);
assert!(unchanged_user.user.phone_number.is_none());
}
#[tokio::test]
async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() {
let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 1,
users: vec![projection_user(
"user_existing_phone_bind",
"phone_existing_bind",
Some("+8613800138032"),
)],
identities: vec![],
refresh_sessions: vec![],
phone_codes: vec![],
wechat_states: vec![],
})
.expect("projection should restore");
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store);
let now = OffsetDateTime::now_utc();
let wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-existing-phone-bind".to_string(),
provider_union_id: Some("wx-union-existing-phone-bind".to_string()),
display_name: Some("待绑定微信用户".to_string()),
avatar_url: None,
session_key: None,
},
})
.await
.expect("wechat login should succeed")
.user;
phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138032".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now,
)
.await
.expect("bind phone code should send");
let merged = phone_service
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: wechat_user.id,
country_code: None,
pure_phone_number: "13800138032".to_string(),
verify_code: "123456".to_string(),
wechat_display_name: None,
},
now + Duration::seconds(1),
)
.await
.expect("bind phone should merge existing phone user");
assert_eq!(merged.user.id, "user_existing_phone_bind");
assert!(merged.user.wechat_bound);
assert_eq!(
wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-existing-phone-bind".to_string(),
provider_union_id: Some("wx-union-existing-phone-bind".to_string()),
display_name: Some("已归并微信用户".to_string()),
avatar_url: None,
session_key: None,
},
})
.await
.expect("wechat login should reuse merged user")
.user
.id,
"user_existing_phone_bind"
);
}
#[tokio::test]
async fn bind_wechat_phone_uses_submitted_display_name_when_identity_has_no_display_name() {
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 {
country_code: None,
pure_phone_number: "13800138031".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone login code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
country_code: None,
pure_phone_number: "13800138031".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-mini-bind".to_string(),
provider_union_id: Some("wx-union-mini-bind".to_string()),
display_name: None,
avatar_url: None,
session_key: Some("mini-session-key".to_string()),
},
})
.await
.expect("mini program wechat login should succeed")
.user;
assert_eq!(wechat_user.wechat_display_name, None);
assert_eq!(
wechat_user.wechat_account.as_deref(),
Some("wx-openid-mini-bind")
);
assert_ne!(wechat_user.id, phone_user.id);
phone_service
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: "13800138031".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(),
country_code: None,
pure_phone_number: "13800138031".to_string(),
verify_code: "123456".to_string(),
wechat_display_name: Some("补填微信昵称".to_string()),
},
now + Duration::seconds(3),
)
.await
.expect("bind phone should succeed");
assert_eq!(merged.user.id, phone_user.id);
assert!(merged.user.wechat_bound);
assert_eq!(merged.user.display_name, "补填微信昵称");
assert_eq!(
merged.user.wechat_display_name.as_deref(),
Some("补填微信昵称")
);
assert_eq!(
merged.user.wechat_account.as_deref(),
Some("wx-openid-mini-bind")
);
let restored_user = build_password_service(store)
.get_user_by_id(&phone_user.id)
.expect("user lookup should succeed")
.expect("merged user should exist")
.user;
assert_eq!(restored_user.display_name, "补填微信昵称");
assert_eq!(
restored_user.wechat_display_name.as_deref(),
Some("补填微信昵称")
);
assert_eq!(
restored_user.wechat_account.as_deref(),
Some("wx-openid-mini-bind")
);
}
}