Refactor auth router and add performance optimizations #15

Merged
jimmy merged 2 commits from dev into main 2026-03-01 22:25:15 +08:00
5 changed files with 242 additions and 167 deletions
+12 -1
View File
@@ -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",
+1 -165
View File
@@ -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)):
+187
View File
@@ -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}
+1 -1
View File
@@ -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()
+41
View File
@@ -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"}