feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { getToken, clearToken, redirectToPortalLogin, isAuthEnabled } from './auth'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (!headers.has('Content-Type') && init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...init, headers })
|
||||
if (res.status === 401 && isAuthEnabled()) {
|
||||
clearToken()
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = (await res.json()) as { error?: { message?: string } }
|
||||
message = body.error?.message ?? message
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/** Portal JWT storage for EvoFirewall (app id `fw`). */
|
||||
|
||||
const TOKEN_KEY = 'fw_auth_token'
|
||||
const HANDOFF_AT_KEY = 'fw_portal_handoff_at'
|
||||
const HANDOFF_COOLDOWN_MS = 12_000
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export type AccessClaims = {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
is_admin?: boolean
|
||||
exp?: number
|
||||
}
|
||||
|
||||
export type RuntimeAuthConfig = {
|
||||
required: boolean
|
||||
portalUrl: string
|
||||
}
|
||||
|
||||
let runtimeConfig: RuntimeAuthConfig | 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(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
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 {
|
||||
/* fallback */
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: viteAuthEnabled(),
|
||||
portalUrl: vitePortalUrl(),
|
||||
}
|
||||
return runtimeConfig
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
return runtimeConfig?.required ?? viteAuthEnabled()
|
||||
}
|
||||
|
||||
export function authPortalUrl(): string {
|
||||
return runtimeConfig?.portalUrl ?? vitePortalUrl()
|
||||
}
|
||||
|
||||
export function isPortalHandoffCoolingDown(): boolean {
|
||||
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||
if (!raw) return false
|
||||
return Date.now() - Number(raw) < HANDOFF_COOLDOWN_MS
|
||||
}
|
||||
|
||||
export function markPortalHandoff(): void {
|
||||
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function parseClaims(token: string): AccessClaims | null {
|
||||
try {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) return null
|
||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
return JSON.parse(json) as AccessClaims
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenValid(): boolean {
|
||||
const t = getToken()
|
||||
if (!t) return false
|
||||
const c = parseClaims(t)
|
||||
if (!c?.exp) return !!t
|
||||
return c.exp * 1000 > Date.now() + 5_000
|
||||
}
|
||||
|
||||
export function redirectToPortalLogin(returnTo: string) {
|
||||
if (isPortalHandoffCoolingDown()) return
|
||||
markPortalHandoff()
|
||||
const portal = authPortalUrl()
|
||||
const url = `${portal}/?return_to=${encodeURIComponent(returnTo)}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken()
|
||||
window.location.href = `${authPortalUrl()}/logout`
|
||||
}
|
||||
|
||||
export const CURRENT_APP_ID = 'fw'
|
||||
Reference in New Issue
Block a user