feat(server-rs): 接入真实短信验证码链路
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user