From 812aa614dfded69af0547dc6eb32690cd38f889a Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 13:49:36 +0700 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D1=80=D0=B5=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=20=D0=BD=D0=B0=20portal=20=D0=BF=D0=BE=20run?= =?UTF-8?q?time=20/api/auth/config=20=D0=BF=D1=80=D0=B8=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- apps/api/src/plugins/auth.test.ts | 2 + apps/api/src/plugins/auth.ts | 12 +++++ apps/web/src/lib/api-client.ts | 69 ++++++++++++++++-------- apps/web/src/lib/auth.ts | 75 +++++++++++++++++++++++---- apps/web/src/routes/_auth.tsx | 11 ++-- apps/web/src/routes/auth.callback.tsx | 13 ++--- docker-compose.yml | 4 ++ 7 files changed, 144 insertions(+), 42 deletions(-) diff --git a/apps/api/src/plugins/auth.test.ts b/apps/api/src/plugins/auth.test.ts index e132452..83a8490 100644 --- a/apps/api/src/plugins/auth.test.ts +++ b/apps/api/src/plugins/auth.test.ts @@ -23,9 +23,11 @@ describe('auth plugin (AUTH_REQUIRED)', () => { AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: secret, AUTH_ISSUER: issuer, + AUTH_PORTAL_URL: 'http://192.168.100.67:8080', }) expect(cfg.required).toBe(true) expect(cfg.jwtSecret).toBe(secret) + expect(cfg.portalUrl).toBe('http://192.168.100.67:8080') }) it('401 without token; 403 without vps app; 403 without permission; 200 with rights', async () => { diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 4dc58d7..6c59bbf 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -11,6 +11,7 @@ export type AuthConfig = { required: boolean jwtSecret: string issuer: string + portalUrl: string } declare module 'fastify' { @@ -61,12 +62,18 @@ export function loadAuthConfig( env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me'), issuer: env.AUTH_ISSUER ?? env.ISSUER ?? 'https://auth.shnt.top', + portalUrl: ( + env.AUTH_PORTAL_URL ?? + env.VITE_AUTH_PORTAL_URL ?? + 'http://localhost:5175' + ).replace(/\/$/, ''), } } function isPublicPath(url: string): boolean { const path = url.split('?')[0] ?? url if (path === '/health' || path === '/ready') return true + if (path === '/api/auth/config') return true if (path.startsWith('/api/integrations/cfdm')) return true return false } @@ -75,6 +82,11 @@ export const authPlugin = fp(async (app) => { const config = loadAuthConfig() app.decorate('authConfig', config) + app.get('/api/auth/config', async () => ({ + required: config.required, + portal_url: config.portalUrl, + })) + if (!config.required) { app.log.info('AUTH_REQUIRED=false — portal JWT middleware disabled') return diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 7f507d7..01b37c0 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -8,7 +8,7 @@ import type { Payment, BalanceLedgerRow, } from '@/types/entities' -import { clearToken, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth' +import { clearToken, ensureAuthConfig, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth' const API_BASE = import.meta.env.VITE_API_URL ?? '' @@ -27,20 +27,22 @@ async function fetchApi(path: string, options: RequestInit = {}): Promise if (options.body != null && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } - if (isAuthEnabled()) { - const token = getToken() - if (token && !headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${token}`) - } + // Always attach token if present (API may require it even without VITE_AUTH_ENABLED) + const token = getToken() + if (token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`) } const res = await fetch(url, { ...options, headers, }) if (!res.ok) { - if (isAuthEnabled() && res.status === 401) { + if (res.status === 401) { clearToken() - redirectToPortalLogin() + const cfg = await ensureAuthConfig() + if (cfg.required || isAuthEnabled()) { + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + } } let message = res.statusText || 'API error' try { @@ -153,23 +155,37 @@ export const api = { downloadBackupJson: async (): Promise => { const headers = new Headers() - if (isAuthEnabled()) { - const token = getToken() - if (token) headers.set('Authorization', `Bearer ${token}`) - } + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) const res = await fetch(`${API_BASE}/api/backup/json`, { headers }) - if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) + if (!res.ok) { + if (res.status === 401) { + clearToken() + const cfg = await ensureAuthConfig() + if (cfg.required || isAuthEnabled()) { + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + } + } + throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) + } return res.blob() }, downloadBackupDatabase: async (): Promise => { const headers = new Headers() - if (isAuthEnabled()) { - const token = getToken() - if (token) headers.set('Authorization', `Bearer ${token}`) - } + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) const res = await fetch(`${API_BASE}/api/backup/database`, { headers }) - if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) + if (!res.ok) { + if (res.status === 401) { + clearToken() + const cfg = await ensureAuthConfig() + if (cfg.required || isAuthEnabled()) { + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + } + } + throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) + } return res.blob() }, @@ -178,16 +194,23 @@ export const api = { importBackupDatabase: async (buffer: ArrayBuffer) => { const headers = new Headers({ 'Content-Type': 'application/octet-stream' }) - if (isAuthEnabled()) { - const token = getToken() - if (token) headers.set('Authorization', `Bearer ${token}`) - } + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) const res = await fetch(`${API_BASE}/api/backup/database`, { method: 'POST', headers, body: buffer, }) - if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) + if (!res.ok) { + if (res.status === 401) { + clearToken() + const cfg = await ensureAuthConfig() + if (cfg.required || isAuthEnabled()) { + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + } + } + throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) + } return res.json() }, diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 9589676..bb5807d 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,6 +1,7 @@ /** Portal JWT storage + claims helpers for VPS Tracker UI. */ const TOKEN_KEY = 'vps_auth_token' +const API_BASE = import.meta.env.VITE_API_URL ?? '' export type AccessClaims = { sub: string @@ -13,6 +14,66 @@ export type AccessClaims = { exp?: number } +export type RuntimeAuthConfig = { + required: boolean + portalUrl: string +} + +let runtimeConfig: RuntimeAuthConfig | null = null +let runtimeConfigPromise: Promise | null = null + +function viteAuthEnabled(): boolean { + return ( + import.meta.env.VITE_AUTH_ENABLED === 'true' || + import.meta.env.VITE_AUTH_ENABLED === '1' + ) +} + +function vitePortalUrl(): string { + return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace( + /\/$/, + '', + ) +} + +/** Load auth mode from API (Docker-friendly). Falls back to VITE_* flags. */ +export async function ensureAuthConfig(): Promise { + if (runtimeConfig) return runtimeConfig + if (runtimeConfigPromise) return runtimeConfigPromise + + runtimeConfigPromise = (async () => { + try { + const res = await fetch(`${API_BASE}/api/auth/config`) + if (res.ok) { + const data = (await res.json()) as { + required?: boolean + portal_url?: string + } + runtimeConfig = { + required: Boolean(data.required) || viteAuthEnabled(), + portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''), + } + return runtimeConfig + } + } catch { + /* ignore — use vite defaults */ + } + runtimeConfig = { + required: viteAuthEnabled(), + portalUrl: vitePortalUrl(), + } + return runtimeConfig + })().finally(() => { + runtimeConfigPromise = null + }) + + return runtimeConfigPromise +} + +export function getAuthConfigSync(): RuntimeAuthConfig | null { + return runtimeConfig +} + export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) } @@ -26,17 +87,13 @@ export function clearToken() { } export function isAuthEnabled(): boolean { - return ( - import.meta.env.VITE_AUTH_ENABLED === 'true' || - import.meta.env.VITE_AUTH_ENABLED === '1' - ) + if (runtimeConfig) return runtimeConfig.required + return viteAuthEnabled() } export function authPortalUrl(): string { - return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace( - /\/$/, - '', - ) + if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl + return vitePortalUrl() } export function redirectToPortalLogin(returnTo?: string) { @@ -44,7 +101,7 @@ export function redirectToPortalLogin(returnTo?: string) { returnTo ?? `${window.location.origin}/auth/callback` const url = new URL(authPortalUrl()) url.searchParams.set('return_to', callback) - window.location.href = url.toString() + window.location.assign(url.toString()) } export function parseHashToken(hash: string): { diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 229f834..8189f9d 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -2,23 +2,26 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' import { snapshotQueryOptions } from '@/queries/snapshot' import { can, + ensureAuthConfig, firstAllowedPath, getClaims, getToken, - isAuthEnabled, permissionForPath, redirectToPortalLogin, } from '@/lib/auth' export const Route = createFileRoute('/_auth')({ - beforeLoad: ({ location }) => { - if (!isAuthEnabled()) return + beforeLoad: async ({ location }) => { + const cfg = await ensureAuthConfig() + if (!cfg.required) return const token = getToken() const claims = getClaims() if (!token || !claims) { redirectToPortalLogin(`${window.location.origin}/auth/callback`) - throw new Error('Redirecting to auth portal') + // Abort route load while browser navigates away + await new Promise(() => {}) + return } if (!claims.apps.includes('vps')) { throw redirect({ to: '/' }) diff --git a/apps/web/src/routes/auth.callback.tsx b/apps/web/src/routes/auth.callback.tsx index f6b3b73..520bccc 100644 --- a/apps/web/src/routes/auth.callback.tsx +++ b/apps/web/src/routes/auth.callback.tsx @@ -1,19 +1,20 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { + ensureAuthConfig, firstAllowedPath, - isAuthEnabled, parseHashToken, + redirectToPortalLogin, setToken, } from '@/lib/auth' export const Route = createFileRoute('/auth/callback')({ - beforeLoad: () => { - if (!isAuthEnabled()) { - throw redirect({ to: '/dashboard' }) - } + beforeLoad: async () => { + await ensureAuthConfig() const { accessToken } = parseHashToken(window.location.hash) if (!accessToken) { - throw redirect({ to: '/' }) + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + await new Promise(() => {}) + return } setToken(accessToken) window.history.replaceState(null, '', '/auth/callback') diff --git a/docker-compose.yml b/docker-compose.yml index 480eeb3..fbb6441 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,10 @@ services: environment: PORT: "3001" RUNTIME: "fastify" + AUTH_REQUIRED: ${AUTH_REQUIRED:-false} + AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-dev-secret-change-me} + AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top} + AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-http://localhost:8080} volumes: - ./data:/app/data