feat(server-rs): 接入真实短信验证码链路
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
use std::{collections::HashSet, error::Error, fmt};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
error::Error,
|
||||
fmt,
|
||||
};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use hmac::{Hmac, Mac};
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
@@ -15,6 +24,18 @@ pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
|
||||
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
|
||||
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
|
||||
pub const DEFAULT_SMS_ENDPOINT: &str = "dypnsapi.aliyuncs.com";
|
||||
pub const DEFAULT_SMS_COUNTRY_CODE: &str = "86";
|
||||
pub const DEFAULT_SMS_TEMPLATE_PARAM_KEY: &str = "code";
|
||||
pub const DEFAULT_SMS_MOCK_VERIFY_CODE: &str = "123456";
|
||||
pub const DEFAULT_SMS_CODE_LENGTH: u8 = 6;
|
||||
pub const DEFAULT_SMS_CODE_TYPE: u8 = 1;
|
||||
pub const DEFAULT_SMS_VALID_TIME_SECONDS: u64 = 300;
|
||||
pub const DEFAULT_SMS_INTERVAL_SECONDS: u64 = 60;
|
||||
pub const DEFAULT_SMS_DUPLICATE_POLICY: u8 = 1;
|
||||
pub const DEFAULT_SMS_CASE_AUTH_POLICY: u8 = 1;
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -89,6 +110,71 @@ pub struct RefreshCookieConfig {
|
||||
refresh_session_ttl_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SmsAuthProviderKind {
|
||||
Mock,
|
||||
Aliyun,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SmsAuthConfig {
|
||||
pub provider: SmsAuthProviderKind,
|
||||
pub endpoint: String,
|
||||
pub access_key_id: Option<String>,
|
||||
pub access_key_secret: Option<String>,
|
||||
pub sign_name: String,
|
||||
pub template_code: String,
|
||||
pub template_param_key: String,
|
||||
pub country_code: String,
|
||||
pub scheme_name: Option<String>,
|
||||
pub code_length: u8,
|
||||
pub code_type: u8,
|
||||
pub valid_time_seconds: u64,
|
||||
pub interval_seconds: u64,
|
||||
pub duplicate_policy: u8,
|
||||
pub case_auth_policy: u8,
|
||||
pub return_verify_code: bool,
|
||||
pub mock_verify_code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SmsSendCodeRequest {
|
||||
pub national_phone_number: String,
|
||||
pub scene: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SmsSendCodeResult {
|
||||
pub cooldown_seconds: u64,
|
||||
pub expires_in_seconds: u64,
|
||||
pub provider_request_id: Option<String>,
|
||||
pub provider_out_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SmsVerifyCodeRequest {
|
||||
pub national_phone_number: String,
|
||||
pub verify_code: String,
|
||||
pub provider_out_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SmsAuthProvider {
|
||||
Mock(MockSmsAuthProvider),
|
||||
Aliyun(AliyunSmsAuthProvider),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MockSmsAuthProvider {
|
||||
config: SmsAuthConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AliyunSmsAuthProvider {
|
||||
client: Client,
|
||||
config: SmsAuthConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum JwtError {
|
||||
InvalidConfig(&'static str),
|
||||
@@ -108,6 +194,57 @@ pub enum PasswordHashError {
|
||||
VerifyFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SmsProviderError {
|
||||
InvalidConfig(String),
|
||||
InvalidVerifyCode,
|
||||
Upstream(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AliyunSendSmsVerifyCodeResponse {
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
request_id: Option<String>,
|
||||
#[serde(default)]
|
||||
success: Option<bool>,
|
||||
#[serde(default)]
|
||||
model: Option<AliyunSendSmsVerifyCodeModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AliyunSendSmsVerifyCodeModel {
|
||||
#[serde(default, rename = "BizId")]
|
||||
_biz_id: Option<String>,
|
||||
#[serde(default, rename = "OutId")]
|
||||
out_id: Option<String>,
|
||||
#[serde(default, rename = "RequestId")]
|
||||
request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AliyunCheckSmsVerifyCodeResponse {
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
success: Option<bool>,
|
||||
#[serde(default)]
|
||||
model: Option<AliyunCheckSmsVerifyCodeModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AliyunCheckSmsVerifyCodeModel {
|
||||
#[serde(default, rename = "OutId")]
|
||||
_out_id: Option<String>,
|
||||
#[serde(default, rename = "VerifyResult")]
|
||||
verify_result: Option<String>,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn new(
|
||||
issuer: String,
|
||||
@@ -211,6 +348,366 @@ impl RefreshCookieConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl SmsAuthProviderKind {
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"mock" => Some(Self::Mock),
|
||||
"aliyun" => Some(Self::Aliyun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SmsAuthConfig {
|
||||
pub fn new(
|
||||
provider: SmsAuthProviderKind,
|
||||
endpoint: String,
|
||||
access_key_id: Option<String>,
|
||||
access_key_secret: Option<String>,
|
||||
sign_name: String,
|
||||
template_code: String,
|
||||
template_param_key: String,
|
||||
country_code: String,
|
||||
scheme_name: Option<String>,
|
||||
code_length: u8,
|
||||
code_type: u8,
|
||||
valid_time_seconds: u64,
|
||||
interval_seconds: u64,
|
||||
duplicate_policy: u8,
|
||||
case_auth_policy: u8,
|
||||
return_verify_code: bool,
|
||||
mock_verify_code: String,
|
||||
) -> Result<Self, SmsProviderError> {
|
||||
let endpoint = normalize_required_string(&endpoint)
|
||||
.unwrap_or_else(|| DEFAULT_SMS_ENDPOINT.to_string());
|
||||
let template_param_key = normalize_required_string(&template_param_key)
|
||||
.unwrap_or_else(|| DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string());
|
||||
let country_code = normalize_required_string(&country_code)
|
||||
.unwrap_or_else(|| DEFAULT_SMS_COUNTRY_CODE.to_string());
|
||||
let scheme_name = normalize_optional_string(scheme_name);
|
||||
let mock_verify_code = normalize_required_string(&mock_verify_code)
|
||||
.unwrap_or_else(|| DEFAULT_SMS_MOCK_VERIFY_CODE.to_string());
|
||||
|
||||
if !(4..=8).contains(&code_length) {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"短信验证码长度必须在 4 到 8 之间".to_string(),
|
||||
));
|
||||
}
|
||||
if !(1..=7).contains(&code_type) {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"短信验证码类型取值非法".to_string(),
|
||||
));
|
||||
}
|
||||
if interval_seconds == 0 || valid_time_seconds == 0 {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"短信验证码有效期和发送间隔必须大于 0".to_string(),
|
||||
));
|
||||
}
|
||||
if !(1..=2).contains(&duplicate_policy) {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"短信验证码重复策略取值非法".to_string(),
|
||||
));
|
||||
}
|
||||
if !(1..=2).contains(&case_auth_policy) {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"短信验证码大小写校验策略取值非法".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
match provider {
|
||||
SmsAuthProviderKind::Mock => {}
|
||||
SmsAuthProviderKind::Aliyun => {
|
||||
if normalize_required_string(&sign_name).is_none() {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"阿里云短信签名不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if normalize_required_string(&template_code).is_none() {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"阿里云短信模板编码不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if access_key_id
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string)
|
||||
.is_none()
|
||||
|| access_key_secret
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string)
|
||||
.is_none()
|
||||
{
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"阿里云短信 AccessKey 未配置".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
provider,
|
||||
endpoint,
|
||||
access_key_id: access_key_id.and_then(|value| normalize_required_string(&value)),
|
||||
access_key_secret: access_key_secret
|
||||
.and_then(|value| normalize_required_string(&value)),
|
||||
sign_name: sign_name.trim().to_string(),
|
||||
template_code: template_code.trim().to_string(),
|
||||
template_param_key,
|
||||
country_code,
|
||||
scheme_name,
|
||||
code_length,
|
||||
code_type,
|
||||
valid_time_seconds,
|
||||
interval_seconds,
|
||||
duplicate_policy,
|
||||
case_auth_policy,
|
||||
return_verify_code,
|
||||
mock_verify_code,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SmsAuthProvider {
|
||||
pub fn new(config: SmsAuthConfig) -> Result<Self, SmsProviderError> {
|
||||
match config.provider {
|
||||
SmsAuthProviderKind::Mock => Ok(Self::Mock(MockSmsAuthProvider { config })),
|
||||
SmsAuthProviderKind::Aliyun => Ok(Self::Aliyun(AliyunSmsAuthProvider {
|
||||
client: Client::new(),
|
||||
config,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_code(
|
||||
&self,
|
||||
request: SmsSendCodeRequest,
|
||||
) -> Result<SmsSendCodeResult, SmsProviderError> {
|
||||
match self {
|
||||
Self::Mock(provider) => provider.send_code(request).await,
|
||||
Self::Aliyun(provider) => provider.send_code(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_code(
|
||||
&self,
|
||||
request: SmsVerifyCodeRequest,
|
||||
) -> Result<(), SmsProviderError> {
|
||||
match self {
|
||||
Self::Mock(provider) => provider.verify_code(request).await,
|
||||
Self::Aliyun(provider) => provider.verify_code(request).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockSmsAuthProvider {
|
||||
async fn send_code(
|
||||
&self,
|
||||
request: SmsSendCodeRequest,
|
||||
) -> Result<SmsSendCodeResult, SmsProviderError> {
|
||||
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
|
||||
|
||||
Ok(SmsSendCodeResult {
|
||||
cooldown_seconds: self.config.interval_seconds,
|
||||
expires_in_seconds: self.config.valid_time_seconds,
|
||||
provider_request_id: Some("mock-request-id".to_string()),
|
||||
provider_out_id: Some(provider_out_id),
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify_code(
|
||||
&self,
|
||||
request: SmsVerifyCodeRequest,
|
||||
) -> Result<(), SmsProviderError> {
|
||||
if request.verify_code.trim() != self.config.mock_verify_code {
|
||||
return Err(SmsProviderError::InvalidVerifyCode);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AliyunSmsAuthProvider {
|
||||
async fn send_code(
|
||||
&self,
|
||||
request: SmsSendCodeRequest,
|
||||
) -> Result<SmsSendCodeResult, SmsProviderError> {
|
||||
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
|
||||
let template_param = serde_json::json!({
|
||||
self.config.template_param_key.clone(): "##code##",
|
||||
"min": self.config.valid_time_seconds,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut query = BTreeMap::new();
|
||||
query.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
|
||||
query.insert("Format".to_string(), "json".to_string());
|
||||
query.insert("Version".to_string(), "2017-05-25".to_string());
|
||||
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
|
||||
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
|
||||
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
|
||||
query.insert("SignatureVersion".to_string(), "1.0".to_string());
|
||||
query.insert(
|
||||
"AccessKeyId".to_string(),
|
||||
self.config
|
||||
.access_key_id
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
query.insert(
|
||||
"PhoneNumber".to_string(),
|
||||
request.national_phone_number.trim().to_string(),
|
||||
);
|
||||
query.insert(
|
||||
"CountryCode".to_string(),
|
||||
self.config.country_code.clone(),
|
||||
);
|
||||
query.insert("SignName".to_string(), self.config.sign_name.clone());
|
||||
query.insert(
|
||||
"TemplateCode".to_string(),
|
||||
self.config.template_code.clone(),
|
||||
);
|
||||
query.insert("TemplateParam".to_string(), template_param);
|
||||
query.insert("CodeLength".to_string(), self.config.code_length.to_string());
|
||||
query.insert("CodeType".to_string(), self.config.code_type.to_string());
|
||||
query.insert(
|
||||
"ValidTime".to_string(),
|
||||
self.config.valid_time_seconds.to_string(),
|
||||
);
|
||||
query.insert(
|
||||
"Interval".to_string(),
|
||||
self.config.interval_seconds.to_string(),
|
||||
);
|
||||
query.insert(
|
||||
"DuplicatePolicy".to_string(),
|
||||
self.config.duplicate_policy.to_string(),
|
||||
);
|
||||
query.insert(
|
||||
"ReturnVerifyCode".to_string(),
|
||||
self.config.return_verify_code.to_string(),
|
||||
);
|
||||
query.insert("OutId".to_string(), provider_out_id.clone());
|
||||
if let Some(scheme_name) = self.config.scheme_name.clone() {
|
||||
query.insert("SchemeName".to_string(), scheme_name);
|
||||
}
|
||||
self.sign_query(&mut query)?;
|
||||
|
||||
let payload = self
|
||||
.client
|
||||
.post(build_aliyun_sms_url(&self.config.endpoint)?)
|
||||
.form(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| SmsProviderError::Upstream(format!("短信验证码发送失败:{error}")))?;
|
||||
|
||||
let body = parse_aliyun_json_response(payload, "短信验证码发送失败").await?;
|
||||
if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") {
|
||||
return Err(map_aliyun_provider_error(
|
||||
"短信验证码发送失败",
|
||||
body.message,
|
||||
body.code,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(SmsSendCodeResult {
|
||||
cooldown_seconds: self.config.interval_seconds,
|
||||
expires_in_seconds: self.config.valid_time_seconds,
|
||||
provider_request_id: body
|
||||
.request_id
|
||||
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.clone())),
|
||||
provider_out_id: body.model.and_then(|model| model.out_id),
|
||||
})
|
||||
}
|
||||
|
||||
async fn verify_code(
|
||||
&self,
|
||||
request: SmsVerifyCodeRequest,
|
||||
) -> Result<(), SmsProviderError> {
|
||||
let mut query = BTreeMap::new();
|
||||
query.insert("Action".to_string(), "CheckSmsVerifyCode".to_string());
|
||||
query.insert("Format".to_string(), "json".to_string());
|
||||
query.insert("Version".to_string(), "2017-05-25".to_string());
|
||||
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
|
||||
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
|
||||
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
|
||||
query.insert("SignatureVersion".to_string(), "1.0".to_string());
|
||||
query.insert(
|
||||
"AccessKeyId".to_string(),
|
||||
self.config
|
||||
.access_key_id
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
query.insert(
|
||||
"PhoneNumber".to_string(),
|
||||
request.national_phone_number.trim().to_string(),
|
||||
);
|
||||
query.insert(
|
||||
"CountryCode".to_string(),
|
||||
self.config.country_code.clone(),
|
||||
);
|
||||
query.insert(
|
||||
"VerifyCode".to_string(),
|
||||
request.verify_code.trim().to_string(),
|
||||
);
|
||||
query.insert(
|
||||
"CaseAuthPolicy".to_string(),
|
||||
self.config.case_auth_policy.to_string(),
|
||||
);
|
||||
if let Some(scheme_name) = self.config.scheme_name.clone() {
|
||||
query.insert("SchemeName".to_string(), scheme_name);
|
||||
}
|
||||
if let Some(provider_out_id) = request.provider_out_id {
|
||||
query.insert("OutId".to_string(), provider_out_id);
|
||||
}
|
||||
self.sign_query(&mut query)?;
|
||||
|
||||
let payload = self
|
||||
.client
|
||||
.post(build_aliyun_sms_url(&self.config.endpoint)?)
|
||||
.form(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?;
|
||||
|
||||
let body = parse_aliyun_json_response_for_verify(payload).await?;
|
||||
if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") {
|
||||
return Err(map_aliyun_provider_error(
|
||||
"验证码校验失败",
|
||||
body.message,
|
||||
body.code,
|
||||
));
|
||||
}
|
||||
if body
|
||||
.model
|
||||
.and_then(|model| model.verify_result)
|
||||
.as_deref()
|
||||
!= Some("PASS")
|
||||
{
|
||||
return Err(SmsProviderError::InvalidVerifyCode);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sign_query(&self, query: &mut BTreeMap<String, String>) -> Result<(), SmsProviderError> {
|
||||
let access_key_secret = self
|
||||
.config
|
||||
.access_key_secret
|
||||
.as_deref()
|
||||
.ok_or_else(|| SmsProviderError::InvalidConfig("阿里云短信 AccessKeySecret 未配置".to_string()))?;
|
||||
let canonicalized = canonicalize_aliyun_rpc_params(query);
|
||||
let string_to_sign = format!(
|
||||
"POST&{}&{}",
|
||||
aliyun_percent_encode("/"),
|
||||
aliyun_percent_encode(&canonicalized)
|
||||
);
|
||||
let mut signer = HmacSha1::new_from_slice(format!("{access_key_secret}&").as_bytes())
|
||||
.map_err(|error| SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}")))?;
|
||||
signer.update(string_to_sign.as_bytes());
|
||||
let signature = BASE64_STANDARD.encode(signer.finalize().into_bytes());
|
||||
query.insert("Signature".to_string(), signature);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessTokenClaims {
|
||||
pub fn from_input(
|
||||
input: AccessTokenClaimsInput,
|
||||
@@ -506,6 +1003,187 @@ fn map_verify_error(error: jsonwebtoken::errors::Error) -> JwtError {
|
||||
JwtError::VerifyFailed(message)
|
||||
}
|
||||
|
||||
fn build_sms_provider_out_id(scene: &str, national_phone_number: &str) -> String {
|
||||
let phone_suffix = national_phone_number
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{scene}_{}_{}", phone_suffix, new_uuid_simple_string())
|
||||
}
|
||||
|
||||
fn build_aliyun_sms_url(endpoint: &str) -> Result<String, SmsProviderError> {
|
||||
let endpoint = endpoint
|
||||
.trim()
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_matches('/');
|
||||
if endpoint.is_empty() {
|
||||
return Err(SmsProviderError::InvalidConfig(
|
||||
"阿里云短信 endpoint 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(format!("https://{endpoint}/"))
|
||||
}
|
||||
|
||||
fn current_aliyun_timestamp() -> String {
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
fn canonicalize_aliyun_rpc_params(params: &BTreeMap<String, String>) -> String {
|
||||
params
|
||||
.iter()
|
||||
.filter(|(key, _)| key.as_str() != "Signature")
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
aliyun_percent_encode(key),
|
||||
aliyun_percent_encode(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn aliyun_percent_encode(value: &str) -> String {
|
||||
urlencoding::encode(value)
|
||||
.into_owned()
|
||||
.replace('+', "%20")
|
||||
.replace('*', "%2A")
|
||||
.replace("%7E", "~")
|
||||
}
|
||||
|
||||
async fn parse_aliyun_json_response(
|
||||
response: reqwest::Response,
|
||||
fallback_message: &str,
|
||||
) -> Result<AliyunSendSmsVerifyCodeResponse, SmsProviderError> {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}:{error}")))?;
|
||||
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(&body).map_err(|error| {
|
||||
SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}"))
|
||||
})?;
|
||||
if status.is_client_error() || status.is_server_error() {
|
||||
return Err(map_http_status_to_sms_provider_error(
|
||||
fallback_message,
|
||||
status,
|
||||
serde_json::from_str::<Value>(&body).ok(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn parse_aliyun_json_response_for_verify(
|
||||
response: reqwest::Response,
|
||||
) -> Result<AliyunCheckSmsVerifyCodeResponse, SmsProviderError> {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?;
|
||||
let payload = serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(&body)
|
||||
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:响应解析失败:{error}")))?;
|
||||
if status.is_client_error() || status.is_server_error() {
|
||||
return Err(map_http_status_to_sms_provider_error(
|
||||
"验证码校验失败",
|
||||
status,
|
||||
serde_json::from_str::<Value>(&body).ok(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn map_http_status_to_sms_provider_error(
|
||||
fallback_message: &str,
|
||||
status: StatusCode,
|
||||
payload: Option<Value>,
|
||||
) -> SmsProviderError {
|
||||
let provider_message = payload
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("Message").and_then(Value::as_str))
|
||||
.unwrap_or_default();
|
||||
let provider_code = payload
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("Code").and_then(Value::as_str))
|
||||
.unwrap_or_default();
|
||||
|
||||
if status.is_client_error() {
|
||||
return map_aliyun_provider_error(
|
||||
fallback_message,
|
||||
Some(provider_message.to_string()),
|
||||
Some(provider_code.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
SmsProviderError::Upstream(build_provider_error_message(
|
||||
fallback_message,
|
||||
provider_message,
|
||||
))
|
||||
}
|
||||
|
||||
fn map_aliyun_provider_error(
|
||||
fallback_message: &str,
|
||||
provider_message: Option<String>,
|
||||
provider_code: Option<String>,
|
||||
) -> SmsProviderError {
|
||||
let provider_message = provider_message.unwrap_or_default();
|
||||
let provider_code = provider_code.unwrap_or_default();
|
||||
let normalized_code = provider_code.trim().to_ascii_uppercase();
|
||||
|
||||
if normalized_code.contains("VERIFY")
|
||||
|| normalized_code.contains("CODE")
|
||||
|| normalized_code.contains("CHECK")
|
||||
{
|
||||
return SmsProviderError::InvalidVerifyCode;
|
||||
}
|
||||
|
||||
if normalized_code.contains("MOBILE")
|
||||
|| normalized_code.contains("PHONE")
|
||||
|| normalized_code.contains("SIGN")
|
||||
|| normalized_code.contains("TEMPLATE")
|
||||
|| normalized_code.contains("ACCESSKEY")
|
||||
{
|
||||
return SmsProviderError::InvalidConfig(build_provider_error_message(
|
||||
fallback_message,
|
||||
&provider_message,
|
||||
));
|
||||
}
|
||||
|
||||
SmsProviderError::Upstream(build_provider_error_message(
|
||||
fallback_message,
|
||||
&provider_message,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_provider_error_message(prefix: &str, provider_message: &str) -> String {
|
||||
let provider_message = provider_message.trim();
|
||||
if provider_message.is_empty() {
|
||||
prefix.to_string()
|
||||
} else {
|
||||
format!("{prefix}:{provider_message}")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SmsProviderError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidConfig(message) | Self::Upstream(message) => f.write_str(message),
|
||||
Self::InvalidVerifyCode => f.write_str("验证码错误"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SmsProviderError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -543,6 +1221,29 @@ mod tests {
|
||||
.expect("refresh cookie config should be valid")
|
||||
}
|
||||
|
||||
fn build_mock_sms_config() -> SmsAuthConfig {
|
||||
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")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_sign_and_verify_access_token() {
|
||||
let config = build_jwt_config();
|
||||
@@ -669,4 +1370,103 @@ mod tests {
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
assert!(cookie.contains("Max-Age=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sms_auth_provider_kind_parses_supported_values() {
|
||||
assert_eq!(SmsAuthProviderKind::parse("mock"), Some(SmsAuthProviderKind::Mock));
|
||||
assert_eq!(
|
||||
SmsAuthProviderKind::parse("aliyun"),
|
||||
Some(SmsAuthProviderKind::Aliyun)
|
||||
);
|
||||
assert_eq!(SmsAuthProviderKind::parse("other"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_sms_provider_sends_and_verifies_code() {
|
||||
let provider =
|
||||
SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build");
|
||||
let send_result = provider
|
||||
.send_code(SmsSendCodeRequest {
|
||||
national_phone_number: "13800138000".to_string(),
|
||||
scene: "login".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("send code should succeed");
|
||||
|
||||
assert_eq!(send_result.cooldown_seconds, DEFAULT_SMS_INTERVAL_SECONDS);
|
||||
assert_eq!(send_result.expires_in_seconds, DEFAULT_SMS_VALID_TIME_SECONDS);
|
||||
assert_eq!(
|
||||
send_result.provider_request_id.as_deref(),
|
||||
Some("mock-request-id")
|
||||
);
|
||||
assert!(send_result.provider_out_id.is_some());
|
||||
|
||||
provider
|
||||
.verify_code(SmsVerifyCodeRequest {
|
||||
national_phone_number: "13800138000".to_string(),
|
||||
verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
|
||||
provider_out_id: send_result.provider_out_id,
|
||||
})
|
||||
.await
|
||||
.expect("verify code should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_sms_provider_rejects_wrong_code() {
|
||||
let provider =
|
||||
SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build");
|
||||
|
||||
let error = provider
|
||||
.verify_code(SmsVerifyCodeRequest {
|
||||
national_phone_number: "13800138000".to_string(),
|
||||
verify_code: "000000".to_string(),
|
||||
provider_out_id: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("wrong verify code should fail");
|
||||
|
||||
assert_eq!(error, SmsProviderError::InvalidVerifyCode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliyun_sms_config_requires_access_key() {
|
||||
let error = SmsAuthConfig::new(
|
||||
SmsAuthProviderKind::Aliyun,
|
||||
DEFAULT_SMS_ENDPOINT.to_string(),
|
||||
None,
|
||||
None,
|
||||
"测试签名".to_string(),
|
||||
"SMS_001".to_string(),
|
||||
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_err("aliyun config without access key should fail");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
SmsProviderError::InvalidConfig("阿里云短信 AccessKey 未配置".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalize_aliyun_rpc_params_keeps_sorted_percent_encoded_order() {
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("TemplateParam".to_string(), "{\"code\":\"##code##\"}".to_string());
|
||||
params.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
|
||||
params.insert("PhoneNumber".to_string(), "13800138000".to_string());
|
||||
|
||||
assert_eq!(
|
||||
canonicalize_aliyun_rpc_params(¶ms),
|
||||
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user