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.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)

View File

@@ -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"),

View File

@@ -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()

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.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(
'<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">
IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span>
</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>
{# ==================== OVERVIEW TAB ==================== #}
@@ -184,6 +197,11 @@
</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 #}
{% 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;
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;
}

View File

@@ -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;