From 18536f0706876450b4c79ad74af847e5b0d20c5f Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 22:19:59 +0100 Subject: [PATCH] feat: implement FastAPI authentication with password protection for admin panel --- src/app.py | 6 +- src/config.py | 4 ++ src/routes/api.py | 56 ++++++++++++++++- src/routes/htmx.py | 26 ++++++++ src/templates/jinja2/dashboard/index.html | 18 ++++++ .../dashboard/partials/admin_panel.html | 4 ++ src/templates/static/css/dashboard.css | 17 ++++++ src/templates/static/js/dashboard.js | 61 ++++++++++++++++++- 8 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/admin_panel.html diff --git a/src/app.py b/src/app.py index 2b2df92..87904b8 100644 --- a/src/app.py +++ b/src/app.py @@ -72,11 +72,15 @@ async def lifespan(app: FastAPI): tasks_master = get_tasksmaster() tasks_master.run_scheduled_tasks() + password_line = "" + if config.dashboard_password_generated: + password_line = f"\n\nDASHBOARD PASSWORD (auto-generated)\n{config.dashboard_password}" + banner = f""" ============================================================ DASHBOARD AVAILABLE AT -{config.dashboard_secret_path} +{config.dashboard_secret_path}{password_line} ============================================================ """ app_logger.info(banner) diff --git a/src/config.py b/src/config.py index 0d6d648..f43b390 100644 --- a/src/config.py +++ b/src/config.py @@ -29,6 +29,7 @@ class Config: canary_token_tries: int = 10 dashboard_secret_path: str = None dashboard_password: Optional[str] = None + dashboard_password_generated: bool = False probability_error_codes: int = 0 # Percentage (0-100) # Crawl limiting settings - for legitimate vs malicious crawlers @@ -179,8 +180,10 @@ class Config: # Handle dashboard_password - auto-generate if null/not set dashboard_password = dashboard.get("password") + dashboard_password_generated = False if dashboard_password is None: dashboard_password = os.urandom(25).hex() + dashboard_password_generated = True return cls( port=server.get("port", 5000), @@ -203,6 +206,7 @@ class Config: canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, dashboard_password=dashboard_password, + dashboard_password_generated=dashboard_password_generated, probability_error_codes=behavior.get("probability_error_codes", 0), exports_path=exports.get("path", "exports"), backups_path=backups.get("path", "backups"), diff --git a/src/routes/api.py b/src/routes/api.py index d94b3b6..9ef8f0f 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -7,13 +7,19 @@ All endpoints are prefixed with the secret dashboard path. """ import os +import secrets +import hmac -from fastapi import APIRouter, Request, Response, Query +from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse +from pydantic import BaseModel from dependencies import get_db from logger import get_app_logger +# Server-side session token store (valid tokens for authenticated sessions) +_auth_tokens: set = set() + router = APIRouter() @@ -26,6 +32,54 @@ def _no_cache_headers() -> dict: } +class AuthRequest(BaseModel): + password: str + + +def verify_auth(request: Request) -> bool: + """Check if the request has a valid auth session cookie.""" + token = request.cookies.get("krawl_auth") + return token is not None and token in _auth_tokens + + +@router.post("/api/auth") +async def authenticate(request: Request, body: AuthRequest): + config = request.app.state.config + if hmac.compare_digest(body.password, config.dashboard_password): + token = secrets.token_hex(32) + _auth_tokens.add(token) + response = JSONResponse(content={"authenticated": True}) + response.set_cookie( + key="krawl_auth", + value=token, + httponly=True, + samesite="strict", + ) + return response + return JSONResponse( + content={"authenticated": False, "error": "Invalid password"}, + status_code=401, + ) + + +@router.post("/api/auth/logout") +async def logout(request: Request): + token = request.cookies.get("krawl_auth") + if token and token in _auth_tokens: + _auth_tokens.discard(token) + response = JSONResponse(content={"authenticated": False}) + response.delete_cookie(key="krawl_auth") + return response + + +@router.get("/api/auth/check") +async def auth_check(request: Request): + """Check if the current session is authenticated.""" + if verify_auth(request): + return JSONResponse(content={"authenticated": True}) + return JSONResponse(content={"authenticated": False}, status_code=401) + + @router.get("/api/all-ip-stats") async def all_ip_stats(request: Request): db = get_db() diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 549f044..a793c87 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -6,8 +6,10 @@ Server-rendered HTML partials for table pagination, sorting, IP details, and sea """ from fastapi import APIRouter, Request, Response, Query +from fastapi.responses import HTMLResponse from dependencies import get_db, get_templates +from routes.api import verify_auth router = APIRouter() @@ -408,3 +410,27 @@ async def htmx_search( "pagination": result["pagination"], }, ) + + +# ── Protected Admin Panel ──────────────────────────────────────────── + + +@router.get("/htmx/admin") +async def htmx_admin(request: Request): + if not verify_auth(request): + return HTMLResponse( + '
' + '

Nice try bozo

' + '
' + 'Diddy' + '
', + status_code=200, + ) + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/admin_panel.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + }, + ) diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index fef46c6..8fba3e7 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -59,6 +59,19 @@ IP Insight + Admin + {# Lock icon (not authenticated) #} + + + + + + {# Logout icon (authenticated) #} + + + + + {# ==================== OVERVIEW TAB ==================== #} @@ -184,6 +197,11 @@ + {# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #} +
+
+
+ {# Raw request modal - Alpine.js #} {% include "dashboard/partials/raw_request_modal.html" %} diff --git a/src/templates/jinja2/dashboard/partials/admin_panel.html b/src/templates/jinja2/dashboard/partials/admin_panel.html new file mode 100644 index 0000000..f70bb65 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/admin_panel.html @@ -0,0 +1,4 @@ +
+

Admin Panel

+

This is a protected panel. More features coming soon.

+
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 7297e89..98d1472 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -854,6 +854,23 @@ tbody { color: #484f58; background: transparent; } +.tab-right { + margin-left: auto; +} +.tab-lock-btn { + display: flex; + align-items: center; + padding: 12px 16px; + color: #8b949e; +} +.tab-lock-btn:hover { + color: #f0883e; + background: #1c2128; +} +.tab-lock-btn svg { + width: 16px; + height: 16px; +} .tab-content { display: none; } diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 329e1e4..c1b12d5 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -20,7 +20,16 @@ document.addEventListener('alpine:init', () => { // IP Insight state insightIp: null, - init() { + // Auth state (UI only — actual security enforced server-side via cookie) + authenticated: false, + + async init() { + // Check if already authenticated (cookie-based) + try { + const resp = await fetch(`${this.dashboardPath}/api/auth/check`, { credentials: 'same-origin' }); + if (resp.ok) this.authenticated = true; + } catch {} + // Handle hash-based tab routing const hash = window.location.hash.slice(1); if (hash === 'ip-stats' || hash === 'attacks') { @@ -32,8 +41,9 @@ document.addEventListener('alpine:init', () => { const h = window.location.hash.slice(1); if (h === 'ip-stats' || h === 'attacks') { this.switchToAttacks(); + } else if (h === 'admin') { + if (this.authenticated) this.switchToAdmin(); } else if (h !== 'ip-insight') { - // Don't switch away from ip-insight via hash if already there if (this.tab !== 'ip-insight') { this.switchToOverview(); } @@ -61,6 +71,53 @@ document.addEventListener('alpine:init', () => { window.location.hash = '#overview'; }, + switchToAdmin() { + if (!this.authenticated) return; + this.tab = 'admin'; + window.location.hash = '#admin'; + this.$nextTick(() => { + const container = document.getElementById('admin-htmx-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${this.dashboardPath}/htmx/admin`, { + target: '#admin-htmx-container', + swap: 'innerHTML' + }); + } + }); + }, + + async logout() { + try { + await fetch(`${this.dashboardPath}/api/auth/logout`, { + method: 'POST', + credentials: 'same-origin', + }); + } catch {} + this.authenticated = false; + if (this.tab === 'admin') this.switchToOverview(); + }, + + async promptAuth() { + const password = prompt('Enter dashboard password:'); + if (!password) return; + try { + const resp = await fetch(`${this.dashboardPath}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ password }), + }); + if (resp.ok) { + this.authenticated = true; + this.switchToAdmin(); + } else { + alert('Invalid password'); + } + } catch { + alert('Authentication failed'); + } + }, + switchToIpInsight() { // Only allow switching if an IP is selected if (!this.insightIp) return;