forked from wangziqi/gongxue-base
feat: support Aliyun PNVS SMS verification
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 推荐:
|
||||
|
||||
@@ -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",
|
||||
|
||||
26
scripts/aliyun-pnvs-provider-contract-test.js
Normal file
26
scripts/aliyun-pnvs-provider-contract-test.js
Normal 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');
|
||||
8
scripts/deploy/env/api.env.example
vendored
8
scripts/deploy/env/api.env.example
vendored
@@ -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
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user