from __future__ import annotations from fastapi import APIRouter, Depends, Request, Response from findata_core.config import get_settings from findata_core.db import connection from pydantic import BaseModel, EmailStr from findata_api import auth as A from findata_api.errors import ApiError router = APIRouter(prefix="/auth", tags=["auth"]) class LoginIn(BaseModel): email: EmailStr password: str class CodeIn(BaseModel): code: str def _set_cookie(resp: Response, sid: str) -> None: s = get_settings() resp.set_cookie(A.SESSION_COOKIE, sid, httponly=True, secure=s.is_production or s.public_url.startswith("https"), samesite="strict", max_age=int(A.SESSION_TTL.total_seconds()), path="/") @router.post("/login") def login(body: LoginIn, request: Request, response: Response): with connection() as conn: u = conn.execute("SELECT * FROM users WHERE email=%s", (body.email.lower(),)).fetchone() ok, _ = __import__("findata_api.ratelimit", fromlist=["bucket"]).bucket.allow(f"login:{A.client_ip(request) or '?'}", 10) if not ok: raise ApiError(429, "rate_limited", "Too many login attempts") if not u or u["disabled"] or not A.verify_password(u["password_hash"], body.password): A.audit(request, "auth.login", body.email, outcome="denied") raise ApiError(401, "invalid_credentials", "Invalid e-mail or password") sid, csrf, mfa_passed = A.create_session(u, A.client_ip(request), request.headers.get("user-agent")) _set_cookie(response, sid) request.state.principal = A.Principal("user", str(u["id"]), u["email"], u["role"]) A.audit(request, "auth.login", u["email"], mfa_required=not mfa_passed) return {"ok": True, "mfa_required": not mfa_passed, "csrf_token": csrf, "email": u["email"], "role": u["role"]} @router.post("/mfa") def mfa(body: CodeIn, request: Request, p: A.Principal = Depends(A.require_user_session)): if p.mfa_passed: return {"ok": True, "csrf_token": p.csrf_token} if request.headers.get("x-csrf-token") != p.csrf_token: raise ApiError(403, "csrf", "Missing or invalid CSRF token") if not A.totp_verify(p.id, body.code): A.audit(request, "auth.mfa", p.email, outcome="denied") raise ApiError(401, "invalid_code", "Invalid one-time code") A.mark_mfa_passed(p.session_id) A.audit(request, "auth.mfa", p.email) return {"ok": True, "csrf_token": p.csrf_token} @router.post("/logout") def logout(request: Request, response: Response, p: A.Principal = Depends(A.require_user_session)): A.revoke_session(p.session_id) response.delete_cookie(A.SESSION_COOKIE, path="/") A.audit(request, "auth.logout", p.email) return {"ok": True} @router.get("/me") def me(request: Request, p: A.Principal = Depends(A.get_principal)): if p.kind == "anonymous": raise ApiError(401, "unauthenticated", "Not signed in") totp_enabled = None if p.kind == "user": with connection() as conn: u = conn.execute("SELECT totp_enabled FROM users WHERE id=%s", (p.id,)).fetchone() totp_enabled = bool(u and u["totp_enabled"]) return {"kind": p.kind, "email": p.email, "role": p.role, "scopes": sorted(p.scopes), "mfa_passed": p.mfa_passed, "totp_enabled": totp_enabled, "csrf_token": p.csrf_token} @router.post("/totp/setup") def totp_setup(request: Request, p: A.Principal = Depends(A.require("admin"))): out = A.totp_setup(p.id, p.email) A.audit(request, "auth.totp_setup", p.email) return {"otpauth_uri": out["otpauth_uri"], "qr_png_data_url": out["qr_png_data_url"], "secret": out["secret"]} @router.post("/totp/verify") def totp_verify(body: CodeIn, request: Request, p: A.Principal = Depends(A.require("admin"))): if not A.totp_verify(p.id, body.code, enable=True): raise ApiError(400, "invalid_code", "Code did not match; try again") A.audit(request, "auth.totp_enabled", p.email) return {"ok": True, "totp_enabled": True}