mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 14:44:48 +00:00
feat(oauth-gateway): add oauthGatewayUrl configuration and handling
- Introduced a new configuration for oauthGatewayUrl in system settings, allowing for flexible OAuth callback handling. - Updated the SystemSettingService to parse and normalize the oauthGatewayUrl. - Enhanced the AuthProvider to utilize the oauthGatewayUrl for constructing redirect URIs for third-party logins. - Added validation for the oauthGatewayUrl to ensure it adheres to HTTP/HTTPS protocols. - Updated UI schema to include oauthGatewayUrl field for user input. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SystemSettingField> = {
|
||||
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
|
||||
@@ -69,6 +70,18 @@ export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
|
||||
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',
|
||||
|
||||
@@ -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<AuthModuleOptions> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, { clientId: string; clientSecret: string; redirectUri?: string }> {
|
||||
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<Record<string, { clientId: string; clientSecret: string; redirectURI?: string }>>(
|
||||
(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<BetterAuthInstance> {
|
||||
private async createAuthForEndpoint(tenantSlug: string | null): Promise<BetterAuthInstance> {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user