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

@@ -0,0 +1,163 @@
# Axum 手机验证码真实短信 Provider 接入设计
日期:`2026-04-22`
## 1. 文档目的
这份文档用于冻结 Rust `api-server + module-auth + platform-auth` 切换到真实短信链路时的最小可编码边界,解决当前 `module-auth` 仍固定使用 mock 验证码 `123456`,导致 `server-rs` 无法接入真实短信发送与真实验证码校验的问题。
## 2. 当前问题
截至 `2026-04-22`Rust 侧手机号登录存在以下状态:
1. `POST /api/auth/phone/send-code` 已存在,但 `module-auth` 内部仍写死 `123456`
2. `POST /api/auth/phone/login` 校验的是本地内存快照里的固定验证码,不是真实短信平台生成的验证码。
3. 即使把发送动作切到真实阿里云短信,如果校验仍留在本地 mock整条登录链仍然不可用。
## 3. 本次目标
本次必须达成:
1. Rust 侧短信 provider 支持 `mock``aliyun` 两种模式。
2. `send-code``aliyun` 模式下调用阿里云 `SendSmsVerifyCode`
3. `phone/login``wechat/bind-phone``aliyun` 模式下调用阿里云 `CheckSmsVerifyCode`
4. `module-auth` 不再保存验证码明文,只保存发送冷却、有效期和失败次数所需的最小快照。
5. `shared-contracts` 公开响应 contract 维持不变,仍只返回:
- `ok`
- `cooldownSeconds`
- `expiresInSeconds`
- `providerRequestId`
## 4. crate 边界
### 4.1 `platform-auth`
负责:
1. 短信 provider 配置结构。
2. `mock / aliyun` provider 实现。
3. 阿里云 RPC 请求签名、发送与校验。
4. provider 级错误归一化。
### 4.2 `module-auth`
负责:
1. 手机号归一化。
2. 发送冷却与验证码快照 TTL。
3. 校验失败次数累加与耗尽删除。
4. 手机号用户创建、复用、微信补绑归并。
### 4.3 `api-server`
负责:
1. 从环境变量读取短信 provider 配置。
2. 构建 `SmsAuthProvider` 并注入 `PhoneAuthService`
3. 把领域错误映射成 HTTP 错误。
## 5. 配置设计
新增或继续使用以下环境变量:
1. `SMS_AUTH_ENABLED`
2. `SMS_AUTH_PROVIDER`
- `mock`
- `aliyun`
3. `ALIYUN_SMS_ENDPOINT`
- 默认 `dypnsapi.aliyuncs.com`
4. `ALIYUN_SMS_ACCESS_KEY_ID`
5. `ALIYUN_SMS_ACCESS_KEY_SECRET`
6. `ALIYUN_SMS_SIGN_NAME`
7. `ALIYUN_SMS_TEMPLATE_CODE`
8. `ALIYUN_SMS_TEMPLATE_PARAM_KEY`
- 默认 `code`
9. `ALIYUN_SMS_COUNTRY_CODE`
- 默认 `86`
10. `ALIYUN_SMS_SCHEME_NAME`
11. `ALIYUN_SMS_CODE_LENGTH`
- 默认 `6`
12. `ALIYUN_SMS_CODE_TYPE`
- 默认 `1`
13. `ALIYUN_SMS_VALID_TIME_SECONDS`
- 默认 `300`
14. `ALIYUN_SMS_INTERVAL_SECONDS`
- 默认 `60`
15. `ALIYUN_SMS_DUPLICATE_POLICY`
- 默认 `1`
16. `ALIYUN_SMS_CASE_AUTH_POLICY`
- 默认 `1`
17. `ALIYUN_SMS_RETURN_VERIFY_CODE`
- 默认 `false`
18. `SMS_AUTH_MOCK_VERIFY_CODE`
- 默认 `123456`
## 6. provider 行为
### 6.1 `mock`
1. 发送验证码时不访问外部网络。
2. 返回固定 `mock-request-id`
3. 校验时使用内存中的 mock 验证码。
### 6.2 `aliyun`
1. 发送验证码调用 `SendSmsVerifyCode`
2. 校验验证码调用 `CheckSmsVerifyCode`
3. 使用阿里云 RPC 签名口径:
- `SignatureMethod=HMAC-SHA1`
- `SignatureVersion=1.0`
4. 当前仍只支持中国大陆手机号。
## 7. 状态与快照
`module-auth` 内部验证码快照保留:
1. `phone_number`
2. `scene`
3. `expires_at`
4. `last_sent_at`
5. `failed_attempts`
明确不再保留:
1. 验证码明文
2. 验证码 hash
校验流程改为:
1. 先检查是否存在活跃快照。
2. 再检查是否过期。
3. 再调用 provider 做真实验证码校验。
4. 校验失败时累加失败次数。
5. 达到上限时删除快照并返回 `429`
6. 校验成功后删除快照。
## 8. 错误语义
1. 手机号格式错误:`400`
2. 验证码格式错误:`400`
3. 验证码不存在或已过期:`400`
4. 校验失败:`400`
5. 验证码错误次数耗尽:`429`
6. 阿里云配置缺失:`500`
7. 阿里云上游失败:`502`
## 9. 测试要求
至少覆盖:
1. `mock` provider 的发送与登录仍可跑通。
2. `aliyun` provider 缺配置时会在服务初始化阶段报错。
3. 发送冷却逻辑不依赖验证码明文仍然有效。
4. 校验失败次数耗尽后会删除快照。
5. `send-code` 成功时仍返回既有 contract。
## 10. 非目标
本次明确不做:
1. 短信送达回执接口
2. `sms_auth_event` 真实持久化
3. 图形验证码
4. 更细粒度的 provider 错误码透传 DTO

View File

@@ -19,6 +19,7 @@
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。
- [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。
- [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429``Retry-After` contract。
- [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)??? Rust `api-server + module-auth + platform-auth` ?????? provider ? crate ???????????/????????????
- [WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)Rust `api-server` 微信登录实现设计,冻结微信 provider 接入、系统 JWT 签发边界、`wechat/start` / `wechat/callback` / `wechat/bind-phone` 闭环,以及与后续 `SpacetimeDB` claims 透传的关系。
- [WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md](./WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md):微信登录从本地 mock 到真实微信开放平台联调的执行手册,覆盖环境变量、回调域名、代理头要求、验证步骤与常见失败排查。
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。

5
server-rs/Cargo.lock generated
View File

@@ -1847,9 +1847,14 @@ name = "platform-auth"
version = "0.1.0"
dependencies = [
"argon2",
"base64 0.22.1",
"hmac",
"jsonwebtoken",
"rand_core 0.6.4",
"reqwest",
"serde",
"serde_json",
"sha1",
"sha2",
"shared-kernel",
"time",

View File

@@ -1291,7 +1291,10 @@ mod tests {
payload["expiresInSeconds"],
Value::Number(serde_json::Number::from(300))
);
assert_eq!(payload["providerRequestId"], Value::Null);
assert_eq!(
payload["providerRequestId"],
Value::String("mock-request-id".to_string())
);
}
#[tokio::test]

View File

@@ -25,6 +25,23 @@ pub struct AppConfig {
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
pub sms_auth_enabled: bool,
pub sms_auth_provider: String,
pub sms_endpoint: String,
pub sms_access_key_id: Option<String>,
pub sms_access_key_secret: Option<String>,
pub sms_sign_name: String,
pub sms_template_code: String,
pub sms_template_param_key: String,
pub sms_country_code: String,
pub sms_scheme_name: Option<String>,
pub sms_code_length: u8,
pub sms_code_type: u8,
pub sms_valid_time_seconds: u64,
pub sms_interval_seconds: u64,
pub sms_duplicate_policy: u8,
pub sms_case_auth_policy: u8,
pub sms_return_verify_code: bool,
pub sms_mock_verify_code: String,
pub wechat_auth_enabled: bool,
pub wechat_auth_provider: String,
pub wechat_app_id: Option<String>,
@@ -79,6 +96,23 @@ impl Default for AppConfig {
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(),
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
sms_access_key_id: None,
sms_access_key_secret: None,
sms_sign_name: "速通互联验证码".to_string(),
sms_template_code: "100001".to_string(),
sms_template_param_key: "code".to_string(),
sms_country_code: "86".to_string(),
sms_scheme_name: None,
sms_code_length: 6,
sms_code_type: 1,
sms_valid_time_seconds: 300,
sms_interval_seconds: 60,
sms_duplicate_policy: 1,
sms_case_auth_policy: 1,
sms_return_verify_code: false,
sms_mock_verify_code: "123456".to_string(),
wechat_auth_enabled: false,
wechat_auth_provider: "mock".to_string(),
wechat_app_id: None,
@@ -194,6 +228,60 @@ impl AppConfig {
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
config.sms_auth_enabled = sms_auth_enabled;
}
if let Some(sms_auth_provider) = read_first_non_empty_env(&["SMS_AUTH_PROVIDER"]) {
config.sms_auth_provider = sms_auth_provider;
}
if let Some(sms_endpoint) = read_first_non_empty_env(&["ALIYUN_SMS_ENDPOINT"]) {
config.sms_endpoint = sms_endpoint;
}
config.sms_access_key_id = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_ID"]);
config.sms_access_key_secret = read_first_non_empty_env(&["ALIYUN_SMS_ACCESS_KEY_SECRET"]);
if let Some(sms_sign_name) = read_first_non_empty_env(&["ALIYUN_SMS_SIGN_NAME"]) {
config.sms_sign_name = sms_sign_name;
}
if let Some(sms_template_code) = read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_CODE"]) {
config.sms_template_code = sms_template_code;
}
if let Some(sms_template_param_key) =
read_first_non_empty_env(&["ALIYUN_SMS_TEMPLATE_PARAM_KEY"])
{
config.sms_template_param_key = sms_template_param_key;
}
if let Some(sms_country_code) = read_first_non_empty_env(&["ALIYUN_SMS_COUNTRY_CODE"]) {
config.sms_country_code = sms_country_code;
}
config.sms_scheme_name = read_first_non_empty_env(&["ALIYUN_SMS_SCHEME_NAME"]);
if let Some(sms_code_length) = read_first_u8_env(&["ALIYUN_SMS_CODE_LENGTH"]) {
config.sms_code_length = sms_code_length;
}
if let Some(sms_code_type) = read_first_u8_env(&["ALIYUN_SMS_CODE_TYPE"]) {
config.sms_code_type = sms_code_type;
}
if let Some(sms_valid_time_seconds) =
read_first_duration_seconds_env(&["ALIYUN_SMS_VALID_TIME_SECONDS"])
{
config.sms_valid_time_seconds = sms_valid_time_seconds;
}
if let Some(sms_interval_seconds) =
read_first_duration_seconds_env(&["ALIYUN_SMS_INTERVAL_SECONDS"])
{
config.sms_interval_seconds = sms_interval_seconds;
}
if let Some(sms_duplicate_policy) = read_first_u8_env(&["ALIYUN_SMS_DUPLICATE_POLICY"]) {
config.sms_duplicate_policy = sms_duplicate_policy;
}
if let Some(sms_case_auth_policy) = read_first_u8_env(&["ALIYUN_SMS_CASE_AUTH_POLICY"]) {
config.sms_case_auth_policy = sms_case_auth_policy;
}
if let Some(sms_return_verify_code) =
read_first_bool_env(&["ALIYUN_SMS_RETURN_VERIFY_CODE"])
{
config.sms_return_verify_code = sms_return_verify_code;
}
if let Some(sms_mock_verify_code) = read_first_non_empty_env(&["SMS_AUTH_MOCK_VERIFY_CODE"])
{
config.sms_mock_verify_code = sms_mock_verify_code;
}
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
config.wechat_auth_enabled = wechat_auth_enabled;
@@ -439,6 +527,11 @@ fn read_first_u64_env(keys: &[&str]) -> Option<u64> {
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
}
fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
keys.iter()
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
}
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
keys.iter().find_map(|key| {
env::var(key)
@@ -515,6 +608,10 @@ fn parse_u64(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
fn parse_u8(raw: &str) -> Option<u8> {
raw.trim().parse::<u8>().ok()
}
fn parse_positive_u16(raw: &str) -> Option<u16> {
let value = raw.trim().parse::<u16>().ok()?;
if value == 0 {

View File

@@ -46,6 +46,7 @@ pub async fn send_phone_code(
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_phone_auth_error)?;
Ok(json_success_body(

View File

@@ -16,6 +16,7 @@ use module_runtime::RuntimeSnapshotRecord;
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
@@ -54,6 +55,7 @@ pub struct AppState {
pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
SmsProvider(SmsProviderError),
Oss(OssError),
Llm(LlmError),
}
@@ -78,9 +80,30 @@ impl AppState {
)?;
let oss_client = build_oss_client(&config)?;
let auth_store = InMemoryAuthStore::default();
let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new(
SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| {
SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string())
})?,
config.sms_endpoint.clone(),
config.sms_access_key_id.clone(),
config.sms_access_key_secret.clone(),
config.sms_sign_name.clone(),
config.sms_template_code.clone(),
config.sms_template_param_key.clone(),
config.sms_country_code.clone(),
config.sms_scheme_name.clone(),
config.sms_code_length,
config.sms_code_type,
config.sms_valid_time_seconds,
config.sms_interval_seconds,
config.sms_duplicate_policy,
config.sms_case_auth_policy,
config.sms_return_verify_code,
config.sms_mock_verify_code.clone(),
)?)?;
let password_entry_service = PasswordEntryService::new(auth_store.clone());
let auth_user_service = AuthUserService::new(auth_store.clone());
let phone_auth_service = PhoneAuthService::new(auth_store.clone());
let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
let wechat_auth_state_service =
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
@@ -331,6 +354,7 @@ impl fmt::Display for AppStateInitError {
match self {
Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"),
Self::SmsProvider(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
Self::Llm(error) => write!(f, "{error}"),
}
@@ -351,6 +375,12 @@ impl From<RefreshCookieError> for AppStateInitError {
}
}
impl From<SmsProviderError> for AppStateInitError {
fn from(value: SmsProviderError) -> Self {
Self::SmsProvider(value)
}
}
impl From<OssError> for AppStateInitError {
fn from(value: OssError) -> Self {
Self::Oss(value)

View File

@@ -43,10 +43,11 @@
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
13. [../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
14. [../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](../../../docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
## 4. 边界约束
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 或阿里云 RPC 逻辑写进主工程。
2. 短信、微信、JWT、Cookie、密码哈希等平台适配优先通过 `crates/platform-auth` 承接。
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
@@ -54,3 +55,4 @@
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。
8. 当前 `module-auth` 已承接当前 refresh session 吊销与用户 `token_version` 递增能力,供 `/api/auth/logout` 复用。
9. 当前手机号验证码真实 provider 由 `platform-auth` 注入,`module-auth` 只保留冷却、TTL、失败次数和账号编排不保存验证码明文。

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(

View File

@@ -6,6 +6,11 @@ license.workspace = true
[dependencies]
argon2 = "0.5"
base64 = "0.22"
hmac = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1"
sha1 = "0.10"
sha2 = "0.10"
jsonwebtoken = "9"
rand_core = { version = "0.6", features = ["getrandom"] }

View File

@@ -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(&params),
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
);
}
}