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:
+195
@@ -0,0 +1,195 @@
|
||||
"""DRE Customer Portal API — FastAPI app + all 18 routers.
|
||||
Port 127.0.0.1:8093. systemd: dre-portal.service (User=root).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from . import auth as authmod
|
||||
from . import db
|
||||
from . import dreemail
|
||||
from .db import get_conn, utcnow_iso
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("dre.main")
|
||||
|
||||
app = FastAPI(title="DRE Customer Portal API", version="1.0.0", docs_url="/docs", redoc_url=None)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://portal.debtrecoveryexperts.com", "http://127.0.0.1:8093"],
|
||||
allow_methods=["GET", "POST", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers (intake, claims, staff)
|
||||
from .intake import router as intake_router # noqa: E402
|
||||
from .claims import router as claims_router # noqa: E402
|
||||
from .staff import router as staff_router # noqa: E402
|
||||
|
||||
app.include_router(intake_router)
|
||||
app.include_router(claims_router)
|
||||
app.include_router(staff_router)
|
||||
|
||||
|
||||
def _err(code: str, message: str, status_code: int):
|
||||
return JSONResponse(status_code=status_code,
|
||||
content={"error": {"code": code, "message": message}})
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup():
|
||||
db.init_db()
|
||||
logger.info("DRE portal started; db=%s", db.get_db_path())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Error envelopes
|
||||
# ---------------------------------------------------------------
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_handler(request: Request, exc: RequestValidationError):
|
||||
errors = exc.errors()
|
||||
parts = []
|
||||
for e in errors:
|
||||
loc = ".".join(str(x) for x in e.get("loc", []))
|
||||
parts.append(f"{loc}: {e.get('msg', 'invalid')}")
|
||||
msg = "; ".join(parts) if parts else "Validation error"
|
||||
return _err("validation_error", msg, status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
@app.exception_handler(ValidationError)
|
||||
async def _pyd_validation_handler(request: Request, exc: ValidationError):
|
||||
parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()]
|
||||
return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _http_handler(request: Request, exc: HTTPException):
|
||||
"""Map auth/forbidden HTTPExceptions onto the unified {'error':{...}} envelope."""
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict) and "code" in detail and "message" in detail:
|
||||
return _err(detail["code"], detail["message"], exc.status_code)
|
||||
return _err("internal_error", "An internal error occurred.", exc.status_code)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _internal_handler(request: Request, exc: Exception):
|
||||
logger.exception("internal error: %s", exc)
|
||||
return _err("internal_error", "An internal error occurred.", status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. GET /api/health
|
||||
# ---------------------------------------------------------------
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "time": utcnow_iso()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2-5. Auth endpoints (magic-link)
|
||||
# ---------------------------------------------------------------
|
||||
@app.post("/api/auth/request")
|
||||
async def auth_request(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
email_in = body.get("email") if isinstance(body, dict) else None
|
||||
if not email_in:
|
||||
return _err("validation_error", "email is required.", status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
ip = authmod.get_client_ip(request)
|
||||
if not authmod.rate_limit_auth_request(email_in.lower(), ip):
|
||||
return _err("rate_limited", "Too many requests. Please try again later.",
|
||||
status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
email_lc = email_in.lower()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, contact_name, company_name FROM clients WHERE email = ?", (email_lc,)).fetchone()
|
||||
if row is not None:
|
||||
raw_token = authmod.create_magic_token(conn, row["id"], ip)
|
||||
conn.commit()
|
||||
# Best-effort email
|
||||
try:
|
||||
dreemail.send_magic_link(email_lc, raw_token, "")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("magic link email failed: %s", exc)
|
||||
# Anti-enumeration: always same response
|
||||
return {"message": "If an account exists, a login link has been sent."}
|
||||
|
||||
|
||||
@app.post("/api/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
token = body.get("token") if isinstance(body, dict) else None
|
||||
if not token:
|
||||
return _err("validation_error", "token is required.", status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
ip = authmod.get_client_ip(request)
|
||||
if not authmod.rate_limit_auth_verify(ip):
|
||||
return _err("rate_limited", "Too many attempts. Please try again later.",
|
||||
status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
with get_conn() as conn:
|
||||
client_id = authmod.verify_magic_token(conn, token)
|
||||
if client_id is None:
|
||||
return _err("unauthorized", "Invalid or expired token.", status.HTTP_401_UNAUTHORIZED)
|
||||
session_token, expires_at = authmod.create_session(conn, client_id)
|
||||
client = conn.execute(
|
||||
"SELECT client_number, company_name, contact_name FROM clients WHERE id = ?", (client_id,)
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"session_token": session_token,
|
||||
"expires_at": expires_at,
|
||||
"client": {
|
||||
"client_number": client["client_number"],
|
||||
"company_name": client["company_name"],
|
||||
"contact_name": client["contact_name"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def auth_logout(request: Request, session: dict = Depends(authmod.require_client)):
|
||||
"""Revoke the current session by hash."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
raw_token = auth.split(" ", 1)[1].strip() if auth.lower().startswith("bearer ") else ""
|
||||
import hashlib
|
||||
session_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET revoked_at = ? WHERE session_hash = ?",
|
||||
(utcnow_iso(), session_hash),
|
||||
)
|
||||
conn.commit()
|
||||
return {"message": "Logged out."}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def auth_me(session: dict = Depends(authmod.require_client)):
|
||||
import hashlib
|
||||
client_id = session["client_id"]
|
||||
with get_conn() as conn:
|
||||
c = conn.execute(
|
||||
"SELECT client_number, company_name, contact_name, email, phone, created_at FROM clients WHERE id = ?",
|
||||
(client_id,),
|
||||
).fetchone()
|
||||
claim_count = conn.execute("SELECT COUNT(*) AS n FROM claims WHERE client_id = ?", (client_id,)).fetchone()["n"]
|
||||
return {
|
||||
"client": {
|
||||
"client_number": c["client_number"],
|
||||
"company_name": c["company_name"],
|
||||
"contact_name": c["contact_name"],
|
||||
"email": c["email"],
|
||||
"phone": c["phone"],
|
||||
"member_since": c["created_at"],
|
||||
},
|
||||
"claim_count": claim_count,
|
||||
}
|
||||
Reference in New Issue
Block a user