feat: implement FastAPI authentication with password protection for admin panel

This commit is contained in:
Lorenzo Venerandi
2026-03-06 22:19:59 +01:00
parent 755de7f231
commit 18536f0706
8 changed files with 188 additions and 4 deletions

View File

@@ -72,11 +72,15 @@ async def lifespan(app: FastAPI):
tasks_master = get_tasksmaster() tasks_master = get_tasksmaster()
tasks_master.run_scheduled_tasks() 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""" banner = f"""
============================================================ ============================================================
DASHBOARD AVAILABLE AT DASHBOARD AVAILABLE AT
{config.dashboard_secret_path} {config.dashboard_secret_path}{password_line}
============================================================ ============================================================
""" """
app_logger.info(banner) app_logger.info(banner)

View File

@@ -29,6 +29,7 @@ class Config:
canary_token_tries: int = 10 canary_token_tries: int = 10
dashboard_secret_path: str = None dashboard_secret_path: str = None
dashboard_password: Optional[str] = None dashboard_password: Optional[str] = None
dashboard_password_generated: bool = False
probability_error_codes: int = 0 # Percentage (0-100) probability_error_codes: int = 0 # Percentage (0-100)
# Crawl limiting settings - for legitimate vs malicious crawlers # Crawl limiting settings - for legitimate vs malicious crawlers
@@ -179,8 +180,10 @@ class Config:
# Handle dashboard_password - auto-generate if null/not set # Handle dashboard_password - auto-generate if null/not set
dashboard_password = dashboard.get("password") dashboard_password = dashboard.get("password")
dashboard_password_generated = False
if dashboard_password is None: if dashboard_password is None:
dashboard_password = os.urandom(25).hex() dashboard_password = os.urandom(25).hex()
dashboard_password_generated = True
return cls( return cls(
port=server.get("port", 5000), port=server.get("port", 5000),
@@ -203,6 +206,7 @@ class Config:
canary_token_tries=canary.get("token_tries", 10), canary_token_tries=canary.get("token_tries", 10),
dashboard_secret_path=dashboard_path, dashboard_secret_path=dashboard_path,
dashboard_password=dashboard_password, dashboard_password=dashboard_password,
dashboard_password_generated=dashboard_password_generated,
probability_error_codes=behavior.get("probability_error_codes", 0), probability_error_codes=behavior.get("probability_error_codes", 0),
exports_path=exports.get("path", "exports"), exports_path=exports.get("path", "exports"),
backups_path=backups.get("path", "backups"), backups_path=backups.get("path", "backups"),

View File

@@ -7,13 +7,19 @@ All endpoints are prefixed with the secret dashboard path.
""" """
import os 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 fastapi.responses import JSONResponse, PlainTextResponse
from pydantic import BaseModel
from dependencies import get_db from dependencies import get_db
from logger import get_app_logger from logger import get_app_logger
# Server-side session token store (valid tokens for authenticated sessions)
_auth_tokens: set = set()
router = APIRouter() 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") @router.get("/api/all-ip-stats")
async def all_ip_stats(request: Request): async def all_ip_stats(request: Request):
db = get_db() db = get_db()

View File

@@ -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 import APIRouter, Request, Response, Query
from fastapi.responses import HTMLResponse
from dependencies import get_db, get_templates from dependencies import get_db, get_templates
from routes.api import verify_auth
router = APIRouter() router = APIRouter()
@@ -408,3 +410,27 @@ async def htmx_search(
"pagination": result["pagination"], "pagination": result["pagination"],
}, },
) )
# ── Protected Admin Panel ────────────────────────────────────────────
@router.get("/htmx/admin")
async def htmx_admin(request: Request):
if not verify_auth(request):
return HTMLResponse(
'<div class="table-container" style="text-align:center;padding:80px 20px;">'
'<h1 style="color:#f0883e;font-size:48px;margin:20px 0 10px;">Nice try bozo</h1>'
'<br>'
'<img src="https://media0.giphy.com/media/v1.Y2lkPTZjMDliOTUyaHQ3dHRuN2wyOW1kZndjaHdkY2dhYzJ6d2gzMDJkNm53ZnNrdnNlZCZlcD12MV9naWZzX3NlYXJjaCZjdD1n/mOY97EXNisstZqJht9/200w.gif" alt="Diddy">'
'</div>',
status_code=200,
)
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/admin_panel.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
},
)

View File

@@ -59,6 +59,19 @@
<a class="tab-button" :class="{ active: tab === 'ip-insight', disabled: !insightIp }" @click.prevent="insightIp && switchToIpInsight()" href="#ip-insight"> <a class="tab-button" :class="{ active: tab === 'ip-insight', disabled: !insightIp }" @click.prevent="insightIp && switchToIpInsight()" href="#ip-insight">
IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span> IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span>
</a> </a>
<a class="tab-button tab-right" :class="{ active: tab === 'admin' }" x-show="authenticated" x-cloak @click.prevent="switchToAdmin()" href="#admin">Admin</a>
{# Lock icon (not authenticated) #}
<a class="tab-button tab-lock-btn" :class="{ 'tab-right': !authenticated }" @click.prevent="promptAuth()" x-show="!authenticated" href="#" title="Unlock protected panels">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/>
</svg>
</a>
{# Logout icon (authenticated) #}
<a class="tab-button tab-lock-btn" @click.prevent="logout()" x-show="authenticated" x-cloak href="#" title="Logout">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2 2.75C2 1.784 2.784 1 3.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/>
</svg>
</a>
</div> </div>
{# ==================== OVERVIEW TAB ==================== #} {# ==================== OVERVIEW TAB ==================== #}
@@ -184,6 +197,11 @@
</div> </div>
</div> </div>
{# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #}
<div x-show="tab === 'admin'" x-cloak>
<div id="admin-htmx-container"></div>
</div>
{# Raw request modal - Alpine.js #} {# Raw request modal - Alpine.js #}
{% include "dashboard/partials/raw_request_modal.html" %} {% include "dashboard/partials/raw_request_modal.html" %}

View File

@@ -0,0 +1,4 @@
<div class="table-container" style="text-align: center; padding: 60px 20px;">
<h2 style="color: #58a6ff;">Admin Panel</h2>
<p style="color: #8b949e; font-size: 16px;">This is a protected panel. More features coming soon.</p>
</div>

View File

@@ -854,6 +854,23 @@ tbody {
color: #484f58; color: #484f58;
background: transparent; 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 { .tab-content {
display: none; display: none;
} }

View File

@@ -20,7 +20,16 @@ document.addEventListener('alpine:init', () => {
// IP Insight state // IP Insight state
insightIp: null, 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 // Handle hash-based tab routing
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
if (hash === 'ip-stats' || hash === 'attacks') { if (hash === 'ip-stats' || hash === 'attacks') {
@@ -32,8 +41,9 @@ document.addEventListener('alpine:init', () => {
const h = window.location.hash.slice(1); const h = window.location.hash.slice(1);
if (h === 'ip-stats' || h === 'attacks') { if (h === 'ip-stats' || h === 'attacks') {
this.switchToAttacks(); this.switchToAttacks();
} else if (h === 'admin') {
if (this.authenticated) this.switchToAdmin();
} else if (h !== 'ip-insight') { } else if (h !== 'ip-insight') {
// Don't switch away from ip-insight via hash if already there
if (this.tab !== 'ip-insight') { if (this.tab !== 'ip-insight') {
this.switchToOverview(); this.switchToOverview();
} }
@@ -61,6 +71,53 @@ document.addEventListener('alpine:init', () => {
window.location.hash = '#overview'; 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() { switchToIpInsight() {
// Only allow switching if an IP is selected // Only allow switching if an IP is selected
if (!this.insightIp) return; if (!this.insightIp) return;