diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 8c7e4e5..8d22068 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -1,11 +1,12 @@ from fastapi import FastAPI, Depends, Request from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded -from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security +from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp import auth as auth_module from rbac import require_page, require_write @@ -31,6 +32,9 @@ app.add_middleware( allow_headers=["Authorization", "Content-Type"], ) +# Compress responses (static files and API responses) +app.add_middleware(GZipMiddleware, minimum_size=1000) + @app.exception_handler(RateLimitExceeded) async def rate_limit_handler(request: Request, exc: RateLimitExceeded): return JSONResponse(status_code=429, content={"detail": "Too many requests, try again later"}) @@ -43,6 +47,11 @@ async def security_headers(request: Request, call_next): response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: ws:; frame-ancestors 'none'" + + # Cache static assets with hashed filenames (Vite generates these) + if request.url.path.startswith("/assets/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response # Audit logger @@ -147,6 +156,8 @@ async def _inject_user(request: Request, user = Depends(auth_module.get_current_ # Public auth endpoints app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(passkey.router, prefix="/api/auth", tags=["passkey"]) +app.include_router(totp.router, prefix="/api/auth", tags=["totp"]) # Protected endpoints with page-level RBAC app.include_router(docker_router.router, prefix="/api/docker", diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index a08677c..52615b3 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -1,10 +1,8 @@ +"""Core authentication endpoints: login, token refresh, user info, proxy auth.""" from datetime import timedelta import asyncio -import time -import json import httpx from fastapi import APIRouter, Depends, HTTPException, status, Request -from fastapi.responses import JSONResponse from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address @@ -12,10 +10,6 @@ import jwt import config import auth import logging -import pyotp -from webauthn import generate_registration_options, verify_registration_response, generate_authentication_options, verify_authentication_response -from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement -from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json logger = logging.getLogger(__name__) router = APIRouter() @@ -37,14 +31,6 @@ class Token(BaseModel): class RefreshRequest(BaseModel): refresh_token: str -class Setup2FAResponse(BaseModel): - secret: str - uri: str - -class Verify2FARequest(BaseModel): - secret: str - code: str - async def send_failed_login_alert(ip_address: str, username: str, reason: str): logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason) if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_CHAT_ID: @@ -183,27 +169,6 @@ async def refresh_token(req: RefreshRequest, request: Request): refresh_token = auth.create_refresh_token(data={"sub": username, "role": role}) return {"access_token": access_token, "refresh_token": refresh_token} -@router.post("/setup-2fa/generate", response_model=Setup2FAResponse) -async def generate_2fa(current_user = Depends(auth.get_current_user)): - secret = pyotp.random_base32() - uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.username, issuer_name="NAS Dashboard") - return {"secret": secret, "uri": uri} - -@router.post("/setup-2fa/verify") -async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(auth.get_current_user)): - totp = pyotp.TOTP(request.secret) - if not totp.verify(request.code): - raise HTTPException(status_code=400, detail="Invalid code") - - # Save secret - auth.save_totp_secret(request.secret) - return {"message": "2FA enabled successfully"} - -@router.post("/setup-2fa/disable") -async def disable_2fa(current_user = Depends(auth.get_current_user)): - auth.save_totp_secret("") - return {"message": "2FA disabled"} - class ChangePasswordRequest(BaseModel): current_password: str new_password: str @@ -218,135 +183,6 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_ auth.save_password_hash(auth.get_password_hash(req.new_password)) return {"message": "Password changed successfully"} -# WebAuthn — in-memory challenge store with TTL -_challenges = {} - -def _store_challenge(challenge: bytes): - _challenges[challenge] = time.time() - now = time.time() - expired = [k for k, v in _challenges.items() if now - v > 300] - for k in expired: - _challenges.pop(k, None) - -def _get_challenge(client_data_b64: str) -> bytes: - if not client_data_b64: - raise HTTPException(status_code=400, detail="Missing clientDataJSON") - try: - data = json.loads(base64url_to_bytes(client_data_b64).decode('utf-8')) - chal_bytes = base64url_to_bytes(data.get("challenge", "")) - except Exception: - raise HTTPException(status_code=400, detail="Invalid clientDataJSON") - - entry = _challenges.pop(chal_bytes, None) - if not entry or time.time() - entry > 300: - raise HTTPException(status_code=400, detail="Challenge expired or invalid") - return chal_bytes - -@router.post("/passkey/register/options") -async def passkey_register_options(current_user = Depends(auth.get_current_user)): - existing = auth.load_passkey_credentials() - exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing] - options = generate_registration_options( - rp_id=config.WEBAUTHN_RP_ID, - rp_name="NAS Dashboard", - user_id=current_user.username.encode(), - user_name=current_user.username, - user_display_name=current_user.username, - exclude_credentials=exclude, - ) - _store_challenge(options.challenge) - return JSONResponse(content=json.loads(options_to_json(options))) - -@router.post("/passkey/register/verify") -async def passkey_register_verify(request: Request, current_user = Depends(auth.get_current_user)): - body = await request.json() - challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) - try: - verification = verify_registration_response( - credential=body, - expected_challenge=challenge, - expected_rp_id=config.WEBAUTHN_RP_ID, - expected_origin=config.WEBAUTHN_ORIGINS, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - auth.save_passkey_credential({ - "credential_id": bytes_to_base64url(verification.credential_id), - "public_key": bytes_to_base64url(verification.credential_public_key), - "sign_count": verification.sign_count, - "name": body.get("name", "Passkey"), - "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - }) - return {"ok": True} - -@router.post("/passkey/login/options") -@limiter.limit("10/minute") -async def passkey_login_options(request: Request): - creds = auth.load_passkey_credentials() - if not creds: - raise HTTPException(status_code=404, detail="No passkeys registered") - allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds] - options = generate_authentication_options( - rp_id=config.WEBAUTHN_RP_ID, - allow_credentials=allow, - user_verification=UserVerificationRequirement.PREFERRED, - ) - _store_challenge(options.challenge) - return JSONResponse(content=json.loads(options_to_json(options))) - -@router.post("/passkey/login/verify") -@limiter.limit("10/minute") -async def passkey_login_verify(request: Request): - body = await request.json() - challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) - creds = auth.load_passkey_credentials() - cred_id_b64 = body.get("id", "") - matched = next((c for c in creds if c["credential_id"] == cred_id_b64), None) - if not matched: - raise HTTPException(status_code=400, detail="Unknown credential") - try: - verification = verify_authentication_response( - credential=body, - expected_challenge=challenge, - expected_rp_id=config.WEBAUTHN_RP_ID, - expected_origin=config.WEBAUTHN_ORIGINS, - credential_public_key=base64url_to_bytes(matched["public_key"]), - credential_current_sign_count=matched["sign_count"], - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - matched["sign_count"] = verification.new_sign_count - data = auth._load_auth_data() - data["passkey_credentials"] = creds - auth._save_auth_data(data) - username = config.ADMIN_USER - access_token = auth.create_access_token( - data={"sub": username, "role": "admin"}, - expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), - ) - refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"}) - return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username, "role": "admin"} - -@router.get("/passkey/list") -async def passkey_list(current_user = Depends(auth.get_current_user)): - creds = auth.load_passkey_credentials() - return {"passkeys": [{"id": c["credential_id"], "name": c.get("name", "Passkey"), "created_at": c.get("created_at", "")} for c in creds]} - -@router.post("/passkey/delete") -async def passkey_delete(request: Request, current_user = Depends(auth.get_current_user)): - body = await request.json() - cred_id = body.get("id") - data = auth._load_auth_data() - creds = data.get("passkey_credentials", []) - data["passkey_credentials"] = [c for c in creds if c["credential_id"] != cred_id] - auth._save_auth_data(data) - return {"ok": True} - -@router.post("/passkey/clear") -async def passkey_clear(current_user = Depends(auth.get_current_user)): - auth.clear_passkey_credentials() - return {"ok": True} - # RBAC admin endpoints @router.get("/rbac/config") async def rbac_config(current_user = Depends(auth.get_current_user)): diff --git a/dashboard/backend/routers/passkey.py b/dashboard/backend/routers/passkey.py new file mode 100644 index 0000000..2dde6b6 --- /dev/null +++ b/dashboard/backend/routers/passkey.py @@ -0,0 +1,187 @@ +"""WebAuthn Passkey authentication endpoints.""" +import json +import time +from datetime import timedelta +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.util import get_remote_address +from webauthn import ( + generate_registration_options, + verify_registration_response, + generate_authentication_options, + verify_authentication_response +) +from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json +import auth +import config + +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) + +# In-memory challenge store with TTL +_challenges = {} + +def _store_challenge(challenge: bytes): + """Store a WebAuthn challenge with timestamp for TTL tracking.""" + _challenges[challenge] = time.time() + # Clean up expired challenges (>5 minutes old) + now = time.time() + expired = [k for k, v in _challenges.items() if now - v > 300] + for k in expired: + _challenges.pop(k, None) + +def _get_challenge(client_data_b64: str) -> bytes: + """Extract and validate challenge from clientDataJSON.""" + if not client_data_b64: + raise HTTPException(status_code=400, detail="Missing clientDataJSON") + try: + data = json.loads(base64url_to_bytes(client_data_b64).decode('utf-8')) + chal_bytes = base64url_to_bytes(data.get("challenge", "")) + except Exception: + raise HTTPException(status_code=400, detail="Invalid clientDataJSON") + + entry = _challenges.pop(chal_bytes, None) + if not entry or time.time() - entry > 300: + raise HTTPException(status_code=400, detail="Challenge expired or invalid") + return chal_bytes + +@router.post("/passkey/register/options") +async def passkey_register_options(current_user = Depends(auth.get_current_user)): + """Generate WebAuthn registration options for adding a new passkey.""" + existing = auth.load_passkey_credentials() + exclude = [ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) + for c in existing + ] + options = generate_registration_options( + rp_id=config.WEBAUTHN_RP_ID, + rp_name="NAS Dashboard", + user_id=current_user.username.encode(), + user_name=current_user.username, + user_display_name=current_user.username, + exclude_credentials=exclude, + ) + _store_challenge(options.challenge) + return JSONResponse(content=json.loads(options_to_json(options))) + +@router.post("/passkey/register/verify") +async def passkey_register_verify(request: Request, current_user = Depends(auth.get_current_user)): + """Verify and save a new passkey registration.""" + body = await request.json() + challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) + try: + verification = verify_registration_response( + credential=body, + expected_challenge=challenge, + expected_rp_id=config.WEBAUTHN_RP_ID, + expected_origin=config.WEBAUTHN_ORIGINS, + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + auth.save_passkey_credential({ + "credential_id": bytes_to_base64url(verification.credential_id), + "public_key": bytes_to_base64url(verification.credential_public_key), + "sign_count": verification.sign_count, + "name": body.get("name", "Passkey"), + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + }) + return {"ok": True} + +@router.post("/passkey/login/options") +@limiter.limit("10/minute") +async def passkey_login_options(request: Request): + """Generate WebAuthn authentication options for passkey login.""" + creds = auth.load_passkey_credentials() + if not creds: + raise HTTPException(status_code=404, detail="No passkeys registered") + + allow = [ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) + for c in creds + ] + options = generate_authentication_options( + rp_id=config.WEBAUTHN_RP_ID, + allow_credentials=allow, + user_verification=UserVerificationRequirement.PREFERRED, + ) + _store_challenge(options.challenge) + return JSONResponse(content=json.loads(options_to_json(options))) + +@router.post("/passkey/login/verify") +@limiter.limit("10/minute") +async def passkey_login_verify(request: Request): + """Verify passkey authentication and issue tokens.""" + body = await request.json() + challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) + creds = auth.load_passkey_credentials() + cred_id_b64 = body.get("id", "") + matched = next((c for c in creds if c["credential_id"] == cred_id_b64), None) + if not matched: + raise HTTPException(status_code=400, detail="Unknown credential") + + try: + verification = verify_authentication_response( + credential=body, + expected_challenge=challenge, + expected_rp_id=config.WEBAUTHN_RP_ID, + expected_origin=config.WEBAUTHN_ORIGINS, + credential_public_key=base64url_to_bytes(matched["public_key"]), + credential_current_sign_count=matched["sign_count"], + ) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Update sign count + matched["sign_count"] = verification.new_sign_count + data = auth._load_auth_data() + data["passkey_credentials"] = creds + auth._save_auth_data(data) + + username = config.ADMIN_USER + access_token = auth.create_access_token( + data={"sub": username, "role": "admin"}, + expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"}) + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "user": username, + "role": "admin" + } + +@router.get("/passkey/list") +async def passkey_list(current_user = Depends(auth.get_current_user)): + """List all registered passkeys for the current user.""" + creds = auth.load_passkey_credentials() + return { + "passkeys": [ + { + "id": c["credential_id"], + "name": c.get("name", "Passkey"), + "created_at": c.get("created_at", "") + } + for c in creds + ] + } + +@router.post("/passkey/delete") +async def passkey_delete(request: Request, current_user = Depends(auth.get_current_user)): + """Delete a specific passkey by credential ID.""" + body = await request.json() + cred_id = body.get("id") + data = auth._load_auth_data() + creds = data.get("passkey_credentials", []) + data["passkey_credentials"] = [c for c in creds if c["credential_id"] != cred_id] + auth._save_auth_data(data) + return {"ok": True} + +@router.post("/passkey/clear") +async def passkey_clear(current_user = Depends(auth.get_current_user)): + """Clear all passkeys for the current user.""" + auth.clear_passkey_credentials() + return {"ok": True} diff --git a/dashboard/backend/routers/system.py b/dashboard/backend/routers/system.py index c42f323..fc6c121 100644 --- a/dashboard/backend/routers/system.py +++ b/dashboard/backend/routers/system.py @@ -39,7 +39,7 @@ def system_stats(): except Exception: pass mem = psutil.virtual_memory() - cpu_pct = psutil.cpu_percent(interval=0.5) + cpu_pct = psutil.cpu_percent(interval=0) # Non-blocking, uses cached value load_1, load_5, load_15 = psutil.getloadavg() uptime_s = time.time() - psutil.boot_time() diff --git a/dashboard/backend/routers/totp.py b/dashboard/backend/routers/totp.py new file mode 100644 index 0000000..8209f85 --- /dev/null +++ b/dashboard/backend/routers/totp.py @@ -0,0 +1,41 @@ +"""TOTP (Time-based One-Time Password) 2FA endpoints.""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +import pyotp +import auth + +router = APIRouter() + +class Setup2FAResponse(BaseModel): + secret: str + uri: str + +class Verify2FARequest(BaseModel): + secret: str + code: str + +@router.post("/setup-2fa/generate", response_model=Setup2FAResponse) +async def generate_2fa(current_user = Depends(auth.get_current_user)): + """Generate a new TOTP secret and provisioning URI for 2FA setup.""" + secret = pyotp.random_base32() + uri = pyotp.totp.TOTP(secret).provisioning_uri( + name=current_user.username, + issuer_name="NAS Dashboard" + ) + return {"secret": secret, "uri": uri} + +@router.post("/setup-2fa/verify") +async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(auth.get_current_user)): + """Verify TOTP code and enable 2FA for the user.""" + totp = pyotp.TOTP(request.secret) + if not totp.verify(request.code): + raise HTTPException(status_code=400, detail="Invalid code") + + auth.save_totp_secret(request.secret) + return {"message": "2FA enabled successfully"} + +@router.post("/setup-2fa/disable") +async def disable_2fa(current_user = Depends(auth.get_current_user)): + """Disable 2FA for the user.""" + auth.save_totp_secret("") + return {"message": "2FA disabled"}