forked from wangziqi/gongxue-base
feat: add china auth providers
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
oauthProviderPlaceholderRoute,
|
||||
sendSmsCodeRoute,
|
||||
verifySmsCodeRoute,
|
||||
wechatMiniappLoginRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const authRoutes: RouteDefinition[] = [
|
||||
@@ -13,6 +14,6 @@ export const authRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/auth/me', meRoute],
|
||||
['POST', '/api/auth/logout', logoutRoute],
|
||||
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/wechat-miniapp', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/wechat-miniapp', wechatMiniappLoginRoute],
|
||||
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
|
||||
];
|
||||
|
||||
192
apps/api/src/features/auth/provider-config.ts
Normal file
192
apps/api/src/features/auth/provider-config.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { HttpError } from '../../core/http.js';
|
||||
import { config as appConfig } from '../../core/config.js';
|
||||
|
||||
export type TenantSecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system';
|
||||
|
||||
export interface TenantSecretConfig {
|
||||
secretScope: TenantSecretScope;
|
||||
secretKey: string;
|
||||
secretValue: string | null;
|
||||
secretJson: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TenantAuthProviderConfig {
|
||||
tenantId: string;
|
||||
provider: string;
|
||||
status: 'active' | 'testing' | 'disabled';
|
||||
configPublic: Record<string, unknown>;
|
||||
secret: TenantSecretConfig | null;
|
||||
}
|
||||
|
||||
interface TenantAuthProviderRow {
|
||||
provider: string;
|
||||
status: 'active' | 'testing' | 'disabled';
|
||||
configPublic: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface TenantSecretRow {
|
||||
secretScope: TenantSecretScope;
|
||||
secretKey: string;
|
||||
secretValue: string | null;
|
||||
secretJson: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function parseSecretRef(value: unknown): { scope: TenantSecretScope; key: string } | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const parts = value.trim().split(':');
|
||||
if (parts.length !== 3 || parts[0] !== 'app_private.tenant_secrets') return null;
|
||||
const scope = parts[1] as TenantSecretScope;
|
||||
const key = parts[2];
|
||||
if (!['payment', 'sms', 'oauth', 'storage', 'crm', 'ai', 'system'].includes(scope) || !key) return null;
|
||||
return { scope, key };
|
||||
}
|
||||
|
||||
function defaultSecretScope(provider: string): TenantSecretScope {
|
||||
const normalized = provider.toLowerCase().replace(/[_\s]/g, '-');
|
||||
if (normalized.includes('sms') || normalized === 'aliyun' || normalized === 'tencent') return 'sms';
|
||||
return 'oauth';
|
||||
}
|
||||
|
||||
async function loadTenantSecret(
|
||||
tenantId: string,
|
||||
configPublic: Record<string, unknown>,
|
||||
provider: string,
|
||||
): Promise<TenantSecretConfig | null> {
|
||||
const secretRef = parseSecretRef(configPublic.secretRef);
|
||||
const scope = secretRef?.scope || defaultSecretScope(provider);
|
||||
const key = secretRef?.key || provider;
|
||||
|
||||
const row = await queryOne<TenantSecretRow>(
|
||||
`
|
||||
select secret_scope as "secretScope", secret_key as "secretKey",
|
||||
secret_value as "secretValue", secret_json as "secretJson"
|
||||
from app_private.tenant_secrets
|
||||
where tenant_id = $1
|
||||
and secret_scope = $2
|
||||
and secret_key = $3
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, scope, key],
|
||||
);
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
secretScope: row.secretScope,
|
||||
secretKey: row.secretKey,
|
||||
secretValue: row.secretValue,
|
||||
secretJson: objectValue(row.secretJson),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTenantAuthProviderConfig(
|
||||
tenantId: string,
|
||||
providers: string[],
|
||||
): Promise<TenantAuthProviderConfig | null> {
|
||||
const uniqueProviders = [...new Set(providers.map(item => item.trim()).filter(Boolean))];
|
||||
if (uniqueProviders.length === 0) return null;
|
||||
|
||||
const rows = await query<TenantAuthProviderRow>(
|
||||
`
|
||||
select provider, status, config_public as "configPublic"
|
||||
from public.tenant_auth_providers
|
||||
where tenant_id = $1
|
||||
and provider = any($2::text[])
|
||||
and status in ('active', 'testing')
|
||||
`,
|
||||
[tenantId, uniqueProviders],
|
||||
);
|
||||
|
||||
const row = uniqueProviders.map(provider => rows.find(item => item.provider === provider)).find(Boolean);
|
||||
if (!row) return null;
|
||||
|
||||
const configPublic = objectValue(row.configPublic);
|
||||
const secret = await loadTenantSecret(tenantId, configPublic, row.provider);
|
||||
return {
|
||||
tenantId,
|
||||
provider: row.provider,
|
||||
status: row.status,
|
||||
configPublic,
|
||||
secret,
|
||||
};
|
||||
}
|
||||
|
||||
export function requirePublicString(config: TenantAuthProviderConfig, keys: string[], code: string) {
|
||||
for (const key of keys) {
|
||||
const value = config.configPublic[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
throw new HttpError(503, `${config.provider} public config is missing ${keys[0]}`, code);
|
||||
}
|
||||
|
||||
export function optionalPublicString(config: TenantAuthProviderConfig, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = config.configPublic[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isLocalDevHost(hostname: string) {
|
||||
return ['127.0.0.1', 'localhost', '::1'].includes(hostname);
|
||||
}
|
||||
|
||||
function isAllowedHost(hostname: string, allowedHosts: string[]) {
|
||||
return allowedHosts.some(host => hostname === host || hostname.endsWith(`.${host}`));
|
||||
}
|
||||
|
||||
export function providerEndpoint(
|
||||
authProvider: TenantAuthProviderConfig,
|
||||
fallback: string,
|
||||
allowedHosts: string[],
|
||||
code = 'PROVIDER_ENDPOINT_NOT_ALLOWED',
|
||||
) {
|
||||
const raw = optionalPublicString(authProvider, ['endpoint']) || fallback;
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = new URL(raw);
|
||||
} catch {
|
||||
throw new HttpError(503, `${authProvider.provider} endpoint is invalid`, code);
|
||||
}
|
||||
|
||||
const localDev = !appConfig.isProduction && isLocalDevHost(endpoint.hostname);
|
||||
if (!localDev && endpoint.protocol !== 'https:') {
|
||||
throw new HttpError(503, `${authProvider.provider} endpoint must use HTTPS`, code);
|
||||
}
|
||||
if (!localDev && !isAllowedHost(endpoint.hostname, allowedHosts)) {
|
||||
throw new HttpError(503, `${authProvider.provider} endpoint host is not allowed`, code);
|
||||
}
|
||||
endpoint.username = '';
|
||||
endpoint.password = '';
|
||||
return endpoint.toString();
|
||||
}
|
||||
|
||||
export function optionalPublicObject(config: TenantAuthProviderConfig, key: string) {
|
||||
return objectValue(config.configPublic[key]);
|
||||
}
|
||||
|
||||
export function optionalPublicArray(config: TenantAuthProviderConfig, key: string) {
|
||||
const value = config.configPublic[key];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function secretString(config: TenantAuthProviderConfig, keys: string[]) {
|
||||
const secret = config.secret;
|
||||
if (!secret) return '';
|
||||
if (typeof secret.secretValue === 'string' && secret.secretValue.trim()) return secret.secretValue.trim();
|
||||
for (const key of keys) {
|
||||
const value = secret.secretJson[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function requireSecretString(config: TenantAuthProviderConfig, keys: string[], code: string) {
|
||||
const value = secretString(config, keys);
|
||||
if (value) return value;
|
||||
throw new HttpError(503, `${config.provider} secret is missing ${keys[0]}`, code);
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ 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 {
|
||||
loadTenantAuthProviderConfig,
|
||||
optionalPublicString,
|
||||
providerEndpoint,
|
||||
requirePublicString,
|
||||
requireSecretString,
|
||||
} from './provider-config.js';
|
||||
import {
|
||||
assertChinaPhone,
|
||||
clientIpFrom,
|
||||
@@ -12,6 +19,7 @@ import {
|
||||
generateSmsCode,
|
||||
hashSmsCode,
|
||||
normalizePurpose,
|
||||
upsertOAuthUser,
|
||||
upsertPhoneUser,
|
||||
userAgentFrom,
|
||||
writeLoginEvent,
|
||||
@@ -56,23 +64,51 @@ function jsonObject(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
async function activeSmsProviderName(tenantId: string) {
|
||||
if (config.authSmsProvider !== 'mock') return config.authSmsProvider;
|
||||
function normalizeSmsProviderName(value: string) {
|
||||
const normalized = value.toLowerCase().replace(/_/g, '-');
|
||||
if (normalized === 'aliyun' || normalized === 'aliyun-sms') return 'aliyun';
|
||||
if (normalized === 'tencent' || normalized === 'tencent-sms') return 'tencent';
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
function smsProviderAliases(value: string) {
|
||||
const provider = normalizeSmsProviderName(value);
|
||||
if (provider === 'aliyun') return ['aliyun', 'aliyun-sms', 'aliyun_sms'];
|
||||
if (provider === 'tencent') return ['tencent', 'tencent-sms', 'tencent_sms'];
|
||||
return ['mock'];
|
||||
}
|
||||
|
||||
async function activeSmsProvider(tenantId: string) {
|
||||
if (config.authSmsProvider !== 'mock') {
|
||||
const name = normalizeSmsProviderName(config.authSmsProvider);
|
||||
return {
|
||||
name,
|
||||
providerConfig: name === 'mock' ? null : await loadTenantAuthProviderConfig(tenantId, smsProviderAliases(name)),
|
||||
};
|
||||
}
|
||||
|
||||
const rows = await query<{ provider: string }>(
|
||||
`
|
||||
select provider
|
||||
from public.tenant_auth_providers
|
||||
where tenant_id = $1
|
||||
and provider in ('aliyun', 'tencent', 'mock')
|
||||
and provider in ('aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms', 'mock')
|
||||
and status in ('active', 'testing')
|
||||
order by case provider when 'aliyun' then 0 when 'tencent' then 1 else 2 end
|
||||
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
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return rows[0]?.provider || config.authSmsProvider;
|
||||
const name = normalizeSmsProviderName(rows[0]?.provider || config.authSmsProvider);
|
||||
return {
|
||||
name,
|
||||
providerConfig: name === 'mock' ? null : await loadTenantAuthProviderConfig(tenantId, smsProviderAliases(name)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
@@ -107,8 +143,8 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN');
|
||||
}
|
||||
|
||||
const providerName = await activeSmsProviderName(tenantId);
|
||||
const provider = createSmsProvider(providerName);
|
||||
const providerChoice = await activeSmsProvider(tenantId);
|
||||
const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig);
|
||||
if (config.isProduction && provider.name === 'mock') {
|
||||
throw new HttpError(503, 'SMS provider is not configured for production', 'SMS_PROVIDER_REQUIRED');
|
||||
}
|
||||
@@ -366,11 +402,141 @@ export async function logoutRoute(ctx: RequestContext) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function normalizeOAuthProvider(value: string) {
|
||||
const normalized = value.toLowerCase().replace(/_/g, '-');
|
||||
if (normalized === 'wechat' || normalized === 'wechat-web') return 'wechat-web';
|
||||
if (normalized === 'wechat-miniapp' || normalized === 'wechat-mini') return 'wechat-miniapp';
|
||||
if (normalized === 'qq' || normalized === 'qq-oauth') return 'qq';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function oauthProviderAliases(value: string) {
|
||||
const provider = normalizeOAuthProvider(value);
|
||||
if (provider === 'wechat-miniapp') return ['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wechatMiniapp'];
|
||||
if (provider === 'wechat-web') return ['wechat-web', 'wechat_web', 'wechat'];
|
||||
if (provider === 'qq') return ['qq', 'qq-oauth', 'qq_oauth'];
|
||||
return [provider];
|
||||
}
|
||||
|
||||
async function callWechatCode2Session(input: {
|
||||
endpoint: string;
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
code: string;
|
||||
}) {
|
||||
const url = new URL(input.endpoint);
|
||||
url.searchParams.set('appid', input.appId);
|
||||
url.searchParams.set('secret', input.appSecret);
|
||||
url.searchParams.set('js_code', input.code);
|
||||
url.searchParams.set('grant_type', 'authorization_code');
|
||||
|
||||
const response = await fetch(url);
|
||||
const raw = jsonObject(await response.json().catch(() => ({})));
|
||||
const errcode = Number(raw.errcode || 0);
|
||||
if (!response.ok || errcode) {
|
||||
const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
|
||||
throw new HttpError(401, `WeChat miniapp code exchange failed${suffix}`, 'WECHAT_CODE_EXCHANGE_FAILED');
|
||||
}
|
||||
|
||||
const openId = typeof raw.openid === 'string' ? raw.openid.trim() : '';
|
||||
const sessionKey = typeof raw.session_key === 'string' ? raw.session_key.trim() : '';
|
||||
const unionId = typeof raw.unionid === 'string' ? raw.unionid.trim() : '';
|
||||
if (!openId || !sessionKey) {
|
||||
throw new HttpError(401, 'WeChat miniapp response is missing openid or session_key', 'WECHAT_CODE_EXCHANGE_INVALID');
|
||||
}
|
||||
|
||||
return { openId, sessionKey, unionId, raw };
|
||||
}
|
||||
|
||||
export async function wechatMiniappLoginRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const code = requiredString(body, 'code');
|
||||
const profile = jsonObject(body.profile);
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('wechat-miniapp'));
|
||||
if (!providerConfig) {
|
||||
throw new HttpError(503, 'WeChat miniapp auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
const appId = requirePublicString(providerConfig, ['appId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
|
||||
const appSecret = requireSecretString(providerConfig, ['appSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
|
||||
const endpoint = providerEndpoint(
|
||||
providerConfig,
|
||||
'https://api.weixin.qq.com/sns/jscode2session',
|
||||
['weixin.qq.com'],
|
||||
'OAUTH_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
const exchanged = await callWechatCode2Session({ endpoint, appId, appSecret, code });
|
||||
const provider = 'wechat-miniapp';
|
||||
const providerSubject = `${appId}:${exchanged.openId}`;
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const { user, isNewUser } = await upsertOAuthUser(client, {
|
||||
tenantId,
|
||||
provider,
|
||||
providerSubject,
|
||||
openId: exchanged.openId,
|
||||
unionId: exchanged.unionId || null,
|
||||
profile,
|
||||
secretPayload: {
|
||||
hasSessionKey: true,
|
||||
sessionKeyUpdatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const session = await createLoginSession(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: {
|
||||
appId,
|
||||
openId: exchanged.openId,
|
||||
unionId: exchanged.unionId || null,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider,
|
||||
identifier: exchanged.openId,
|
||||
result: 'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: {
|
||||
appId,
|
||||
unionId: exchanged.unionId || null,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
provider,
|
||||
user,
|
||||
isNewUser,
|
||||
session,
|
||||
identity: {
|
||||
provider,
|
||||
openId: exchanged.openId,
|
||||
unionId: exchanged.unionId || null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function oauthProviderPlaceholderRoute(ctx: RequestContext) {
|
||||
const provider = ctx.url.pathname.split('/').at(-1) || 'oauth';
|
||||
const normalized = normalizeOAuthProvider(provider);
|
||||
const configured = await loadTenantAuthProviderConfig(await tenantIdFrom(ctx), oauthProviderAliases(normalized));
|
||||
throw new HttpError(
|
||||
501,
|
||||
`${provider} OAuth adapter is scaffolded but not configured. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
|
||||
'PROVIDER_NOT_CONFIGURED',
|
||||
configured ? 501 : 503,
|
||||
`${provider} OAuth adapter is not implemented yet. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
|
||||
configured ? 'PROVIDER_NOT_IMPLEMENTED' : 'PROVIDER_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,195 @@ export async function upsertPhoneUser(
|
||||
return { user, isNewUser: true };
|
||||
}
|
||||
|
||||
export async function upsertOAuthUser(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
provider: string;
|
||||
providerSubject: string;
|
||||
openId?: string | null;
|
||||
unionId?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
profile?: Record<string, unknown>;
|
||||
secretPayload?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const existing = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
||||
from public.user_identities i
|
||||
join public.platform_users u on u.id = i.user_id
|
||||
where i.provider = $1
|
||||
and i.provider_subject = $2
|
||||
limit 1
|
||||
`,
|
||||
[input.provider, input.providerSubject],
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
const user = existing.rows[0];
|
||||
await client.query(
|
||||
`
|
||||
update public.user_identities
|
||||
set union_id = coalesce($3, union_id),
|
||||
open_id = coalesce($4, open_id),
|
||||
phone = coalesce($5, phone),
|
||||
email = coalesce($6::citext, email),
|
||||
secret_payload = case
|
||||
when $7::jsonb = '{}'::jsonb then secret_payload
|
||||
else secret_payload || $7::jsonb
|
||||
end,
|
||||
updated_at = now()
|
||||
where provider = $1 and provider_subject = $2
|
||||
`,
|
||||
[
|
||||
input.provider,
|
||||
input.providerSubject,
|
||||
input.unionId || null,
|
||||
input.openId || null,
|
||||
input.phone || null,
|
||||
input.email || null,
|
||||
JSON.stringify(input.secretPayload || {}),
|
||||
],
|
||||
);
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: false };
|
||||
}
|
||||
|
||||
const unionMatch = input.unionId
|
||||
? await client.query<PlatformUserSummary>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
||||
from public.user_identities i
|
||||
join public.platform_users u on u.id = i.user_id
|
||||
where i.union_id = $1
|
||||
and i.provider in ('wechat-miniapp', 'wechat_miniapp', 'wechat_web')
|
||||
order by u.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
[input.unionId],
|
||||
)
|
||||
: null;
|
||||
|
||||
const phoneMatch =
|
||||
!unionMatch?.rows[0] && input.phone
|
||||
? await client.query<PlatformUserSummary>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt"
|
||||
from public.platform_users u
|
||||
where u.phone = $1
|
||||
order by u.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
[input.phone],
|
||||
)
|
||||
: null;
|
||||
|
||||
const matchedUser = unionMatch?.rows[0] || phoneMatch?.rows[0] || null;
|
||||
if (matchedUser) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_identities (
|
||||
user_id, provider, provider_subject, union_id, open_id, phone, email, secret_payload
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::citext, $8::jsonb)
|
||||
on conflict (provider, provider_subject)
|
||||
do update set user_id = excluded.user_id,
|
||||
union_id = excluded.union_id,
|
||||
open_id = excluded.open_id,
|
||||
phone = excluded.phone,
|
||||
email = excluded.email,
|
||||
secret_payload = excluded.secret_payload,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
matchedUser.id,
|
||||
input.provider,
|
||||
input.providerSubject,
|
||||
input.unionId || null,
|
||||
input.openId || null,
|
||||
input.phone || matchedUser.phone || null,
|
||||
input.email || null,
|
||||
JSON.stringify(input.secretPayload || {}),
|
||||
],
|
||||
);
|
||||
await ensureStudentTenantRecords(client, input.tenantId, matchedUser.id);
|
||||
return { user: matchedUser, isNewUser: false };
|
||||
}
|
||||
|
||||
const profile = input.profile || {};
|
||||
const nickname =
|
||||
typeof profile.nickname === 'string'
|
||||
? profile.nickname
|
||||
: typeof profile.nickName === 'string'
|
||||
? profile.nickName
|
||||
: typeof profile.name === 'string'
|
||||
? profile.name
|
||||
: null;
|
||||
const avatarUrl =
|
||||
typeof profile.avatarUrl === 'string'
|
||||
? profile.avatarUrl
|
||||
: typeof profile.avatar_url === 'string'
|
||||
? profile.avatar_url
|
||||
: null;
|
||||
|
||||
const userResult = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
insert into public.platform_users (username, phone, email, name, avatar_url, primary_role, raw_profile)
|
||||
values ($1, $2, $3::citext, $4, $5, 'student', $6::jsonb)
|
||||
returning id, username, phone, name, avatar_url as "avatarUrl",
|
||||
primary_role as "primaryRole", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
`${input.provider.replace(/[^a-z0-9]/gi, '_')}_${crypto.randomBytes(5).toString('hex')}`,
|
||||
input.phone || null,
|
||||
input.email || null,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
JSON.stringify({
|
||||
source: input.provider,
|
||||
profile,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.user_identities (
|
||||
user_id, provider, provider_subject, union_id, open_id, phone, email, secret_payload
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::citext, $8::jsonb)
|
||||
on conflict (provider, provider_subject)
|
||||
do update set user_id = excluded.user_id,
|
||||
union_id = excluded.union_id,
|
||||
open_id = excluded.open_id,
|
||||
phone = excluded.phone,
|
||||
email = excluded.email,
|
||||
secret_payload = excluded.secret_payload,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
user.id,
|
||||
input.provider,
|
||||
input.providerSubject,
|
||||
input.unionId || null,
|
||||
input.openId || null,
|
||||
input.phone || null,
|
||||
input.email || null,
|
||||
JSON.stringify(input.secretPayload || {}),
|
||||
],
|
||||
);
|
||||
|
||||
await ensureStudentTenantRecords(client, input.tenantId, user.id);
|
||||
return { user, isNewUser: true };
|
||||
}
|
||||
|
||||
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
await client.query(
|
||||
`
|
||||
|
||||
@@ -19,31 +19,109 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先
|
||||
- `POST /api/auth/sms/verify`:验证码登录,自动创建或复用 `platform_users`。
|
||||
- `GET /api/auth/me`:通过 Bearer token 获取当前用户。
|
||||
- `POST /api/auth/logout`:吊销迁移期 session。
|
||||
- `POST /api/auth/oauth/wechat`、`/wechat-miniapp`、`/qq`:provider 占位,已固定错误码 `PROVIDER_NOT_CONFIGURED`。
|
||||
- `POST /api/auth/oauth/wechat-miniapp`:微信小程序 `code2Session` 登录,后端换取 openid/unionid,签发 `tk_` session。
|
||||
- `POST /api/auth/oauth/wechat`、`/qq`:provider 占位,已固定 `PROVIDER_NOT_CONFIGURED` / `PROVIDER_NOT_IMPLEMENTED`。
|
||||
- `tenant_auth_providers`:租户级公开认证配置。
|
||||
- `sms_verification_codes`:验证码审计表,不保存明文 code。
|
||||
- `auth_login_events`:登录事件审计。
|
||||
- `app_private.auth_sessions`:迁移期 session token hash。
|
||||
- 阿里云短信 `SendSms` provider:使用租户级 AccessKey、签名、模板发送。
|
||||
- 腾讯云短信 `SendSms` provider:使用租户级 SecretId/SecretKey、SdkAppId、签名、模板发送。
|
||||
|
||||
## 短信 Provider
|
||||
|
||||
本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境如果仍为 mock,会直接拒绝发送。
|
||||
|
||||
后续真实 provider:
|
||||
真实 provider:
|
||||
|
||||
- `aliyun`:接阿里云短信 `SendSms`,需要 AccessKey、签名、模板 ID。
|
||||
- `tencent`:接腾讯云短信 `SendSms`,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。
|
||||
- `aliyun` / `aliyun-sms`:阿里云短信 `SendSms`,需要 AccessKey、签名、模板 ID。
|
||||
- `tencent` / `tencent-sms`:腾讯云短信 `SendSms`,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。
|
||||
|
||||
密钥策略:
|
||||
|
||||
- AccessKey/SecretKey 不进入 `tenant_settings.public_config`。
|
||||
- 租户级密钥写 `app_private.tenant_secrets(secret_scope='sms')` 或生产 Vault。
|
||||
- 前端只能看到 provider 是否启用、签名展示名、隐私协议链接等非敏感配置。
|
||||
- provider endpoint 默认使用官方域名,生产环境只允许 HTTPS 官方域名;本地测试可使用 `localhost/127.0.0.1` fake server。
|
||||
|
||||
### 阿里云短信配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "aliyun",
|
||||
"status": "active",
|
||||
"configPublic": {
|
||||
"signName": "工学教育",
|
||||
"templateCode": "SMS_123456789",
|
||||
"regionId": "cn-hangzhou"
|
||||
},
|
||||
"secret": {
|
||||
"secretScope": "sms",
|
||||
"secretKey": "aliyun",
|
||||
"secretJson": {
|
||||
"accessKeyId": "LTAI...",
|
||||
"accessKeySecret": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 腾讯云短信配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "tencent",
|
||||
"status": "active",
|
||||
"configPublic": {
|
||||
"smsSdkAppId": "1400000000",
|
||||
"signName": "工学教育",
|
||||
"templateId": "123456",
|
||||
"region": "ap-guangzhou",
|
||||
"templateParamSet": ["{code}"]
|
||||
},
|
||||
"secret": {
|
||||
"secretScope": "sms",
|
||||
"secretKey": "tencent",
|
||||
"secretJson": {
|
||||
"secretId": "AKID...",
|
||||
"secretKey": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 微信/QQ 登录
|
||||
|
||||
微信小程序登录应由前端传 `wx.login` code 到 `/api/auth/oauth/wechat-miniapp`,后端调用微信 `code2Session` 换取 openid/session_key/unionid,再落 `user_identities`。
|
||||
|
||||
已实现微信小程序登录主链路:
|
||||
|
||||
- 前端传 `code` 和可选 `profile`。
|
||||
- 后端读取租户 `wechat-miniapp` / `wechat_miniapp` provider 配置。
|
||||
- `appId` 存在 `config_public`,`appSecret` 存在 `app_private.tenant_secrets(secret_scope='oauth')`。
|
||||
- `provider_subject` 使用 `appId:openid`,避免不同小程序 openid 碰撞。
|
||||
- `session_key` 不返回前端,不写入公开 `raw_profile`;当前只记录 `hasSessionKey` 和更新时间标记。
|
||||
- 登录成功写 `auth_login_events`,并签发 `tk_` session。
|
||||
|
||||
配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "wechat-miniapp",
|
||||
"displayName": "微信小程序登录",
|
||||
"status": "active",
|
||||
"configPublic": {
|
||||
"appId": "wx...",
|
||||
"envVersion": "release"
|
||||
},
|
||||
"secret": {
|
||||
"secretScope": "oauth",
|
||||
"secretKey": "wechat-miniapp",
|
||||
"secretValue": "小程序 AppSecret"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
微信网页 OAuth 和 QQ OAuth 也必须在后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 `referral` feature,不继续堆在 auth 模块里。
|
||||
|
||||
## 支付 Provider
|
||||
|
||||
@@ -35,9 +35,10 @@
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 短信验证码登录 | 迁移期 | 已有验证码、冷却、hash、登录事件;mock provider 可本地联调 |
|
||||
| 短信验证码登录 | 可联调 | 已有验证码、冷却、hash、登录事件;支持 mock、阿里云短信、腾讯云短信 provider,生产仍需真实账号联调 |
|
||||
| 迁移期 session | 迁移期 | `tk_` token hash 存在 `app_private.auth_sessions`,用户态接口已优先解析 bearer session 并拒绝伪造 userId/tenantId |
|
||||
| 微信/QQ OAuth | 待补齐 | 目前是 placeholder |
|
||||
| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 |
|
||||
| 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder,需要 code 换 token、回调域名、账号合并和审计 |
|
||||
| 平台管理员鉴权 | 迁移期 | `x-platform-admin-key` 已可通过配置禁用;生产前必须换平台管理员 JWT/服务端会话 |
|
||||
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions`,接口有权限点校验 |
|
||||
| 自定义角色模板 | 待补齐 | 当前有权限 JSON 覆盖,缺角色模板、菜单/模块/字段级权限配置 UI/API |
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
- `x-user-id` 或 body/query 的 `userId` 只允许在 `ALLOW_LEGACY_AUTH_HEADERS=true` 的本地/迁移期环境使用。
|
||||
- `x-platform-admin-key` 只允许在 `ALLOW_PLATFORM_ADMIN_KEY=true` 的本地/迁移期环境使用。
|
||||
- 本地短信 provider 可使用 `mock`。
|
||||
- 真实短信 provider 已支持阿里云和腾讯云,密钥只能从 `app_private.tenant_secrets` 读取。
|
||||
- 微信小程序登录已由后端调用 `code2Session`,前端不得接触 AppSecret 或 session_key。
|
||||
- `NODE_ENV=production` 下禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true`、`AUTH_SMS_PROVIDER=mock`、默认/弱密钥和 `CORS_ORIGIN=*`。
|
||||
|
||||
这些只允许用于本地开发和内网联调,不允许作为正式云端验收方案。
|
||||
@@ -70,6 +72,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
|
||||
- 商户密钥、短信 secret、OAuth secret 不允许明文长期存储。
|
||||
- 生产应使用 KMS/Vault 或 envelope encryption。
|
||||
- API 只返回 `secretRef`、掩码和配置状态。
|
||||
- provider endpoint 生产环境必须使用 HTTPS 官方域名;本地测试才允许 `localhost/127.0.0.1` fake server。
|
||||
|
||||
7. RLS 与 API 双层回归
|
||||
- 数据库 RLS 要按 `tenant_id` 拦截。
|
||||
|
||||
@@ -54,8 +54,9 @@
|
||||
- 租户自有商户收款和平台代收/服务商模式。
|
||||
|
||||
2. 国内登录和短信
|
||||
- 阿里云短信、腾讯云短信 adapter。
|
||||
- 微信小程序登录、微信网页登录、QQ 登录。
|
||||
- 已完成阿里云短信、腾讯云短信 adapter 的后端实现和本地 fake endpoint 测试。
|
||||
- 已完成微信小程序 `code2Session` 登录主链路。
|
||||
- 继续补微信网页登录、QQ 登录、手机号绑定/换绑、真实生产账号联调。
|
||||
- 旧 PocketBase 用户账号和新身份体系的映射/补绑。
|
||||
|
||||
3. 导入体系扩展
|
||||
|
||||
@@ -129,7 +129,7 @@ tenant:<tenantId>:theme
|
||||
| 页面 | 主要接口 |
|
||||
| --- | --- |
|
||||
| 启动页 | `GET /api/tenant/resolve` |
|
||||
| 登录页 | `POST /api/auth/sms/send`、`POST /api/auth/sms/verify`、后续微信/QQ provider |
|
||||
| 登录页 | `POST /api/auth/sms/send`、`POST /api/auth/sms/verify`、`POST /api/auth/oauth/wechat-miniapp`、后续微信网页/QQ provider |
|
||||
| 首页 | `/api/catalog/content-entries`、`/api/catalog/banners`、`/api/catalog/announcements`、`/api/profile/me` |
|
||||
| 选地区 | `/api/catalog/regions`、`/api/commerce/entitlements/check` |
|
||||
| 题库入口 | `/api/catalog/content-entries` |
|
||||
@@ -182,6 +182,69 @@ content_entries
|
||||
- 管理后台菜单按 `GET /api/tenant-admin/permissions` 和用户权限渲染。
|
||||
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。
|
||||
|
||||
## 登录对接
|
||||
|
||||
### 短信登录
|
||||
|
||||
开发环境可以先使用 mock 短信,接口会返回 `debugCode`。生产环境禁止依赖 `debugCode`。
|
||||
|
||||
```text
|
||||
POST /api/auth/sms/send
|
||||
body: { "phone": "13800000000", "purpose": "login" }
|
||||
|
||||
POST /api/auth/sms/verify
|
||||
body: { "phone": "13800000000", "code": "123456", "purpose": "login" }
|
||||
```
|
||||
|
||||
成功后保存:
|
||||
|
||||
```text
|
||||
session.token
|
||||
session.expiresAt
|
||||
user
|
||||
```
|
||||
|
||||
后续请求统一带:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <session.token>
|
||||
x-tenant-id: <tenantId>
|
||||
```
|
||||
|
||||
### 微信小程序登录
|
||||
|
||||
微信小程序端调用 `Taro.login()` 获取 code,然后交给后端:
|
||||
|
||||
```text
|
||||
POST /api/auth/oauth/wechat-miniapp
|
||||
body: {
|
||||
"code": "<wx.login code>",
|
||||
"profile": {
|
||||
"nickName": "...",
|
||||
"avatarUrl": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
成功响应包含:
|
||||
|
||||
```text
|
||||
provider
|
||||
user
|
||||
isNewUser
|
||||
session.token
|
||||
session.expiresAt
|
||||
identity.openId
|
||||
identity.unionId
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 前端不接触 `appSecret`。
|
||||
- 前端不会拿到微信 `session_key`。
|
||||
- 如果登录前已经解析到推广码,登录成功后再调用 `/api/referral/bind` 完成首绑保护。
|
||||
- 手机号授权后续应走独立的“绑定手机号”接口,不要把微信手机号解密逻辑写在页面里。
|
||||
|
||||
## 第一阶段页面建议
|
||||
|
||||
1. `pages/bootstrap/index`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
@@ -36,6 +37,7 @@ let serverProcess = null;
|
||||
let serverLogs = '';
|
||||
let legacyDisabledServer = null;
|
||||
let legacyDisabledServerLogs = '';
|
||||
let fakeWechatServer = null;
|
||||
|
||||
function buildUrl(path, query = {}) {
|
||||
return buildUrlAt(apiBase, path, query);
|
||||
@@ -200,6 +202,56 @@ async function startLegacyDisabledServer() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
async function startFakeWechatServer() {
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const requests = [];
|
||||
fakeWechatServer = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
requests.push({
|
||||
method: req.method,
|
||||
pathname: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
});
|
||||
|
||||
if (url.pathname !== '/sns/jscode2session') {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ errcode: 404, errmsg: 'not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
url.searchParams.get('appid') !== 'wx-smoke-appid' ||
|
||||
url.searchParams.get('secret') !== 'wechat-app-secret-smoke' ||
|
||||
url.searchParams.get('grant_type') !== 'authorization_code'
|
||||
) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ errcode: 40013, errmsg: 'invalid appid or secret' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const jsCode = url.searchParams.get('js_code') || 'unknown';
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
openid: `openid-${jsCode}`,
|
||||
session_key: `session-key-${jsCode}`,
|
||||
unionid: 'unionid-smoke-user',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
fakeWechatServer.once('error', reject);
|
||||
fakeWechatServer.listen(port, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: `${baseUrl}/sns/jscode2session`,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForProcessExit(child, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -366,6 +418,10 @@ function stopServer() {
|
||||
if (legacyDisabledServer && !legacyDisabledServer.killed) {
|
||||
legacyDisabledServer.kill();
|
||||
}
|
||||
if (fakeWechatServer) {
|
||||
fakeWechatServer.close();
|
||||
fakeWechatServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testCatalogAndLearning() {
|
||||
@@ -1426,6 +1482,7 @@ async function testTenantContentAssetsAndImports() {
|
||||
}
|
||||
|
||||
async function testTenantAdminOps() {
|
||||
const fakeWechat = await startFakeWechatServer();
|
||||
const denied = await request('/api/tenant-admin/branding', {
|
||||
method: 'PUT',
|
||||
body: { brandName: '学生不能改品牌' },
|
||||
@@ -1477,6 +1534,7 @@ async function testTenantAdminOps() {
|
||||
configPublic: {
|
||||
appId: 'wx-smoke-appid',
|
||||
envVersion: 'trial',
|
||||
endpoint: fakeWechat.endpoint,
|
||||
},
|
||||
secret: {
|
||||
secretValue: 'wechat-app-secret-smoke',
|
||||
@@ -1488,6 +1546,32 @@ async function testTenantAdminOps() {
|
||||
assert.equal(authProvider.item?.secret?.hasSecretValue, true, 'auth provider should report masked secret status');
|
||||
assert.ok(!JSON.stringify(authProvider).includes('wechat-app-secret-smoke'), 'auth provider response must not include secret plaintext');
|
||||
|
||||
const miniappLogin = await request('/api/auth/oauth/wechat-miniapp', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: 'integration-code-001',
|
||||
profile: {
|
||||
nickName: '微信烟测学生',
|
||||
avatarUrl: 'https://example.test/avatar.png',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(miniappLogin.provider, 'wechat-miniapp', 'wechat miniapp login should return provider');
|
||||
assert.ok(miniappLogin.user?.id, 'wechat miniapp login should create or resolve user');
|
||||
assert.ok(miniappLogin.session?.token?.startsWith('tk_'), 'wechat miniapp login should issue API session token');
|
||||
assert.equal(miniappLogin.identity?.openId, 'openid-integration-code-001', 'wechat miniapp login should expose openId');
|
||||
assert.equal(miniappLogin.identity?.unionId, 'unionid-smoke-user', 'wechat miniapp login should expose unionId');
|
||||
assert.equal(fakeWechat.requests.at(-1)?.query?.js_code, 'integration-code-001', 'wechat code should be exchanged server-side');
|
||||
assert.ok(!JSON.stringify(miniappLogin).includes('session-key-integration-code-001'), 'login response must not leak WeChat session_key');
|
||||
assert.ok(!JSON.stringify(miniappLogin).includes('wechat-app-secret-smoke'), 'login response must not leak app secret');
|
||||
|
||||
const miniappMe = await request('/api/auth/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${miniappLogin.session.token}` },
|
||||
});
|
||||
assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me');
|
||||
|
||||
const paymentAccount = await request('/api/tenant-admin/payment-accounts', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
|
||||
Reference in New Issue
Block a user