fix(auth): send sms verify codes via aliyun
This commit is contained in:
@@ -9,6 +9,7 @@ platform-auth = { workspace = true }
|
||||
shared-kernel = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting", "parsing"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
|
||||
@@ -18,10 +18,11 @@ use std::{
|
||||
};
|
||||
|
||||
use platform_auth::{
|
||||
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password,
|
||||
SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password,
|
||||
verify_password,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_kernel::{
|
||||
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
|
||||
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
||||
@@ -77,6 +78,7 @@ struct StoredRefreshSession {
|
||||
struct StoredPhoneCode {
|
||||
phone_number: String,
|
||||
scene: PhoneAuthScene,
|
||||
verify_code_hash: String,
|
||||
expires_at: String,
|
||||
last_sent_at: String,
|
||||
failed_attempts: u32,
|
||||
@@ -117,6 +119,7 @@ pub struct AuthUserService {
|
||||
pub struct PhoneAuthService {
|
||||
store: InMemoryAuthStore,
|
||||
sms_provider: SmsAuthProvider,
|
||||
verify_code_salt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -431,6 +434,7 @@ impl PhoneAuthService {
|
||||
Self {
|
||||
store,
|
||||
sms_provider,
|
||||
verify_code_salt: new_uuid_simple_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +446,7 @@ impl PhoneAuthService {
|
||||
let scene = input.scene.clone();
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
|
||||
let verify_code = self.generate_phone_verify_code();
|
||||
info!(
|
||||
scene = scene.as_str(),
|
||||
provider = self.sms_provider.kind().as_str(),
|
||||
@@ -457,12 +462,19 @@ impl PhoneAuthService {
|
||||
let expires_at = format_rfc3339(expires_at).map_err(|message| {
|
||||
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
|
||||
})?;
|
||||
let verify_code_hash = hash_phone_verify_code(
|
||||
&self.verify_code_salt,
|
||||
&normalized_phone.e164,
|
||||
&scene,
|
||||
&verify_code,
|
||||
);
|
||||
|
||||
let provider_result = self
|
||||
.sms_provider
|
||||
.send_code(SmsSendCodeRequest {
|
||||
national_phone_number,
|
||||
scene: input.scene.as_str().to_string(),
|
||||
verify_code,
|
||||
})
|
||||
.await
|
||||
.map_err(map_sms_provider_error_to_phone_error)?;
|
||||
@@ -488,6 +500,7 @@ impl PhoneAuthService {
|
||||
StoredPhoneCode {
|
||||
phone_number: normalized_phone.e164.clone(),
|
||||
scene,
|
||||
verify_code_hash,
|
||||
expires_at,
|
||||
last_sent_at: format_rfc3339(now).map_err(|message| {
|
||||
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
|
||||
@@ -516,28 +529,12 @@ impl PhoneAuthService {
|
||||
) -> 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(
|
||||
let provider_out_id = self.verify_phone_code(
|
||||
&normalized_phone.e164,
|
||||
&PhoneAuthScene::Login,
|
||||
&input.verify_code,
|
||||
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
|
||||
@@ -582,30 +579,12 @@ impl PhoneAuthService {
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
verify_sms_code_format(&input.verify_code)?;
|
||||
validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?;
|
||||
let provider_out_id = self.store.assert_phone_code_active(
|
||||
let provider_out_id = self.verify_phone_code(
|
||||
&normalized_phone.e164,
|
||||
&PhoneAuthScene::ResetPassword,
|
||||
&input.verify_code,
|
||||
now,
|
||||
)?;
|
||||
match self
|
||||
.sms_provider
|
||||
.verify_code(SmsVerifyCodeRequest {
|
||||
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
||||
verify_code: input.verify_code.trim().to_string(),
|
||||
provider_out_id: provider_out_id.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => self.store.consume_phone_code_success(
|
||||
&normalized_phone.e164,
|
||||
&PhoneAuthScene::ResetPassword,
|
||||
)?,
|
||||
Err(SmsProviderError::InvalidVerifyCode) => self.store.consume_phone_code_failure(
|
||||
&normalized_phone.e164,
|
||||
&PhoneAuthScene::ResetPassword,
|
||||
)?,
|
||||
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
|
||||
}
|
||||
|
||||
self.store
|
||||
.find_by_phone_number(&normalized_phone.e164)?
|
||||
@@ -632,28 +611,12 @@ impl PhoneAuthService {
|
||||
) -> 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(
|
||||
self.verify_phone_code(
|
||||
&normalized_phone.e164,
|
||||
&PhoneAuthScene::BindPhone,
|
||||
&input.verify_code,
|
||||
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
|
||||
@@ -677,6 +640,35 @@ impl PhoneAuthService {
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_phone_code(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
scene: &PhoneAuthScene,
|
||||
verify_code: &str,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<Option<String>, PhoneAuthError> {
|
||||
let stored = self.store.get_active_phone_code(phone_number, scene, now)?;
|
||||
let expected_hash =
|
||||
hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code);
|
||||
if stored.verify_code_hash != expected_hash {
|
||||
self.store.consume_phone_code_failure(phone_number, scene)?;
|
||||
return Err(PhoneAuthError::InvalidVerifyCode);
|
||||
}
|
||||
self.store.consume_phone_code_success(phone_number, scene)?;
|
||||
Ok(stored.provider_out_id)
|
||||
}
|
||||
|
||||
fn generate_phone_verify_code(&self) -> String {
|
||||
match self.sms_provider.kind() {
|
||||
SmsAuthProviderKind::Mock => self
|
||||
.sms_provider
|
||||
.mock_verify_code()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "123456".to_string()),
|
||||
SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bind_wechat_verified_phone(
|
||||
&self,
|
||||
input: BindWechatVerifiedPhoneInput,
|
||||
@@ -1518,12 +1510,12 @@ impl InMemoryAuthStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_phone_code_active(
|
||||
fn get_active_phone_code(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
scene: &PhoneAuthScene,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<Option<String>, PhoneAuthError> {
|
||||
) -> Result<StoredPhoneCode, PhoneAuthError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -1543,7 +1535,7 @@ impl InMemoryAuthStore {
|
||||
state.phone_codes_by_key.remove(&key);
|
||||
return Err(PhoneAuthError::VerifyCodeExpired);
|
||||
}
|
||||
Ok(stored.provider_out_id)
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
fn consume_phone_code_success(
|
||||
@@ -2065,7 +2057,6 @@ impl InMemoryAuthStore {
|
||||
|
||||
fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError {
|
||||
match error {
|
||||
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
|
||||
SmsProviderError::InvalidConfig(message) => {
|
||||
PhoneAuthError::SmsProviderInvalidConfig(message)
|
||||
}
|
||||
@@ -2139,6 +2130,36 @@ fn build_random_password_seed() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_random_phone_verify_code() -> String {
|
||||
let digest = Sha256::digest(new_uuid_simple_string().as_bytes());
|
||||
let mut digits = digest
|
||||
.iter()
|
||||
.take(SMS_CODE_LENGTH)
|
||||
.map(|byte| char::from(b'0' + (*byte % 10)))
|
||||
.collect::<String>();
|
||||
while digits.len() < SMS_CODE_LENGTH {
|
||||
digits.push('0');
|
||||
}
|
||||
digits
|
||||
}
|
||||
|
||||
fn hash_phone_verify_code(
|
||||
salt: &str,
|
||||
phone_number: &str,
|
||||
scene: &PhoneAuthScene,
|
||||
verify_code: &str,
|
||||
) -> String {
|
||||
let content = format!(
|
||||
"{}:{}:{}:{}",
|
||||
salt,
|
||||
phone_number.trim(),
|
||||
scene.as_str(),
|
||||
verify_code.trim()
|
||||
);
|
||||
let digest = Sha256::digest(content.as_bytes());
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
|
||||
format_shared_rfc3339(value)
|
||||
}
|
||||
@@ -2655,6 +2676,14 @@ mod tests {
|
||||
assert!(bind_result.await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_phone_verify_code_is_six_digits() {
|
||||
let code = generate_random_phone_verify_code();
|
||||
|
||||
assert_eq!(code.len(), SMS_CODE_LENGTH);
|
||||
assert!(code.chars().all(|character| character.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phone_login_expires_code_after_too_many_wrong_attempts() {
|
||||
let service = build_phone_service(build_store());
|
||||
|
||||
Reference in New Issue
Block a user