From 4069218074e14b817d4c90e1bb6a5eb08f746261 Mon Sep 17 00:00:00 2001 From: DeepZone Date: Wed, 20 May 2026 20:14:31 +0100 Subject: [PATCH] fix(auth): bootstrap setup/login flow and enforce check auth --- README.md | 4 +- RELEASE_NOTES.md | 13 +++-- backend/app/api/routes_health.py | 2 +- backend/app/api/routes_system.py | 2 +- backend/app/core/auth.py | 2 - backend/app/core/system_status.py | 2 +- backend/app/main.py | 2 +- backend/pyproject.toml | 2 +- backend/tests/test_api_smoke.py | 59 ++++++++++++++++++- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.tsx | 84 +++++++++++++++++++-------- frontend/src/api.ts | 8 ++- frontend/src/components/Layout.tsx | 2 +- frontend/src/components/LoginView.tsx | 25 ++++++++ frontend/src/components/SetupView.tsx | 32 ++++++++++ 16 files changed, 199 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/LoginView.tsx create mode 100644 frontend/src/components/SetupView.tsx diff --git a/README.md b/README.md index a01cd71..3fffc63 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RouteForge Logo

- Version + Version License Status Selfhosted @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external ## Current Alpha Status -RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.0-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.1-beta**. ## Quickstart with Docker Compose diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 02832f7..4ff8a6d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,15 +1,16 @@ # Release Notes -## v0.6.0-beta +## v0.6.1-beta -**SQLite Volume Permission Hotfix** +**Auth Bootstrap Fix** ### Highlights -- Fixes SQLite readonly database errors after non-root container hardening. -- Backend entrypoint now prepares `/app/data` permissions for the `routeforge` runtime user. -- Runtime remains non-root. -- Troubleshooting documentation added. +- Fixed missing Initial Admin Setup screen. +- App now blocks dashboard/check views until setup/login state is resolved. +- Fixed NoneType crash when running checks without authenticated user. +- Check endpoints now return 401/403 instead of HTTP 500. +- API requests include session cookies consistently. --- diff --git a/backend/app/api/routes_health.py b/backend/app/api/routes_health.py index 5d9281b..1bf30bf 100644 --- a/backend/app/api/routes_health.py +++ b/backend/app/api/routes_health.py @@ -8,4 +8,4 @@ @router.get('/health') def health() -> dict: - return {"status": "ok", "version": "v0.5.5-beta", "database": get_database_status(engine).get("status", "unknown")} + return {"status": "ok", "version": "v0.6.1-beta", "database": get_database_status(engine).get("status", "unknown")} diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py index 7dc234b..e6f24db 100644 --- a/backend/app/api/routes_system.py +++ b/backend/app/api/routes_system.py @@ -12,7 +12,7 @@ def system_info(): return { 'name': 'RouteForge', - 'version': 'v0.6.0-beta', + 'version': 'v0.6.1-beta', 'demo_mode': settings.demo_mode, 'read_only': True, 'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'], diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 15435ac..1eb480d 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -60,8 +60,6 @@ def require_authenticated_user(user: User = Depends(get_current_user)) -> User: def require_role(*roles: str): def checker(request: Request, db: Session = Depends(get_db)): - if db.query(User).count() == 0: - return None current = get_current_user(request, db) if current.role not in roles: raise HTTPException(status_code=403, detail="Insufficient role") diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index 3dba033..8a3cf23 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -117,7 +117,7 @@ def build_system_status(engine: Engine | None) -> dict: return { "status": "ok", "name": settings.app_name, - "version": "v0.6.0-beta", + "version": "v0.6.1-beta", "read_only": True, "mode": "demo" if settings.demo_mode else "live", "demo_mode": settings.demo_mode, diff --git a/backend/app/main.py b/backend/app/main.py index f5c6566..6244fe0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,7 +17,7 @@ logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logger = logging.getLogger("routeforge") -app = FastAPI(title="RouteForge", version="0.6.0") +app = FastAPI(title="RouteForge", version="0.6.1") app.add_middleware( CORSMiddleware, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 28e7291..64c0b37 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routeforge-backend" -version = "0.6.0" +version = "0.6.1" description = "RouteForge backend" license = "AGPL-3.0-or-later" requires-python = ">=3.12" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index eeb28ad..62a08e3 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -14,10 +14,20 @@ def _client() -> TestClient: import app.database as database importlib.reload(main_module) + database.Base.metadata.drop_all(bind=database.engine) database.Base.metadata.create_all(bind=database.engine) return TestClient(main_module.app) +def _setup_and_login(client: TestClient, username: str = "admin", password: str = "AdminPass123!") -> None: + setup = client.post('/api/auth/setup', json={'username': username, 'email': 'admin@example.org', 'password': password, 'password_confirm': password}) + if setup.status_code == 403: + login = client.post('/api/auth/login', json={'username': username, 'password': password}) + assert login.status_code == 200 + return + assert setup.status_code == 200 + + def test_health() -> None: client = _client() response = client.get('/health') @@ -27,6 +37,7 @@ def test_health() -> None: def test_prefix_check_without_origin_as() -> None: client = _client() + _setup_and_login(client) response = client.post('/api/check/prefix', json={'prefix': '193.0.6.0/24'}) assert response.status_code == 200 payload = response.json() @@ -42,6 +53,7 @@ def test_prefix_check_without_origin_as() -> None: def test_asn_check() -> None: client = _client() + _setup_and_login(client) response = client.post('/api/check/asn', json={'asn': 'AS3320'}) assert response.status_code == 200 payload = response.json() @@ -56,6 +68,7 @@ def test_asn_check() -> None: def test_asn_check_without_prefixes_has_batch_reason() -> None: client = _client() + _setup_and_login(client) response = client.post('/api/check/asn', json={'asn': 'AS4491'}) assert response.status_code == 200 details = response.json().get('details', {}) @@ -67,6 +80,7 @@ def test_asn_check_without_prefixes_has_batch_reason() -> None: def test_asn_rpki_batch() -> None: client = _client() + _setup_and_login(client) response = client.post('/api/check/asn-rpki', json={'asn': 'AS3320', 'limit': 3}) assert response.status_code == 200 payload = response.json() @@ -79,6 +93,7 @@ def test_asn_rpki_batch() -> None: def test_asn_rpki_batch_without_prefixes() -> None: client = _client() + _setup_and_login(client) response = client.post('/api/check/asn-rpki', json={'asn': 'AS4491', 'limit': 25}) assert response.status_code == 200 payload = response.json() @@ -99,6 +114,7 @@ def test_system_info() -> None: def test_reports_list_empty_or_present() -> None: client = _client() + _setup_and_login(client) response = client.get('/api/reports') assert response.status_code == 200 payload = response.json() @@ -107,6 +123,7 @@ def test_reports_list_empty_or_present() -> None: def test_preflight_check() -> None: client = _client() + _setup_and_login(client) response = client.post('/api/check/preflight', json={'prefix': '192.0.2.0/24', 'planned_origin_as': 'AS3320'}) assert response.status_code == 200 payload = response.json() @@ -124,6 +141,7 @@ def test_preflight_check() -> None: def test_report_export_endpoints() -> None: client = _client() + _setup_and_login(client) check_response = client.post('/api/check/prefix', json={'prefix': '193.0.6.0/24'}) assert check_response.status_code == 200 report_id = check_response.json().get('report_id') @@ -146,6 +164,7 @@ def test_report_export_endpoints() -> None: def test_report_export_not_found() -> None: client = _client() + _setup_and_login(client) for endpoint in ('summary', 'markdown', 'html'): response = client.get(f'/api/reports/999999/{endpoint}') assert response.status_code == 404 @@ -154,10 +173,11 @@ def test_report_export_not_found() -> None: def test_system_status_endpoint() -> None: client = _client() + _setup_and_login(client) response = client.get('/api/system/status') assert response.status_code == 200 payload = response.json() - assert payload.get('version') == 'v0.5.5-beta' + assert payload.get('version') == 'v0.6.1-beta' assert payload.get('read_only') is True assert payload.get('database', {}).get('status') assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None @@ -172,6 +192,7 @@ def test_safe_database_url() -> None: def test_system_status_includes_migration_fields() -> None: client = _client() + _setup_and_login(client) response = client.get('/api/system/status') assert response.status_code == 200 database = response.json().get('database', {}) @@ -190,3 +211,39 @@ def connect(self): payload = ss.get_database_status(FakeBrokenEngine()) assert payload.get('status') == 'error' assert payload.get('migration_status') == 'error' + + +def test_setup_required_without_users() -> None: + client = _client() + response = client.get('/api/auth/setup-required') + assert response.status_code == 200 + assert response.json().get('setup_required') is True + + +def test_asn_check_requires_authentication() -> None: + client = _client() + response = client.post('/api/check/asn', json={'asn': 'AS3320'}) + assert response.status_code == 401 + + +def test_asn_check_forbidden_for_viewer() -> None: + client = _client() + _setup_and_login(client) + create = client.post('/api/users', json={'username': 'viewer', 'email': 'viewer@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'}) + assert create.status_code == 200 + client.post('/api/auth/logout') + login = client.post('/api/auth/login', json={'username': 'viewer', 'password': 'ViewerPass123!'}) + assert login.status_code == 200 + response = client.post('/api/check/asn', json={'asn': 'AS3320'}) + assert response.status_code == 403 + + +def test_asn_check_allowed_for_operator() -> None: + client = _client() + _setup_and_login(client) + create = client.post('/api/users', json={'username': 'operator1', 'email': 'op@example.org', 'password': 'OperatorPass123!', 'role': 'operator'}) + assert create.status_code == 200 + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'operator1', 'password': 'OperatorPass123!'}).status_code == 200 + response = client.post('/api/check/asn', json={'asn': 'AS3320'}) + assert response.status_code == 200 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1ef36a..4e79bba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "routeforge-frontend", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "routeforge-frontend", - "version": "0.6.0", + "version": "0.6.1", "license": "AGPL-3.0-or-later", "dependencies": { "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 151d409..c3b3636 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "routeforge-frontend", - "version": "0.6.0", + "version": "0.6.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18594f2..012210e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,54 +1,90 @@ import { useEffect, useMemo, useState } from 'react' -import { getReportHtml, getReportMarkdown, getReportSummary, getReports, getSystemInfo, getSystemStatus } from './api' +import { ApiError, getMe, getReportHtml, getReportMarkdown, getReportSummary, getReports, getSetupRequired, getSystemInfo, getSystemStatus, login, setupAdmin } from './api' import { AsnCheckForm } from './components/AsnCheckForm' import { Layout } from './components/Layout' +import { LoginView } from './components/LoginView' import { PrefixCheckForm } from './components/PrefixCheckForm' import { PreflightCheckForm } from './components/PreflightCheckForm' +import { SetupView } from './components/SetupView' import { StatusBadge } from './components/StatusBadge' import type { ReportListItem, SystemInfo, SystemStatus } from './types' type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'about' +type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error' export default function App() { + const [authMode, setAuthMode] = useState('loading') + const [authError, setAuthError] = useState('') const [active, setActive] = useState('dashboard') const [reports, setReports] = useState([]) const [system, setSystem] = useState(null) const [systemStatus, setSystemStatus] = useState(null) const [systemStatusError, setSystemStatusError] = useState('') - useEffect(() => { + const loadAppData = () => { getReports().then(setReports).catch(() => setReports([])) getSystemInfo().then(setSystem).catch(() => null) - getSystemStatus().then((payload) => { setSystemStatus(payload); setSystemStatusError('') }).catch(() => setSystemStatusError('System status could not be loaded.')) - }, []) + getSystemStatus().then((payload) => { setSystemStatus(payload); setSystemStatusError('') }).catch((err: unknown) => { + if (err instanceof ApiError && err.status === 401) { + setAuthMode('login'); setAuthError('Your session has expired. Please log in again.'); return + } + if (err instanceof ApiError && err.status === 403) { + setSystemStatusError('You do not have permission to perform this action.'); return + } + setSystemStatusError('System status could not be loaded.') + }) + } - const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.0-beta · read-only preflight checks', [system]) + const bootstrapAuth = async () => { + setAuthMode('loading'); setAuthError('') + try { + const setup = await getSetupRequired() + if (setup.setup_required) { setAuthMode('setup'); return } + try { + await getMe() + setAuthMode('app') + loadAppData() + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 401) { setAuthMode('login'); return } + setAuthMode('error'); setAuthError('Authentication state could not be loaded.') + } + } catch { + setAuthMode('error'); setAuthError('Setup state could not be loaded.') + } + } + + useEffect(() => { bootstrapAuth() }, []) + + const onSetupSubmit = async (payload: { username: string; email?: string; password: string; password_confirm: string }) => { + setAuthError('') + try { + const res = await setupAdmin(payload) + if (res.user) { await bootstrapAuth(); return } + setAuthMode('login') + } catch (err: unknown) { setAuthError(err instanceof Error ? err.message : 'Setup failed') } + } + const onLoginSubmit = async (username: string, password: string) => { + setAuthError('') + try { await login({ username, password }); await bootstrapAuth() } catch (err: unknown) { setAuthError(err instanceof Error ? err.message : 'Login failed') } + } + + if (authMode === 'loading') return

Loading authentication state...
+ if (authMode === 'setup') return + if (authMode === 'login') return + if (authMode === 'error') return
{authError}
+ + const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.1-beta · read-only preflight checks', [system]) const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', about: 'About RouteForge' }[active] const proxyStatus = systemStatusError ? 'ERROR' : 'OK' const migrationStatus = systemStatus?.database?.migration_status || 'unknown' return - {active === 'dashboard' &&
-

RouteForge v0.6.0-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

-

System Health

{systemStatusError ?

{systemStatusError}

:
Status: {systemStatus?.status || 'unknown'}
Mode: {systemStatus?.mode || 'unknown'}
Database: {systemStatus?.database?.status || 'unknown'}
Version: {systemStatus?.version || 'unknown'}
}
-
} + {active === 'dashboard' &&

RouteForge v0.6.1-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

} {active === 'asn' && } {active === 'prefix' && } {active === 'preflight' && } - {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
ZeitpunktTypResourceOrigin-ASHolderStatusKurzfassungActions
{new Date(r.created_at).toLocaleString()}{r.check_type === 'preflight' ? 'Preflight' : r.check_type}{r.input_resource}{r.origin_as || '-'}{r.holder || 'Unknown'}{r.summary}
}
} - {active === 'system' &&
- {systemStatusError &&
{systemStatusError}
} - {systemStatus && <> -

Overall Status

{systemStatus.status}

-
-
Version: {systemStatus.version}
Mode: {systemStatus.mode}
Read-only: {String(systemStatus.read_only)}
Demo mode: {String(systemStatus.demo_mode)}
-
API Proxy: {proxyStatus}
Database: {systemStatus.database?.status || 'unknown'}
Schema Version: {systemStatus.database?.schema_version || 'unknown'}
Migration Head: {systemStatus.database?.migration_head || 'unknown'}
Migration Status:
-
- {systemStatus.security_warnings && systemStatus.security_warnings.length > 0 &&

Security Warnings

    {systemStatus.security_warnings.map((warning) =>
  • {warning}
  • )}
} -

RIPEstat Settings

{JSON.stringify(systemStatus.ripestat, null, 2)}
-

Features

{JSON.stringify(systemStatus.features, null, 2)}
- } -
} - {active === 'about' &&

RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.

Version: v0.6.0-beta

} + {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
{r.summary}
}
} + {active === 'system' &&
{systemStatusError &&
{systemStatusError}
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status:
}
} + {active === 'about' &&

Version: v0.6.1-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9deb088..88bf68a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,7 +15,7 @@ export class ApiError extends Error { } async function requestJson(url: string, options: RequestInit): Promise { - const response = await fetch(url, options) + const response = await fetch(url, { ...options, credentials: 'include' }) const rawText = await response.text() let parsedBody: unknown = rawText if (rawText) { @@ -31,7 +31,7 @@ async function requestJson(url: string, options: RequestInit): Promise { } async function requestText(url: string, options: RequestInit): Promise { - const response = await fetch(url, options) + const response = await fetch(url, { ...options, credentials: 'include' }) const text = await response.text() if (!response.ok) { throw new ApiError(`HTTP ${response.status}: ${text || response.statusText || 'Request failed'}`, response.status, text) @@ -53,3 +53,7 @@ export const getReportSummary = (reportId: number) => requestText(apiUrl(`/api/r export const checkPreflight = (prefix: string, planned_origin_as: string) => requestJson(apiUrl('/api/check/preflight'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, planned_origin_as }) }) export const getSystemStatus = () => requestJson(apiUrl('/api/system/status'), { method: 'GET' }) +export const getSetupRequired = () => requestJson<{ setup_required: boolean }>(apiUrl('/api/auth/setup-required'), { method: 'GET' }) +export const getMe = () => requestJson<{ user: { id: number; username: string; email?: string; role: string } }>(apiUrl('/api/auth/me'), { method: 'GET' }) +export const setupAdmin = (payload: { username: string; email?: string; password: string; password_confirm: string }) => requestJson<{ user?: { id: number; username: string } }>(apiUrl('/api/auth/setup'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) +export const login = (payload: { username: string; password: string }) => requestJson<{ user?: { id: number; username: string } }>(apiUrl('/api/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ee80535..bc40c6d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -34,7 +34,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode }:
{demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.6.0-beta + v0.6.1-beta
{children}
diff --git a/frontend/src/components/LoginView.tsx b/frontend/src/components/LoginView.tsx new file mode 100644 index 0000000..af96dd2 --- /dev/null +++ b/frontend/src/components/LoginView.tsx @@ -0,0 +1,25 @@ +import { FormEvent, useState } from 'react' + +export function LoginView({ onSubmit, error }: { onSubmit: (username: string, password: string) => Promise; error: string }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [busy, setBusy] = useState(false) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + try { + await onSubmit(username, password) + } finally { setBusy(false) } + } + + return
+

Login

+
+ setUsername(e.target.value)} required /> + setPassword(e.target.value)} required /> + {error &&

{error}

} + +
+
+} diff --git a/frontend/src/components/SetupView.tsx b/frontend/src/components/SetupView.tsx new file mode 100644 index 0000000..71cf185 --- /dev/null +++ b/frontend/src/components/SetupView.tsx @@ -0,0 +1,32 @@ +import { FormEvent, useState } from 'react' + +type SetupPayload = { username: string; email?: string; password: string; password_confirm: string } + +export function SetupView({ onSubmit, error }: { onSubmit: (payload: SetupPayload) => Promise; error: string }) { + const [username, setUsername] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [passwordConfirm, setPasswordConfirm] = useState('') + const [busy, setBusy] = useState(false) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + try { + await onSubmit({ username, email: email || undefined, password, password_confirm: passwordConfirm }) + } finally { setBusy(false) } + } + + return
+

Initial Admin Setup

+

Create the first administrator account.

+
+ setUsername(e.target.value)} required /> + setEmail(e.target.value)} /> + setPassword(e.target.value)} required /> + setPasswordConfirm(e.target.value)} required /> + {error &&

{error}

} + +
+
+}