auth: Sprint 02 — auth hardening (Pydantic models, challenge binding, admin gates)

- Add max_length validation to LoginRequest (username 128, password 1024)
- Replace raw request.json() with Pydantic models in RBAC override/update endpoints
- Replace raw request.json() with Pydantic models in passkey register/login/delete
- Bind passkey challenges to session-bound challenge_id (prevents cross-session replay)
- Gate audit log and security log endpoints behind admin role
- Fix fragile opc_db.json.dumps() → import json directly
- Add COOKIE_SECURE=False startup warning (suppress with ALLOW_INSECURE_COOKIES)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-05-03 13:45:42 +08:00
parent 323ae474a8
commit 1a37f34401
7 changed files with 101 additions and 62 deletions
+19 -21
View File
@@ -7,7 +7,7 @@ from datetime import timedelta
import httpx
import jwt
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from slowapi import Limiter
from slowapi.util import get_remote_address
@@ -20,8 +20,8 @@ limiter = Limiter(key_func=get_remote_address)
class LoginRequest(BaseModel):
username: str
password: str
username: str = Field(..., max_length=128)
password: str = Field(..., max_length=1024)
totp_code: str = ""
@@ -239,20 +239,23 @@ async def rbac_config(current_user=Depends(auth.get_current_user)):
return load_rbac()
class RbacOverrideRequest(BaseModel):
pages: list[str] | str
class RbacUpdateRoleRequest(BaseModel):
pages: list[str] | str
sidebar_links: list[str] | str | None = None
@router.put("/rbac/overrides/{username}")
async def rbac_set_override(username: str, request: Request, current_user=Depends(auth.get_current_user)):
async def rbac_set_override(username: str, body: RbacOverrideRequest, current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac
body = await request.json()
pages = body.get("pages")
if pages is None:
raise HTTPException(status_code=400, detail="Missing pages field")
def mutator(rbac):
existing = rbac.setdefault("user_overrides", {}).get(username, {})
existing["pages"] = pages
existing["pages"] = body.pages
rbac["user_overrides"][username] = existing
update_rbac(mutator)
@@ -301,25 +304,20 @@ async def rbac_delete_override(username: str, current_user=Depends(auth.get_curr
@router.put("/rbac/roles/{role}")
async def rbac_update_role(role: str, request: Request, current_user=Depends(auth.get_current_user)):
async def rbac_update_role(role: str, body: RbacUpdateRoleRequest, current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac
body = await request.json()
pages = body.get("pages")
sidebar_links = body.get("sidebar_links")
if pages is None:
raise HTTPException(status_code=400, detail="Missing pages field")
if pages != "*" and not isinstance(pages, list):
if body.pages != "*" and not isinstance(body.pages, list):
raise HTTPException(status_code=400, detail="pages must be '*' or a list")
if sidebar_links is not None and sidebar_links != "*" and not isinstance(sidebar_links, list):
if body.sidebar_links is not None and body.sidebar_links != "*" and not isinstance(body.sidebar_links, list):
raise HTTPException(status_code=400, detail="sidebar_links must be '*' or a list")
def mutator(rbac):
role_config = {"pages": pages}
if sidebar_links is not None:
role_config["sidebar_links"] = sidebar_links
role_config = {"pages": body.pages}
if body.sidebar_links is not None:
role_config["sidebar_links"] = body.sidebar_links
rbac.setdefault("role_defaults", {})[role] = role_config
update_rbac(mutator)
+56 -29
View File
@@ -3,10 +3,13 @@
import json
import logging
import time
import uuid
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from slowapi import Limiter
from slowapi.util import get_remote_address
from webauthn import (
@@ -25,22 +28,26 @@ router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)
# In-memory challenge store with TTL
_challenges = {}
# In-memory challenge store with TTL: {challenge_id: (challenge_bytes, timestamp)}
_challenges: dict[str, tuple[bytes, float]] = {}
def _store_challenge(challenge: bytes):
"""Store a WebAuthn challenge with timestamp for TTL tracking."""
_challenges[challenge] = time.time()
def _store_challenge(challenge: bytes) -> str:
"""Store a WebAuthn challenge and return a session-bound challenge_id."""
challenge_id = uuid.uuid4().hex
_challenges[challenge_id] = (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]
expired = [k for k, v in _challenges.items() if now - v[1] > 300]
for k in expired:
_challenges.pop(k, None)
return challenge_id
def _get_challenge(client_data_b64: str) -> bytes:
"""Extract and validate challenge from clientDataJSON."""
def _get_challenge(challenge_id: str | None, client_data_b64: str) -> bytes:
"""Look up challenge by session-bound challenge_id and validate clientDataJSON."""
if not challenge_id:
raise HTTPException(status_code=400, detail="Missing challenge_id")
if not client_data_b64:
raise HTTPException(status_code=400, detail="Missing clientDataJSON")
try:
@@ -49,12 +56,31 @@ def _get_challenge(client_data_b64: str) -> bytes:
except Exception:
raise HTTPException(status_code=400, detail="Invalid clientDataJSON")
entry = _challenges.pop(chal_bytes, None)
if not entry or time.time() - entry > 300:
entry = _challenges.pop(challenge_id, None)
if not entry or time.time() - entry[1] > 300:
raise HTTPException(status_code=400, detail="Challenge expired or invalid")
stored_challenge = entry[0]
if stored_challenge != chal_bytes:
raise HTTPException(status_code=400, detail="Challenge mismatch")
return chal_bytes
# Pydantic models for passkey request validation
class PasskeyVerifyRequest(BaseModel):
id: str = ""
rawId: str = ""
response: dict[str, Any] = {}
type: str = "public-key"
challenge_id: str | None = None
name: str = ""
# Allow extra fields for authenticator-specific extensions
model_config = {"extra": "allow"}
class PasskeyDeleteRequest(BaseModel):
id: str
@router.post("/passkey/register/options")
@limiter.limit("5/minute")
async def passkey_register_options(request: Request, current_user=Depends(auth.get_current_user)):
@@ -69,23 +95,24 @@ async def passkey_register_options(request: Request, current_user=Depends(auth.g
user_display_name=current_user.username,
exclude_credentials=exclude,
)
_store_challenge(options.challenge)
return JSONResponse(content=json.loads(options_to_json(options)))
chal_id = _store_challenge(options.challenge)
result = json.loads(options_to_json(options))
result["challenge_id"] = chal_id
return JSONResponse(content=result)
@router.post("/passkey/register/verify")
async def passkey_register_verify(request: Request, current_user=Depends(auth.get_current_user)):
async def passkey_register_verify(body: PasskeyVerifyRequest, 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", ""))
challenge = _get_challenge(body.challenge_id, body.response.get("clientDataJSON", ""))
try:
verification = verify_registration_response(
credential=body,
credential=body.model_dump(),
expected_challenge=challenge,
expected_rp_id=config.WEBAUTHN_RP_ID,
expected_origin=config.WEBAUTHN_ORIGINS,
)
except Exception as e:
except Exception:
logger.exception("Passkey registration verification failed")
raise HTTPException(status_code=400, detail="Invalid passkey registration")
@@ -94,7 +121,7 @@ async def passkey_register_verify(request: Request, current_user=Depends(auth.ge
"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"),
"name": body.name or "Passkey",
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"username": current_user.username,
"role": current_user.role,
@@ -117,32 +144,33 @@ async def passkey_login_options(request: Request):
allow_credentials=allow,
user_verification=UserVerificationRequirement.PREFERRED,
)
_store_challenge(options.challenge)
return JSONResponse(content=json.loads(options_to_json(options)))
chal_id = _store_challenge(options.challenge)
result = json.loads(options_to_json(options))
result["challenge_id"] = chal_id
return JSONResponse(content=result)
@router.post("/passkey/login/verify")
@limiter.limit("10/minute")
async def passkey_login_verify(request: Request, response: Response):
async def passkey_login_verify(body: PasskeyVerifyRequest, request: Request, response: Response):
"""Verify passkey authentication and issue tokens."""
body = await request.json()
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
challenge = _get_challenge(body.challenge_id, body.response.get("clientDataJSON", ""))
creds = auth.load_passkey_credentials()
cred_id_b64 = body.get("id", "")
cred_id_b64 = body.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,
credential=body.model_dump(),
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:
except Exception:
logger.exception("Passkey authentication verification failed")
raise HTTPException(status_code=400, detail="Invalid passkey authentication")
@@ -184,10 +212,9 @@ async def passkey_list(current_user=Depends(auth.get_current_user)):
@router.post("/passkey/delete")
async def passkey_delete(request: Request, current_user=Depends(auth.get_current_user)):
async def passkey_delete(body: PasskeyDeleteRequest, current_user=Depends(auth.get_current_user)):
"""Delete a specific passkey by credential ID."""
body = await request.json()
cred_id = body.get("id")
cred_id = body.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]
+6 -2
View File
@@ -4,8 +4,9 @@ from collections import Counter
from datetime import datetime, time, timedelta, timezone
import docker
from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, HTTPException, Query
import auth_service as auth
from config import DOCKER_HOST
router = APIRouter()
@@ -203,8 +204,11 @@ def security_logs(
ip: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user=Depends(auth.get_current_user),
):
"""Fetch and parse security-relevant logs from Authelia and Caddy."""
"""Fetch and parse security-relevant logs from Authelia and Caddy. Admin only."""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
results = []
# Authelia logs
+5 -2
View File
@@ -5,8 +5,9 @@ import time
import httpx
import psutil
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
import auth_service as auth
import config
from config import OPENCLAW_GATEWAY_TOKEN, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT
@@ -79,7 +80,9 @@ def system_stats():
@router.get("/audit-log")
def audit_log(lines: int = Query(100, le=500)):
def audit_log(lines: int = Query(100, le=500), current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
if not os.path.exists(log_path):
return {"entries": []}