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>(
|
||||
|
||||
Reference in New Issue
Block a user