forked from wangziqi/gongxue-base
feat: add china auth providers
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '../../core/http.js';
|
||||
import {
|
||||
optionalPublicArray,
|
||||
optionalPublicObject,
|
||||
optionalPublicString,
|
||||
providerEndpoint,
|
||||
requirePublicString,
|
||||
requireSecretString,
|
||||
type TenantAuthProviderConfig,
|
||||
} from './provider-config.js';
|
||||
|
||||
export type SmsProviderName = 'mock' | 'aliyun' | 'tencent';
|
||||
export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'qq';
|
||||
export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'wechat-miniapp' | 'qq';
|
||||
|
||||
export interface SmsSendInput {
|
||||
tenantId: string;
|
||||
@@ -21,6 +33,68 @@ export interface SmsProvider {
|
||||
send(input: SmsSendInput): Promise<SmsSendResult>;
|
||||
}
|
||||
|
||||
function hmacSha256(key: crypto.BinaryLike | crypto.KeyObject, message: string) {
|
||||
return crypto.createHmac('sha256', key).update(message, 'utf8').digest();
|
||||
}
|
||||
|
||||
function sha256Hex(message: string) {
|
||||
return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function encodeRFC3986(value: string) {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
}
|
||||
|
||||
function canonicalQuery(params: Record<string, string>) {
|
||||
return Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${encodeRFC3986(key)}=${encodeRFC3986(params[key] ?? '')}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function safeRaw(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function maskedProviderError(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 send failed${suffix}`, 'SMS_PROVIDER_SEND_FAILED');
|
||||
}
|
||||
|
||||
function templateParams(config: TenantAuthProviderConfig, input: SmsSendInput) {
|
||||
const configured = optionalPublicArray(config, 'templateParamSet')
|
||||
.concat(optionalPublicArray(config, 'templateParams'))
|
||||
.map(item => String(item));
|
||||
if (configured.length > 0) {
|
||||
return configured.map(item =>
|
||||
item
|
||||
.replace(/\{\{\s*code\s*\}\}/gi, input.code)
|
||||
.replace(/\{code\}/gi, input.code)
|
||||
.replace(/\$\{code\}/gi, input.code),
|
||||
);
|
||||
}
|
||||
|
||||
const mapping = optionalPublicObject(config, 'templateParam');
|
||||
if (Object.keys(mapping).length > 0) {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(mapping)) {
|
||||
normalized[key] = String(value)
|
||||
.replace(/\{\{\s*code\s*\}\}/gi, input.code)
|
||||
.replace(/\{code\}/gi, input.code)
|
||||
.replace(/\$\{code\}/gi, input.code);
|
||||
}
|
||||
if (!Object.values(normalized).includes(input.code)) normalized.code = input.code;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return { code: input.code };
|
||||
}
|
||||
|
||||
function phoneWithChinaCode(phone: string) {
|
||||
return phone.startsWith('+') ? phone : `+86${phone}`;
|
||||
}
|
||||
|
||||
class MockSmsProvider implements SmsProvider {
|
||||
readonly name = 'mock' as const;
|
||||
|
||||
@@ -37,16 +111,149 @@ class NotConfiguredSmsProvider implements SmsProvider {
|
||||
constructor(readonly name: SmsProviderName) {}
|
||||
|
||||
async send(): Promise<SmsSendResult> {
|
||||
throw new Error(`${this.name} SMS provider is not configured yet`);
|
||||
throw new HttpError(503, `${this.name} SMS provider is not configured`, 'SMS_PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
}
|
||||
|
||||
export function createSmsProvider(name: string): SmsProvider {
|
||||
if (name === 'aliyun') return new NotConfiguredSmsProvider('aliyun');
|
||||
if (name === 'tencent') return new NotConfiguredSmsProvider('tencent');
|
||||
class AliyunSmsProvider implements SmsProvider {
|
||||
readonly name = 'aliyun' as const;
|
||||
|
||||
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',
|
||||
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);
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
status: 'sent',
|
||||
providerMessageId: typeof raw.BizId === 'string' ? raw.BizId : undefined,
|
||||
raw: {
|
||||
requestId: raw.RequestId,
|
||||
code,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TencentSmsProvider implements SmsProvider {
|
||||
readonly name = 'tencent' as const;
|
||||
|
||||
constructor(private readonly providerConfig: TenantAuthProviderConfig) {}
|
||||
|
||||
async send(input: SmsSendInput): Promise<SmsSendResult> {
|
||||
const secretId = requireSecretString(this.providerConfig, ['secretId', 'SecretId'], 'SMS_SECRET_REQUIRED');
|
||||
const secretKey = requireSecretString(this.providerConfig, ['secretKey', 'SecretKey'], 'SMS_SECRET_REQUIRED');
|
||||
const smsSdkAppId = requirePublicString(this.providerConfig, ['smsSdkAppId', 'appId'], 'SMS_PUBLIC_CONFIG_REQUIRED');
|
||||
const signName = requirePublicString(this.providerConfig, ['signName'], 'SMS_PUBLIC_CONFIG_REQUIRED');
|
||||
const templateId = requirePublicString(this.providerConfig, ['templateId'], 'SMS_PUBLIC_CONFIG_REQUIRED');
|
||||
const region = optionalPublicString(this.providerConfig, ['region']) || 'ap-guangzhou';
|
||||
const endpoint = providerEndpoint(
|
||||
this.providerConfig,
|
||||
'https://sms.tencentcloudapi.com',
|
||||
['tencentcloudapi.com'],
|
||||
'SMS_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
const host = new URL(endpoint).host;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const date = new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
const payload = JSON.stringify({
|
||||
PhoneNumberSet: [phoneWithChinaCode(input.phone)],
|
||||
SmsSdkAppId: smsSdkAppId,
|
||||
SignName: signName,
|
||||
TemplateId: templateId,
|
||||
TemplateParamSet: templateParams(this.providerConfig, input),
|
||||
});
|
||||
|
||||
const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${host}\nx-tc-action:sendsms\n`;
|
||||
const signedHeaders = 'content-type;host;x-tc-action';
|
||||
const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, sha256Hex(payload)].join('\n');
|
||||
const credentialScope = `${date}/sms/tc3_request`;
|
||||
const stringToSign = ['TC3-HMAC-SHA256', timestamp, credentialScope, sha256Hex(canonicalRequest)].join('\n');
|
||||
const secretDate = hmacSha256(`TC3${secretKey}`, date);
|
||||
const secretService = hmacSha256(secretDate, 'sms');
|
||||
const secretSigning = hmacSha256(secretService, 'tc3_request');
|
||||
const signature = crypto.createHmac('sha256', secretSigning).update(stringToSign, 'utf8').digest('hex');
|
||||
const authorization = `TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization,
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
host,
|
||||
'x-tc-action': 'SendSms',
|
||||
'x-tc-region': region,
|
||||
'x-tc-timestamp': String(timestamp),
|
||||
'x-tc-version': optionalPublicString(this.providerConfig, ['version']) || '2021-01-11',
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
const raw = safeRaw(await response.json().catch(() => ({})));
|
||||
const responsePayload = safeRaw(raw.Response);
|
||||
const error = safeRaw(responsePayload.Error);
|
||||
if (!response.ok || Object.keys(error).length > 0) maskedProviderError(this.name, response.status, responsePayload);
|
||||
|
||||
const sendStatusSet = Array.isArray(responsePayload.SendStatusSet) ? responsePayload.SendStatusSet : [];
|
||||
const firstStatus = safeRaw(sendStatusSet[0]);
|
||||
const code = typeof firstStatus.Code === 'string' ? firstStatus.Code : 'Ok';
|
||||
if (code !== 'Ok') maskedProviderError(this.name, response.status || 200, responsePayload);
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
status: 'sent',
|
||||
providerMessageId: typeof firstStatus.SerialNo === 'string' ? firstStatus.SerialNo : undefined,
|
||||
raw: {
|
||||
requestId: responsePayload.RequestId,
|
||||
code,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createSmsProvider(name: string, providerConfig?: TenantAuthProviderConfig | null): SmsProvider {
|
||||
if (name === 'aliyun') return providerConfig ? new AliyunSmsProvider(providerConfig) : new NotConfiguredSmsProvider('aliyun');
|
||||
if (name === 'tencent') return providerConfig ? new TencentSmsProvider(providerConfig) : new NotConfiguredSmsProvider('tencent');
|
||||
return new MockSmsProvider();
|
||||
}
|
||||
|
||||
export function supportedOAuthProviders(): OAuthProviderName[] {
|
||||
return ['wechat_web', 'wechat_miniapp', 'qq'];
|
||||
return ['wechat_web', 'wechat_miniapp', 'wechat-miniapp', 'qq'];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user