From 40057b64c82fddbf0e597a282a444e4f27e24ed7 Mon Sep 17 00:00:00 2001 From: lendry Date: Mon, 29 Jun 2026 23:35:12 +0300 Subject: [PATCH] fix and update --- .env.example | 4 +- apps/sso-core/src/app.module.ts | 2 + .../src/domain/oauth-ssl-sans.service.ts | 99 +++++++++++++++++++ apps/sso-core/src/domain/rbac.service.ts | 7 +- .../sso-core/src/infra/oauth-ssl-sans.util.ts | 68 +++++++++++++ docker-compose.yml | 3 + install.sh | 75 ++++++++++++-- 7 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 apps/sso-core/src/domain/oauth-ssl-sans.service.ts create mode 100644 apps/sso-core/src/infra/oauth-ssl-sans.util.ts diff --git a/.env.example b/.env.example index 6af26bb..dee31c6 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,8 @@ SSL_CERT_DIR= SSL_CERT_FULLCHAIN= SSL_CERT_KEY= -# Доп. DNS-имена в self-signed сертификат (сайты приложений в LAN через запятую) -# EXTRA_SSL_SANS=myapp.lan,portal.company.lpr +# Доп. DNS-имена в self-signed сертификат (опционально, поверх авто из OAuth redirect_uri) +# EXTRA_SSL_SANS=legacy-app.lan DOMAIN_API=api.idpmvk.lpr DOMAIN_FRONTEND=sso.idpmvk.lpr diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts index ae258b6..2dbd635 100644 --- a/apps/sso-core/src/app.module.ts +++ b/apps/sso-core/src/app.module.ts @@ -8,6 +8,7 @@ import { AuthGrpcController } from './domain/auth-grpc.controller'; import { AuthService } from './domain/auth.service'; import { PinService } from './domain/pin.service'; import { RbacService } from './domain/rbac.service'; +import { OAuthSslSansService } from './domain/oauth-ssl-sans.service'; import { SecurityService } from './domain/security.service'; import { PrismaService } from './infra/prisma.service'; import { RedisService } from './infra/redis.service'; @@ -70,6 +71,7 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser AdminSeedService, AdminService, RbacService, + OAuthSslSansService, SecurityService, ProfileService, DocumentsService, diff --git a/apps/sso-core/src/domain/oauth-ssl-sans.service.ts b/apps/sso-core/src/domain/oauth-ssl-sans.service.ts new file mode 100644 index 0000000..cfbe2a2 --- /dev/null +++ b/apps/sso-core/src/domain/oauth-ssl-sans.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { PrismaService } from '../infra/prisma.service'; +import { + collectOAuthSslHostnames, + parseHostnameFromPublicUrl, + resolveRegistrableDomain +} from '../infra/oauth-ssl-sans.util'; + +const OAUTH_SSL_SANS_SETTING_KEY = 'OAUTH_SSL_SANS'; + +@Injectable() +export class OAuthSslSansService implements OnModuleInit { + private readonly logger = new Logger(OAuthSslSansService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService + ) {} + + async onModuleInit() { + try { + await this.syncFromDatabase(); + } catch (error) { + this.logger.warn( + `Не удалось синхронизировать SAN из OAuth redirect URI: ${error instanceof Error ? error.message : 'ошибка'}` + ); + } + } + + async syncFromDatabase(): Promise { + const clients = await this.prisma.oAuthClient.findMany({ + where: { isActive: true }, + select: { redirectUris: true } + }); + const redirectUris = clients.flatMap((client) => client.redirectUris); + const hostnames = collectOAuthSslHostnames(redirectUris, this.buildExcludedHostnames()); + + await this.persistHostnames(hostnames); + return hostnames; + } + + private buildExcludedHostnames(): Set { + const excluded = new Set(); + + for (const envKey of ['DOMAIN_API', 'DOMAIN_FRONTEND', 'DOMAIN_DOCS', 'DOMAIN_WS', 'DOMAIN_MINIO', 'DOMAIN_MINIO_CONSOLE']) { + const value = this.config.get(envKey)?.trim().toLowerCase(); + if (value) excluded.add(value); + } + + for (const url of [ + this.config.get('PUBLIC_API_URL'), + this.config.get('PUBLIC_FRONTEND_URL'), + this.config.get('PUBLIC_DOCS_URL'), + this.config.get('PUBLIC_WS_URL') + ]) { + const host = parseHostnameFromPublicUrl(url); + if (host) excluded.add(host); + } + + const idpHosts = [...excluded]; + for (const host of idpHosts) { + excluded.add(resolveRegistrableDomain(host)); + const parts = host.split('.'); + if (parts.length >= 3) { + excluded.add(parts.slice(-2).join('.')); + } + } + + return excluded; + } + + private async persistHostnames(hostnames: string[]) { + const serialized = hostnames.join(','); + await this.prisma.systemSetting.upsert({ + where: { key: OAUTH_SSL_SANS_SETTING_KEY }, + create: { + key: OAUTH_SSL_SANS_SETTING_KEY, + value: serialized, + description: 'DNS-имена из redirect_uri OAuth-приложений для SAN сертификата (авто)', + isSecret: false + }, + update: { + value: serialized, + description: 'DNS-имена из redirect_uri OAuth-приложений для SAN сертификата (авто)' + } + }); + + const targetFile = + this.config.get('OAUTH_SSL_SANS_FILE')?.trim() || + '/data/nginx-certs/oauth-redirect-sans.txt'; + const body = `${hostnames.join('\n')}\n`; + await mkdir(dirname(targetFile), { recursive: true }); + await writeFile(targetFile, body, 'utf8'); + this.logger.log(`OAuth SSL SAN обновлён (${hostnames.length}): ${hostnames.join(', ') || '—'}`); + } +} diff --git a/apps/sso-core/src/domain/rbac.service.ts b/apps/sso-core/src/domain/rbac.service.ts index 370ea68..0733e41 100644 --- a/apps/sso-core/src/domain/rbac.service.ts +++ b/apps/sso-core/src/domain/rbac.service.ts @@ -4,6 +4,7 @@ import { OAuthClientType } from '../generated/prisma/client'; import { PrismaService } from '../infra/prisma.service'; import { AccessService, UserAccessContext } from './access.service'; import { NotificationsService } from './notifications.service'; +import { OAuthSslSansService } from './oauth-ssl-sans.service'; import { DEFAULT_USER_ROLE_SLUG, BOT_ROLE_SLUG } from './rbac.constants'; export interface OAuthClientListItem { @@ -24,7 +25,8 @@ export class RbacService { constructor( private readonly prisma: PrismaService, private readonly access: AccessService, - private readonly notifications: NotificationsService + private readonly notifications: NotificationsService, + private readonly oauthSslSans: OAuthSslSansService ) {} async createRole(actorUserId: string, data: { slug: string; name: string; description?: string; permissionSlugs?: string[] }) { @@ -198,6 +200,7 @@ export class RbacService { include: { scopes: { include: { scope: true } }, createdBy: true } }); + await this.oauthSslSans.syncFromDatabase().catch(() => undefined); return { client, clientSecret }; } @@ -240,6 +243,7 @@ export class RbacService { include: { scopes: { include: { scope: true } }, createdBy: true } }); + await this.oauthSslSans.syncFromDatabase().catch(() => undefined); return { client: updated, clientSecret: undefined as string | undefined }; } @@ -250,6 +254,7 @@ export class RbacService { } await this.assertCanManageOAuthClient(actorUserId, existing); await this.prisma.oAuthClient.delete({ where: { id: existing.id } }); + await this.oauthSslSans.syncFromDatabase().catch(() => undefined); return { clientId }; } diff --git a/apps/sso-core/src/infra/oauth-ssl-sans.util.ts b/apps/sso-core/src/infra/oauth-ssl-sans.util.ts new file mode 100644 index 0000000..1df971b --- /dev/null +++ b/apps/sso-core/src/infra/oauth-ssl-sans.util.ts @@ -0,0 +1,68 @@ +const INTERNAL_HOSTNAMES = new Set([ + 'api-gateway', + 'sso-core', + 'frontend', + 'docs', + 'media-ws', + 'minio', + 'postgres', + 'redis', + 'rabbitmq', + 'ldap-auth' +]); + +export function resolveRegistrableDomain(hostname: string): string { + const host = hostname.trim().toLowerCase(); + if (!host) return host; + const labels = host.split('.').filter(Boolean); + if (labels.length <= 2) return host; + return `${labels[labels.length - 2]}.${labels[labels.length - 1]}`; +} + +export function extractHostnameFromRedirectUri(uri: string): string | null { + try { + const parsed = new URL(uri.trim()); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return null; + } + const host = parsed.hostname.trim().toLowerCase(); + return host || null; + } catch { + return null; + } +} + +export function isInternalOAuthSslHostname(hostname: string): boolean { + const host = hostname.trim().toLowerCase(); + if (!host || INTERNAL_HOSTNAMES.has(host)) { + return true; + } + if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + return false; + } + return !host.includes('.'); +} + +export function collectOAuthSslHostnames(redirectUris: string[], excludedHostnames: Iterable): string[] { + const excluded = new Set(Array.from(excludedHostnames, (item) => item.trim().toLowerCase()).filter(Boolean)); + const result = new Set(); + + for (const uri of redirectUris) { + const host = extractHostnameFromRedirectUri(uri); + if (!host || excluded.has(host) || isInternalOAuthSslHostname(host)) { + continue; + } + result.add(host); + } + + return [...result].sort((a, b) => a.localeCompare(b)); +} + +export function parseHostnameFromPublicUrl(value?: string | null): string | null { + if (!value?.trim()) return null; + try { + return new URL(value.trim()).hostname.toLowerCase(); + } catch { + return null; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index f36e047..8d37f89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,6 +95,9 @@ services: PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} RABBITMQ_URL: amqp://${RABBITMQ_DEFAULT_USER:-lendry}:${RABBITMQ_DEFAULT_PASS:-lendry_password}@rabbitmq:5672/ LDAP_AUTH_URL: http://ldap-auth:8086 + OAUTH_SSL_SANS_FILE: /data/nginx-certs/oauth-redirect-sans.txt + volumes: + - ./nginx/certs:/data/nginx-certs ports: - "3001:3001" - "50051:50051" diff --git a/install.sh b/install.sh index 96ae756..5b7ef3e 100644 --- a/install.sh +++ b/install.sh @@ -1603,6 +1603,45 @@ registrable_domain() { fi } +collect_oauth_redirect_sans_hosts() { + local f="${LOCAL_CERTS_DIR}/oauth-redirect-sans.txt" + [[ -f "$f" ]] || return 0 + local host + while IFS= read -r host || [[ -n "$host" ]]; do + host="${host#"${host%%[![:space:]]*}"}" + host="${host%"${host##*[![:space:]]}"}" + [[ -z "$host" || "$host" == \#* ]] && continue + echo "$host" + done < "$f" +} + +# Нормализованный список SAN для сравнения сертификата с желаемым набором. +self_signed_sans_fingerprint() { + collect_self_signed_sans | tr ',' '\n' | sed 's/^DNS://' | sort -u | paste -sd, - +} + +current_self_signed_sans_fingerprint() { + local cert="${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" + [[ -f "$cert" ]] || return 1 + openssl x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \ + | sed 's/ *subjectAltName=//;s/DNS://g' \ + | tr ',' '\n' | sed 's/^ *//' | sort -u | paste -sd, - +} + +renew_self_signed_cert_if_sans_changed() { + [[ "$(env_get SSL_TYPE none)" != "selfsigned" ]] && return 0 + local desired current + desired="$(self_signed_sans_fingerprint)" + current="$(current_self_signed_sans_fingerprint 2>/dev/null || true)" + if [[ -z "$current" || "$desired" != "$current" ]]; then + if [[ -n "$current" ]]; then + log "SAN сертификата изменились (OAuth redirect URI) — пересоздаём self-signed" + fi + generate_all_self_signed_certs --force + return 0 + fi +} + collect_self_signed_sans() { local -a sans=() local base domain @@ -1626,7 +1665,14 @@ collect_self_signed_sans() { sans+=("DNS:${apex}") fi - # Дополнительные SAN для сторонних приложений в LAN (через запятую в .env). + # Авто: домены из redirect_uri OAuth-приложений (nginx/certs/oauth-redirect-sans.txt). + local oauth_host + while IFS= read -r oauth_host; do + [[ -z "$oauth_host" ]] && continue + sans+=("DNS:${oauth_host}") + done < <(collect_oauth_redirect_sans_hosts) + + # Ручной override (дополнительно к OAuth), через запятую в .env. local extra_sans="${EXTRA_SSL_SANS:-}" if [[ -n "$extra_sans" ]]; then local entry @@ -1639,7 +1685,17 @@ collect_self_signed_sans() { done fi - (IFS=,; echo "${sans[*]}") + # Уникальные SAN + local -A _seen=() + local -a unique=() + local item key + for item in "${sans[@]}"; do + key="${item#DNS:}" + [[ -n "${_seen[$key]:-}" ]] && continue + _seen[$key]=1 + unique+=("$item") + done + (IFS=,; echo "${unique[*]}") } find_custom_cert_file() { @@ -1820,6 +1876,7 @@ install_custom_certificates() { } generate_shared_self_signed_cert() { + local force="${1:-}" local cert_dir="${LOCAL_CERTS_DIR}/shared-intranet" local base sans cn @@ -1829,7 +1886,7 @@ generate_shared_self_signed_cert() { cn="*.${base}" sans="$(collect_self_signed_sans)" - if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" ]]; then + if [[ -f "${cert_dir}/fullchain.pem" && -f "${cert_dir}/privkey.pem" && "$force" != "--force" ]]; then return 0 fi @@ -1856,7 +1913,7 @@ generate_all_self_signed_certs() { if [[ "$force" == "--force" ]]; then rm -f "${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" "${LOCAL_CERTS_DIR}/shared-intranet/privkey.pem" fi - generate_shared_self_signed_cert + generate_shared_self_signed_cert "$force" ok "Самоподписанный сертификат создан: ${LOCAL_CERTS_DIR}/shared-intranet" } @@ -2775,13 +2832,13 @@ print_intranet_hint() { if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then echo " Сертификат: ${LOCAL_CERTS_DIR}/shared-intranet (общий для всех доменов IdP)" echo " FedCM/One Tap: браузер открывает ${apex_domain:-корневой домен}/.well-known/web-identity —" - echo " примите сертификат для ${DOMAIN_FRONTEND}, ${DOMAIN_API}${extra_apex} и сайта вашего приложения." + echo " примите сертификат для ${DOMAIN_FRONTEND}, ${DOMAIN_API}${extra_apex} и доменов OAuth-приложений." + echo " SAN OAuth-приложений подтягиваются автоматически из redirect_uri (файл oauth-redirect-sans.txt)." echo " Надёжно: импортируйте fullchain.pem в «Доверенные корневые центры» на каждом ПК (Windows/macOS)." if [[ -n "$(env_get EXTRA_SSL_SANS "")" ]]; then - echo " EXTRA_SSL_SANS: $(env_get EXTRA_SSL_SANS "")" - else - echo " Для другого домена приложения добавьте в .env: EXTRA_SSL_SANS=myapp.lan и ./install.sh --fix-all" + echo " EXTRA_SSL_SANS (ручной доп.): $(env_get EXTRA_SSL_SANS "")" fi + echo " После нового OAuth-клиента: ./install.sh --fix-all (или обновление SSL) пересоздаст сертификат." echo "" fi if [[ "$(env_get LDAP_USE_HOST_NETWORK false)" == "true" ]]; then @@ -3340,7 +3397,7 @@ action_fix_all_errors() { case "$ssl_type" in none) write_nginx_http_only || warn "Не удалось записать HTTP-конфиги Nginx" ;; selfsigned) - generate_all_self_signed_certs + renew_self_signed_cert_if_sans_changed write_all_nginx_configs "selfsigned" || warn "Не удалось обновить конфиги Nginx" ;; custom)