Files
Gan, Jimmy 1a37f34401 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>
2026-05-03 13:45:42 +08:00

230 lines
8.6 KiB
Python

"""WebAuthn Passkey authentication endpoints."""
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 (
generate_authentication_options,
generate_registration_options,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url, options_to_json
from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
import auth_service as auth
import config
router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)
# In-memory challenge store with TTL: {challenge_id: (challenge_bytes, timestamp)}
_challenges: dict[str, tuple[bytes, float]] = {}
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[1] > 300]
for k in expired:
_challenges.pop(k, None)
return challenge_id
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:
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(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)):
"""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,
)
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(body: PasskeyVerifyRequest, current_user=Depends(auth.get_current_user)):
"""Verify and save a new passkey registration."""
challenge = _get_challenge(body.challenge_id, body.response.get("clientDataJSON", ""))
try:
verification = verify_registration_response(
credential=body.model_dump(),
expected_challenge=challenge,
expected_rp_id=config.WEBAUTHN_RP_ID,
expected_origin=config.WEBAUTHN_ORIGINS,
)
except Exception:
logger.exception("Passkey registration verification failed")
raise HTTPException(status_code=400, detail="Invalid passkey registration")
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.name or "Passkey",
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"username": current_user.username,
"role": current_user.role,
}
)
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,
)
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(body: PasskeyVerifyRequest, request: Request, response: Response):
"""Verify passkey authentication and issue tokens."""
challenge = _get_challenge(body.challenge_id, body.response.get("clientDataJSON", ""))
creds = auth.load_passkey_credentials()
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.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:
logger.exception("Passkey authentication verification failed")
raise HTTPException(status_code=400, detail="Invalid passkey authentication")
# Update sign count
matched["sign_count"] = verification.new_sign_count
data = auth._load_auth_data()
data["passkey_credentials"] = creds
auth._save_auth_data(data)
# Use stored username and role from passkey credential
username = matched.get("username", config.ADMIN_USER)
role = matched.get("role", "admin")
access_token = auth.create_access_token(
data={"sub": username, "role": role},
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
auth.set_auth_cookies(response, access_token, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user": username,
"role": role,
}
@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(body: PasskeyDeleteRequest, current_user=Depends(auth.get_current_user)):
"""Delete a specific passkey by credential 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]
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}