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:
+227
@@ -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
|
||||
Reference in New Issue
Block a user