feat: support Aliyun PNVS SMS verification

This commit is contained in:
Codex
2026-07-03 22:34:19 +08:00
parent 8ebe505dba
commit 69b3d80621
11 changed files with 441 additions and 59 deletions

View File

@@ -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');

View File

@@ -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<string, unknown>;
}
export interface SmsSendResult {
provider: SmsProviderName;
status: 'sent' | 'mocked';
verification?: 'local' | 'provider';
providerMessageId?: string;
raw?: Record<string, unknown>;
}
export interface SmsVerifyInput {
tenantId: string;
phone: string;
code: string;
purpose: string;
outId?: string;
metadata: Record<string, unknown>;
}
export interface SmsVerifyResult {
verified: boolean;
raw?: Record<string, unknown>;
}
export interface SmsProvider {
name: SmsProviderName;
send(input: SmsSendInput): Promise<SmsSendResult>;
verify?(input: SmsVerifyInput): Promise<SmsVerifyResult>;
}
function hmacSha256(key: crypto.BinaryLike | crypto.KeyObject, message: string) {
@@ -62,6 +82,12 @@ function maskedProviderError(provider: string, status: number, raw: Record<strin
throw new HttpError(status >= 400 && status < 500 ? 502 : 503, `${provider} SMS send failed${suffix}`, 'SMS_PROVIDER_SEND_FAILED');
}
function maskedProviderVerifyError(provider: string, status: number, raw: Record<string, unknown>) {
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<string, string>;
},
) {
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<SmsSendResult> {
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<string, string> = {};
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<SmsSendResult> {
const signName = requirePublicString(this.providerConfig, ['signName'], 'SMS_PUBLIC_CONFIG_REQUIRED');
const templateCode = requirePublicString(this.providerConfig, ['templateCode'], 'SMS_PUBLIC_CONFIG_REQUIRED');
const params: Record<string, string> = {
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<SmsVerifyResult> {
const params: Record<string, string> = {
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();
}

View File

@@ -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<string, unknown> | 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<SmsCodeRow>(
`
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<string, unknown> | 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<SmsVerifyResult>(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<PhoneBindResult>(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<PlatformUserSummary>(