feat(server-rs): 接入真实短信验证码链路

This commit is contained in:
2026-04-23 00:09:51 +08:00
parent 1223f597d2
commit 9cb996b80e
11 changed files with 1274 additions and 40 deletions

View File

@@ -5,7 +5,10 @@ use std::{
sync::{Arc, Mutex},
};
use platform_auth::{hash_password, verify_password};
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,
@@ -17,7 +20,6 @@ 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_MOCK_VERIFY_CODE: &str = "123456";
const SMS_CODE_TTL_MINUTES: i64 = 5;
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
@@ -332,10 +334,10 @@ struct StoredRefreshSession {
struct StoredPhoneCode {
phone_number: String,
scene: PhoneAuthScene,
verify_code: String,
expires_at: String,
last_sent_at: String,
failed_attempts: u32,
provider_out_id: Option<String>,
}
#[derive(Clone, Debug)]
@@ -371,6 +373,7 @@ pub struct AuthUserService {
#[derive(Clone, Debug)]
pub struct PhoneAuthService {
store: InMemoryAuthStore,
sms_provider: SmsAuthProvider,
}
#[derive(Clone, Debug)]
@@ -562,11 +565,14 @@ impl RefreshSessionService {
}
impl PhoneAuthService {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self {
Self {
store,
sms_provider,
}
}
pub fn send_code(
pub async fn send_code(
&self,
input: SendPhoneCodeInput,
now: OffsetDateTime,
@@ -579,25 +585,33 @@ impl PhoneAuthService {
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
})?;
// 当前阶段先冻结 mock 短信行为,只记录验证码快照,不接真实短信供应商。
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,
verify_code: SMS_MOCK_VERIFY_CODE.to_string(),
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: SMS_CODE_COOLDOWN_SECONDS,
expires_in_seconds: (SMS_CODE_TTL_MINUTES * 60) as u64,
provider_request_id: None,
cooldown_seconds: provider_result.cooldown_seconds,
expires_in_seconds: provider_result.expires_in_seconds,
provider_request_id: provider_result.provider_request_id,
})
}
@@ -608,12 +622,28 @@ impl PhoneAuthService {
) -> Result<PhoneLoginResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?;
self.store.consume_phone_code(
let provider_out_id = self.store.assert_phone_code_active(
&normalized_phone.e164,
&PhoneAuthScene::Login,
input.verify_code.trim(),
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
@@ -651,12 +681,28 @@ impl PhoneAuthService {
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?;
self.store.consume_phone_code(
let provider_out_id = self.store.assert_phone_code_active(
&normalized_phone.e164,
&PhoneAuthScene::BindPhone,
input.verify_code.trim(),
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
@@ -1187,13 +1233,12 @@ impl InMemoryAuthStore {
Ok(())
}
fn consume_phone_code(
fn assert_phone_code_active(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
verify_code: &str,
now: OffsetDateTime,
) -> Result<(), PhoneAuthError> {
) -> Result<Option<String>, PhoneAuthError> {
let mut state = self
.inner
.lock()
@@ -1213,21 +1258,47 @@ impl InMemoryAuthStore {
state.phone_codes_by_key.remove(&key);
return Err(PhoneAuthError::VerifyCodeExpired);
}
if stored.verify_code != verify_code.trim() {
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;
}
return Err(PhoneAuthError::InvalidVerifyCode);
}
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,
@@ -1689,6 +1760,15 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError
}
}
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),
@@ -1758,6 +1838,16 @@ 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_{}_{}",
@@ -1829,7 +1919,13 @@ impl WechatAuthScene {
#[cfg(test)]
mod tests {
use platform_auth::hash_refresh_session_token;
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::*;
@@ -1842,7 +1938,30 @@ mod tests {
}
fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService {
PhoneAuthService::new(store)
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 {
@@ -1963,6 +2082,7 @@ mod tests {
},
now,
)
.await
.expect("first phone code should send");
let error = service
@@ -1973,6 +2093,7 @@ mod tests {
},
now + Duration::seconds(10),
)
.await
.expect_err("same scene send should be cooled down");
match error {
@@ -1996,6 +2117,7 @@ mod tests {
},
now,
)
.await
.expect("login scene code should send");
let bind_result = service.send_code(
SendPhoneCodeInput {
@@ -2005,7 +2127,7 @@ mod tests {
now + Duration::seconds(1),
);
assert!(bind_result.is_ok());
assert!(bind_result.await.is_ok());
}
#[tokio::test]
@@ -2021,6 +2143,7 @@ mod tests {
},
now,
)
.await
.expect("phone code should send");
for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS {
@@ -2053,7 +2176,7 @@ mod tests {
.login(
PhoneLoginInput {
phone_number: "13800138000".to_string(),
verify_code: SMS_MOCK_VERIFY_CODE.to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)),
)
@@ -2069,12 +2192,13 @@ mod tests {
},
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: SMS_MOCK_VERIFY_CODE.to_string(),
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
},
now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)),
)
@@ -2370,7 +2494,7 @@ mod tests {
#[tokio::test]
async fn wechat_login_hits_existing_user_by_union_id_before_openid() {
let store = build_store();
let phone_service = PhoneAuthService::new(store.clone());
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store);
let now = OffsetDateTime::now_utc();
@@ -2382,6 +2506,7 @@ mod tests {
},
now,
)
.await
.expect("phone code should send");
let phone_user = phone_service
.login(
@@ -2434,7 +2559,7 @@ mod tests {
#[tokio::test]
async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() {
let store = build_store();
let phone_service = PhoneAuthService::new(store.clone());
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store.clone());
let now = OffsetDateTime::now_utc();
@@ -2446,6 +2571,7 @@ mod tests {
},
now,
)
.await
.expect("phone login code should send");
let phone_user = phone_service
.login(
@@ -2486,6 +2612,7 @@ mod tests {
},
now + Duration::seconds(2),
)
.await
.expect("bind phone code should send");
let merged = phone_service
.bind_wechat_phone(