Merge pull request #121 from BlessedRebuS/feat/add-fastapi-authentication

Feat/add fastapi authentication
This commit is contained in:
Patrick Di Fazio
2026-03-07 17:16:50 +01:00
committed by GitHub
20 changed files with 534 additions and 8 deletions

View File

@@ -117,6 +117,7 @@ docker run -d \
-e KRAWL_PORT=5000 \ -e KRAWL_PORT=5000 \
-e KRAWL_DELAY=100 \ -e KRAWL_DELAY=100 \
-e KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" \ -e KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" \
-e KRAWL_DASHBOARD_PASSWORD="my-secret-password" \
-v krawl-data:/app/data \ -v krawl-data:/app/data \
--name krawl \ --name krawl \
ghcr.io/blessedrebus/krawl:latest ghcr.io/blessedrebus/krawl:latest
@@ -138,6 +139,7 @@ services:
environment: environment:
- CONFIG_LOCATION=config.yaml - CONFIG_LOCATION=config.yaml
- TZ=Europe/Rome - TZ=Europe/Rome
# - KRAWL_DASHBOARD_PASSWORD=my-secret-password
volumes: volumes:
- ./config.yaml:/app/config.yaml:ro - ./config.yaml:/app/config.yaml:ro
# bind mount for firewall exporters # bind mount for firewall exporters
@@ -197,6 +199,7 @@ You can use the [config.yaml](config.yaml) file for advanced configurations, suc
| `KRAWL_CANARY_TOKEN_URL` | External canary token URL | None | | `KRAWL_CANARY_TOKEN_URL` | External canary token URL | None |
| `KRAWL_CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` | | `KRAWL_CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` |
| `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | | `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated |
| `KRAWL_DASHBOARD_PASSWORD` | Password for protected dashboard panels | Auto-generated |
| `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | | `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` |
| `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` | | `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` |
| `KRAWL_EXPORTS_PATH` | Path where firewalls rule sets are exported | `exports` | | `KRAWL_EXPORTS_PATH` | Path where firewalls rule sets are exported | `exports` |
@@ -228,8 +231,9 @@ export KRAWL_LINKS_PER_PAGE_RANGE="5,25"
export KRAWL_HTTP_RISKY_METHODS_THRESHOLD="0.2" export KRAWL_HTTP_RISKY_METHODS_THRESHOLD="0.2"
export KRAWL_VIOLATED_ROBOTS_THRESHOLD="0.15" export KRAWL_VIOLATED_ROBOTS_THRESHOLD="0.15"
# Set custom dashboard path # Set custom dashboard path and password
export KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" export KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard"
export KRAWL_DASHBOARD_PASSWORD="my-secret-password"
``` ```
Example of a Docker run with env variables: Example of a Docker run with env variables:
@@ -239,6 +243,7 @@ docker run -d \
-p 5000:5000 \ -p 5000:5000 \
-e KRAWL_PORT=5000 \ -e KRAWL_PORT=5000 \
-e KRAWL_DELAY=100 \ -e KRAWL_DELAY=100 \
-e KRAWL_DASHBOARD_PASSWORD="my-secret-password" \
-e KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" \ -e KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" \
--name krawl \ --name krawl \
ghcr.io/blessedrebus/krawl:latest ghcr.io/blessedrebus/krawl:latest

View File

@@ -25,6 +25,11 @@ dashboard:
# secret_path: super-secret-dashboard-path # secret_path: super-secret-dashboard-path
secret_path: null secret_path: null
# Password for accessing protected dashboard panels.
# If null, a random password will be generated and printed in the logs.
# Can also be set via KRAWL_DASHBOARD_PASSWORD env var.
password: null
backups: backups:
path: "backups" path: "backups"
cron: "*/30 * * * *" cron: "*/30 * * * *"

View File

@@ -10,6 +10,8 @@ services:
- "5000:5000" - "5000:5000"
environment: environment:
- CONFIG_LOCATION=config.yaml - CONFIG_LOCATION=config.yaml
# Uncomment to set a custom dashboard password (auto-generated if not set)
# - KRAWL_DASHBOARD_PASSWORD=your-secret-password
# set this to change timezone, alternatively mount /etc/timezone or /etc/localtime based on the time system management of the host environment # set this to change timezone, alternatively mount /etc/timezone or /etc/localtime based on the time system management of the host environment
# - TZ=${TZ} # - TZ=${TZ}
volumes: volumes:

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: krawl-chart name: krawl-chart
description: A Helm chart for Krawl honeypot server description: A Helm chart for Krawl honeypot server
type: application type: application
version: 1.1.3 version: 1.1.4
appVersion: 1.1.3 appVersion: 1.1.4
keywords: keywords:
- honeypot - honeypot
- security - security

View File

@@ -96,6 +96,7 @@ The following table lists the main configuration parameters of the Krawl chart a
| Parameter | Description | Default | | Parameter | Description | Default |
|-----------|-------------|---------| |-----------|-------------|---------|
| `config.dashboard.secret_path` | Secret dashboard path (auto-generated if null) | `null` | | `config.dashboard.secret_path` | Secret dashboard path (auto-generated if null) | `null` |
| `dashboardPassword` | Password for protected panels (injected via Secret as `KRAWL_DASHBOARD_PASSWORD` env, auto-generated if empty) | `""` |
### API Configuration ### API Configuration

View File

@@ -47,6 +47,13 @@ spec:
- name: TZ - name: TZ
value: {{ .Values.timezone | quote }} value: {{ .Values.timezone | quote }}
{{- end }} {{- end }}
{{- if .Values.dashboardPassword }}
- name: KRAWL_DASHBOARD_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "krawl.fullname" . }}-dashboard
key: dashboard-password
{{- end }}
volumeMounts: volumeMounts:
- name: config - name: config
mountPath: /app/config.yaml mountPath: /app/config.yaml

View File

@@ -0,0 +1,11 @@
{{- if .Values.dashboardPassword }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "krawl.fullname" . }}-dashboard
labels:
{{- include "krawl.labels" . | nindent 4 }}
type: Opaque
stringData:
dashboard-password: {{ .Values.dashboardPassword | quote }}
{{- end }}

View File

@@ -60,6 +60,10 @@ tolerations: []
affinity: {} affinity: {}
# Dashboard password for protected panels
# If empty, a random password will be auto-generated and printed in the logs
dashboardPassword: ""
# Application configuration (config.yaml structure) # Application configuration (config.yaml structure)
config: config:
server: server:

View File

@@ -22,13 +22,17 @@ Once the EXTERNAL-IP is assigned, access your deception server at `http://<EXTER
### Retrieving Dashboard Path ### Retrieving Dashboard Path
Check server startup logs or get the secret with Check server startup logs or get the secret with
```bash ```bash
kubectl get secret krawl-server -n krawl-system \ kubectl get secret krawl-server -n krawl-system \
-o jsonpath='{.data.dashboard-path}' | base64 -d && echo -o jsonpath='{.data.dashboard-path}' | base64 -d && echo
``` ```
### Setting Dashboard Password
To set a custom password for protected dashboard panels, create the `secret.yaml` manifest (see `kubernetes/manifests/secret.yaml`) and uncomment the `KRAWL_DASHBOARD_PASSWORD` env var in the deployment. If not set, a random password is auto-generated and printed in the pod logs.
### From Source (Python 3.11+) ### From Source (Python 3.11+)
Clone the repository: Clone the repository:

View File

@@ -33,6 +33,12 @@ spec:
env: env:
- name: CONFIG_LOCATION - name: CONFIG_LOCATION
value: "config.yaml" value: "config.yaml"
# Uncomment to use dashboard password from secret
# - name: KRAWL_DASHBOARD_PASSWORD
# valueFrom:
# secretKeyRef:
# name: krawl-dashboard
# key: dashboard-password
volumeMounts: volumeMounts:
- name: config - name: config
mountPath: /app/config.yaml mountPath: /app/config.yaml

View File

@@ -0,0 +1,15 @@
# Source: krawl-chart/templates/secret.yaml
# Uncomment and set your dashboard password below.
# If not created, the password will be auto-generated and printed in the pod logs.
#
# apiVersion: v1
# kind: Secret
# metadata:
# name: krawl-dashboard
# namespace: krawl-system
# labels:
# app.kubernetes.io/name: krawl
# app.kubernetes.io/instance: krawl
# type: Opaque
# stringData:
# dashboard-password: "your-secret-password"

View File

@@ -72,11 +72,17 @@ 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

@@ -28,6 +28,8 @@ class Config:
canary_token_url: Optional[str] = None canary_token_url: Optional[str] = None
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_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
@@ -176,6 +178,13 @@ class Config:
if dashboard_path[:1] != "/": if dashboard_path[:1] != "/":
dashboard_path = f"/{dashboard_path}" dashboard_path = f"/{dashboard_path}"
# 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( return cls(
port=server.get("port", 5000), port=server.get("port", 5000),
delay=server.get("delay", 100), delay=server.get("delay", 100),
@@ -196,6 +205,8 @@ class Config:
canary_token_url=canary.get("token_url"), canary_token_url=canary.get("token_url"),
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_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

@@ -6,14 +6,28 @@ Migrated from handler.py dashboard API endpoints.
All endpoints are prefixed with the secret dashboard path. All endpoints are prefixed with the secret dashboard path.
""" """
import hashlib
import hmac
import os import os
import secrets
import time
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()
# Bruteforce protection: tracks failed attempts per IP
# { ip: { "attempts": int, "locked_until": float } }
_auth_attempts: dict = {}
_AUTH_MAX_ATTEMPTS = 5
_AUTH_BASE_LOCKOUT = 30 # seconds, doubles on each lockout
router = APIRouter() router = APIRouter()
@@ -26,6 +40,103 @@ def _no_cache_headers() -> dict:
} }
class AuthRequest(BaseModel):
fingerprint: 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):
ip = request.client.host
# Check if IP is currently locked out
record = _auth_attempts.get(ip)
if record and record["locked_until"] > time.time():
remaining = int(record["locked_until"] - time.time())
return JSONResponse(
content={
"authenticated": False,
"error": f"Too many attempts. Try again in {remaining}s",
"locked": True,
"retry_after": remaining,
},
status_code=429,
)
config = request.app.state.config
expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest()
if hmac.compare_digest(body.fingerprint, expected):
# Success — clear failed attempts
_auth_attempts.pop(ip, None)
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
# Failed attempt — track and possibly lock out
if not record:
record = {"attempts": 0, "locked_until": 0, "lockouts": 0}
_auth_attempts[ip] = record
record["attempts"] += 1
if record["attempts"] >= _AUTH_MAX_ATTEMPTS:
lockout = _AUTH_BASE_LOCKOUT * (2 ** record["lockouts"])
record["locked_until"] = time.time() + lockout
record["lockouts"] += 1
record["attempts"] = 0
get_app_logger().warning(
f"Auth bruteforce: IP {ip} locked out for {lockout}s "
f"(lockout #{record['lockouts']})"
)
return JSONResponse(
content={
"authenticated": False,
"error": f"Too many attempts. Locked for {lockout}s",
"locked": True,
"retry_after": lockout,
},
status_code=429,
)
remaining_attempts = _AUTH_MAX_ATTEMPTS - record["attempts"]
return JSONResponse(
content={
"authenticated": False,
"error": f"Invalid password. {remaining_attempts} attempt{'s' if remaining_attempts != 1 else ''} remaining",
},
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,8 +197,16 @@
</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" %}
{# Auth modal - Alpine.js #}
{% include "dashboard/partials/auth_modal.html" %}
</div> </div>
{% endblock %} {% endblock %}

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

@@ -0,0 +1,39 @@
{# Authentication modal - Alpine.js controlled #}
<div class="auth-modal"
x-show="authModal.show"
x-cloak
@click.self="closeAuthModal()"
@keydown.escape.window="authModal.show && closeAuthModal()"
>
<div class="auth-modal-content">
<div class="auth-modal-header">
<div class="auth-modal-title">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="20" height="20" 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>
<h3>Authentication Required</h3>
</div>
<span class="auth-modal-close" @click="closeAuthModal()">&times;</span>
</div>
<form class="auth-modal-body" @submit.prevent="submitAuth()">
<p class="auth-modal-description">Enter the dashboard password to access protected panels.</p>
<div class="auth-modal-input-group">
<input type="password"
class="auth-modal-input"
:class="{ 'auth-modal-input-error': authModal.error }"
x-model="authModal.password"
x-ref="authPasswordInput"
placeholder="Password"
autocomplete="off" />
<p class="auth-modal-error" x-show="authModal.error" x-text="authModal.error" x-cloak></p>
</div>
<div class="auth-modal-footer">
<button type="button" class="auth-modal-btn auth-modal-btn-cancel" @click="closeAuthModal()">Cancel</button>
<button type="submit" class="auth-modal-btn auth-modal-btn-submit" :disabled="authModal.loading">
<span x-show="!authModal.loading">Unlock</span>
<span x-show="authModal.loading" x-cloak>Verifying...</span>
</button>
</div>
</form>
</div>
</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;
} }
@@ -1151,6 +1168,139 @@ tbody {
opacity: 1; opacity: 1;
} }
/* Auth Modal */
.auth-modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.auth-modal-content {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
width: 400px;
max-width: 90vw;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
animation: authModalIn 0.2s ease-out;
}
@keyframes authModalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.auth-modal-header {
padding: 20px 24px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #30363d;
}
.auth-modal-title {
display: flex;
align-items: center;
gap: 10px;
color: #58a6ff;
}
.auth-modal-title h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #e6edf3;
}
.auth-modal-close {
color: #8b949e;
font-size: 24px;
font-weight: bold;
cursor: pointer;
line-height: 20px;
transition: color 0.2s;
}
.auth-modal-close:hover {
color: #c9d1d9;
}
.auth-modal-body {
padding: 24px;
}
.auth-modal-description {
margin: 0 0 20px;
color: #8b949e;
font-size: 14px;
line-height: 1.5;
}
.auth-modal-input-group {
margin-bottom: 20px;
}
.auth-modal-input {
width: 100%;
padding: 10px 14px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.auth-modal-input:focus {
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
}
.auth-modal-input-error {
border-color: #f85149;
}
.auth-modal-input-error:focus {
border-color: #f85149;
box-shadow: 0 0 0 3px rgba(248, 81, 73, 0.15);
}
.auth-modal-error {
margin: 8px 0 0;
color: #f85149;
font-size: 13px;
}
.auth-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.auth-modal-btn {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
}
.auth-modal-btn-cancel {
background: transparent;
border-color: #30363d;
color: #8b949e;
}
.auth-modal-btn-cancel:hover {
background: #21262d;
color: #c9d1d9;
}
.auth-modal-btn-submit {
background: #238636;
color: #fff;
border-color: rgba(240, 246, 252, 0.1);
}
.auth-modal-btn-submit:hover {
background: #2ea043;
}
.auth-modal-btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Attack Types Cell Styling */ /* Attack Types Cell Styling */
.attack-types-cell { .attack-types-cell {
max-width: 280px; max-width: 280px;

View File

@@ -20,7 +20,17 @@ 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,
authModal: { show: false, password: '', error: '', loading: 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 +42,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 +72,93 @@ 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();
},
promptAuth() {
this.authModal = { show: true, password: '', error: '', loading: false };
this.$nextTick(() => {
if (this.$refs.authPasswordInput) this.$refs.authPasswordInput.focus();
});
},
closeAuthModal() {
this.authModal.show = false;
this.authModal.password = '';
this.authModal.error = '';
this.authModal.loading = false;
},
async submitAuth() {
const password = this.authModal.password;
if (!password) {
this.authModal.error = 'Please enter a password';
return;
}
this.authModal.error = '';
this.authModal.loading = true;
try {
const msgBuf = new TextEncoder().encode(password);
const hashBuf = await crypto.subtle.digest('SHA-256', msgBuf);
const fingerprint = Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, '0')).join('');
const resp = await fetch(`${this.dashboardPath}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ fingerprint }),
});
if (resp.ok) {
this.authenticated = true;
this.closeAuthModal();
this.switchToAdmin();
} else {
const data = await resp.json().catch(() => ({}));
this.authModal.error = data.error || 'Invalid password';
this.authModal.password = '';
this.authModal.loading = false;
if (data.locked && data.retry_after) {
let remaining = data.retry_after;
const interval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(interval);
this.authModal.error = '';
} else {
this.authModal.error = `Too many attempts. Try again in ${remaining}s`;
}
}, 1000);
}
}
} catch {
this.authModal.error = 'Authentication failed';
this.authModal.loading = false;
}
},
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;