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>(

View File

@@ -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 或写成未知 providerAPI 启动和 `readiness:production` 都会直接失败。
本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境只允许 `aliyun/aliyun-sms``aliyun-pnvs/aliyun-sms-auth``tencent/tencent-sms`;如果仍为 mock 或写成未知 providerAPI 启动和 `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

View File

@@ -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 推荐:

View File

@@ -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",

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

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