fix and update
This commit is contained in:
@@ -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,
|
||||
|
||||
99
apps/sso-core/src/domain/oauth-ssl-sans.service.ts
Normal file
99
apps/sso-core/src/domain/oauth-ssl-sans.service.ts
Normal file
@@ -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<string[]> {
|
||||
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<string> {
|
||||
const excluded = new Set<string>();
|
||||
|
||||
for (const envKey of ['DOMAIN_API', 'DOMAIN_FRONTEND', 'DOMAIN_DOCS', 'DOMAIN_WS', 'DOMAIN_MINIO', 'DOMAIN_MINIO_CONSOLE']) {
|
||||
const value = this.config.get<string>(envKey)?.trim().toLowerCase();
|
||||
if (value) excluded.add(value);
|
||||
}
|
||||
|
||||
for (const url of [
|
||||
this.config.get<string>('PUBLIC_API_URL'),
|
||||
this.config.get<string>('PUBLIC_FRONTEND_URL'),
|
||||
this.config.get<string>('PUBLIC_DOCS_URL'),
|
||||
this.config.get<string>('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<string>('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(', ') || '—'}`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
68
apps/sso-core/src/infra/oauth-ssl-sans.util.ts
Normal file
68
apps/sso-core/src/infra/oauth-ssl-sans.util.ts
Normal file
@@ -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>): string[] {
|
||||
const excluded = new Set(Array.from(excludedHostnames, (item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
const result = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user