From 69b3d80621f699ef1f951c0906f32e25a9d3af06 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 22:34:19 +0800 Subject: [PATCH] feat: support Aliyun PNVS SMS verification --- apps/api/src/core/config.ts | 15 +- apps/api/src/features/auth/providers.ts | 216 +++++++++++++++--- apps/api/src/features/auth/routes.ts | 71 ++++-- docs/refactor/auth-payment-provider-plan.md | 88 ++++++- .../web-launch-acceptance-checklist.md | 2 +- package.json | 2 +- scripts/aliyun-pnvs-provider-contract-test.js | 26 +++ scripts/deploy/env/api.env.example | 8 +- scripts/production-config-failfast-test.js | 2 +- scripts/production-readiness-check-test.js | 33 +++ scripts/production-readiness-check.js | 37 ++- 11 files changed, 441 insertions(+), 59 deletions(-) create mode 100644 scripts/aliyun-pnvs-provider-contract-test.js diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index d6b785b7..ff56ae33 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -53,7 +53,18 @@ const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key'; const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024; const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024; const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024; -const PRODUCTION_SMS_PROVIDERS = new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']); +const PRODUCTION_SMS_PROVIDERS = new Set([ + 'aliyun', + 'aliyun-sms', + 'aliyun_sms', + 'aliyun-pnvs', + 'aliyun_pnvs', + 'aliyun-sms-auth', + 'aliyun_sms_auth', + 'tencent', + 'tencent-sms', + 'tencent_sms', +]); const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']); function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BODY_BYTES) { @@ -102,7 +113,7 @@ function validateProductionConfig(nextConfig: ApiConfig) { const failures: string[] = []; if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production'); if (!PRODUCTION_SMS_PROVIDERS.has(nextConfig.authSmsProvider.trim().toLowerCase())) { - failures.push('AUTH_SMS_PROVIDER must be aliyun/aliyun-sms or tencent/tencent-sms in production'); + failures.push('AUTH_SMS_PROVIDER must be aliyun/aliyun-sms, aliyun-pnvs, or tencent/tencent-sms in production'); } if (isUnsafeSecret(nextConfig.authCodePepper, DEFAULT_AUTH_CODE_PEPPER)) { failures.push('AUTH_CODE_PEPPER must be a strong production secret'); diff --git a/apps/api/src/features/auth/providers.ts b/apps/api/src/features/auth/providers.ts index 23cc4d32..91366cf0 100644 --- a/apps/api/src/features/auth/providers.ts +++ b/apps/api/src/features/auth/providers.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import { HttpError } from '../../core/http.js'; import { optionalPublicArray, + optionalPublicBoolean, optionalPublicObject, optionalPublicString, providerEndpoint, @@ -10,7 +11,7 @@ import { type TenantAuthProviderConfig, } from '../../core/tenant-provider-config.js'; -export type SmsProviderName = 'mock' | 'aliyun' | 'tencent'; +export type SmsProviderName = 'mock' | 'aliyun' | 'aliyun-pnvs' | 'tencent'; export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'wechat-miniapp' | 'qq'; export interface SmsSendInput { @@ -18,19 +19,38 @@ export interface SmsSendInput { phone: string; code: string; purpose: string; + outId: string; + ttlSeconds: number; + cooldownSeconds: number; metadata: Record; } export interface SmsSendResult { provider: SmsProviderName; status: 'sent' | 'mocked'; + verification?: 'local' | 'provider'; providerMessageId?: string; raw?: Record; } +export interface SmsVerifyInput { + tenantId: string; + phone: string; + code: string; + purpose: string; + outId?: string; + metadata: Record; +} + +export interface SmsVerifyResult { + verified: boolean; + raw?: Record; +} + export interface SmsProvider { name: SmsProviderName; send(input: SmsSendInput): Promise; + verify?(input: SmsVerifyInput): Promise; } function hmacSha256(key: crypto.BinaryLike | crypto.KeyObject, message: string) { @@ -62,6 +82,12 @@ function maskedProviderError(provider: string, status: number, raw: Record= 400 && status < 500 ? 502 : 503, `${provider} SMS send failed${suffix}`, 'SMS_PROVIDER_SEND_FAILED'); } +function maskedProviderVerifyError(provider: string, status: number, raw: Record) { + const code = typeof raw.Code === 'string' ? raw.Code : typeof raw.code === 'string' ? raw.code : ''; + const suffix = code ? ` (${code})` : ''; + throw new HttpError(status >= 400 && status < 500 ? 502 : 503, `${provider} SMS verify failed${suffix}`, 'SMS_PROVIDER_VERIFY_FAILED'); +} + function templateParams(config: TenantAuthProviderConfig, input: SmsSendInput) { const configured = optionalPublicArray(config, 'templateParamSet') .concat(optionalPublicArray(config, 'templateParams')) @@ -95,6 +121,49 @@ function phoneWithChinaCode(phone: string) { return phone.startsWith('+') ? phone : `+86${phone}`; } +async function callAliyunRpc( + providerConfig: TenantAuthProviderConfig, + input: { + provider: string; + action: string; + endpointFallback: string; + allowedHosts: string[]; + params: Record; + }, +) { + const accessKeyId = requireSecretString(providerConfig, ['accessKeyId', 'AccessKeyId'], 'SMS_SECRET_REQUIRED'); + const accessKeySecret = requireSecretString(providerConfig, ['accessKeySecret', 'AccessKeySecret'], 'SMS_SECRET_REQUIRED'); + const endpoint = providerEndpoint(providerConfig, input.endpointFallback, input.allowedHosts, 'SMS_ENDPOINT_NOT_ALLOWED'); + const date = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + const params = { + AccessKeyId: accessKeyId, + Action: input.action, + Format: 'JSON', + RegionId: optionalPublicString(providerConfig, ['regionId']) || 'cn-hangzhou', + SignatureMethod: 'HMAC-SHA1', + SignatureNonce: crypto.randomUUID(), + SignatureVersion: '1.0', + Timestamp: date, + Version: '2017-05-25', + ...input.params, + }; + + const canonical = canonicalQuery(params); + const stringToSign = `POST&%2F&${encodeRFC3986(canonical)}`; + const signature = crypto.createHmac('sha1', `${accessKeySecret}&`).update(stringToSign, 'utf8').digest('base64'); + const body = `${canonical}&Signature=${encodeRFC3986(signature)}`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + body, + }); + const raw = safeRaw(await response.json().catch(() => ({}))); + return { response, raw }; +} + class MockSmsProvider implements SmsProvider { readonly name = 'mock' as const; @@ -121,43 +190,21 @@ class AliyunSmsProvider implements SmsProvider { constructor(private readonly providerConfig: TenantAuthProviderConfig) {} async send(input: SmsSendInput): Promise { - const accessKeyId = requireSecretString(this.providerConfig, ['accessKeyId', 'AccessKeyId'], 'SMS_SECRET_REQUIRED'); - const accessKeySecret = requireSecretString(this.providerConfig, ['accessKeySecret', 'AccessKeySecret'], 'SMS_SECRET_REQUIRED'); const signName = requirePublicString(this.providerConfig, ['signName'], 'SMS_PUBLIC_CONFIG_REQUIRED'); const templateCode = requirePublicString(this.providerConfig, ['templateCode'], 'SMS_PUBLIC_CONFIG_REQUIRED'); - const endpoint = providerEndpoint(this.providerConfig, 'https://dysmsapi.aliyuncs.com', ['aliyuncs.com'], 'SMS_ENDPOINT_NOT_ALLOWED'); - const date = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); - const nonce = crypto.randomUUID(); - const params = { - AccessKeyId: accessKeyId, - Action: 'SendSms', - Format: 'JSON', + const { response, raw } = await callAliyunRpc(this.providerConfig, { + provider: this.name, + action: 'SendSms', + endpointFallback: 'https://dysmsapi.aliyuncs.com', + allowedHosts: ['aliyuncs.com'], + params: { PhoneNumbers: input.phone, - RegionId: optionalPublicString(this.providerConfig, ['regionId']) || 'cn-hangzhou', - SignatureMethod: 'HMAC-SHA1', - SignatureNonce: nonce, - SignatureVersion: '1.0', SignName: signName, TemplateCode: templateCode, TemplateParam: JSON.stringify(templateParams(this.providerConfig, input)), - Timestamp: date, - Version: '2017-05-25', - }; - - const canonical = canonicalQuery(params); - const stringToSign = `POST&%2F&${encodeRFC3986(canonical)}`; - const signature = crypto.createHmac('sha1', `${accessKeySecret}&`).update(stringToSign, 'utf8').digest('base64'); - const body = `${canonical}&Signature=${encodeRFC3986(signature)}`; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', }, - body, }); - const raw = safeRaw(await response.json().catch(() => ({}))); const code = typeof raw.Code === 'string' ? raw.Code : ''; if (!response.ok || code !== 'OK') maskedProviderError(this.name, response.status, raw); @@ -173,6 +220,116 @@ class AliyunSmsProvider implements SmsProvider { } } +function pnvsTemplateParam(config: TenantAuthProviderConfig, input: SmsSendInput) { + const configured = optionalPublicObject(config, 'templateParam'); + const min = String(Math.max(1, Math.ceil(input.ttlSeconds / 60))); + const base = Object.keys(configured).length > 0 ? configured : { code: '##code##', min }; + const normalized: Record = {}; + for (const [key, value] of Object.entries(base)) { + normalized[key] = String(value) + .replace(/\{\{\s*min\s*\}\}/gi, min) + .replace(/\{min\}/gi, min) + .replace(/\$\{min\}/gi, min); + } + if (!Object.values(normalized).includes('##code##')) normalized.code = '##code##'; + if (!Object.prototype.hasOwnProperty.call(normalized, 'min')) normalized.min = min; + return JSON.stringify(normalized); +} + +function optionalIntegerString(config: TenantAuthProviderConfig, keys: string[], fallback?: string) { + const value = optionalPublicString(config, keys); + if (value && /^\d+$/.test(value)) return value; + return fallback; +} + +class AliyunPnvsSmsProvider implements SmsProvider { + readonly name = 'aliyun-pnvs' as const; + + constructor(private readonly providerConfig: TenantAuthProviderConfig) {} + + async send(input: SmsSendInput): Promise { + const signName = requirePublicString(this.providerConfig, ['signName'], 'SMS_PUBLIC_CONFIG_REQUIRED'); + const templateCode = requirePublicString(this.providerConfig, ['templateCode'], 'SMS_PUBLIC_CONFIG_REQUIRED'); + const params: Record = { + CountryCode: optionalPublicString(this.providerConfig, ['countryCode']) || '86', + PhoneNumber: input.phone, + SignName: signName, + TemplateCode: templateCode, + TemplateParam: pnvsTemplateParam(this.providerConfig, input), + OutId: input.outId, + CodeType: optionalIntegerString(this.providerConfig, ['codeType'], '1') || '1', + CodeLength: optionalIntegerString(this.providerConfig, ['codeLength'], '6') || '6', + ValidTime: optionalIntegerString(this.providerConfig, ['validTime'], String(input.ttlSeconds)) || String(input.ttlSeconds), + DuplicatePolicy: optionalIntegerString(this.providerConfig, ['duplicatePolicy'], '1') || '1', + Interval: optionalIntegerString(this.providerConfig, ['interval'], String(input.cooldownSeconds)) || String(input.cooldownSeconds), + }; + const schemeName = optionalPublicString(this.providerConfig, ['schemeName']); + if (schemeName) params.SchemeName = schemeName; + const smsUpExtendCode = optionalPublicString(this.providerConfig, ['smsUpExtendCode']); + if (smsUpExtendCode) params.SmsUpExtendCode = smsUpExtendCode; + const autoRetry = optionalIntegerString(this.providerConfig, ['autoRetry']); + if (autoRetry) params.AutoRetry = autoRetry; + if (optionalPublicBoolean(this.providerConfig, 'returnVerifyCode')) params.ReturnVerifyCode = 'true'; + + const { response, raw } = await callAliyunRpc(this.providerConfig, { + provider: this.name, + action: 'SendSmsVerifyCode', + endpointFallback: 'https://dypnsapi.aliyuncs.com', + allowedHosts: ['aliyuncs.com'], + params, + }); + const code = typeof raw.Code === 'string' ? raw.Code : ''; + if (!response.ok || code !== 'OK' || raw.Success === false) maskedProviderError(this.name, response.status, raw); + const model = safeRaw(raw.Model); + + return { + provider: this.name, + status: 'sent', + verification: 'provider', + providerMessageId: typeof model.BizId === 'string' ? model.BizId : undefined, + raw: { + requestId: raw.RequestId, + modelRequestId: model.RequestId, + outId: model.OutId || input.outId, + code, + }, + }; + } + + async verify(input: SmsVerifyInput): Promise { + const params: Record = { + CountryCode: optionalPublicString(this.providerConfig, ['countryCode']) || '86', + PhoneNumber: input.phone, + VerifyCode: input.code, + }; + const schemeName = optionalPublicString(this.providerConfig, ['schemeName']); + if (schemeName) params.SchemeName = schemeName; + if (input.outId) params.OutId = input.outId; + const caseAuthPolicy = optionalIntegerString(this.providerConfig, ['caseAuthPolicy']); + if (caseAuthPolicy) params.CaseAuthPolicy = caseAuthPolicy; + + const { response, raw } = await callAliyunRpc(this.providerConfig, { + provider: this.name, + action: 'CheckSmsVerifyCode', + endpointFallback: 'https://dypnsapi.aliyuncs.com', + allowedHosts: ['aliyuncs.com'], + params, + }); + const code = typeof raw.Code === 'string' ? raw.Code : ''; + if (!response.ok || code !== 'OK' || raw.Success === false) maskedProviderVerifyError(this.name, response.status, raw); + const model = safeRaw(raw.Model); + return { + verified: model.VerifyResult === 'PASS', + raw: { + requestId: raw.RequestId, + outId: model.OutId || input.outId || null, + verifyResult: model.VerifyResult || null, + code, + }, + }; + } +} + class TencentSmsProvider implements SmsProvider { readonly name = 'tencent' as const; @@ -250,6 +407,7 @@ class TencentSmsProvider implements SmsProvider { export function createSmsProvider(name: string, providerConfig?: TenantAuthProviderConfig | null): SmsProvider { if (name === 'aliyun') return providerConfig ? new AliyunSmsProvider(providerConfig) : new NotConfiguredSmsProvider('aliyun'); + if (name === 'aliyun-pnvs') return providerConfig ? new AliyunPnvsSmsProvider(providerConfig) : new NotConfiguredSmsProvider('aliyun-pnvs'); if (name === 'tencent') return providerConfig ? new TencentSmsProvider(providerConfig) : new NotConfiguredSmsProvider('tencent'); return new MockSmsProvider(); } diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index fad252a8..268e3d69 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -4,7 +4,7 @@ import { currentSessionFromContext, hydrateRequestAuth } from '../../core/auth-c import { query, transaction } from '../../core/db.js'; import { HttpError, type RequestContext } from '../../core/http.js'; import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js'; -import { createSmsProvider } from './providers.js'; +import { createSmsProvider, type SmsProvider } from './providers.js'; import { loadTenantAuthProviderConfig, optionalPublicString, @@ -33,6 +33,8 @@ interface SmsCodeRow { attempts: number; expiresAt: string; status: string; + provider: string; + metadata: Record | null; } interface CooldownRow { @@ -77,6 +79,7 @@ function jsonObject(value: unknown) { async function consumeSmsCode( client: import('pg').PoolClient, + provider: SmsProvider, input: { tenantId: string; phone: string; @@ -89,7 +92,7 @@ async function consumeSmsCode( const expectedHash = hashSmsCode(input.tenantId, input.phone, input.purpose, input.code); const codeResult = await client.query( ` - select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status + select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status, provider, metadata from public.sms_verification_codes where tenant_id = $1 and phone = $2 @@ -140,7 +143,26 @@ async function consumeSmsCode( return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' }; } - const matched = hashEquals(smsCode.codeHash, expectedHash); + const metadata = jsonObject(smsCode.metadata); + let matched = hashEquals(smsCode.codeHash, expectedHash); + let providerVerifyRaw: Record | null = null; + const verificationMode = typeof metadata.verification === 'string' ? metadata.verification : 'local'; + if (verificationMode === 'provider') { + if (!provider.verify || provider.name !== smsCode.provider) { + return { ok: false, statusCode: 400, message: 'SMS provider verification is unavailable', code: 'SMS_PROVIDER_VERIFY_UNAVAILABLE' }; + } + const providerVerifyResult = await provider.verify({ + tenantId: input.tenantId, + phone: input.phone, + code: input.code, + purpose: input.purpose, + outId: typeof metadata.outId === 'string' ? metadata.outId : smsCode.id, + metadata, + }); + matched = providerVerifyResult.verified; + providerVerifyRaw = providerVerifyResult.raw || {}; + } + if (!matched) { const nextAttempts = smsCode.attempts + 1; const blocked = nextAttempts >= 5; @@ -155,13 +177,13 @@ async function consumeSmsCode( ); await writeLoginEvent(client, { tenantId: input.tenantId, - provider: 'sms', + provider: `sms:${smsCode.provider}`, identifier: input.phone, result: blocked ? 'blocked' : 'failed', failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID', ipAddress: input.ipAddress, userAgent: input.userAgent, - metadata: { purpose: input.purpose, attempts: nextAttempts }, + metadata: { purpose: input.purpose, attempts: nextAttempts, providerVerifyResult: providerVerifyRaw || undefined }, }); return { ok: false, @@ -174,10 +196,12 @@ async function consumeSmsCode( await client.query( ` update public.sms_verification_codes - set status = 'verified', consumed_at = now() + set status = 'verified', + consumed_at = now(), + metadata = metadata || $3::jsonb where tenant_id = $1 and id = $2 `, - [input.tenantId, smsCode.id], + [input.tenantId, smsCode.id, JSON.stringify(providerVerifyRaw ? { providerVerifyResult: providerVerifyRaw } : {})], ); return { ok: true }; @@ -185,6 +209,7 @@ async function consumeSmsCode( function normalizeSmsProviderName(value: string) { const normalized = value.toLowerCase().replace(/_/g, '-'); + if (normalized === 'aliyun-pnvs' || normalized === 'aliyun-pnvs-sms' || normalized === 'aliyun-sms-auth') return 'aliyun-pnvs'; if (normalized === 'aliyun' || normalized === 'aliyun-sms') return 'aliyun'; if (normalized === 'tencent' || normalized === 'tencent-sms') return 'tencent'; return 'mock'; @@ -192,6 +217,7 @@ function normalizeSmsProviderName(value: string) { function smsProviderAliases(value: string) { const provider = normalizeSmsProviderName(value); + if (provider === 'aliyun-pnvs') return ['aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth']; if (provider === 'aliyun') return ['aliyun', 'aliyun-sms', 'aliyun_sms']; if (provider === 'tencent') return ['tencent', 'tencent-sms', 'tencent_sms']; return ['mock']; @@ -211,12 +237,13 @@ async function activeSmsProvider(tenantId: string) { select provider from public.tenant_auth_providers where tenant_id = $1 - and provider in ('aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms', 'mock') + and provider in ('aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth', 'aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms', 'mock') and status in ('active', 'testing') order by case - when provider in ('aliyun', 'aliyun-sms', 'aliyun_sms') then 0 - when provider in ('tencent', 'tencent-sms', 'tencent_sms') then 1 - else 2 + when provider in ('aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth') then 0 + when provider in ('aliyun', 'aliyun-sms', 'aliyun_sms') then 1 + when provider in ('tencent', 'tencent-sms', 'tencent_sms') then 2 + else 3 end limit 1 `, @@ -269,7 +296,17 @@ export async function sendSmsCodeRoute(ctx: RequestContext) { } const code = generateSmsCode(); - const providerResult = await provider.send({ tenantId, phone, code, purpose, metadata }); + const outId = crypto.randomUUID(); + const providerResult = await provider.send({ + tenantId, + phone, + code, + purpose, + outId, + ttlSeconds: config.authCodeTtlSeconds, + cooldownSeconds: config.authSmsCooldownSeconds, + metadata, + }); const codeHash = hashSmsCode(tenantId, phone, purpose, code); const expiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString(); @@ -294,6 +331,8 @@ export async function sendSmsCodeRoute(ctx: RequestContext) { userAgent || null, JSON.stringify({ ...metadata, + outId, + verification: providerResult.verification || 'local', providerStatus: providerResult.status, providerMessageId: providerResult.providerMessageId || null, }), @@ -331,7 +370,9 @@ export async function verifySmsCodeRoute(ctx: RequestContext) { const userAgent = userAgentFrom(ctx); const result = await transaction(async client => { - const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent }); + const providerChoice = await activeSmsProvider(tenantId); + const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig); + const consumed = await consumeSmsCode(client, provider, { tenantId, phone, purpose, code, ipAddress, userAgent }); if (!consumed.ok) return consumed; if (purpose !== 'login') { @@ -448,7 +489,9 @@ export async function bindPhoneRoute(ctx: RequestContext) { const ipAddress = clientIpFrom(ctx); const userAgent = userAgentFrom(ctx); const result = await transaction(async client => { - const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent }); + const providerChoice = await activeSmsProvider(tenantId); + const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig); + const consumed = await consumeSmsCode(client, provider, { tenantId, phone, purpose, code, ipAddress, userAgent }); if (!consumed.ok) return consumed; const userResult = await client.query( diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md index 2e966c82..d4c8a874 100644 --- a/docs/refactor/auth-payment-provider-plan.md +++ b/docs/refactor/auth-payment-provider-plan.md @@ -26,7 +26,7 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先 ## 当前已实现 -- `POST /api/auth/sms/send`:手机号验证码发送,验证码只保存 HMAC hash。 +- `POST /api/auth/sms/send`:手机号验证码发送。传统短信 provider 的验证码只保存 HMAC hash;阿里云 PNVS 短信认证 provider 由阿里云生成并核验验证码,本地只保存发送流水、`outId`、频控和审计。 - `POST /api/auth/sms/verify`:验证码登录,自动创建或复用 `platform_users`。 - `GET /api/auth/me`:通过 Bearer token 获取当前用户。 - `POST /api/auth/logout`:吊销迁移期 session。 @@ -38,6 +38,7 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先 - `auth_login_events`:登录事件审计。 - `app_private.auth_sessions`:迁移期 session token hash。 - 阿里云短信 `SendSms` provider:使用租户级 AccessKey、签名、模板发送。 +- 阿里云 PNVS 短信认证 `SendSmsVerifyCode` / `CheckSmsVerifyCode` provider:使用租户级 AccessKey、签名、模板发送和核验验证码,适合不想走传统短信模板资质链路的手机号登录场景。 - 腾讯云短信 `SendSms` provider:使用租户级 SecretId/SecretKey、SdkAppId、签名、模板发送。 - `POST /api/commerce/payments/create`:按订单创建微信支付 JSAPI 或支付宝 WAP 支付参数。 - `POST /api/commerce/payments/notify/wechat_pay`:微信支付 API v3 通知验签、AES-GCM 解密、幂等落库和权益开通。 @@ -45,11 +46,12 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先 ## 短信 Provider -本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境只允许 `aliyun/aliyun-sms` 或 `tencent/tencent-sms`;如果仍为 mock 或写成未知 provider,API 启动和 `readiness:production` 都会直接失败。 +本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境只允许 `aliyun/aliyun-sms`、`aliyun-pnvs/aliyun-sms-auth` 或 `tencent/tencent-sms`;如果仍为 mock 或写成未知 provider,API 启动和 `readiness:production` 都会直接失败。 真实 provider: - `aliyun` / `aliyun-sms`:阿里云短信 `SendSms`,需要 AccessKey、签名、模板 ID。 +- `aliyun-pnvs` / `aliyun-sms-auth`:阿里云 PNVS 短信认证服务,调用 `SendSmsVerifyCode` 发送,调用 `CheckSmsVerifyCode` 核验;验证码由阿里云生成并校验,本地不保存明文验证码。 - `tencent` / `tencent-sms`:腾讯云短信 `SendSms`,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。 密钥策略: @@ -60,6 +62,88 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先 - provider endpoint 默认使用官方域名,生产环境只允许 HTTPS 官方域名;本地测试可使用 `localhost/127.0.0.1` fake server。 - `readiness:production:db` 会检查 active/testing 短信 provider 的 `signName/templateCode` 或 `smsSdkAppId/signName/templateId`,并阻断公开配置里的 secret-like 字段。 +### 阿里云 PNVS 短信认证配置示例 + +`AUTH_SMS_PROVIDER` 可设为 `aliyun-pnvs`,租户公开配置写 `tenant_auth_providers.config_public`,AccessKey 写 `app_private.tenant_secrets`。PNVS 模板参数推荐使用阿里云占位 `##code##`,如 `{"code":"##code##","min":"5"}`;后端会用 `AUTH_CODE_TTL_SECONDS` 自动补 `min`。 + +```json +{ + "provider": "aliyun-pnvs", + "status": "active", + "configPublic": { + "signName": "工学教育", + "templateCode": "SMS_123456789", + "endpoint": "https://dypnsapi.aliyuncs.com", + "regionId": "cn-hangzhou", + "templateParam": { + "code": "##code##", + "min": "5" + }, + "codeLength": "6", + "validTime": "300", + "interval": "60", + "duplicatePolicy": "1", + "secretRef": "app_private.tenant_secrets:sms:aliyun-pnvs" + }, + "secret": { + "secretScope": "sms", + "secretKey": "aliyun-pnvs", + "secretJson": { + "accessKeyId": "LTAI********", + "accessKeySecret": "********" + } + } +} +``` + +生产 bootstrap SQL 参考: + +```sql +insert into app_private.tenant_secrets ( + tenant_id, secret_scope, secret_key, provider, secret_json, last_rotated_at +) +values ( + :'tenant_id'::uuid, + 'sms', + 'aliyun-pnvs', + 'aliyun-pnvs', + jsonb_build_object('accessKeyId', :'access_key_id', 'accessKeySecret', :'access_key_secret'), + now() +) +on conflict (tenant_id, secret_scope, secret_key) +do update set provider = excluded.provider, + secret_json = excluded.secret_json, + last_rotated_at = now(), + updated_at = now(); + +insert into public.tenant_auth_providers ( + tenant_id, provider, status, display_name, config_public +) +values ( + :'tenant_id'::uuid, + 'aliyun-pnvs', + 'active', + '阿里云短信认证', + jsonb_build_object( + 'signName', :'sign_name', + 'templateCode', :'template_code', + 'endpoint', 'https://dypnsapi.aliyuncs.com', + 'regionId', 'cn-hangzhou', + 'templateParam', jsonb_build_object('code', '##code##', 'min', '5'), + 'codeLength', '6', + 'validTime', '300', + 'interval', '60', + 'duplicatePolicy', '1', + 'secretRef', 'app_private.tenant_secrets:sms:aliyun-pnvs' + ) +) +on conflict (tenant_id, provider) +do update set status = excluded.status, + display_name = excluded.display_name, + config_public = excluded.config_public, + updated_at = now(); +``` + ### 阿里云短信配置示例 ```json diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 4c42ffc9..51adc5ba 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -113,7 +113,7 @@ npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-pe ALLOW_LEGACY_AUTH_HEADERS=false ALLOW_PLATFORM_ADMIN_KEY=false CORS_ORIGIN=https://student.example.com,https://tenant-admin.example.com,https://platform-admin.example.com -AUTH_SMS_PROVIDER=aliyun 或 tencent +AUTH_SMS_PROVIDER=aliyun-pnvs、aliyun 或 tencent ``` 生产 worker 推荐: diff --git a/package.json b/package.json index 815faf0d..172b9a83 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node scripts/aliyun-pnvs-provider-contract-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", diff --git a/scripts/aliyun-pnvs-provider-contract-test.js b/scripts/aliyun-pnvs-provider-contract-test.js new file mode 100644 index 00000000..62e7d760 --- /dev/null +++ b/scripts/aliyun-pnvs-provider-contract-test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const providerSource = fs.readFileSync(path.join(process.cwd(), 'apps/api/src/features/auth/providers.ts'), 'utf8'); +const routeSource = fs.readFileSync(path.join(process.cwd(), 'apps/api/src/features/auth/routes.ts'), 'utf8'); +const deployEnvExample = fs.readFileSync(path.join(process.cwd(), 'scripts/deploy/env/api.env.example'), 'utf8'); +const providerDoc = fs.readFileSync(path.join(process.cwd(), 'docs/refactor/auth-payment-provider-plan.md'), 'utf8'); +const launchChecklist = fs.readFileSync(path.join(process.cwd(), 'docs/refactor/web-launch-acceptance-checklist.md'), 'utf8'); +const readinessSource = fs.readFileSync(path.join(process.cwd(), 'scripts/production-readiness-check.js'), 'utf8'); + +assert.match(providerSource, /class AliyunPnvsSmsProvider/, 'PNVS provider class should exist'); +assert.match(providerSource, /SendSmsVerifyCode/, 'PNVS provider should call SendSmsVerifyCode'); +assert.match(providerSource, /CheckSmsVerifyCode/, 'PNVS provider should call CheckSmsVerifyCode'); +assert.match(providerSource, /VerifyResult.*PASS/s, 'PNVS verify should require provider PASS result'); +assert.match(providerSource, /##code##/, 'PNVS provider should preserve Aliyun-generated code placeholder'); +assert.match(routeSource, /aliyun-pnvs/, 'auth routes should recognize aliyun-pnvs aliases'); +assert.match(routeSource, /verification.*provider/s, 'auth routes should store provider verification mode'); +assert.match(routeSource, /provider\.verify/, 'auth routes should delegate PNVS verification to provider'); +assert.match(deployEnvExample, /AUTH_SMS_PROVIDER=aliyun-pnvs/, 'deploy env example should prefer aliyun-pnvs'); +assert.match(readinessSource, /aliyunPnvs/, 'production readiness should validate aliyun-pnvs public config'); +assert.match(providerDoc, /SendSmsVerifyCode/, 'provider doc should document PNVS send action'); +assert.match(providerDoc, /CheckSmsVerifyCode/, 'provider doc should document PNVS verify action'); +assert.match(launchChecklist, /AUTH_SMS_PROVIDER=aliyun-pnvs/, 'launch checklist should allow aliyun-pnvs'); + +console.log('[PASS] Aliyun PNVS provider contract'); diff --git a/scripts/deploy/env/api.env.example b/scripts/deploy/env/api.env.example index 2729b2f9..c0ccfccd 100644 --- a/scripts/deploy/env/api.env.example +++ b/scripts/deploy/env/api.env.example @@ -18,11 +18,9 @@ AUTH_SESSION_SECRET=replace-with-strong-random-session-secret AUTH_CODE_PEPPER=replace-with-strong-random-code-pepper PLATFORM_ADMIN_API_KEY=replace-with-strong-random-platform-admin-key -AUTH_SMS_PROVIDER=aliyun -ALIYUN_SMS_ACCESS_KEY_ID=replace-with-access-key-id -ALIYUN_SMS_ACCESS_KEY_SECRET=replace-with-access-key-secret -ALIYUN_SMS_SIGN_NAME=replace-with-sms-sign -ALIYUN_SMS_TEMPLATE_LOGIN=replace-with-template-code +# Supported production values: aliyun, aliyun-pnvs, tencent. +# Tenant-level SMS AccessKey/SecretKey live in app_private.tenant_secrets, not in this env file. +AUTH_SMS_PROVIDER=aliyun-pnvs WECHAT_MINIAPP_APP_ID=replace-with-miniapp-app-id WECHAT_MINIAPP_APP_SECRET=replace-with-miniapp-app-secret diff --git a/scripts/production-config-failfast-test.js b/scripts/production-config-failfast-test.js index a7273a61..c4917174 100644 --- a/scripts/production-config-failfast-test.js +++ b/scripts/production-config-failfast-test.js @@ -69,7 +69,7 @@ const unsafeApiSmsProvider = runImport(apiConfigUrl, { assert.notEqual(unsafeApiSmsProvider.status, 0, 'production API config should reject unsupported SMS provider'); assert.match( unsafeApiSmsProvider.output, - /AUTH_SMS_PROVIDER must be aliyun\/aliyun-sms or tencent\/tencent-sms/, + /AUTH_SMS_PROVIDER must be aliyun\/aliyun-sms, aliyun-pnvs, or tencent\/tencent-sms/, 'API config should name supported production SMS providers', ); diff --git a/scripts/production-readiness-check-test.js b/scripts/production-readiness-check-test.js index ca778c12..a96856bd 100644 --- a/scripts/production-readiness-check-test.js +++ b/scripts/production-readiness-check-test.js @@ -132,6 +132,39 @@ assert.ok( 'env-only readiness should explicitly warn that DB checks are skipped', ); +const safeAliyunPnvs = runReadiness(` +NODE_ENV=production +DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku +CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com +AUTH_SMS_PROVIDER=aliyun-pnvs +AUTH_CODE_PEPPER=${strongSecretA} +AUTH_SESSION_SECRET=${strongSecretB} +AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json +AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1 +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +PLATFORM_ADMIN_API_KEY=${strongSecretC} +STORAGE_DEFAULT_PROVIDER=aliyun_oss +STORAGE_DEFAULT_BUCKET=tiku-assets +STORAGE_REQUIRE_TENANT_PREFIX=true +ALIYUN_OSS_REGION=cn-hangzhou +ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY +ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder +WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http +WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan +WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +WORKER_CRM_BATCH_SIZE=20 +WORKER_COMMERCE_BATCH_SIZE=20 +WORKER_ASSET_BATCH_SIZE=50 +WORKER_IMPORT_BATCH_SIZE=5 +WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5 +`); + +assert.equal(safeAliyunPnvs.status, 0, `aliyun-pnvs readiness should pass without blockers: ${safeAliyunPnvs.stdout} ${safeAliyunPnvs.stderr}`); +assert.equal(safeAliyunPnvs.payload.summary?.blocker, 0, 'aliyun-pnvs readiness should have no blockers'); + const unsafeProviderFixture = runReadiness( ` NODE_ENV=production diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js index 90306fb3..0e099fe4 100644 --- a/scripts/production-readiness-check.js +++ b/scripts/production-readiness-check.js @@ -10,11 +10,23 @@ const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me'; const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me'; const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key'; const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024; -const PRODUCTION_SMS_PROVIDERS = new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']); +const PRODUCTION_SMS_PROVIDERS = new Set([ + 'aliyun', + 'aliyun-sms', + 'aliyun_sms', + 'aliyun-pnvs', + 'aliyun_pnvs', + 'aliyun-sms-auth', + 'aliyun_sms_auth', + 'tencent', + 'tencent-sms', + 'tencent_sms', +]); const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']); const AUTH_PROVIDER_ALIASES = { - sms: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']), + sms: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'aliyun-pnvs', 'aliyun_pnvs', 'aliyun-sms-auth', 'aliyun_sms_auth', 'tencent', 'tencent-sms', 'tencent_sms']), aliyun: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms']), + aliyunPnvs: new Set(['aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth']), tencent: new Set(['tencent', 'tencent-sms', 'tencent_sms']), wechatMiniapp: new Set(['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wx-miniapp', 'wx_miniapp']), wechatWeb: new Set(['wechat-web', 'wechat_web', 'wechat', 'wechat-oauth', 'wechat_oauth']), @@ -232,6 +244,23 @@ function validateAuthProviderPublicConfig(row) { return; } + if (providerIn(provider, AUTH_PROVIDER_ALIASES.aliyunPnvs)) { + blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [ + { label: 'signName', keys: ['signName'] }, + { label: 'templateCode', keys: ['templateCode'] }, + ])); + validateProviderUrl({ + id: `db.auth.${safeProviderName(row.provider)}.endpoint`, + value: publicString(configPublic, ['endpoint']), + allowedHosts: ['aliyuncs.com'], + details, + }); + if (!publicString(configPublic, ['regionId'])) { + warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Aliyun PNVS regionId is not set; default cn-hangzhou will be used', details); + } + return; + } + if (providerIn(provider, AUTH_PROVIDER_ALIASES.tencent)) { blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [ { label: 'smsSdkAppId/appId', keys: ['smsSdkAppId', 'appId'] }, @@ -507,7 +536,7 @@ function validateEnv() { const authSmsProvider = env('AUTH_SMS_PROVIDER', 'mock').trim().toLowerCase(); if (!PRODUCTION_SMS_PROVIDERS.has(authSmsProvider)) { - block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER must be aliyun/aliyun-sms or tencent/tencent-sms in production', { + block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER must be aliyun/aliyun-sms, aliyun-pnvs, or tencent/tencent-sms in production', { provider: authSmsProvider || '(empty)', }); } else { @@ -842,7 +871,7 @@ async function validateDatabase() { left join app_private.tenant_secrets s on s.tenant_id = p.tenant_id and s.secret_scope = case - when lower(replace(p.provider, '_', '-')) in ('aliyun', 'aliyun-sms', 'tencent', 'tencent-sms') then 'sms' + when lower(replace(p.provider, '_', '-')) in ('aliyun', 'aliyun-sms', 'aliyun-pnvs', 'aliyun-pnvs-sms', 'aliyun-sms-auth', 'tencent', 'tencent-sms') then 'sms' else 'oauth' end and s.secret_key = coalesce(nullif(split_part(p.config_public->>'secretRef', ':', 3), ''), p.provider)