From 9cc6c8d958ec5593350c030555edab6fcb0861bb Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 4 Sep 2026 14:23:23 +0700 Subject: [PATCH] feat(routes): add access-denied route and update authentication flow - Introduced a new Access Denied route to handle unauthorized access. - Updated routing logic to redirect to the Access Denied page when necessary. - Enhanced authentication checks to prevent infinite redirect loops and improve user experience. - Adjusted API client to handle JWT rejection scenarios more gracefully. --- apps/web/src/lib/api-client.ts | 7 ++++ apps/web/src/lib/app-switcher-config.ts | 8 +++- apps/web/src/lib/auth.ts | 2 +- apps/web/src/routeTree.gen.ts | 21 ++++++++++ apps/web/src/routes/__root.tsx | 3 +- apps/web/src/routes/_auth.tsx | 9 +++-- apps/web/src/routes/access-denied.tsx | 47 +++++++++++++++++++++ apps/web/src/routes/auth.callback.tsx | 54 +++++++++++++++++++++---- docs/deploy-traefik.md | 2 + 9 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/routes/access-denied.tsx diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index b3f845c..93bd9d7 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -30,6 +30,13 @@ async function handoffOnUnauthorized(): Promise { redirectToPortalLogin(`${window.location.origin}/auth/callback`) return } + // Cooldown / recent handoff — stop SSO storm (wrong JWT secret / issuer). + if (cfg.required || isAuthEnabled()) { + window.location.assign( + `${window.location.origin}/auth/callback?error=jwt_rejected`, + ) + return + } if (!cfg.required && !isAuthEnabled()) { window.location.href = '/login' } diff --git a/apps/web/src/lib/app-switcher-config.ts b/apps/web/src/lib/app-switcher-config.ts index 28c95c3..2355f65 100644 --- a/apps/web/src/lib/app-switcher-config.ts +++ b/apps/web/src/lib/app-switcher-config.ts @@ -62,5 +62,11 @@ export function getAppUrl( export function getCurrentApp( config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG, ): AppSwitcherEntry { - return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]! + const current = config.apps.find((app) => app.id === CURRENT_APP_ID) + if (current) return current + if (config.apps[0]) return config.apps[0] + return ( + DEFAULT_APP_SWITCHER_CONFIG.apps.find((a) => a.id === CURRENT_APP_ID) ?? + DEFAULT_APP_SWITCHER_CONFIG.apps[0]! + ) } diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index cfb0643..0d547b1 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -255,5 +255,5 @@ export function firstAllowedPath(): string { const perm = permissionForPath(path) if (!perm || can(perm)) return path } - return '/' + return '/access-denied' } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b1fb022..85ee944 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' +import { Route as AccessDeniedRouteImport } from './routes/access-denied' import { Route as AuthRouteImport } from './routes/_auth' import { Route as AuthIndexRouteImport } from './routes/_auth/index' import { Route as AuthCallbackRouteImport } from './routes/auth.callback' @@ -28,6 +29,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const AccessDeniedRoute = AccessDeniedRouteImport.update({ + id: '/access-denied', + path: '/access-denied', + getParentRoute: () => rootRouteImport, +} as any) const AuthRoute = AuthRouteImport.update({ id: '/_auth', getParentRoute: () => rootRouteImport, @@ -91,6 +97,7 @@ const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof AuthIndexRoute + '/access-denied': typeof AccessDeniedRoute '/login': typeof LoginRoute '/settings': typeof AuthSettingsRouteRouteWithChildren '/aliases': typeof AuthAliasesRoute @@ -104,6 +111,7 @@ export interface FileRoutesByFullPath { '/settings/': typeof AuthSettingsIndexRoute } export interface FileRoutesByTo { + '/access-denied': typeof AccessDeniedRoute '/login': typeof LoginRoute '/aliases': typeof AuthAliasesRoute '/nodes': typeof AuthNodesRoute @@ -119,6 +127,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_auth': typeof AuthRouteWithChildren + '/access-denied': typeof AccessDeniedRoute '/login': typeof LoginRoute '/_auth/settings': typeof AuthSettingsRouteRouteWithChildren '/_auth/aliases': typeof AuthAliasesRoute @@ -136,6 +145,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/access-denied' | '/login' | '/settings' | '/aliases' @@ -149,6 +159,7 @@ export interface FileRouteTypes { | '/settings/' fileRoutesByTo: FileRoutesByTo to: + | '/access-denied' | '/login' | '/aliases' | '/nodes' @@ -163,6 +174,7 @@ export interface FileRouteTypes { id: | '__root__' | '/_auth' + | '/access-denied' | '/login' | '/_auth/settings' | '/_auth/aliases' @@ -179,6 +191,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { AuthRoute: typeof AuthRouteWithChildren + AccessDeniedRoute: typeof AccessDeniedRoute LoginRoute: typeof LoginRoute AuthCallbackRoute: typeof AuthCallbackRoute } @@ -192,6 +205,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/access-denied': { + id: '/access-denied' + path: '/access-denied' + fullPath: '/access-denied' + preLoaderRoute: typeof AccessDeniedRouteImport + parentRoute: typeof rootRouteImport + } '/_auth': { id: '/_auth' path: '' @@ -318,6 +338,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const rootRouteChildren: RootRouteChildren = { AuthRoute: AuthRouteWithChildren, + AccessDeniedRoute: AccessDeniedRoute, LoginRoute: LoginRoute, AuthCallbackRoute: AuthCallbackRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2e6957e..44acc59 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -16,7 +16,8 @@ export const Route = createRootRouteWithContext()({ beforeLoad: async ({ location }) => { const isLogin = location.pathname === '/login' const isCallback = location.pathname === '/auth/callback' - if (isCallback) return + const isAccessDenied = location.pathname === '/access-denied' + if (isCallback || isAccessDenied) return const cfg = await ensureAuthConfig() const token = getToken() diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 22d8dc9..473b27e 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -30,16 +30,19 @@ export const Route = createFileRoute('/_auth')({ await new Promise(() => {}) return } + // NEVER redirect to `/` here — `/` is under `_auth` and causes an infinite loop + // (browser: «Страница не отвечает»). if (!claims.apps.includes('cdn')) { - throw redirect({ to: '/' }) + throw redirect({ to: '/access-denied' }) } const perm = permissionForPath(location.pathname) if (perm && !can(perm)) { const fallback = firstAllowedPath() - if (fallback !== location.pathname) { - throw redirect({ to: fallback as '/' }) + if (fallback === '/access-denied' || fallback === location.pathname) { + throw redirect({ to: '/access-denied' }) } + throw redirect({ to: fallback as '/' }) } }, component: () => ( diff --git a/apps/web/src/routes/access-denied.tsx b/apps/web/src/routes/access-denied.tsx new file mode 100644 index 0000000..7faad24 --- /dev/null +++ b/apps/web/src/routes/access-denied.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + authPortalUrl, + clearToken, + ensureAuthConfig, + redirectToPortalLogout, +} from '@/lib/auth' +import { Button } from '@cdnmanager/ui/components/button' + +export const Route = createFileRoute('/access-denied')({ + beforeLoad: async () => { + await ensureAuthConfig() + }, + component: AccessDeniedPage, +}) + +function AccessDeniedPage() { + return ( +
+

Нет доступа к CDN Manager

+

+ В JWT нет приложения cdn или нужных прав{' '} + cdn:*. Выдайте доступ в Auth Portal → + Админка → пользователи, затем войдите снова. +

+
+ + +
+
+ ) +} diff --git a/apps/web/src/routes/auth.callback.tsx b/apps/web/src/routes/auth.callback.tsx index 0382398..fff2edc 100644 --- a/apps/web/src/routes/auth.callback.tsx +++ b/apps/web/src/routes/auth.callback.tsx @@ -7,11 +7,24 @@ import { firstAllowedPath, getClaims, getToken, + markPortalHandoff, parseHashToken, redirectToPortalLogin, setToken, } from '@/lib/auth' +async function verifyTokenAccepted(token: string): Promise { + try { + const res = await fetch('/api/v1/locations', { + headers: { Authorization: `Bearer ${token}` }, + }) + // 401 = JWT rejected (secret/issuer). 403 = JWT ok, RBAC — still accepted. + return res.status !== 401 + } catch { + return true + } +} + export const Route = createFileRoute('/auth/callback')({ validateSearch: (search: Record) => ({ error: typeof search.error === 'string' ? search.error : undefined, @@ -19,25 +32,49 @@ export const Route = createFileRoute('/auth/callback')({ beforeLoad: async ({ search }) => { await ensureAuthConfig() - if (search.error === 'sso_loop') { + if (search.error === 'sso_loop' || search.error === 'jwt_rejected') { return } const { accessToken } = parseHashToken(window.location.hash) if (accessToken) { setToken(accessToken) + // Start cooldown so a following API 401 cannot re-enter portal SSO storm. + markPortalHandoff() clearPortalHandoffFlag() + const claims = getClaims() if (!claims) { clearToken() - window.location.assign(authPortalUrl()) - await new Promise(() => {}) - return + throw redirect({ + to: '/auth/callback', + search: { error: 'jwt_rejected' }, + }) } - throw redirect({ to: firstAllowedPath() as '/' }) + if (!claims.apps.includes('cdn')) { + throw redirect({ to: '/access-denied' }) + } + + const ok = await verifyTokenAccepted(accessToken) + if (!ok) { + clearToken() + throw redirect({ + to: '/auth/callback', + search: { error: 'jwt_rejected' }, + }) + } + + const next = firstAllowedPath() + if (next === '/access-denied') { + throw redirect({ to: '/access-denied' }) + } + throw redirect({ to: next as '/' }) } if (getToken() && getClaims()) { clearPortalHandoffFlag() + if (!getClaims()!.apps.includes('cdn')) { + throw redirect({ to: '/access-denied' }) + } throw redirect({ to: firstAllowedPath() as '/' }) } const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) @@ -51,13 +88,14 @@ export const Route = createFileRoute('/auth/callback')({ function AuthCallbackPage() { const { error } = Route.useSearch() - if (error === 'sso_loop') { + if (error === 'sso_loop' || error === 'jwt_rejected') { return (

Сессия не принята

- Повторный вход через portal остановлен (защита от цикла редиректов). - Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен. + {error === 'jwt_rejected' + ? 'API отклонил JWT (обычно разный AUTH_JWT_SECRET / AUTH_ISSUER с portal). Проверьте .env контейнера CDN Manager.' + : 'Повторный вход через portal остановлен (защита от цикла редиректов). Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.'}{' '} Войдите заново на portal, затем откройте CDN Manager.

diff --git a/docs/deploy-traefik.md b/docs/deploy-traefik.md index 1f6267e..7f77e08 100644 --- a/docs/deploy-traefik.md +++ b/docs/deploy-traefik.md @@ -100,6 +100,8 @@ nano .env # заполнить секреты На стороне портала добавьте origin в `RETURN_TO_ALLOWLIST` (`https://cdn.shnt.top`) и выдайте app **`cdn`** + права `cdn:*`. App Switcher URL: тот же origin. +**Важно:** `AUTH_JWT_SECRET` в CDN Manager **должен совпадать** с `JWT_SECRET` auth-portal, `AUTH_ISSUER` — с `ISSUER` портала. Иначе после SSO UI зацикливается / «Страница не отвечает». + --- ## 3. Запуск