Files
Genarrative/server-rs/crates/module-auth/src/lib.rs

2650 lines
89 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.
use std::{
collections::HashMap,
error::Error,
fmt,
sync::{Arc, Mutex},
};
use platform_auth::{
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password,
verify_password,
};
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};
const USERNAME_MIN_LENGTH: usize = 3;
const USERNAME_MAX_LENGTH: usize = 24;
const PASSWORD_MIN_LENGTH: usize = 6;
const PASSWORD_MAX_LENGTH: usize = 128;
const SMS_CODE_LENGTH: usize = 6;
const SMS_CODE_TTL_MINUTES: i64 = 5;
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthLoginMethod {
Password,
Phone,
Wechat,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuthBindingStatus {
Active,
PendingBindPhone,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthUser {
pub id: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub username: String,
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthScene {
Login,
BindPhone,
ChangePhone,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneNumberSnapshot {
pub e164: String,
pub masked_national_number: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,
pub scene: PhoneAuthScene,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeResult {
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginInput {
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityProfile {
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginInput {
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveWechatLoginResult {
pub user: AuthUser,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthScene {
Desktop,
WechatInApp,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateInput {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatAuthStateRecord {
pub wechat_state_id: String,
pub state_token: String,
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsumeWechatAuthStateResult {
pub state: WechatAuthStateRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneInput {
pub user_id: String,
pub phone_number: String,
pub verify_code: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionInput {
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefreshSessionClientInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_instance_id: Option<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefreshSessionRecord {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub client_info: RefreshSessionClientInfo,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionResult {
pub session: RefreshSessionRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionInput {
pub refresh_token_hash: String,
pub next_refresh_token_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionResult {
pub session: RefreshSessionRecord,
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListActiveRefreshSessionsResult {
pub sessions: Vec<RefreshSessionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionInput {
pub user_id: String,
pub refresh_token_hash: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutCurrentSessionResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsInput {
pub user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogoutAllSessionsResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PasswordEntryError {
InvalidUsername,
InvalidPasswordLength,
InvalidCredentials,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PhoneAuthError {
InvalidPhoneNumber,
InvalidVerifyCode,
VerifyCodeNotFound,
VerifyCodeExpired,
SendCoolingDown { retry_after_seconds: u64 },
VerifyAttemptsExceeded,
UserNotFound,
UserStateMismatch,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WechatAuthError {
MissingProfile,
StateNotFound,
StateExpired,
StateConsumed,
UserNotFound,
MissingWechatIdentity,
Store(String),
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshSessionError {
MissingToken,
SessionNotFound,
SessionExpired,
UserNotFound,
Store(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogoutError {
UserNotFound,
Store(String),
}
#[derive(Clone, Debug)]
pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>,
}
#[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)]
struct StoredPasswordUser {
user: AuthUser,
password_hash: String,
phone_number: Option<String>,
}
#[derive(Clone, Debug)]
struct StoredRefreshSession {
session: RefreshSessionRecord,
}
#[derive(Clone, Debug)]
struct StoredPhoneCode {
phone_number: String,
scene: PhoneAuthScene,
expires_at: String,
last_sent_at: String,
failed_attempts: u32,
provider_out_id: Option<String>,
}
#[derive(Clone, Debug)]
struct StoredWechatAuthState {
state: WechatAuthStateRecord,
}
#[derive(Clone, Debug)]
struct StoredWechatIdentity {
user_id: String,
provider_uid: String,
provider_union_id: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct PasswordEntryService {
store: InMemoryAuthStore,
}
#[derive(Clone, Debug)]
pub struct RefreshSessionService {
store: InMemoryAuthStore,
refresh_session_ttl_days: u32,
}
#[derive(Clone, Debug)]
pub struct AuthUserService {
store: InMemoryAuthStore,
}
#[derive(Clone, Debug)]
pub struct PhoneAuthService {
store: InMemoryAuthStore,
sms_provider: SmsAuthProvider,
}
#[derive(Clone, Debug)]
pub struct WechatAuthStateService {
store: InMemoryAuthStore,
state_ttl_minutes: u32,
}
#[derive(Clone, Debug)]
pub struct WechatAuthService {
store: InMemoryAuthStore,
}
impl PasswordEntryService {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
}
pub async fn execute(
&self,
input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> {
let username = normalize_username(&input.username)?;
validate_password(&input.password)?;
if let Some(existing_user) = self.store.find_by_username(&username)? {
let is_valid = verify_password(&existing_user.password_hash, &input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
return Ok(PasswordEntryResult {
user: existing_user.user,
created: false,
});
}
let password_hash = hash_password(&input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
match self.store.create_user(username.clone(), password_hash) {
Ok(user) => Ok(PasswordEntryResult {
user,
created: true,
}),
Err(CreateUserError::AlreadyExists) => {
let existing_user = self.store.find_by_username(&username)?.ok_or_else(|| {
PasswordEntryError::Store("唯一键冲突后未能重新读取账号".to_string())
})?;
let is_valid = verify_password(&existing_user.password_hash, &input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
if !is_valid {
return Err(PasswordEntryError::InvalidCredentials);
}
Ok(PasswordEntryResult {
user: existing_user.user,
created: false,
})
}
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
}
}
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 }))
}
}
impl RefreshSessionService {
pub fn new(store: InMemoryAuthStore, refresh_session_ttl_days: u32) -> Self {
Self {
store,
refresh_session_ttl_days,
}
}
pub fn create_session(
&self,
input: CreateRefreshSessionInput,
now: OffsetDateTime,
) -> Result<CreateRefreshSessionResult, RefreshSessionError> {
self.store
.find_by_user_id(&input.user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let session_id = build_prefixed_uuid_id("usess_");
let expires_at = now
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
.ok_or_else(|| {
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
})?;
let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?;
let expires_at_iso = format_rfc3339_with_context(expires_at, "refresh session 过期时间")?;
let session = RefreshSessionRecord {
session_id,
user_id: input.user_id,
refresh_token_hash: input.refresh_token_hash,
issued_by_provider: input.issued_by_provider,
client_info: input.client_info,
expires_at: expires_at_iso,
revoked_at: None,
created_at: now_iso.clone(),
updated_at: now_iso.clone(),
last_seen_at: now_iso,
};
self.store.insert_session(session.clone())?;
Ok(CreateRefreshSessionResult { session })
}
pub fn rotate_session(
&self,
input: RotateRefreshSessionInput,
now: OffsetDateTime,
) -> Result<RotateRefreshSessionResult, RefreshSessionError> {
let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else {
return Err(RefreshSessionError::MissingToken);
};
let session = self
.store
.find_session_by_refresh_token_hash(&refresh_token_hash)?
.ok_or(RefreshSessionError::SessionNotFound)?;
if session.session.revoked_at.is_some() {
return Err(RefreshSessionError::SessionNotFound);
}
let expires_at =
parse_rfc3339_with_context(&session.session.expires_at, "refresh session 过期时间")?;
if expires_at <= now {
return Err(RefreshSessionError::SessionExpired);
}
let user = self
.store
.find_by_user_id(&session.session.user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let next_expires_at = now
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
.ok_or_else(|| {
RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string())
})?;
let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?;
let next_expires_at_iso =
format_rfc3339_with_context(next_expires_at, "refresh session 过期时间")?;
let updated_session = self.store.rotate_session(
&session.session.session_id,
&session.session.refresh_token_hash,
input.next_refresh_token_hash,
next_expires_at_iso,
now_iso.clone(),
now_iso,
)?;
Ok(RotateRefreshSessionResult {
session: updated_session.session,
user: user.user,
})
}
pub fn list_active_sessions_by_user(
&self,
user_id: &str,
now: OffsetDateTime,
) -> Result<ListActiveRefreshSessionsResult, RefreshSessionError> {
self.store
.find_by_user_id(user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let sessions = self.store.list_active_sessions_by_user(user_id, now)?;
Ok(ListActiveRefreshSessionsResult { sessions })
}
}
impl PhoneAuthService {
pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self {
Self {
store,
sms_provider,
}
}
pub async fn send_code(
&self,
input: SendPhoneCodeInput,
now: OffsetDateTime,
) -> Result<SendPhoneCodeResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
let expires_at = now
.checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES))
.ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?;
let expires_at = format_rfc3339(expires_at).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
})?;
let provider_result = self
.sms_provider
.send_code(SmsSendCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
scene: input.scene.as_str().to_string(),
})
.await
.map_err(map_sms_provider_error_to_phone_error)?;
self.store.upsert_phone_code(
StoredPhoneCode {
phone_number: normalized_phone.e164.clone(),
scene: input.scene,
expires_at,
last_sent_at: format_rfc3339(now).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
})?,
failed_attempts: 0,
provider_out_id: provider_result.provider_out_id.clone(),
},
now,
)?;
Ok(SendPhoneCodeResult {
cooldown_seconds: provider_result.cooldown_seconds,
expires_in_seconds: provider_result.expires_in_seconds,
provider_request_id: provider_result.provider_request_id,
})
}
pub async fn login(
&self,
input: PhoneLoginInput,
now: OffsetDateTime,
) -> Result<PhoneLoginResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.store.assert_phone_code_active(
&normalized_phone.e164,
&PhoneAuthScene::Login,
now,
)?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id: provider_out_id.clone(),
})
.await
{
Ok(()) => self
.store
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::Login)?,
Err(SmsProviderError::InvalidVerifyCode) => self
.store
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::Login)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
if let Some(user) = self
.store
.find_by_phone_number(&normalized_phone.e164)?
.map(|stored| stored.user)
{
return Ok(PhoneLoginResult {
user: AuthUser {
login_method: AuthLoginMethod::Phone,
..user
},
created: false,
});
}
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,
})
}
pub async fn bind_wechat_phone(
&self,
input: BindWechatPhoneInput,
now: OffsetDateTime,
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.store.assert_phone_code_active(
&normalized_phone.e164,
&PhoneAuthScene::BindPhone,
now,
)?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id,
})
.await
{
Ok(()) => self
.store
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
Err(SmsProviderError::InvalidVerifyCode) => self
.store
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
let current_user = self
.store
.find_by_user_id(&input.user_id)
.map_err(map_password_error_to_phone_error)?
.ok_or(PhoneAuthError::UserNotFound)?;
if current_user.user.binding_status != AuthBindingStatus::PendingBindPhone {
return Err(PhoneAuthError::UserStateMismatch);
}
if !current_user.user.wechat_bound {
return Err(PhoneAuthError::UserStateMismatch);
}
let merged_user = self
.store
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?;
Ok(BindWechatPhoneResult { user: merged_user })
}
}
impl WechatAuthStateService {
pub fn new(store: InMemoryAuthStore, state_ttl_minutes: u32) -> Self {
Self {
store,
state_ttl_minutes,
}
}
pub fn create_state(
&self,
input: CreateWechatAuthStateInput,
now: OffsetDateTime,
) -> Result<CreateWechatAuthStateResult, WechatAuthError> {
let created_at = format_rfc3339(now).map_err(|message| {
WechatAuthError::Store(format!("微信 state 时间格式化失败:{message}"))
})?;
let expires_at = now
.checked_add(Duration::minutes(i64::from(self.state_ttl_minutes)))
.ok_or_else(|| WechatAuthError::Store("微信 state 过期时间计算溢出".to_string()))?;
let expires_at = format_rfc3339(expires_at).map_err(|message| {
WechatAuthError::Store(format!("微信 state 过期时间格式化失败:{message}"))
})?;
let state = WechatAuthStateRecord {
wechat_state_id: build_prefixed_uuid_id("wxstate_"),
state_token: create_wechat_state_token(),
redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(),
scene: input.scene,
request_user_agent: normalize_optional_string(input.request_user_agent),
expires_at,
consumed_at: None,
created_at: created_at.clone(),
updated_at: created_at,
};
self.store.insert_wechat_state(state.clone())?;
Ok(CreateWechatAuthStateResult { state })
}
pub fn consume_state(
&self,
state_token: &str,
now: OffsetDateTime,
) -> Result<ConsumeWechatAuthStateResult, WechatAuthError> {
let consumed = self.store.consume_wechat_state(state_token, now)?;
Ok(ConsumeWechatAuthStateResult {
state: consumed.state,
})
}
}
impl WechatAuthService {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
}
pub async fn resolve_login(
&self,
input: ResolveWechatLoginInput,
) -> Result<ResolveWechatLoginResult, WechatAuthError> {
if input.profile.provider_uid.trim().is_empty()
&& input
.profile
.provider_union_id
.as_ref()
.is_none_or(|value| value.trim().is_empty())
{
return Err(WechatAuthError::MissingProfile);
}
if let Some(user) = self.store.find_by_wechat_identity(
input.profile.provider_uid.trim(),
input.profile.provider_union_id.as_deref(),
)? {
let refreshed_user = self
.store
.refresh_wechat_identity_profile(&user.id, input.profile)?;
return Ok(ResolveWechatLoginResult {
user: refreshed_user,
created: false,
});
}
let password_hash = hash_password(&build_random_password_seed())
.await
.map_err(|error| WechatAuthError::PasswordHash(error.to_string()))?;
let created_user = self
.store
.create_pending_wechat_user(input.profile, password_hash)?;
Ok(ResolveWechatLoginResult {
user: created_user,
created: true,
})
}
}
impl AuthUserService {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
}
pub fn get_user_by_id(&self, user_id: &str) -> Result<Option<AuthUser>, LogoutError> {
self.store
.find_by_user_id(user_id)
.map(|maybe_user| maybe_user.map(|stored| stored.user))
.map_err(map_password_error_to_logout_error)
}
pub fn logout_current_session(
&self,
input: LogoutCurrentSessionInput,
now: OffsetDateTime,
) -> Result<LogoutCurrentSessionResult, LogoutError> {
if let Some(refresh_token_hash) = input
.refresh_token_hash
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
self.store
.revoke_session_by_refresh_token_hash(refresh_token_hash, now)
.map_err(map_refresh_error_to_logout_error)?;
}
let user = self
.store
.increment_user_token_version(&input.user_id)
.map_err(map_password_error_to_logout_error)?
.ok_or(LogoutError::UserNotFound)?;
Ok(LogoutCurrentSessionResult { user })
}
// 全端登出需要先吊销该用户全部 refresh session再统一提升 token_version
// 让所有旧 access token 在下一次鉴权时立即失效。
pub fn logout_all_sessions(
&self,
input: LogoutAllSessionsInput,
now: OffsetDateTime,
) -> Result<LogoutAllSessionsResult, LogoutError> {
self.store
.revoke_all_sessions_by_user_id(&input.user_id, now)
.map_err(map_refresh_error_to_logout_error)?;
let user = self
.store
.increment_user_token_version(&input.user_id)
.map_err(map_password_error_to_logout_error)?
.ok_or(LogoutError::UserNotFound)?;
Ok(LogoutAllSessionsResult { user })
}
}
impl Default for InMemoryAuthStore {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(InMemoryAuthStoreState {
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 InMemoryAuthStore {
fn find_by_username(
&self,
username: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state.users_by_username.get(username).cloned())
}
fn find_by_user_id(
&self,
user_id: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
.cloned())
}
fn find_by_phone_number(
&self,
phone_number: &str,
) -> Result<Option<StoredPasswordUser>, PhoneAuthError> {
let state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let Some(user_id) = state.phone_to_user_id.get(phone_number) else {
return Ok(None);
};
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == *user_id)
.cloned())
}
fn create_user(
&self,
username: String,
password_hash: String,
) -> Result<AuthUser, CreateUserError> {
let mut state = self
.inner
.lock()
.map_err(|_| CreateUserError::Store("用户仓储锁已中毒".to_string()))?;
if state.users_by_username.contains_key(&username) {
return Err(CreateUserError::AlreadyExists);
}
let user_id = format!("user_{:08}", state.next_user_id);
state.next_user_id += 1;
let user = AuthUser {
id: user_id,
username: username.clone(),
display_name: username.clone(),
phone_number_masked: None,
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
};
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
phone_number: None,
},
);
Ok(user)
}
fn create_phone_user(
&self,
phone_number: PhoneNumberSnapshot,
display_name: String,
password_hash: String,
) -> Result<AuthUser, PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
if state.phone_to_user_id.contains_key(&phone_number.e164) {
return Err(PhoneAuthError::Store(
"手机号已存在,无法重复创建账号".to_string(),
));
}
let user_id = format!("user_{:08}", state.next_user_id);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let user = AuthUser {
id: user_id.clone(),
username: username.clone(),
display_name,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Phone,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
};
state
.phone_to_user_id
.insert(phone_number.e164.clone(), user_id);
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
phone_number: Some(phone_number.e164),
},
);
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 user_id = format!("user_{:08}", state.next_user_id);
state.next_user_id += 1;
let username = build_system_username("wechat", state.next_user_id);
let display_name = profile
.display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("微信旅人")
.to_string();
let user = AuthUser {
id: user_id.clone(),
username: username.clone(),
display_name,
phone_number_masked: None,
login_method: AuthLoginMethod::Wechat,
binding_status: AuthBindingStatus::PendingBindPhone,
wechat_bound: true,
token_version: 1,
};
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
phone_number: None,
},
);
let identity = StoredWechatIdentity {
user_id: user_id.clone(),
provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
provider_union_id: normalize_optional_string(profile.provider_union_id),
display_name: normalize_optional_string(profile.display_name),
avatar_url: normalize_optional_string(profile.avatar_url),
};
if let Some(provider_union_id) = identity.provider_union_id.clone() {
state
.user_id_by_provider_union_id
.insert(provider_union_id, user_id.clone());
}
state
.wechat_identity_by_provider_uid
.insert(identity.provider_uid.clone(), identity);
Ok(user)
}
fn find_by_wechat_identity(
&self,
provider_uid: &str,
provider_union_id: Option<&str>,
) -> Result<Option<AuthUser>, WechatAuthError> {
let state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
if let Some(provider_union_id) = provider_union_id
.map(str::trim)
.filter(|value| !value.is_empty())
&& let Some(user_id) = state.user_id_by_provider_union_id.get(provider_union_id)
&& let Some(stored) = state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == *user_id)
{
return Ok(Some(stored.user.clone()));
}
let Some(identity) = state
.wechat_identity_by_provider_uid
.get(provider_uid.trim())
else {
return Ok(None);
};
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == identity.user_id)
.map(|stored| stored.user.clone()))
}
fn refresh_wechat_identity_profile(
&self,
user_id: &str,
profile: WechatIdentityProfile,
) -> Result<AuthUser, WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
let next_display_name = normalize_optional_string(profile.display_name);
let next_avatar_url = normalize_optional_string(profile.avatar_url);
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
let next_provider_uid =
normalize_required_string(&profile.provider_uid).unwrap_or_default();
{
let identity = state
.wechat_identity_by_provider_uid
.remove(profile.provider_uid.trim())
.or_else(|| {
state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == user_id)
.cloned()
})
.ok_or(WechatAuthError::MissingWechatIdentity)?;
let mut identity = identity;
// 微信同一 unionid 在不同应用或不同阶段可能回传新的 openid这里要把最新 provider_uid 回写,
// 否则下一次只能按 unionid 命中,随后刷新资料时会因为旧 openid 不存在而丢失 identity。
identity.provider_uid = next_provider_uid.clone();
identity.display_name = next_display_name.clone();
identity.avatar_url = next_avatar_url;
identity.provider_union_id = next_provider_union_id.clone();
state
.wechat_identity_by_provider_uid
.insert(next_provider_uid.clone(), identity);
}
if let Some(provider_union_id) = next_provider_union_id {
state
.user_id_by_provider_union_id
.insert(provider_union_id, user_id.to_string());
}
let stored_user = state
.users_by_username
.values_mut()
.find(|stored_user| stored_user.user.id == user_id)
.ok_or(WechatAuthError::UserNotFound)?;
if let Some(display_name) = next_display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
&& stored_user.user.binding_status == AuthBindingStatus::PendingBindPhone
{
stored_user.user.display_name = display_name.to_string();
}
Ok(stored_user.user.clone())
}
fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
if state
.session_id_by_refresh_token_hash
.contains_key(&session.refresh_token_hash)
{
return Err(RefreshSessionError::Store(
"refresh token hash 已存在,无法重复创建会话".to_string(),
));
}
state.session_id_by_refresh_token_hash.insert(
session.refresh_token_hash.clone(),
session.session_id.clone(),
);
state
.sessions_by_id
.insert(session.session_id.clone(), StoredRefreshSession { session });
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 let Some(stored) = state.phone_codes_by_key.get(&key).cloned() {
let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?;
if expires_at > now {
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 Err(PhoneAuthError::SendCoolingDown {
retry_after_seconds: seconds_until(now, cooling_until),
});
}
}
}
state.phone_codes_by_key.insert(key, code);
Ok(())
}
fn assert_phone_code_active(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
now: OffsetDateTime,
) -> Result<Option<String>, PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
let stored = state
.phone_codes_by_key
.get(&key)
.cloned()
.ok_or(PhoneAuthError::VerifyCodeNotFound)?;
let expires_at = OffsetDateTime::parse(
&stored.expires_at,
&time::format_description::well_known::Rfc3339,
)
.map_err(|error| PhoneAuthError::Store(format!("短信验证码过期时间解析失败:{error}")))?;
if expires_at <= now {
state.phone_codes_by_key.remove(&key);
return Err(PhoneAuthError::VerifyCodeExpired);
}
Ok(stored.provider_out_id)
}
fn consume_phone_code_success(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
) -> Result<(), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
state.phone_codes_by_key.remove(&key);
Ok(())
}
fn consume_phone_code_failure(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
) -> Result<(), PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else {
return Err(PhoneAuthError::VerifyCodeNotFound);
};
let next_failed_attempts = stored.failed_attempts.saturating_add(1);
if next_failed_attempts >= SMS_CODE_MAX_FAILED_ATTEMPTS {
state.phone_codes_by_key.remove(&key);
return Err(PhoneAuthError::VerifyAttemptsExceeded);
}
if let Some(current) = state.phone_codes_by_key.get_mut(&key) {
current.failed_attempts = next_failed_attempts;
}
Err(PhoneAuthError::InvalidVerifyCode)
}
fn insert_wechat_state(
&self,
state_record: WechatAuthStateRecord,
) -> Result<(), WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?;
if state
.wechat_states_by_token
.contains_key(&state_record.state_token)
{
return Err(WechatAuthError::Store("微信 state 已存在".to_string()));
}
state.wechat_states_by_token.insert(
state_record.state_token.clone(),
StoredWechatAuthState {
state: state_record,
},
);
Ok(())
}
fn consume_wechat_state(
&self,
state_token: &str,
now: OffsetDateTime,
) -> Result<StoredWechatAuthState, WechatAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?;
let stored = state
.wechat_states_by_token
.get(state_token.trim())
.cloned()
.ok_or(WechatAuthError::StateNotFound)?;
if stored.state.consumed_at.is_some() {
return Err(WechatAuthError::StateConsumed);
}
let expires_at = OffsetDateTime::parse(
&stored.state.expires_at,
&time::format_description::well_known::Rfc3339,
)
.map_err(|error| WechatAuthError::Store(format!("微信 state 过期时间解析失败:{error}")))?;
if expires_at <= now {
return Err(WechatAuthError::StateExpired);
}
let now_iso = format_rfc3339(now).map_err(|message| {
WechatAuthError::Store(format!("微信 state 时间格式化失败:{message}"))
})?;
let current = state
.wechat_states_by_token
.get_mut(state_token.trim())
.ok_or(WechatAuthError::StateNotFound)?;
current.state.consumed_at = Some(now_iso.clone());
current.state.updated_at = now_iso;
Ok(current.clone())
}
fn bind_wechat_phone_to_user(
&self,
pending_user_id: &str,
phone_number: PhoneNumberSnapshot,
) -> Result<AuthUser, PhoneAuthError> {
let mut state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?;
let existing_phone_user_id = state.phone_to_user_id.get(&phone_number.e164).cloned();
if let Some(target_user_id) = existing_phone_user_id
&& target_user_id != pending_user_id
{
let pending_wechat_identity = state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == pending_user_id)
.cloned()
.ok_or(PhoneAuthError::UserStateMismatch)?;
let pending_username = state
.users_by_username
.values()
.find(|stored| stored.user.id == pending_user_id)
.map(|stored| stored.user.username.clone())
.ok_or(PhoneAuthError::UserNotFound)?;
state.users_by_username.remove(&pending_username);
state.wechat_identity_by_provider_uid.insert(
pending_wechat_identity.provider_uid.clone(),
StoredWechatIdentity {
user_id: target_user_id.clone(),
..pending_wechat_identity.clone()
},
);
if let Some(provider_union_id) = pending_wechat_identity.provider_union_id {
state
.user_id_by_provider_union_id
.insert(provider_union_id, target_user_id.clone());
}
let target_user = state
.users_by_username
.values_mut()
.find(|stored| stored.user.id == target_user_id)
.ok_or(PhoneAuthError::UserNotFound)?;
target_user.user.wechat_bound = true;
return Ok(target_user.user.clone());
}
state
.phone_to_user_id
.insert(phone_number.e164.clone(), pending_user_id.to_string());
let stored_user = state
.users_by_username
.values_mut()
.find(|stored| stored.user.id == pending_user_id)
.ok_or(PhoneAuthError::UserNotFound)?;
stored_user.user.phone_number_masked = Some(phone_number.masked_national_number.clone());
stored_user.user.binding_status = AuthBindingStatus::Active;
stored_user.user.wechat_bound = true;
stored_user.phone_number = Some(phone_number.e164);
Ok(stored_user.user.clone())
}
fn find_session_by_refresh_token_hash(
&self,
refresh_token_hash: &str,
) -> Result<Option<StoredRefreshSession>, RefreshSessionError> {
let state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
let Some(session_id) = state
.session_id_by_refresh_token_hash
.get(refresh_token_hash)
else {
return Ok(None);
};
Ok(state.sessions_by_id.get(session_id).cloned())
}
fn list_active_sessions_by_user(
&self,
user_id: &str,
now: OffsetDateTime,
) -> Result<Vec<RefreshSessionRecord>, RefreshSessionError> {
let state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
let now_unix = now.unix_timestamp();
let mut sessions = state
.sessions_by_id
.values()
.filter_map(|stored| {
if stored.session.user_id != user_id {
return None;
}
if stored.session.revoked_at.is_some() {
return None;
}
let expires_at = OffsetDateTime::parse(
&stored.session.expires_at,
&time::format_description::well_known::Rfc3339,
)
.ok()?;
if expires_at.unix_timestamp() <= now_unix {
return None;
}
Some(stored.session.clone())
})
.collect::<Vec<_>>();
sessions.sort_by(|left, right| {
right
.last_seen_at
.cmp(&left.last_seen_at)
.then_with(|| right.created_at.cmp(&left.created_at))
});
Ok(sessions)
}
fn rotate_session(
&self,
session_id: &str,
previous_refresh_token_hash: &str,
next_refresh_token_hash: String,
next_expires_at: String,
updated_at: String,
last_seen_at: String,
) -> Result<StoredRefreshSession, RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
if state
.session_id_by_refresh_token_hash
.contains_key(&next_refresh_token_hash)
{
return Err(RefreshSessionError::Store(
"新 refresh token hash 已存在,无法轮换".to_string(),
));
}
let current_refresh_token_hash = state
.sessions_by_id
.get(session_id)
.ok_or(RefreshSessionError::SessionNotFound)?
.session
.refresh_token_hash
.clone();
if current_refresh_token_hash != previous_refresh_token_hash {
return Err(RefreshSessionError::SessionNotFound);
}
state
.session_id_by_refresh_token_hash
.remove(previous_refresh_token_hash);
let stored = state
.sessions_by_id
.get_mut(session_id)
.ok_or(RefreshSessionError::SessionNotFound)?;
stored.session.refresh_token_hash = next_refresh_token_hash.clone();
stored.session.expires_at = next_expires_at;
stored.session.updated_at = updated_at;
stored.session.last_seen_at = last_seen_at;
let updated_session = stored.clone();
state.session_id_by_refresh_token_hash.insert(
next_refresh_token_hash,
updated_session.session.session_id.clone(),
);
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;
Ok(())
}
fn revoke_all_sessions_by_user_id(
&self,
user_id: &str,
now: OffsetDateTime,
) -> Result<(), RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
let now_iso = now
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}"))
})?;
for stored in state.sessions_by_id.values_mut() {
if stored.session.user_id != user_id {
continue;
}
if stored.session.revoked_at.is_some() {
continue;
}
stored.session.revoked_at = Some(now_iso.clone());
stored.session.updated_at = now_iso.clone();
}
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;
return Ok(Some(stored_user.user.clone()));
}
Ok(None)
}
}
#[derive(Debug, PartialEq, Eq)]
enum CreateUserError {
AlreadyExists,
Store(String),
}
impl AuthLoginMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Password => "password",
Self::Phone => "phone",
Self::Wechat => "wechat",
}
}
}
impl AuthBindingStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::PendingBindPhone => "pending_bind_phone",
}
}
}
impl fmt::Display for PasswordEntryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PasswordEntryError {}
impl fmt::Display for PhoneAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidVerifyCode => f.write_str("验证码错误"),
Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"),
Self::VerifyCodeExpired => f.write_str("验证码已过期"),
Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"),
Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for PhoneAuthError {}
impl fmt::Display for WechatAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingProfile => f.write_str("缺少微信身份信息"),
Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"),
Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"),
Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
}
impl Error for WechatAuthError {}
impl fmt::Display for RefreshSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingToken => f.write_str("缺少刷新会话"),
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
f.write_str("当前登录态已失效,请重新登录")
}
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for RefreshSessionError {}
impl fmt::Display for LogoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UserNotFound => f.write_str("当前登录态已失效,请重新登录"),
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for LogoutError {}
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
}
}
}
fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError {
match error {
PasswordEntryError::Store(message) => PhoneAuthError::Store(message),
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidCredentials => {
PhoneAuthError::Store("用户仓储读取失败".to_string())
}
}
}
fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError {
match error {
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
}
fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError {
match error {
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
SmsProviderError::InvalidConfig(message) | SmsProviderError::Upstream(message) => {
PhoneAuthError::Store(message)
}
}
}
fn map_refresh_error_to_logout_error(error: RefreshSessionError) -> LogoutError {
match error {
RefreshSessionError::Store(message) => LogoutError::Store(message),
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => LogoutError::Store("会话吊销失败".to_string()),
}
}
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
let username = normalize_required_string(raw_username).unwrap_or_default();
let valid_length =
(USERNAME_MIN_LENGTH..=USERNAME_MAX_LENGTH).contains(&username.chars().count());
let valid_chars = username
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_');
if !valid_length || !valid_chars {
return Err(PasswordEntryError::InvalidUsername);
}
Ok(username)
}
fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
let length = password.chars().count();
if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) {
return Err(PasswordEntryError::InvalidPasswordLength);
}
Ok(())
}
fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
let verify_code = verify_code.trim();
if verify_code.len() != SMS_CODE_LENGTH
|| !verify_code
.chars()
.all(|character| character.is_ascii_digit())
{
return Err(PhoneAuthError::InvalidVerifyCode);
}
Ok(())
}
fn normalize_mainland_china_phone_number(
raw_phone_number: &str,
) -> Result<PhoneNumberSnapshot, PhoneAuthError> {
let digits = raw_phone_number
.trim()
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 11 || !digits.starts_with('1') {
return Err(PhoneAuthError::InvalidPhoneNumber);
}
Ok(PhoneNumberSnapshot {
e164: format!("+86{digits}"),
masked_national_number: mask_phone_number(&digits),
})
}
fn mask_phone_number(phone_number: &str) -> String {
format!("{}****{}", &phone_number[..3], &phone_number[7..11])
}
fn build_national_phone_number(e164_phone_number: &str) -> Result<String, PhoneAuthError> {
let digits = e164_phone_number.trim().trim_start_matches('+');
if let Some(national) = digits.strip_prefix("86")
&& national.len() == 11
{
return Ok(national.to_string());
}
Err(PhoneAuthError::InvalidPhoneNumber)
}
fn build_random_password_seed() -> String {
format!(
"seed_{}_{}",
new_uuid_simple_string(),
new_uuid_simple_string()
)
}
fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value)
}
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 build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String {
format!("{}:{}", phone_number.trim(), scene.as_str())
}
fn create_wechat_state_token() -> String {
new_uuid_simple_string()
}
fn format_rfc3339_with_context(
value: OffsetDateTime,
field_label: &str,
) -> Result<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}")))
}
impl PhoneAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::BindPhone => "bind_phone",
Self::ChangePhone => "change_phone",
}
}
}
impl WechatAuthScene {
pub fn as_str(&self) -> &'static str {
match self {
Self::Desktop => "desktop",
Self::WechatInApp => "wechat_in_app",
}
}
}
#[cfg(test)]
mod tests {
use platform_auth::{
DEFAULT_SMS_CASE_AUTH_POLICY, DEFAULT_SMS_CODE_LENGTH, DEFAULT_SMS_CODE_TYPE,
DEFAULT_SMS_COUNTRY_CODE, DEFAULT_SMS_DUPLICATE_POLICY, DEFAULT_SMS_ENDPOINT,
DEFAULT_SMS_INTERVAL_SECONDS, DEFAULT_SMS_MOCK_VERIFY_CODE, DEFAULT_SMS_TEMPLATE_PARAM_KEY,
DEFAULT_SMS_VALID_TIME_SECONDS, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind,
hash_refresh_session_token,
};
use super::*;
fn build_store() -> InMemoryAuthStore {
InMemoryAuthStore::default()
}
fn build_password_service(store: InMemoryAuthStore) -> PasswordEntryService {
PasswordEntryService::new(store)
}
fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService {
let sms_provider = SmsAuthProvider::new(
SmsAuthConfig::new(
SmsAuthProviderKind::Mock,
DEFAULT_SMS_ENDPOINT.to_string(),
None,
None,
String::new(),
String::new(),
DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(),
DEFAULT_SMS_COUNTRY_CODE.to_string(),
None,
DEFAULT_SMS_CODE_LENGTH,
DEFAULT_SMS_CODE_TYPE,
DEFAULT_SMS_VALID_TIME_SECONDS,
DEFAULT_SMS_INTERVAL_SECONDS,
DEFAULT_SMS_DUPLICATE_POLICY,
DEFAULT_SMS_CASE_AUTH_POLICY,
false,
DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
)
.expect("mock sms config should be valid"),
)
.expect("mock sms provider should be valid");
PhoneAuthService::new(store, sms_provider)
}
fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService {
RefreshSessionService::new(store, 30)
}
fn build_user_service(store: InMemoryAuthStore) -> AuthUserService {
AuthUserService::new(store)
}
fn build_client_info() -> RefreshSessionClientInfo {
RefreshSessionClientInfo {
client_type: "web_browser".to_string(),
client_runtime: "chrome".to_string(),
client_platform: "windows".to_string(),
client_instance_id: Some("client-instance-001".to_string()),
device_fingerprint: Some("device-fingerprint-001".to_string()),
device_display_name: "Windows / Chrome".to_string(),
mini_program_app_id: None,
mini_program_env: None,
user_agent: Some("Mozilla/5.0".to_string()),
ip: Some("203.0.113.10".to_string()),
}
}
#[tokio::test]
async fn first_password_entry_creates_user() {
let service = build_password_service(build_store());
let result = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("first login should succeed");
assert!(result.created);
assert_eq!(result.user.id, "user_00000001");
assert_eq!(result.user.username, "guest_001");
assert_eq!(result.user.display_name, "guest_001");
assert_eq!(result.user.login_method, AuthLoginMethod::Password);
assert_eq!(result.user.binding_status, AuthBindingStatus::Active);
}
#[tokio::test]
async fn repeated_password_entry_reuses_same_user() {
let store = build_store();
let service = build_password_service(store);
let first = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("first login should succeed");
let second = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("second login should succeed");
assert!(first.created);
assert!(!second.created);
assert_eq!(second.user.id, first.user.id);
}
#[tokio::test]
async fn repeated_password_entry_rejects_wrong_password() {
let service = build_password_service(build_store());
service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.await
.expect("first login should succeed");
let error = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
password: "secret999".to_string(),
})
.await
.expect_err("wrong password should fail");
assert_eq!(error, PasswordEntryError::InvalidCredentials);
}
#[tokio::test]
async fn invalid_username_returns_bad_request_error() {
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
username: "坏用户名".to_string(),
password: "secret123".to_string(),
})
.await
.expect_err("invalid username should fail");
assert_eq!(error, PasswordEntryError::InvalidUsername);
}
#[tokio::test]
async fn phone_send_code_rejects_same_scene_during_cooldown() {
let service = build_phone_service(build_store());
let now = OffsetDateTime::now_utc();
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("first phone code should send");
let error = service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now + Duration::seconds(10),
)
.await
.expect_err("same scene send should be cooled down");
match error {
PhoneAuthError::SendCoolingDown {
retry_after_seconds,
} => assert!((1..=SMS_CODE_COOLDOWN_SECONDS).contains(&retry_after_seconds)),
other => panic!("unexpected phone auth error: {other:?}"),
}
}
#[tokio::test]
async fn phone_send_code_keeps_different_scenes_isolated() {
let service = build_phone_service(build_store());
let now = OffsetDateTime::now_utc();
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("login scene code should send");
let bind_result = service.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now + Duration::seconds(1),
);
assert!(bind_result.await.is_ok());
}
#[tokio::test]
async fn phone_login_expires_code_after_too_many_wrong_attempts() {
let service = build_phone_service(build_store());
let now = OffsetDateTime::now_utc();
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS {
let error = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "000000".to_string(),
},
now + Duration::seconds(i64::from(attempt)),
)
.await
.expect_err("wrong code should fail before limit");
assert_eq!(error, PhoneAuthError::InvalidVerifyCode);
}
let exhausted_error = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "000000".to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS)),
)
.await
.expect_err("fifth wrong code should exhaust the snapshot");
assert_eq!(exhausted_error, PhoneAuthError::VerifyAttemptsExceeded);
let missing_error = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)),
)
.await
.expect_err("exhausted snapshot should be deleted");
assert_eq!(missing_error, PhoneAuthError::VerifyCodeNotFound);
service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)),
)
.await
.expect("deleted snapshot should allow a new code");
let login = service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)),
)
.await
.expect("new code should login");
assert!(login.created);
assert_eq!(login.user.login_method, AuthLoginMethod::Phone);
}
#[tokio::test]
async fn refresh_session_creation_and_rotation_keep_same_session_id() {
let store = build_store();
let password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store);
let user = password_service
.execute(PasswordEntryInput {
username: "guest_002".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed")
.user;
let now = OffsetDateTime::now_utc();
let first_token_hash = hash_refresh_session_token("refresh-token-01");
let created = refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: first_token_hash.clone(),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
now,
)
.expect("session should create");
let rotated = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: first_token_hash,
next_refresh_token_hash: hash_refresh_session_token("refresh-token-02"),
},
now + Duration::minutes(10),
)
.expect("session should rotate");
assert_eq!(rotated.user.id, user.id);
assert_eq!(rotated.session.session_id, created.session.session_id);
assert_ne!(
rotated.session.refresh_token_hash,
created.session.refresh_token_hash
);
}
#[tokio::test]
async fn refresh_session_rejects_unknown_token_hash() {
let store = build_store();
let refresh_service = build_refresh_service(store);
let error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: hash_refresh_session_token("missing"),
next_refresh_token_hash: hash_refresh_session_token("next"),
},
OffsetDateTime::now_utc(),
)
.expect_err("unknown token should fail");
assert_eq!(error, RefreshSessionError::SessionNotFound);
}
#[tokio::test]
async fn logout_current_session_revokes_session_and_increments_token_version() {
let store = build_store();
let password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store.clone());
let user_service = build_user_service(store);
let user = password_service
.execute(PasswordEntryInput {
username: "guest_logout".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed")
.user;
let refresh_token_hash = hash_refresh_session_token("logout-token");
refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: refresh_token_hash.clone(),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
OffsetDateTime::now_utc(),
)
.expect("session should create");
let result = user_service
.logout_current_session(
LogoutCurrentSessionInput {
user_id: user.id.clone(),
refresh_token_hash: Some(refresh_token_hash.clone()),
},
OffsetDateTime::now_utc(),
)
.expect("logout should succeed");
assert_eq!(result.user.token_version, 2);
let refresh_error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash,
next_refresh_token_hash: hash_refresh_session_token("logout-token-next"),
},
OffsetDateTime::now_utc(),
)
.expect_err("revoked session should fail");
assert_eq!(refresh_error, RefreshSessionError::SessionNotFound);
}
#[tokio::test]
async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() {
let store = build_store();
let password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store.clone());
let user_service = build_user_service(store);
let user = password_service
.execute(PasswordEntryInput {
username: "guest_logout_all".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed")
.user;
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 password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store.clone());
let user = password_service
.execute(PasswordEntryInput {
username: "guest_sessions".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed")
.user;
let now = OffsetDateTime::now_utc();
let active_session = refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("sessions-active"),
issued_by_provider: AuthLoginMethod::Password,
client_info: build_client_info(),
},
now,
)
.expect("active session should create");
refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("sessions-revoked"),
issued_by_provider: AuthLoginMethod::Password,
client_info: RefreshSessionClientInfo {
client_runtime: "edge".to_string(),
device_display_name: "Windows / Edge".to_string(),
..build_client_info()
},
},
now - Duration::minutes(5),
)
.expect("revoked session should create");
store
.revoke_session_by_refresh_token_hash(
&hash_refresh_session_token("sessions-revoked"),
now - Duration::minutes(1),
)
.expect("revoked session should revoke");
refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: hash_refresh_session_token("sessions-expired"),
issued_by_provider: AuthLoginMethod::Password,
client_info: RefreshSessionClientInfo {
client_runtime: "firefox".to_string(),
device_display_name: "Windows / Firefox".to_string(),
..build_client_info()
},
},
now - Duration::days(40),
)
.expect("expired session should create");
let listed = refresh_service
.list_active_sessions_by_user(&user.id, now)
.expect("sessions should list");
assert_eq!(listed.sessions.len(), 1);
assert_eq!(
listed.sessions[0].session_id,
active_session.session.session_id
);
assert_eq!(listed.sessions[0].client_info.client_runtime, "chrome");
assert_eq!(
listed.sessions[0].client_info.device_display_name,
"Windows / Chrome"
);
}
#[tokio::test]
async fn wechat_login_hits_existing_user_by_union_id_before_openid() {
let store = build_store();
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store);
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should succeed")
.user;
let first_wechat = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-first".to_string(),
provider_union_id: Some("wx-union-shared".to_string()),
display_name: Some("微信旅人甲".to_string()),
avatar_url: None,
},
})
.await
.expect("first wechat login should succeed");
assert!(first_wechat.created);
assert_eq!(
first_wechat.user.binding_status,
AuthBindingStatus::PendingBindPhone
);
let second_wechat = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-second".to_string(),
provider_union_id: Some("wx-union-shared".to_string()),
display_name: Some("微信旅人乙".to_string()),
avatar_url: None,
},
})
.await
.expect("second wechat login should succeed");
assert!(!second_wechat.created);
assert_eq!(second_wechat.user.id, first_wechat.user.id);
assert_ne!(second_wechat.user.id, phone_user.id);
assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat);
}
#[tokio::test]
async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() {
let store = build_store();
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store.clone());
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone login code should send");
let phone_user = phone_service
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should succeed")
.user;
let wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-bind".to_string(),
provider_union_id: Some("wx-union-bind".to_string()),
display_name: Some("待绑定微信用户".to_string()),
avatar_url: None,
},
})
.await
.expect("wechat login should succeed")
.user;
assert_eq!(
wechat_user.binding_status,
AuthBindingStatus::PendingBindPhone
);
assert_ne!(wechat_user.id, phone_user.id);
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138000".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now + Duration::seconds(2),
)
.await
.expect("bind phone code should send");
let merged = phone_service
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: wechat_user.id.clone(),
phone_number: "13800138000".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(3),
)
.await
.expect("bind phone should succeed");
assert_eq!(merged.user.id, phone_user.id);
assert_eq!(merged.user.binding_status, AuthBindingStatus::Active);
assert!(merged.user.wechat_bound);
let reused_wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-bind".to_string(),
provider_union_id: Some("wx-union-bind".to_string()),
display_name: Some("已归并微信用户".to_string()),
avatar_url: None,
},
})
.await
.expect("wechat login should reuse merged user");
assert!(!reused_wechat_user.created);
assert_eq!(reused_wechat_user.user.id, phone_user.id);
assert!(reused_wechat_user.user.wechat_bound);
}
}