fix(auth): send sms verify codes via aliyun

This commit is contained in:
2026-05-16 22:33:29 +08:00
parent 804f1e32be
commit c3ad28577c
8 changed files with 239 additions and 349 deletions
+4 -4
View File
@@ -453,15 +453,15 @@
## 本地短信收不到验证码先查 provider
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。
- 处理:真实短信联调时把 `.env.local``SMS_AUTH_PROVIDER` 显式设为 `aliyun`后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`
- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]``api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。
- 处理:真实短信联调时把 `.env.local``SMS_AUTH_PROVIDER` 显式设为 `aliyun`并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com``ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技``ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486``ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE``api-server` 重启会清掉未校验的本地验证码。
- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]``api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效;需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID``ALIYUN_SMS_ACCESS_KEY_SECRET``ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`
- 关联:`scripts/dev-utils.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md``docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`
## 手机验证码登录 500 先查短信 provider 语义
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN``biz.FREQUENCY``check frequency failed`
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。当前验证码校验已经改成本地哈希校验,登录阶段的验证码错误不会再调用阿里云校验接口;若登录前的发送阶段失败,应优先看 `SendSms` 返回的 `Code/Message`
- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`
- 关联:`server-rs/crates/module-auth/src/errors.rs``server-rs/crates/api-server/src/phone_auth.rs``docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`
+3 -3
View File
@@ -79,9 +79,9 @@ SMS_AUTH_ENABLED=false
SMS_AUTH_PROVIDER=aliyun
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
ALIYUN_SMS_COUNTRY_CODE=86
@@ -164,6 +164,30 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- `WECHAT_*`
- `ALIYUN_OSS_*`
### 手机验证码短信
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
生产默认短信配置:
```env
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
```
阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH``ALIYUN_SMS_CODE_TYPE``ALIYUN_SMS_RETURN_VERIFY_CODE``ALIYUN_SMS_CASE_AUTH_POLICY``ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun``SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`
如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行:
```powershell
$env:ALIYUN_SMS_ACCESS_KEY_ID="..."
$env:ALIYUN_SMS_ACCESS_KEY_SECRET="..."
$env:ALIYUN_SMS_REAL_TEST_PHONE_NUMBER="13800138000"
cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture
```
## 埋点与运营查询
用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入:
+1
View File
@@ -1761,6 +1761,7 @@ dependencies = [
"platform-auth",
"serde",
"serde_json",
"sha2",
"shared-kernel",
"time",
"tokio",
+7 -4
View File
@@ -164,11 +164,11 @@ impl Default for AppConfig {
dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(),
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
sms_endpoint: "dysmsapi.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_sign_name: "北京亓盒网络科技".to_string(),
sms_template_code: "SMS_506245486".to_string(),
sms_template_param_key: "code".to_string(),
sms_country_code: "86".to_string(),
sms_scheme_name: None,
@@ -1035,7 +1035,10 @@ mod tests {
config.dashscope_base_url,
"https://dashscope.aliyuncs.com/api/v1"
);
assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com");
assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com");
assert_eq!(config.sms_sign_name, "北京亓盒网络科技");
assert_eq!(config.sms_template_code, "SMS_506245486");
assert_eq!(config.sms_template_param_key, "code");
assert_eq!(
config.wechat_authorize_endpoint,
"https://open.weixin.qq.com/connect/qrconnect"
+1
View File
@@ -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 }
+90 -61
View File
@@ -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());
File diff suppressed because it is too large Load Diff