From aebce54bd783b3a42e3515827929ef6f9ba33a84 Mon Sep 17 00:00:00 2001 From: lendry Date: Mon, 29 Jun 2026 15:40:54 +0300 Subject: [PATCH] fix and update --- apps/frontend/app/auth/login/page.tsx | 114 ++++++++++++++++++++++- apps/frontend/lib/api.ts | 41 ++------ apps/sso-core/src/domain/auth.service.ts | 28 +++--- install.sh | 51 +++++++--- shared/proto/auth.proto | 1 + 5 files changed, 178 insertions(+), 57 deletions(-) diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 1bff039..7392da6 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -134,6 +134,8 @@ export default function LoginPage() { const [otpChannels, setOtpChannels] = useState([]); + const [otpBackupChannels, setOtpBackupChannels] = useState([]); + const [showMethods, setShowMethods] = useState(false); const [step, setStep] = useState('identify'); @@ -397,6 +399,8 @@ export default function LoginPage() { setOtpChannels(result.otpChannels ?? []); + setOtpBackupChannels(result.otpBackupChannels ?? []); + if (result.isTotpEnabled) { @@ -413,6 +417,8 @@ export default function LoginPage() { const channels = result.otpChannels ?? []; + const backupChannels = result.otpBackupChannels ?? []; + if (channels.length > 1) { setStep('otpChannel'); @@ -429,6 +435,24 @@ export default function LoginPage() { } + // Нет основных каналов, но есть резервные — даём выбрать резервную доставку. + + if (backupChannels.length > 1) { + + setStep('otpChannel'); + + return; + + } + + if (backupChannels.length === 1) { + + await requestLoginOtp(backupChannels[0].channel, identifier); + + return; + + } + if (result.hasPassword) { setStep('password'); @@ -658,7 +682,7 @@ export default function LoginPage() { function renderAlternativeMethods(includeOtpRequest: boolean) { - const hasAlternatives = includeOtpRequest || methods.length > 0; + const hasAlternatives = includeOtpRequest || methods.length > 0 || otpBackupChannels.length > 0; if (!hasAlternatives) return null; @@ -736,6 +760,50 @@ export default function LoginPage() { })} + {otpBackupChannels.length ? ( + + <> + +

Резервные контакты

+ + {otpBackupChannels.map((channel) => { + + const Icon = channelIcon[channel.channel] ?? Mail; + + return ( + + + + ); + + })} + + + + ) : null} + ) : null} @@ -1012,6 +1080,50 @@ export default function LoginPage() { })} + {otpBackupChannels.length ? ( + + <> + +

Резервные контакты

+ + {otpBackupChannels.map((channel) => { + + const Icon = channelIcon[channel.channel] ?? Mail; + + return ( + + + + ); + + })} + + + + ) : null} + {totpChallengeToken ? ( diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 84f90d1..37bdd32 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -3,29 +3,20 @@ const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3 const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, ''); function resolveBrowserApiBaseUrl(): string { + // На сервере (SSR) обращаемся к API напрямую по настроенному URL. if (typeof window === 'undefined') { return configuredApiUrl; } + // Если URL уже относительный — используем как есть. if (configuredApiUrl.startsWith('/')) { return configuredApiUrl.replace(/\/+$/, '') || API_ORIGIN_PROXY_PREFIX; } - 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; - } + // В браузере ВСЕГДА ходим через same-origin прокси /idp-api (Next.js rewrites + // проксируют на внутренний api-gateway). Это исключает CORS и работу через + // отдельный домен API (например api.idpmvk.lpr) для запросов самого UI IdP. + return API_ORIGIN_PROXY_PREFIX; } function resolveBrowserWsBaseUrl(): string { @@ -33,23 +24,8 @@ function resolveBrowserWsBaseUrl(): string { return configuredWsUrl; } - const apiBase = resolveBrowserApiBaseUrl(); - if (apiBase === API_ORIGIN_PROXY_PREFIX) { - const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; - } - - try { - const configured = new URL(configuredWsUrl); - if (configured.hostname !== window.location.hostname) { - const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; - } - } catch { - // keep configured URL - } - - return configuredWsUrl; + const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`; } export function getApiUrl(): string { @@ -313,6 +289,7 @@ export interface IdentifyResponse { isPinEnabled: boolean; isTotpEnabled?: boolean; otpChannels?: OtpChannel[]; + otpBackupChannels?: OtpChannel[]; methods?: LoginMethod[]; } diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index ba8c198..1761f96 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -200,30 +200,23 @@ export class AuthService { async identify(login: string) { const user = await this.findUserByRecipient(login); const methods: Array<{ kind: string; channel: string; masked: string }> = []; + // Основные каналы (телефон/почта) — приоритетная доставка OTP по умолчанию. const otpChannels = user ? this.buildOtpChannels(user) : []; + // Резервные каналы — отдельный список, чтобы пользователь мог явно + // запросить код на резервный телефон/почту, но не получал его туда по умолчанию. + const otpBackupChannels = user ? this.buildOtpBackupChannels(user) : []; if (user?.passwordHash) { methods.push({ kind: 'password', channel: 'password', masked: 'Пароль' }); } - if (user) { - const backupChannels: Array<{ channel: string; value: string | null }> = [ - { channel: 'backupEmail', value: user.backupEmail }, - { channel: 'backupPhone', value: user.backupPhone } - ]; - for (const item of backupChannels) { - if (item.value) { - methods.push({ kind: 'otp', channel: item.channel, masked: this.maskTarget(item.value) }); - } - } - } - return { exists: Boolean(user), hasPassword: Boolean(user?.passwordHash), isPinEnabled: Boolean(user?.pinCode?.isEnabled), isTotpEnabled: user ? await this.totp.isEnabledForUser(user.id) : false, otpChannels, + otpBackupChannels, methods }; } @@ -357,6 +350,17 @@ export class AuthService { return channels; } + private buildOtpBackupChannels(user: { backupEmail: string | null; backupPhone: string | null }) { + const channels: Array<{ channel: string; masked: string }> = []; + if (user.backupEmail) { + channels.push({ channel: 'backupEmail', masked: this.maskTarget(user.backupEmail) }); + } + if (user.backupPhone) { + channels.push({ channel: 'backupPhone', masked: this.maskTarget(user.backupPhone) }); + } + return channels; + } + private resolveOtpTarget(recipient: string, channel: string | undefined, user: { email: string | null; phone: string | null; backupEmail: string | null; backupPhone: string | null } | null) { if (!channel || channel === 'primary' || !user) return recipient; const map: Record = { diff --git a/install.sh b/install.sh index d037743..2e883d6 100644 --- a/install.sh +++ b/install.sh @@ -1735,15 +1735,50 @@ build_ws_block() { EOF } +# Блок location / для отдельного домена API (api.example.com). +# Так как фронтенд и API на разных доменах (split-domain), браузер шлёт +# cross-origin запросы и preflight (OPTIONS). Nginx сам отвечает на preflight +# и проставляет CORS-заголовки, отражая Origin (нужно для credentials: cookie/JWT). +# Заголовки CORS от upstream скрываем, чтобы не было дублей Access-Control-Allow-Origin. +build_nginx_api_location_block() { + local upstream + upstream="$(nginx_upstream api)" + cat <