diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts index 8bc75b72..c369e67b 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts @@ -4,6 +4,10 @@ import { z } from 'zod' const nonEmptyString = z.string().trim().min(1) const nullableNonEmptyString = nonEmptyString.nullable() const nullableUrl = z.string().trim().url({ message: '必须是有效的 URL' }).nullable() +const nullableHttpUrl = nullableUrl.refine( + (value) => value === null || value.startsWith('http://') || value.startsWith('https://'), + { message: '只支持 http 或 https 协议' }, +) export const SYSTEM_SETTING_DEFINITIONS = { allowRegistration: { @@ -36,6 +40,12 @@ export const SYSTEM_SETTING_DEFINITIONS = { defaultValue: DEFAULT_BASE_DOMAIN, isSensitive: false, }, + oauthGatewayUrl: { + key: 'system.auth.oauth.gatewayUrl', + schema: nullableHttpUrl, + defaultValue: null as string | null, + isSensitive: false, + }, oauthGoogleClientId: { key: 'system.auth.oauth.google.clientId', schema: nullableNonEmptyString, diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts index a516b39f..b049a51b 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts @@ -53,6 +53,14 @@ export class SystemSettingService { const baseDomain = baseDomainRaw.trim().toLowerCase() + const oauthGatewayUrl = this.normalizeGatewayUrl( + this.parseSetting( + rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGatewayUrl.key], + SYSTEM_SETTING_DEFINITIONS.oauthGatewayUrl.schema, + SYSTEM_SETTING_DEFINITIONS.oauthGatewayUrl.defaultValue, + ), + ) + const oauthGoogleClientId = this.parseSetting( rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.key], SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.schema, @@ -94,6 +102,7 @@ export class SystemSettingService { maxRegistrableUsers, localProviderEnabled, baseDomain, + oauthGatewayUrl, oauthGoogleClientId, oauthGoogleClientSecret, oauthGoogleRedirectUri, @@ -165,6 +174,12 @@ export class SystemSettingService { enqueueUpdate('baseDomain', sanitized) } } + if (patch.oauthGatewayUrl !== undefined) { + const sanitized = this.normalizeGatewayUrl(patch.oauthGatewayUrl) + if (sanitized !== current.oauthGatewayUrl) { + enqueueUpdate('oauthGatewayUrl', sanitized) + } + } if (patch.oauthGoogleClientId !== undefined) { const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId) @@ -247,11 +262,16 @@ export class SystemSettingService { } } - async getAuthModuleConfig(): Promise<{ baseDomain: string; socialProviders: SocialProvidersConfig }> { + async getAuthModuleConfig(): Promise<{ + baseDomain: string + socialProviders: SocialProvidersConfig + oauthGatewayUrl: string | null + }> { const settings = await this.getSettings() return { baseDomain: settings.baseDomain, socialProviders: this.buildSocialProviders(settings), + oauthGatewayUrl: settings.oauthGatewayUrl, } } @@ -294,6 +314,29 @@ export class SystemSettingService { return trimmed.length > 0 ? trimmed : null } + private normalizeGatewayUrl(value: string | null | undefined): string | null { + if (value === undefined || value === null) { + return null + } + + const trimmed = value.trim() + if (trimmed.length === 0) { + return null + } + + try { + const url = new URL(trimmed) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + + const normalizedPath = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '') + return `${url.origin}${normalizedPath}` + } catch { + return null + } + } + private normalizeRedirectPath(value: string | null | undefined): string | null { if (value === undefined || value === null) { return null diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts index 8b523b76..2197a1bc 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts @@ -1,4 +1,5 @@ -import type { UiSchema } from '../ui-schema/ui-schema.type' +import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type' + import type { SystemSettingField } from './system-setting.constants' export interface SystemSettings { @@ -6,6 +7,7 @@ export interface SystemSettings { maxRegistrableUsers: number | null localProviderEnabled: boolean baseDomain: string + oauthGatewayUrl: string | null oauthGoogleClientId: string | null oauthGoogleClientSecret: string | null oauthGoogleRedirectUri: string | null diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts index 768f0f16..b4707674 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts @@ -1,7 +1,8 @@ -import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type' +import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type' + import type { SystemSettingField } from './system-setting.constants' -export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.1.0' +export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.2.0' export const SYSTEM_SETTING_UI_SCHEMA: UiSchema = { version: SYSTEM_SETTING_UI_SCHEMA_VERSION, @@ -69,6 +70,18 @@ export const SYSTEM_SETTING_UI_SCHEMA: UiSchema = { description: '统一配置所有租户可用的第三方登录渠道。', icon: 'shield-check', children: [ + { + type: 'field', + id: 'oauth-gateway-url', + title: 'OAuth 网关地址', + description: '统一的 OAuth 回调入口,例如 https://auth.afilmory.art。留空则直接回调到租户域名。', + helperText: '必须包含 http/https 协议,结尾无需斜杠。', + key: 'oauthGatewayUrl', + component: { + type: 'text', + placeholder: 'https://auth.afilmory.art', + }, + }, { type: 'group', id: 'oauth-google', diff --git a/be/apps/core/src/modules/platform/auth/auth.config.ts b/be/apps/core/src/modules/platform/auth/auth.config.ts index 1c9c16da..c44d4a77 100644 --- a/be/apps/core/src/modules/platform/auth/auth.config.ts +++ b/be/apps/core/src/modules/platform/auth/auth.config.ts @@ -17,6 +17,7 @@ export interface AuthModuleOptions { useDrizzle: boolean socialProviders: SocialProvidersConfig baseDomain: string + oauthGatewayUrl: string | null } @injectable() @@ -25,13 +26,14 @@ export class AuthConfig { async getOptions(): Promise { const prefix = '/auth' - const { socialProviders, baseDomain } = await this.systemSettings.getAuthModuleConfig() + const { socialProviders, baseDomain, oauthGatewayUrl } = await this.systemSettings.getAuthModuleConfig() return { prefix, useDrizzle: true, socialProviders, baseDomain, + oauthGatewayUrl, } } } diff --git a/be/apps/core/src/modules/platform/auth/auth.provider.ts b/be/apps/core/src/modules/platform/auth/auth.provider.ts index 28e790d6..4908018c 100644 --- a/be/apps/core/src/modules/platform/auth/auth.provider.ts +++ b/be/apps/core/src/modules/platform/auth/auth.provider.ts @@ -142,11 +142,9 @@ export class AuthProvider implements OnModuleInit { } private buildBetterAuthProvidersForHost( - host: string, - fallbackHost: string, - protocol: string, tenantSlug: string | null, providers: SocialProvidersConfig, + oauthGatewayUrl: string | null, ): Record { const entries: Array<[keyof SocialProvidersConfig, SocialProviderOptions]> = Object.entries(providers).filter( (entry): entry is [keyof SocialProvidersConfig, SocialProviderOptions] => Boolean(entry[1]), @@ -154,7 +152,7 @@ export class AuthProvider implements OnModuleInit { return entries.reduce>( (acc, [key, value]) => { - const redirectUri = this.buildRedirectUri(protocol, host, fallbackHost, tenantSlug, key, value) + const redirectUri = this.buildRedirectUri(tenantSlug, key, value, oauthGatewayUrl) acc[key] = { clientId: value.clientId, clientSecret: value.clientSecret, @@ -167,55 +165,41 @@ export class AuthProvider implements OnModuleInit { } private buildRedirectUri( - protocol: string, - host: string, - fallbackHost: string, tenantSlug: string | null, provider: keyof SocialProvidersConfig, options: SocialProviderOptions, + oauthGatewayUrl: string | null, ): string | null { const basePath = options.redirectPath ?? `/api/auth/callback/${provider}` if (!basePath.startsWith('/')) { return null } - const redirectHost = this.resolveRedirectHost(host, fallbackHost, tenantSlug) - return `${protocol}://${redirectHost}${basePath}` + if (oauthGatewayUrl) { + return this.buildGatewayRedirectUri(oauthGatewayUrl, basePath, tenantSlug) + } + logger.error( + ['[AuthProvider] OAuth 网关地址未配置,无法为第三方登录生成回调 URL。', `provider=${String(provider)}`].join(' '), + ) + return null } - private resolveRedirectHost(host: string, fallbackHost: string, tenantSlug: string | null): string { - const normalizedSlug = tenantSlug?.trim().toLowerCase() - if (!normalizedSlug) { - return host + private buildGatewayRedirectUri(gatewayBaseUrl: string, basePath: string, tenantSlug: string | null): string { + const normalizedBase = gatewayBaseUrl.replace(/\/+$/, '') + const searchParams = new URLSearchParams() + if (tenantSlug) { + searchParams.set('tenantSlug', tenantSlug) } - - const [hostName, hostPort] = host.split(':') as [string, string?] - if (!hostName.toLowerCase().startsWith(`${normalizedSlug}.`)) { - return host - } - - const [fallbackNameRaw, fallbackPort] = fallbackHost.split(':') as [string, string?] - const fallbackName = fallbackNameRaw?.trim() - if (!fallbackName) { - return host - } - - const portSegment = hostPort ?? fallbackPort ?? '' - return portSegment ? `${fallbackName}:${portSegment}` : fallbackName + const query = searchParams.toString() + return `${normalizedBase}${basePath}${query ? `?${query}` : ''}` } - private async createAuthForEndpoint( - host: string, - protocol: string, - tenantSlug: string | null, - ): Promise { + private async createAuthForEndpoint(tenantSlug: string | null): Promise { const options = await this.getModuleOptions() const db = this.drizzleProvider.getDb() const socialProviders = this.buildBetterAuthProvidersForHost( - host, - options.baseDomain, - protocol, tenantSlug, options.socialProviders, + options.oauthGatewayUrl, ) const cookiePrefix = this.buildCookiePrefix(tenantSlug) @@ -350,7 +334,7 @@ export class AuthProvider implements OnModuleInit { const cacheKey = `${protocol}://${host}::${slugKey}` if (!this.instances.has(cacheKey)) { - const instancePromise = this.createAuthForEndpoint(host, protocol, tenantSlug).then((instance) => { + const instancePromise = this.createAuthForEndpoint(tenantSlug).then((instance) => { logger.info(`Better Auth initialized for ${cacheKey}`) return instance }) diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts index 8b83341c..68ca75b8 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts @@ -19,6 +19,15 @@ const updateSuperAdminSettingsSchema = z .min(1) .regex(/^[a-z0-9.-]+$/i, { message: '无效的基础域名' }) .optional(), + oauthGatewayUrl: z + .string() + .trim() + .url({ message: '必须是有效的 URL' }) + .nullable() + .refine((value) => value === null || value.startsWith('http://') || value.startsWith('https://'), { + message: '仅支持 http 或 https 协议', + }) + .optional(), oauthGoogleClientId: z.string().trim().min(1).nullable().optional(), oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(), oauthGoogleRedirectUri: redirectPathInputSchema.nullable().optional(),