From 01e4917acf96b49b6d8b1cf5e1ad63bd0a052add Mon Sep 17 00:00:00 2001 From: lendry Date: Mon, 29 Jun 2026 15:02:01 +0300 Subject: [PATCH] fix and update --- .env.example | 1 + .../src/controllers/health.controller.ts | 20 ++- apps/api-gateway/src/lib/fedcm-config.ts | 163 ++++++++---------- apps/api-gateway/src/lib/oauth-issuer.ts | 32 +++- apps/api-gateway/src/lib/public-url.ts | 122 +++++++++++++ apps/docs/next.config.ts | 14 +- apps/frontend/lib/api.ts | 25 ++- apps/frontend/lib/system-settings-catalog.ts | 2 +- apps/frontend/next.config.ts | 14 +- apps/frontend/public/sso-widget.js | 65 +++++-- 10 files changed, 326 insertions(+), 132 deletions(-) create mode 100644 apps/api-gateway/src/lib/public-url.ts diff --git a/.env.example b/.env.example index 00aa806..aa185fe 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,7 @@ PUBLIC_API_URL=http://localhost:3002/idp-api PUBLIC_FRONTEND_URL=http://localhost:3002 PUBLIC_DOCS_URL=http://localhost:3003 PUBLIC_WS_URL=ws://localhost:8085/ws +# Docker: http://api-gateway:3000 | локальный npm run dev: http://localhost:3000 INTERNAL_API_URL=http://api-gateway:3000 INTERNAL_WS_URL=http://media-ws:8085 diff --git a/apps/api-gateway/src/controllers/health.controller.ts b/apps/api-gateway/src/controllers/health.controller.ts index 0c56ac0..d8e95a5 100644 --- a/apps/api-gateway/src/controllers/health.controller.ts +++ b/apps/api-gateway/src/controllers/health.controller.ts @@ -1,9 +1,23 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; @Controller('health') export class HealthController { + constructor(private readonly core: CoreGrpcService) {} + @Get() - check() { - return { status: 'ok', service: 'api-gateway' }; + async check() { + try { + await firstValueFrom(this.core.settings.GetSetting({ key: 'PROJECT_NAME' })); + return { status: 'ok', service: 'api-gateway', grpc: 'ok' }; + } catch { + throw new ServiceUnavailableException({ + status: 'error', + service: 'api-gateway', + grpc: 'unavailable', + message: 'Не удалось связаться с sso-core по gRPC' + }); + } } } diff --git a/apps/api-gateway/src/lib/fedcm-config.ts b/apps/api-gateway/src/lib/fedcm-config.ts index 6305963..882798d 100644 --- a/apps/api-gateway/src/lib/fedcm-config.ts +++ b/apps/api-gateway/src/lib/fedcm-config.ts @@ -2,6 +2,15 @@ import type { Request } from 'express'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { resolveFrontendUrl, resolveOAuthIssuer } from './oauth-issuer'; +import { + hostsMatch, + normalizePublicBaseUrl, + preferBrowserReachableBase, + readRequestHost, + readRequestProto, + resolvePublicApiBaseFromRequest, + resolvePublicFrontendBaseFromRequest +} from './public-url'; export interface FedcmEndpoints { issuer: string; @@ -16,94 +25,9 @@ export interface FedcmEndpoints { webIdentityUrl: string; } -function normalizeBaseUrl(url: string) { - return url.trim().replace(/\/+$/, ''); -} - -function readRequestHost(req?: Request): string | undefined { - if (!req) { - return undefined; - } - - const forwarded = req.headers['x-forwarded-host']; - const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host; - return host?.split(',')[0]?.trim() || undefined; -} - -function readRequestProto(req?: Request): string { - if (!req) { - return 'https'; - } - - const forwarded = req.headers['x-forwarded-proto']; - const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.protocol; - return proto?.split(',')[0]?.trim() || 'https'; -} - -function hostsMatch(a: string, b: string) { - if (a === b) { - return true; - } - - return a === 'localhost' && b === 'localhost'; -} - -function resolveSameOriginFrontend(storedFrontend: string, req?: Request): string { - const host = readRequestHost(req); - if (!host) { - return storedFrontend; - } - - const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`); - - try { - const frontendUrl = new URL(storedFrontend); - const requestUrl = new URL(requestOrigin); - - if (hostsMatch(frontendUrl.hostname, requestUrl.hostname)) { - return requestOrigin; - } - } catch { - return storedFrontend; - } - - return storedFrontend; -} - -export function resolveSameOriginIssuer(storedIssuer: string, storedFrontend: string, req?: Request): string { - const host = readRequestHost(req); - if (!host) { - return storedIssuer; - } - - const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`); - const sameOriginApi = `${requestOrigin}/idp-api`; - - try { - const issuerUrl = new URL(storedIssuer); - const frontendUrl = new URL(storedFrontend); - const requestUrl = new URL(requestOrigin); - - const hostMatches = - hostsMatch(issuerUrl.hostname, requestUrl.hostname) || hostsMatch(frontendUrl.hostname, requestUrl.hostname); - - if (!hostMatches) { - return storedIssuer; - } - - if (normalizeBaseUrl(storedIssuer) !== sameOriginApi) { - return sameOriginApi; - } - } catch { - return storedIssuer; - } - - return storedIssuer; -} - function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) { - const base = normalizeBaseUrl(issuer); - const front = normalizeBaseUrl(frontendUrl); + const base = normalizePublicBaseUrl(issuer); + const front = normalizePublicBaseUrl(frontendUrl); let webIdentityOrigin = front; try { @@ -123,6 +47,71 @@ function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) { }; } +function resolveSameOriginFrontend(storedFrontend: string, req?: Request): string { + const fromRequest = resolvePublicFrontendBaseFromRequest(req); + const preferred = preferBrowserReachableBase(storedFrontend, fromRequest); + if (preferred !== storedFrontend) { + return preferred; + } + + const host = readRequestHost(req); + if (!host) { + return storedFrontend; + } + + const requestOrigin = normalizePublicBaseUrl(`${readRequestProto(req)}://${host}`); + + try { + const frontendUrl = new URL(storedFrontend); + const requestUrl = new URL(requestOrigin); + + if (hostsMatch(frontendUrl.hostname, requestUrl.hostname)) { + return requestOrigin; + } + } catch { + return storedFrontend; + } + + return storedFrontend; +} + +export function resolveSameOriginIssuer(storedIssuer: string, storedFrontend: string, req?: Request): string { + const fromRequest = resolvePublicApiBaseFromRequest(req); + const preferred = preferBrowserReachableBase(storedIssuer, fromRequest); + if (preferred !== storedIssuer) { + return preferred; + } + + const host = readRequestHost(req); + if (!host) { + return storedIssuer; + } + + const requestOrigin = normalizePublicBaseUrl(`${readRequestProto(req)}://${host}`); + const sameOriginApi = `${requestOrigin}/idp-api`; + + try { + const issuerUrl = new URL(storedIssuer); + const frontendUrl = new URL(storedFrontend); + const requestUrl = new URL(requestOrigin); + + const hostMatches = + hostsMatch(issuerUrl.hostname, requestUrl.hostname) || hostsMatch(frontendUrl.hostname, requestUrl.hostname); + + if (!hostMatches) { + return storedIssuer; + } + + if (normalizePublicBaseUrl(storedIssuer) !== sameOriginApi) { + return sameOriginApi; + } + } catch { + return fromRequest ?? storedIssuer; + } + + return storedIssuer; +} + export async function resolveFedcmEndpoints(core: CoreGrpcService, req?: Request): Promise { const storedIssuer = await resolveOAuthIssuer(core); const storedFrontend = await resolveFrontendUrl(core); diff --git a/apps/api-gateway/src/lib/oauth-issuer.ts b/apps/api-gateway/src/lib/oauth-issuer.ts index f68f2a8..7ddf2e2 100644 --- a/apps/api-gateway/src/lib/oauth-issuer.ts +++ b/apps/api-gateway/src/lib/oauth-issuer.ts @@ -1,17 +1,35 @@ import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; +import { normalizePublicBaseUrl, isInternalHostname } from './public-url'; function normalizeBaseUrl(url: string) { - return url.trim().replace(/\/+$/, ''); + return normalizePublicBaseUrl(url); +} + +function appendIdpApiPath(base: string) { + const normalized = normalizeBaseUrl(base); + return normalized.endsWith('/idp-api') ? normalized : `${normalized}/idp-api`; +} + +function sanitizeStoredPublicUrl(url: string) { + const normalized = normalizeBaseUrl(url); + try { + if (isInternalHostname(new URL(normalized).hostname)) { + return undefined; + } + } catch { + return normalized; + } + return normalized; } export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http://localhost:3000'): Promise { const envIssuer = process.env.PUBLIC_API_URL?.trim(); try { const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_API_URL' }))) as { value?: string }; - const fromDb = response.value?.trim(); + const fromDb = sanitizeStoredPublicUrl(response.value ?? ''); if (fromDb) { - return normalizeBaseUrl(fromDb); + return fromDb; } } catch { // fallback ниже @@ -26,9 +44,9 @@ export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http const domain = response.value?.trim(); if (domain) { if (domain.startsWith('http://') || domain.startsWith('https://')) { - return normalizeBaseUrl(domain); + return appendIdpApiPath(domain); } - return `https://${domain.replace(/^\/+/, '')}`; + return appendIdpApiPath(`https://${domain.replace(/^\/+/, '')}`); } } catch { // fallback ниже @@ -45,9 +63,9 @@ export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http try { const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_FRONTEND_URL' }))) as { value?: string }; - const fromDb = response.value?.trim(); + const fromDb = sanitizeStoredPublicUrl(response.value ?? ''); if (fromDb) { - return normalizeBaseUrl(fromDb); + return fromDb; } } catch { // fallback ниже diff --git a/apps/api-gateway/src/lib/public-url.ts b/apps/api-gateway/src/lib/public-url.ts new file mode 100644 index 0000000..3459873 --- /dev/null +++ b/apps/api-gateway/src/lib/public-url.ts @@ -0,0 +1,122 @@ +import type { Request } from 'express'; + +const INTERNAL_HOSTNAMES = new Set([ + 'api-gateway', + 'sso-core', + 'frontend', + 'docs', + 'media-ws', + 'minio', + 'postgres', + 'redis', + 'rabbitmq', + 'ldap-auth' +]); + +export function normalizePublicBaseUrl(url: string) { + return url.trim().replace(/\/+$/, ''); +} + +export function isInternalHostname(hostname: string) { + const host = hostname.trim().toLowerCase(); + if (!host) { + return true; + } + + if (INTERNAL_HOSTNAMES.has(host)) { + return true; + } + + if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host === 'localhost') { + return false; + } + + // Одно слово без точки — типичное имя Docker-сервиса (api-gateway, sso-core). + return !host.includes('.'); +} + +export function isBrowserReachableBaseUrl(url: string) { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false; + } + return !isInternalHostname(parsed.hostname); + } catch { + return false; + } +} + +export function readRequestHost(req?: Request): string | undefined { + if (!req) { + return undefined; + } + + const forwarded = req.headers['x-forwarded-host']; + const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host; + return host?.split(',')[0]?.trim() || undefined; +} + +export function readRequestProto(req?: Request): string { + if (!req) { + return 'https'; + } + + const forwarded = req.headers['x-forwarded-proto']; + const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.protocol; + return proto?.split(',')[0]?.trim() || 'https'; +} + +export function resolvePublicOriginFromRequest(req?: Request): string | null { + const host = readRequestHost(req); + if (!host) { + return null; + } + + const hostname = host.split(':')[0]; + if (isInternalHostname(hostname)) { + return null; + } + + return normalizePublicBaseUrl(`${readRequestProto(req)}://${host}`); +} + +export function resolvePublicApiBaseFromRequest(req?: Request): string | null { + const origin = resolvePublicOriginFromRequest(req); + if (!origin) { + return null; + } + + return `${origin}/idp-api`; +} + +export function resolvePublicFrontendBaseFromRequest(req?: Request): string | null { + return resolvePublicOriginFromRequest(req); +} + +export function preferBrowserReachableBase(stored: string, fromRequest: string | null) { + if (fromRequest && isBrowserReachableBaseUrl(fromRequest)) { + try { + const storedUrl = new URL(stored); + if (isInternalHostname(storedUrl.hostname)) { + return fromRequest; + } + } catch { + return fromRequest; + } + + if (normalizePublicBaseUrl(stored) !== normalizePublicBaseUrl(fromRequest)) { + return fromRequest; + } + } + + return stored; +} + +export function hostsMatch(a: string, b: string) { + if (a === b) { + return true; + } + + return a === 'localhost' && b === 'localhost'; +} diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index 767f13e..21a1d01 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -1,10 +1,14 @@ import type { NextConfig } from 'next'; -const internalApiUrl = ( - process.env.INTERNAL_API_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - 'http://localhost:3000' -).replace(/\/$/, ''); +function resolveInternalApiUrl(fallback = 'http://localhost:3000') { + const explicit = process.env.INTERNAL_API_URL?.trim(); + if (explicit) { + return explicit.replace(/\/+$/, ''); + } + return fallback.replace(/\/+$/, ''); +} + +const internalApiUrl = resolveInternalApiUrl('http://localhost:3000'); const nextConfig: NextConfig = { async rewrites() { diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 5b351b7..84f90d1 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -7,16 +7,25 @@ function resolveBrowserApiBaseUrl(): string { return configuredApiUrl; } - try { - const configured = new URL(configuredApiUrl); - if (configured.hostname !== window.location.hostname) { - return API_ORIGIN_PROXY_PREFIX; - } - } catch { - // keep configured URL + if (configuredApiUrl.startsWith('/')) { + return configuredApiUrl.replace(/\/+$/, '') || API_ORIGIN_PROXY_PREFIX; } - return configuredApiUrl; + try { + const configured = new URL(configuredApiUrl); + const sameHost = + configured.hostname === window.location.hostname && + (configured.port || (configured.protocol === 'https:' ? '443' : '80')) === + (window.location.port || (window.location.protocol === 'https:' ? '443' : '80')); + + if (sameHost) { + return API_ORIGIN_PROXY_PREFIX; + } + + return configuredApiUrl.replace(/\/+$/, ''); + } catch { + return API_ORIGIN_PROXY_PREFIX; + } } function resolveBrowserWsBaseUrl(): string { diff --git a/apps/frontend/lib/system-settings-catalog.ts b/apps/frontend/lib/system-settings-catalog.ts index 8f00c49..cb3c526 100644 --- a/apps/frontend/lib/system-settings-catalog.ts +++ b/apps/frontend/lib/system-settings-catalog.ts @@ -31,7 +31,7 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [ label: 'URL API (OAuth issuer)', group: 'general', type: 'text', - hint: 'Базовый URL API для OAuth/OIDC и FedCM. При same-origin деплое: https://ваш-домен/idp-api' + hint: 'Публичный URL, доступный из браузера пользователя. Не используйте api-gateway или другие Docker-имена. Пример: https://id.lendry.ru/idp-api' }, { key: 'PUBLIC_FRONTEND_URL', diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 6024e54..3264bac 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -1,10 +1,14 @@ import type { NextConfig } from 'next'; -const internalApiUrl = ( - process.env.INTERNAL_API_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - 'http://localhost:3000' -).replace(/\/$/, ''); +function resolveInternalApiUrl(fallback = 'http://localhost:3000') { + const explicit = process.env.INTERNAL_API_URL?.trim(); + if (explicit) { + return explicit.replace(/\/+$/, ''); + } + return fallback.replace(/\/+$/, ''); +} + +const internalApiUrl = resolveInternalApiUrl('http://localhost:3000'); const internalWsUrl = (process.env.INTERNAL_WS_URL ?? 'http://media-ws:8085').replace(/\/$/, ''); diff --git a/apps/frontend/public/sso-widget.js b/apps/frontend/public/sso-widget.js index 5076c9e..29b9a9e 100644 --- a/apps/frontend/public/sso-widget.js +++ b/apps/frontend/public/sso-widget.js @@ -66,19 +66,35 @@ return 'IdentityCredential' in global && global.navigator && typeof global.navigator.credentials !== 'undefined'; } + function isBrowserReachableBaseUrl(url) { + try { + var parsed = new URL(url); + var host = parsed.hostname.toLowerCase(); + if (host === 'api-gateway' || host === 'sso-core' || host === 'frontend' || host === 'docs') { + return false; + } + if (host.indexOf('.') === -1 && host !== 'localhost') { + return false; + } + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch (_error) { + return false; + } + } + function loginWithFedCM(idpApiBase, clientId) { if (!supportsFedCM()) { return Promise.resolve(null); } - function requestFedCM(configBase) { + function requestFedCMConfig(configUrl) { return global.navigator.credentials .get({ identity: { providers: [ { - configURL: configBase + '/fedcm/config.json', - configUrl: configBase + '/fedcm/config.json', + configURL: configUrl, + configUrl: configUrl, clientId: clientId } ] @@ -96,40 +112,57 @@ }); } + function requestFedCM(configBase) { + return requestFedCMConfig(configBase + '/fedcm/config.json'); + } + return global.fetch(idpApiBase + '/fedcm/discover.json', { credentials: 'omit' }) .then(function (response) { if (!response.ok) { - return idpApiBase; + return { mode: 'apiBase', value: idpApiBase }; } return response.json().then(function (payload) { if (payload && payload.enabled === false) { return null; } - if (payload && payload.apiBase) { - return trimSlash(payload.apiBase); + if (payload && payload.configUrl && isBrowserReachableBaseUrl(payload.configUrl)) { + return { mode: 'configUrl', value: payload.configUrl, webIdentityUrl: payload.webIdentityUrl }; } - return idpApiBase; + if (payload && payload.apiBase && isBrowserReachableBaseUrl(payload.apiBase)) { + return { mode: 'apiBase', value: trimSlash(payload.apiBase), webIdentityUrl: payload.webIdentityUrl }; + } + if (isBrowserReachableBaseUrl(idpApiBase)) { + return { mode: 'apiBase', value: idpApiBase }; + } + return null; }); }) .catch(function () { - return idpApiBase; + if (isBrowserReachableBaseUrl(idpApiBase)) { + return { mode: 'apiBase', value: idpApiBase }; + } + return null; }) - .then(function (resolvedBase) { - if (!resolvedBase) { + .then(function (resolved) { + if (!resolved) { + if (global.console && typeof global.console.warn === 'function') { + global.console.warn( + '[MVK ID] FedCM: URL IdP недоступен из браузера. Укажите публичный data-idp-url (например https://id.lendry.ru/idp-api), не внутренний Docker-хост.' + ); + } return null; } - return requestFedCM(resolvedBase); + if (resolved.mode === 'configUrl') { + return requestFedCMConfig(resolved.value); + } + return requestFedCM(resolved.value); }) .catch(function (error) { if (global.console && typeof global.console.warn === 'function') { global.console.warn( '[MVK ID] FedCM недоступен:', error, - 'Проверьте доступность', - idpApiBase + '/fedcm/config.json', - 'и', - idpApiBase.replace(/\/idp-api$/, '') + '/.well-known/web-identity', - '(PUBLIC_API_URL должен совпадать с data-idp-url виджета)' + 'Проверьте публичный URL IdP в data-idp-url и доступность /.well-known/web-identity на домене IdP (не /idp-api/.well-known).' ); } return null;