DRE customer portal: FastAPI + SQLite backend (18 endpoints)

- Magic-link auth (sha256-only token storage, 15-min single-use, 7-day sessions)
- Staff-key auth via X-DRE-Staff-Key (constant-time compare)
- SQLite WAL, foreign_keys, parameterized queries, atomic DRE/CLT sequence allocation
- Intake validator rejects SSN/PAN patterns (FDCPA/TDCPA compliance)
- Document upload allowlist + magic-byte check, 20MB cap
- Unified error envelope, money as integer cents
- systemd unit (port 8093, User=root, hardening directives)
- Fixes import bug (auth.py relative imports) and audit_log placeholder mismatch
This commit is contained in:
root
2026-08-21 18:43:33 -04:00
parent c573ed1a14
commit be0750d001
14 changed files with 2662 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
"""Magic-link auth, sessions, staff-key, rate limiting (in-memory sliding window).
Constant-time comparison via hmac.compare_digest for token hashes + staff key.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import time
from collections import defaultdict, deque
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, Request, status
from . import db
from .db import get_conn, new_uuid, utcnow_iso
logger = logging.getLogger("dre.auth")
MAGIC_TOKEN_TTL_MIN = 15
SESSION_TTL_DAYS = 7
# ---------------------------------------------------------------
# Rate limiting — in-memory sliding window (single-instance v1)
# ---------------------------------------------------------------
class RateLimiter:
def __init__(self) -> None:
self._by_email: dict[str, deque[float]] = defaultdict(deque)
self._by_ip: dict[str, deque[float]] = defaultdict(deque)
def _prune(self, dq: deque[float], window_sec: float) -> None:
cutoff = time.time() - window_sec
while dq and dq[0] < cutoff:
dq.popleft()
def check_email(self, email: str, max_count: int, window_sec: float) -> bool:
dq = self._by_email[email]
self._prune(dq, window_sec)
if len(dq) >= max_count:
return False
dq.append(time.time())
return True
def check_ip(self, ip: str, max_count: int, window_sec: float) -> bool:
dq = self._by_ip[ip]
self._prune(dq, window_sec)
if len(dq) >= max_count:
return False
dq.append(time.time())
return True
def check(self, email: str | None, ip: str, max_email: int, email_window: float,
max_ip: int, ip_window: float) -> bool:
if email and not self.check_email(email, max_email, email_window):
return False
if not self.check_ip(ip, max_ip, ip_window):
return False
return True
_limiter = RateLimiter()
def rate_limit_auth_request(email: str | None, ip: str) -> bool:
return _limiter.check(email, ip, max_email=3, email_window=900, max_ip=10, ip_window=3600)
def rate_limit_auth_verify(ip: str) -> bool:
return _limiter.check(None, ip, max_email=999, email_window=1, max_ip=10, ip_window=900)
def rate_limit_intake(ip: str) -> bool:
return _limiter.check_ip(ip, max_count=20, window_sec=3600)
# ---------------------------------------------------------------
# Token / hash helpers
# ---------------------------------------------------------------
def _sha256_hex(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def generate_magic_token() -> str:
return secrets.token_urlsafe(32)
def generate_session_token() -> str:
return secrets.token_urlsafe(32)
def _compare_hash(a: str, b: str) -> bool:
return hmac.compare_digest(a, b)
# ---------------------------------------------------------------
# Staff key
# ---------------------------------------------------------------
def _staff_key() -> str:
return os.environ.get("DRE_STAFF_KEY", "")
def verify_staff_key(provided: str | None) -> bool:
key = _staff_key()
if not key or not provided:
return False
return _compare_hash(provided, key)
# ---------------------------------------------------------------
# FastAPI dependencies
# ---------------------------------------------------------------
def get_client_ip(request: Request) -> str:
# Cloudflare / Caddy may set X-Forwarded-For; use first hop
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def require_staff(request: Request) -> None:
"""Staff-key auth dependency. Raises 403 if missing/wrong."""
provided = request.headers.get("x-dre-staff-key")
if not verify_staff_key(provided):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "forbidden", "message": "Valid staff key required."},
)
def require_client(request: Request) -> dict:
"""Client session auth dependency. Returns {'client_id':..., 'client_number':...}.
Raises 401 if missing/invalid/expired."""
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": "Authentication required."},
)
raw_token = auth.split(" ", 1)[1].strip()
if not raw_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": "Authentication required."},
)
token_hash = _sha256_hex(raw_token)
now = utcnow_iso()
with get_conn() as conn:
row = conn.execute(
"SELECT s.id, s.client_id, s.expires_at, s.revoked_at, c.client_number "
"FROM sessions s JOIN clients c ON c.id = s.client_id "
"WHERE s.session_hash = ?",
(token_hash,),
).fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": "Invalid or expired session."},
)
if row["revoked_at"] is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": "Session revoked."},
)
if row["expires_at"] < now:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "unauthorized", "message": "Session expired."},
)
# Touch last_seen
conn.execute(
"UPDATE sessions SET last_seen_at = ? WHERE id = ?",
(now, row["id"]),
)
conn.commit()
return {"client_id": row["client_id"], "client_number": row["client_number"]}
def create_magic_token(conn, client_id: str, ip: str) -> str:
"""Create a magic-link token row. Returns the RAW token (caller emails it, never stores it)."""
raw = generate_magic_token()
token_hash = _sha256_hex(raw)
now = utcnow_iso()
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=MAGIC_TOKEN_TTL_MIN)).strftime("%Y-%m-%dT%H:%M:%SZ")
conn.execute(
"INSERT INTO auth_tokens (id, client_id, token_hash, expires_at, consumed_at, requested_ip, created_at) "
"VALUES (?, ?, ?, ?, NULL, ?, ?)",
(new_uuid(), client_id, token_hash, expires_at, ip, now),
)
return raw
def prune_expired_tokens(conn) -> None:
"""Delete auth_token rows older than 1 day (lazy sweep on verify)."""
cutoff = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (cutoff,))
def verify_magic_token(conn, raw_token: str) -> str | None:
"""Hash token, find unexpired+unconsumed row. If found: mark consumed, return client_id.
Returns None if no match."""
token_hash = _sha256_hex(raw_token)
now = utcnow_iso()
prune_expired_tokens(conn)
row = conn.execute(
"SELECT id, client_id FROM auth_tokens WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?",
(token_hash, now),
).fetchone()
if row is None:
return None
conn.execute("UPDATE auth_tokens SET consumed_at = ? WHERE id = ?", (now, row["id"]))
return row["client_id"]
def create_session(conn, client_id: str) -> tuple[str, str]:
"""Create a session. Returns (raw_session_token, expires_at_iso)."""
raw = generate_session_token()
session_hash = _sha256_hex(raw)
now = utcnow_iso()
expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")
conn.execute(
"INSERT INTO sessions (id, client_id, session_hash, expires_at, revoked_at, created_at, last_seen_at) "
"VALUES (?, ?, ?, ?, NULL, ?, NULL)",
(new_uuid(), client_id, session_hash, expires_at, now),
)
return raw, expires_at