Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96d754ef26 | ||
|
|
d6e46d30b7 |
@@ -30,13 +30,7 @@ async function handoffOnUnauthorized(): Promise<void> {
|
|||||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Cooldown / recent handoff — stop SSO storm (wrong JWT secret / issuer).
|
// Match CFDM: on cooldown do not open sso_loop / jwt_rejected — caller handles.
|
||||||
if (cfg.required || isAuthEnabled()) {
|
|
||||||
window.location.assign(
|
|
||||||
`${window.location.origin}/auth/callback?error=jwt_rejected`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!cfg.required && !isAuthEnabled()) {
|
if (!cfg.required && !isAuthEnabled()) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,17 @@ export function redirectToPortalLogin(returnTo?: string): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive portal login without return_to — breaks SSO storms when cooldown
|
||||||
|
* blocks silent handoff (expired portal session / rejected JWT). Same pattern as
|
||||||
|
* VPS Tracker / CFDM for invalid hash tokens.
|
||||||
|
*/
|
||||||
|
export function redirectToPortalLoginInteractive(): void {
|
||||||
|
clearToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
window.location.assign(authPortalUrl())
|
||||||
|
}
|
||||||
|
|
||||||
/** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */
|
/** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */
|
||||||
export function redirectToPortalLogout(): void {
|
export function redirectToPortalLogout(): void {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -196,6 +207,8 @@ export function getClaims(): AccessClaims | null {
|
|||||||
if (!claims) return null
|
if (!claims) return null
|
||||||
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||||
clearToken()
|
clearToken()
|
||||||
|
// Allow a fresh portal handoff after local JWT expiry.
|
||||||
|
resetPortalHandoff()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return claims
|
return claims
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getClaims,
|
getClaims,
|
||||||
getToken,
|
getToken,
|
||||||
redirectToPortalLogin,
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
@@ -28,12 +29,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
const ok = redirectToPortalLogin(
|
const ok = redirectToPortalLogin(
|
||||||
`${window.location.origin}/auth/callback`,
|
`${window.location.origin}/auth/callback`,
|
||||||
)
|
)
|
||||||
if (!ok) {
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
throw redirect({
|
|
||||||
to: '/auth/callback',
|
|
||||||
search: { error: 'sso_loop' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -41,12 +37,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
const ok = redirectToPortalLogin(
|
const ok = redirectToPortalLogin(
|
||||||
`${window.location.origin}/auth/callback`,
|
`${window.location.origin}/auth/callback`,
|
||||||
)
|
)
|
||||||
if (!ok) {
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
throw redirect({
|
|
||||||
to: '/auth/callback',
|
|
||||||
search: { error: 'sso_loop' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getToken,
|
getToken,
|
||||||
permissionForPath,
|
permissionForPath,
|
||||||
redirectToPortalLogin,
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
@@ -21,12 +22,7 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
const ok = redirectToPortalLogin(
|
const ok = redirectToPortalLogin(
|
||||||
`${window.location.origin}/auth/callback`,
|
`${window.location.origin}/auth/callback`,
|
||||||
)
|
)
|
||||||
if (!ok) {
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
throw redirect({
|
|
||||||
to: '/auth/callback',
|
|
||||||
search: { error: 'sso_loop' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
CloudIcon,
|
CloudIcon,
|
||||||
MapPinIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -75,12 +74,16 @@ const formSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof formSchema>
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
const ROLES: { value: NodeRole; label: string }[] = [
|
const ROLES: { value: NodeRole; label: string }[] = [
|
||||||
{ value: 'hub', label: 'hub' },
|
{ value: 'hub', label: 'Hub' },
|
||||||
{ value: 'gw', label: 'gw' },
|
{ value: 'gw', label: 'Gateway' },
|
||||||
{ value: 'edge', label: 'edge' },
|
{ value: 'edge', label: 'Edge' },
|
||||||
{ value: 'ix', label: 'ix' },
|
{ value: 'ix', label: 'IX' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<NodeRole, string> = Object.fromEntries(
|
||||||
|
ROLES.map((r) => [r.value, r.label]),
|
||||||
|
) as Record<NodeRole, string>
|
||||||
|
|
||||||
function NodesPage() {
|
function NodesPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
|
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
|
||||||
@@ -302,7 +305,10 @@ function NodesPage() {
|
|||||||
key: 'locationCode',
|
key: 'locationCode',
|
||||||
label: 'Локация',
|
label: 'Локация',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: locations.map((l) => ({ value: l.code, label: l.code })),
|
options: locations.map((l) => ({
|
||||||
|
value: l.code,
|
||||||
|
label: l.name,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'role',
|
||||||
@@ -340,16 +346,35 @@ function NodesPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: 'locationCode',
|
accessorKey: 'locationCode',
|
||||||
header: 'Локация',
|
header: 'Локация',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<span className="flex items-center gap-1.5 text-sm">
|
const n = row.original
|
||||||
<MapPinIcon className="size-3.5" />
|
const loc = locations.find((l) => l.id === n.locationId)
|
||||||
{row.original.locationCode ?? '—'}
|
const city = n.locationName ?? loc?.name ?? n.locationCode ?? '—'
|
||||||
</span>
|
const countryCode = loc?.country ?? undefined
|
||||||
),
|
return (
|
||||||
|
<span className="flex items-center gap-2 text-sm">
|
||||||
|
<CountryFlag code={countryCode ?? undefined} country={countryNameFromCode(countryCode)} />
|
||||||
|
<span className="font-medium">{city}</span>
|
||||||
|
{n.locationCode ? (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{n.locationCode}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: 'Роль',
|
header: 'Роль',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const role = row.original.role as NodeRole
|
||||||
|
return (
|
||||||
|
<span className="text-sm">
|
||||||
|
{ROLE_LABEL[role] ?? row.original.role}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ip',
|
id: 'ip',
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
authPortalUrl,
|
|
||||||
clearPortalHandoffFlag,
|
clearPortalHandoffFlag,
|
||||||
clearToken,
|
clearToken,
|
||||||
ensureAuthConfig,
|
ensureAuthConfig,
|
||||||
firstAllowedPath,
|
firstAllowedPath,
|
||||||
getClaims,
|
getClaims,
|
||||||
getToken,
|
getToken,
|
||||||
markPortalHandoff,
|
|
||||||
parseHashToken,
|
parseHashToken,
|
||||||
redirectToPortalLogin,
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
setToken,
|
setToken,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
|
||||||
@@ -32,24 +31,26 @@ export const Route = createFileRoute('/auth/callback')({
|
|||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
await ensureAuthConfig()
|
await ensureAuthConfig()
|
||||||
|
|
||||||
|
// Dead-end errors → interactive portal login (no return_to storm).
|
||||||
if (search.error === 'sso_loop' || search.error === 'jwt_rejected') {
|
if (search.error === 'sso_loop' || search.error === 'jwt_rejected') {
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken } = parseHashToken(window.location.hash)
|
const { accessToken } = parseHashToken(window.location.hash)
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
setToken(accessToken)
|
setToken(accessToken)
|
||||||
// Start cooldown so a following API 401 cannot re-enter portal SSO storm.
|
// Match CFDM/VPS: clear handoff flag only — do not start a new cooldown
|
||||||
markPortalHandoff()
|
// after a successful SSO (that caused false sso_loop on expiry re-login).
|
||||||
clearPortalHandoffFlag()
|
clearPortalHandoffFlag()
|
||||||
|
|
||||||
const claims = getClaims()
|
const claims = getClaims()
|
||||||
if (!claims) {
|
if (!claims) {
|
||||||
clearToken()
|
clearToken()
|
||||||
throw redirect({
|
redirectToPortalLoginInteractive()
|
||||||
to: '/auth/callback',
|
await new Promise(() => {})
|
||||||
search: { error: 'jwt_rejected' },
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (!claims.apps.includes('cdn')) {
|
if (!claims.apps.includes('cdn')) {
|
||||||
throw redirect({ to: '/access-denied' })
|
throw redirect({ to: '/access-denied' })
|
||||||
@@ -58,10 +59,9 @@ export const Route = createFileRoute('/auth/callback')({
|
|||||||
const ok = await verifyTokenAccepted(accessToken)
|
const ok = await verifyTokenAccepted(accessToken)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
throw redirect({
|
redirectToPortalLoginInteractive()
|
||||||
to: '/auth/callback',
|
await new Promise(() => {})
|
||||||
search: { error: 'jwt_rejected' },
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = firstAllowedPath()
|
const next = firstAllowedPath()
|
||||||
@@ -79,7 +79,8 @@ export const Route = createFileRoute('/auth/callback')({
|
|||||||
}
|
}
|
||||||
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw redirect({ to: '/auth/callback', search: { error: 'sso_loop' } })
|
// Cooldown: fall back to interactive portal login instead of sso_loop page.
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
}
|
}
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
},
|
},
|
||||||
@@ -87,22 +88,10 @@ export const Route = createFileRoute('/auth/callback')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function AuthCallbackPage() {
|
function AuthCallbackPage() {
|
||||||
const { error } = Route.useSearch()
|
// beforeLoad always navigates away; placeholder while assigning location.
|
||||||
if (error === 'sso_loop' || error === 'jwt_rejected') {
|
return (
|
||||||
return (
|
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
Перенаправление на Auth Portal…
|
||||||
<h1 className="text-lg font-semibold">Сессия не принята</h1>
|
</div>
|
||||||
<p className="text-muted-foreground max-w-md text-sm">
|
)
|
||||||
{error === 'jwt_rejected'
|
|
||||||
? 'API отклонил JWT (обычно разный AUTH_JWT_SECRET / AUTH_ISSUER с portal). Проверьте .env контейнера CDN Manager.'
|
|
||||||
: 'Повторный вход через portal остановлен (защита от цикла редиректов). Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.'}{' '}
|
|
||||||
Войдите заново на portal, затем откройте CDN Manager.
|
|
||||||
</p>
|
|
||||||
<a className="text-primary text-sm underline" href={authPortalUrl()}>
|
|
||||||
Открыть Auth Portal
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user