From db79820df01df02d378d5b8938258502c328088c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 6 Jul 2026 22:55:30 +0700 Subject: [PATCH] feat(auth): introduce demo token support and enhance API token handling Added a local demo token for development purposes and improved the API token management by normalizing input tokens. Updated the authentication flow to utilize the new token handling, allowing for better session management and user experience. Enhanced the settings component to support the demo token and provide clear instructions for its use in local development. --- apps/web/src/lib/api-client.ts | 24 +++++- apps/web/src/queries/settings.ts | 6 +- apps/web/src/routes/_auth.tsx | 12 ++- apps/web/src/routes/_auth/access.tsx | 12 ++- apps/web/src/routes/_auth/settings.tsx | 76 +++++++++++++++---- apps/web/src/routes/_auth/tenant-settings.tsx | 8 +- docs/access.md | 2 + internal/httpapi/auth.go | 37 ++++++--- internal/httpapi/auth_dev_settings_test.go | 67 ++++++++++++++++ internal/repository/postgres_entities.go | 6 ++ 10 files changed, 210 insertions(+), 40 deletions(-) create mode 100644 internal/httpapi/auth_dev_settings_test.go diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 9bfbc3e..6d2ad0f 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -11,6 +11,9 @@ import type { export const TOKEN_STORAGE_KEY = 'evobgp_api_token' +/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */ +export const DEV_API_TOKEN = 'dev' + export type Problem = { type?: string title?: string @@ -18,14 +21,31 @@ export type Problem = { detail?: string } +/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */ +export function normalizeApiToken(raw: string): string { + let t = raw.trim() + if (/^bearer\s+/i.test(t)) { + t = t.replace(/^bearer\s+/i, '').trim() + } + return t +} + function getToken(): string | null { if (typeof window === 'undefined') return null - return window.localStorage.getItem(TOKEN_STORAGE_KEY) + const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY) + if (!raw) return null + const normalized = normalizeApiToken(raw) + return normalized || null } export function setToken(token: string | null): void { if (typeof window === 'undefined') return - if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token) + if (!token) { + window.localStorage.removeItem(TOKEN_STORAGE_KEY) + return + } + const normalized = normalizeApiToken(token) + if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized) else window.localStorage.removeItem(TOKEN_STORAGE_KEY) } diff --git a/apps/web/src/queries/settings.ts b/apps/web/src/queries/settings.ts index 52f1dd1..497091b 100644 --- a/apps/web/src/queries/settings.ts +++ b/apps/web/src/queries/settings.ts @@ -42,12 +42,14 @@ export const BOOLEAN_SETTING_KEYS = new Set(['runtime_logs_auto export const settingsKeys = { all: ['settings'] as const, + tenant: (tenantId: string) => [...settingsKeys.all, tenantId] as const, } -export function settingsQueryOptions() { +export function settingsQueryOptions(tenantId?: string | null) { return queryOptions({ - queryKey: settingsKeys.all, + queryKey: settingsKeys.tenant(tenantId ?? ''), queryFn: () => apiJSON('/v1/settings'), + enabled: Boolean(tenantId), staleTime: 30_000, }) } diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 96ba1d2..4708472 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,10 +1,14 @@ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' +import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' + export const Route = createFileRoute('/_auth')({ - beforeLoad: () => { - const token = - typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null - if (!token) { + beforeLoad: ({ location }) => { + // Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`). + if (location.pathname === '/settings') return + const raw = + typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null + if (!raw || !normalizeApiToken(raw)) { throw redirect({ to: '/settings' }) } }, diff --git a/apps/web/src/routes/_auth/access.tsx b/apps/web/src/routes/_auth/access.tsx index c084e72..a3b5361 100644 --- a/apps/web/src/routes/_auth/access.tsx +++ b/apps/web/src/routes/_auth/access.tsx @@ -86,11 +86,12 @@ function AccessComponent() { Роли: viewer (чтение),{' '} editor (CRUD), operator{' '} (apply и настройки), node (API ноды). Полный токен - показывается один раз при создании и ротации. Bearer для браузера — в{' '} + показывается один раз при создании и ротации. Токен браузера — в{' '} настройках - . + ; для локальной разработки с demo-seed подойдёт dev{' '} + (роль operator). @@ -114,11 +115,14 @@ function AccessComponent() { ) : ( - Не удалось определить сессию. Укажите Bearer-токен в{' '} + Не удалось определить сессию. Укажите токен в{' '} настройках {' '} - интерфейса. + (для dev-окружения — dev при включённом demo-seed). + {sessionQuery.isError && sessionQuery.error instanceof Error ? ( + {sessionQuery.error.message} + ) : null} )} diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx index 50c5182..88cd603 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings.tsx @@ -1,6 +1,8 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' +import { Button } from '@evobgp/ui/components/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Input } from '@evobgp/ui/components/input' import { Label } from '@evobgp/ui/components/label' @@ -14,10 +16,10 @@ import { import { PageHeader } from '@/components/page-header' import { LoadingButton } from '@/components/loading-button' -import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' -import { authSessionQueryOptions } from '@/queries/auth' +import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' +import { authKeys, authSessionQueryOptions } from '@/queries/auth' import { toast } from 'sonner' -import { Save } from 'lucide-react' +import { Info, Save } from 'lucide-react' import { useTheme } from 'next-themes' import { useEffect, useState } from 'react' @@ -32,7 +34,14 @@ const THEME_SELECT_ITEMS = [ ] as const function SettingsComponent() { - const { data: session } = useQuery(authSessionQueryOptions()) + const navigate = useNavigate() + const qc = useQueryClient() + const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({ + ...authSessionQueryOptions(), + enabled: Boolean( + typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(), + ), + }) const { theme, setTheme } = useTheme() const [token, setTokenValue] = useState('') @@ -41,10 +50,23 @@ function SettingsComponent() { setTokenValue(t) }, []) - function saveTokenHandler() { - const t = token.trim() - setToken(t || null) + async function applyToken(raw: string) { + const normalized = normalizeApiToken(raw) + setToken(normalized || null) + setTokenValue(normalized) + await qc.invalidateQueries({ queryKey: authKeys.all }) toast.success('Токен сохранён') + if (normalized) { + void navigate({ to: '/dashboard' }) + } + } + + function saveTokenHandler() { + void applyToken(token) + } + + function useDevToken() { + void applyToken(DEV_API_TOKEN) } return ( @@ -54,11 +76,21 @@ function SettingsComponent() { description="Параметры интерфейса и подключения браузера к API." /> + + + Локальная разработка + + При включённом demo-seed API принимает токен dev (роль{' '} + operator). Вводите только значение токена, без префикса{' '} + Bearer — он добавляется автоматически. + + + Подключение к API - Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в + Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа». @@ -71,19 +103,33 @@ function SettingsComponent() { autoComplete="off" value={token} onChange={(e) => setTokenValue(e.target.value)} - placeholder="Bearer …" + placeholder="dev или API-ключ" /> - - - Сохранить токен - +
+ + + Сохранить токен + + +
{session ? (

Активная сессия: tenant {session.tenant_id}, роль{' '} {session.role}.

) : null} + {sessionError ? ( +

+ {sessionQueryError instanceof Error + ? sessionQueryError.message + : 'Не удалось проверить сессию'} + . Для токена dev нужен demo-seed ( + EVOBGP_SEED_DEMO ≠ 0) и запущенный API. +

+ ) : null}
diff --git a/apps/web/src/routes/_auth/tenant-settings.tsx b/apps/web/src/routes/_auth/tenant-settings.tsx index 9265cda..49393b1 100644 --- a/apps/web/src/routes/_auth/tenant-settings.tsx +++ b/apps/web/src/routes/_auth/tenant-settings.tsx @@ -35,9 +35,11 @@ import { RUNTIME_LOGS_SETTING_KEYS, buildPayload, partitionSettings, + settingsKeys, settingsQueryOptions, type BirdSettingKey, } from '@/queries/settings' +import { authSessionQueryOptions } from '@/queries/auth' import { apiMutate } from '@/lib/api-client' export const Route = createFileRoute('/_auth/tenant-settings')({ @@ -70,7 +72,9 @@ const BIRD_LABELS: Record = { function TenantSettingsComponent() { const search = useSearch({ from: '/_auth/tenant-settings' }) - const settingsQ = useQuery(settingsQueryOptions()) + const sessionQ = useQuery(authSessionQueryOptions()) + const tenantId = sessionQ.data?.tenant_id ?? null + const settingsQ = useQuery(settingsQueryOptions(tenantId)) const qc = useQueryClient() const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null @@ -93,7 +97,7 @@ function TenantSettingsComponent() { apiMutate('/v1/settings', 'PATCH', payload), onSuccess: () => { toast.success('Параметры сохранены') - void qc.invalidateQueries({ queryKey: ['settings'] }) + void qc.invalidateQueries({ queryKey: settingsKeys.all }) }, onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'), }) diff --git a/docs/access.md b/docs/access.md index 3292788..29e2a30 100644 --- a/docs/access.md +++ b/docs/access.md @@ -50,6 +50,8 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`. +Если токен `dev` также задан в `EVOBGP_API_KEYS` или таблице `api_key`, **приоритет у явной записи** (production tenant), а не у demo-shortcut. + **Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах). ### PostgreSQL monitoring и maintenance (control plane) diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 00b5abd..be57402 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -63,27 +63,42 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { return } raw := strings.TrimSpace(strings.TrimPrefix(h, p)) - if raw == "dev" { - if a, ok := s.devAuth(); ok { - r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) - next.ServeHTTP(w, r) - return - } - } - matched, ok := s.keyResolver.Lookup(raw) + a, ok := s.resolveAuth(raw) if !ok { writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key") return } - a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID} - if matched.keyID != "" { - go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID) + if a.APIKeyID != "" { + go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID) } r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) next.ServeHTTP(w, r) }) } +func authFromKeyRecord(raw string, rec apiKeyRecord) Auth { + return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID} +} + +// resolveAuth maps a bearer token to tenant identity. +// For the literal token "dev", env/DB keys take precedence over the demo shortcut (devAuth). +func (s *Server) resolveAuth(raw string) (Auth, bool) { + if raw == "dev" { + if rec, ok := s.keyResolver.Lookup(raw); ok { + return authFromKeyRecord(raw, rec), true + } + if a, ok := s.devAuth(); ok { + return a, true + } + return Auth{}, false + } + rec, ok := s.keyResolver.Lookup(raw) + if !ok { + return Auth{}, false + } + return authFromKeyRecord(raw, rec), true +} + func (s *Server) devAuth() (Auth, bool) { tid, _, _, _, _ := s.store.DemoIDs() if tid == "" { diff --git a/internal/httpapi/auth_dev_settings_test.go b/internal/httpapi/auth_dev_settings_test.go new file mode 100644 index 0000000..a8073d5 --- /dev/null +++ b/internal/httpapi/auth_dev_settings_test.go @@ -0,0 +1,67 @@ +package httpapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBearerDevGetSettings(t *testing.T) { + srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/settings", nil) + req.Header.Set("Authorization", "Bearer dev") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%s", resp.StatusCode, b) + } +} + +func TestBearerDevPrefersEnvAPIKeyOverDemoTenant(t *testing.T) { + srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + demoTenant, _, _, _, _ := srv.Store().DemoIDs() + otherTenant := "00000000-0000-4000-8000-000000000001" + mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator") + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil) + req.Header.Set("Authorization", "Bearer dev") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("session status=%d body=%s", resp.StatusCode, b) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + got, _ := body["tenant_id"].(string) + if got != otherTenant { + t.Fatalf("tenant_id=%q want env key tenant %q (demo=%q)", got, otherTenant, demoTenant) + } +} diff --git a/internal/repository/postgres_entities.go b/internal/repository/postgres_entities.go index 10d74e9..dce0db3 100644 --- a/internal/repository/postgres_entities.go +++ b/internal/repository/postgres_entities.go @@ -589,6 +589,9 @@ func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error } func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) { + if _, err := uuid.Parse(tenantID); err != nil { + return nil, store.ErrInvalidInput + } ctx := context.Background() rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID) if err != nil { @@ -610,6 +613,9 @@ func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) { } func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error { + if _, err := uuid.Parse(tenantID); err != nil { + return store.ErrInvalidInput + } if patch == nil { return nil }