Files
Budget_Tool_Martinhal/budget-app/backend/app/routers/auth.py
T

209 lines
8.1 KiB
Python
Raw Normal View History

2026-10-29 20:40:02 +00:00
from datetime import datetime, timezone
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import mfa
from ..audit import record
from ..config import settings
from ..db import get_db
from ..models import User
from ..security import COOKIE, current_user, hash_password, make_token, verify_password
from ..serializers import user_out
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginIn(BaseModel):
email: str
password: str
code: str | None = None # authenticator or recovery code, for users with MFA on
class ProfileIn(BaseModel):
name: str | None = None
language: str | None = None
theme: str | None = None
current_password: str | None = None
new_password: str | None = None
@router.post("/login")
def login(body: LoginIn, response: Response, db: Session = Depends(get_db)):
user = db.scalar(select(User).where(func.lower(User.email) == body.email.strip().lower()))
if not user or not user.active or not verify_password(body.password, user.password_hash):
record(db, user if user and user.active else None, "user", user.id if user else None, "login_failed",
body.email.strip()[:200])
db.commit()
raise HTTPException(401, "invalid_credentials")
if user.mfa_enabled:
if not (body.code or "").strip():
# password is right: the sign-in screen now asks for the code
return {"mfa_required": True}
if mfa.locked(user):
raise HTTPException(429, "mfa_locked")
kind = mfa.check(user, body.code)
if not kind:
record(db, user, "user", user.id, "mfa_failed", user.email)
db.commit()
raise HTTPException(429 if mfa.locked(user) else 401, "mfa_locked" if mfa.locked(user) else "invalid_mfa_code")
if kind == "recovery":
record(db, user, "user", user.id, "mfa_recovery_used", f"{len(user.mfa_recovery or [])} recovery codes left")
response.set_cookie(COOKIE, make_token(user), httponly=True, samesite="lax",
secure=settings.cookie_secure, max_age=settings.session_hours * 3600)
record(db, user, "user", user.id, "login")
db.commit()
return user_out(user)
@router.post("/logout")
def logout(request: Request, response: Response, db: Session = Depends(get_db)):
try:
user = current_user(request, db)
record(db, user, "user", user.id, "logout")
db.commit()
except HTTPException:
pass
response.delete_cookie(COOKIE)
return {"ok": True}
@router.get("/me")
def me(user: User = Depends(current_user)):
return user_out(user)
@router.put("/me")
def update_me(body: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
if body.name:
user.name = body.name.strip()
if body.language in ("pt", "en"):
user.language = body.language
if body.theme in ("system", "light", "dark"):
user.theme = body.theme
if body.new_password:
if not body.current_password or not verify_password(body.current_password, user.password_hash):
raise HTTPException(400, "wrong_password")
if len(body.new_password) < 8:
raise HTTPException(400, "password_too_short")
user.password_hash = hash_password(body.new_password)
record(db, user, "user", user.id, "password_changed")
db.commit()
return user_out(user)
# --- MFA ----------------------------------------------------------------------------------------
class CodeIn(BaseModel):
code: str = ""
password: str = ""
@router.post("/mfa/setup")
def mfa_setup(user: User = Depends(current_user), db: Session = Depends(get_db)):
"""Starts MFA set-up: a new secret to scan as a QR code. MFA is only on after a code is confirmed."""
if user.mfa_enabled:
raise HTTPException(409, "mfa_already_on")
secret = mfa.new_secret()
user.mfa_pending_secret = mfa.seal(secret)
db.commit()
uri = mfa.otpauth_uri(secret, user.email)
return {"secret": secret, "uri": uri, "qr_svg": mfa.qr_svg(uri)}
@router.post("/mfa/enable")
def mfa_enable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
secret = mfa.unseal(user.mfa_pending_secret)
if user.mfa_enabled or not secret:
raise HTTPException(409, "mfa_setup_first")
step = mfa.matching_step(secret, body.code)
if step is None:
raise HTTPException(400, "invalid_mfa_code")
codes, hashes = mfa.new_recovery_codes()
user.mfa_secret, user.mfa_pending_secret, user.mfa_enabled = user.mfa_pending_secret, None, True
user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = datetime.now(timezone.utc), step, hashes
user.mfa_failed, user.mfa_locked_until = 0, None
record(db, user, "user", user.id, "mfa_enabled", user.email)
db.commit()
return {"user": user_out(user), "recovery_codes": codes}
@router.post("/mfa/recovery-codes")
def mfa_new_recovery_codes(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""New recovery codes (the old ones stop working). Needs a current code."""
if not user.mfa_enabled:
raise HTTPException(409, "mfa_off")
if not mfa.check(user, body.code):
db.commit()
raise HTTPException(400, "invalid_mfa_code")
codes, user.mfa_recovery = mfa.new_recovery_codes()
record(db, user, "user", user.id, "mfa_recovery_renewed", user.email)
db.commit()
return {"user": user_out(user), "recovery_codes": codes}
@router.post("/mfa/disable")
def mfa_disable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""Turning MFA off needs the password and a current code (or a recovery code)."""
if not user.mfa_enabled:
raise HTTPException(409, "mfa_off")
if not verify_password(body.password, user.password_hash):
raise HTTPException(400, "wrong_password")
if not mfa.check(user, body.code):
db.commit()
raise HTTPException(400, "invalid_mfa_code")
mfa.disable(user)
record(db, user, "user", user.id, "mfa_disabled", user.email)
db.commit()
return user_out(user)
# --- profile picture ----------------------------------------------------------------------------
AVATAR_MAX_BYTES = 5 * 1024 * 1024
AVATAR_SIZE = 256
def resize_avatar(data: bytes) -> tuple[bytes, str]:
"""Any common picture -> a square JPEG (or PNG when it has transparency) of at most 256 px."""
from PIL import Image, ImageOps, UnidentifiedImageError
try:
img = Image.open(BytesIO(data))
img.load()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError):
raise HTTPException(400, "not_an_image")
img = ImageOps.exif_transpose(img)
side = min(img.size)
left, top = (img.width - side) // 2, (img.height - side) // 2
img = img.crop((left, top, left + side, top + side))
img.thumbnail((AVATAR_SIZE, AVATAR_SIZE))
out = BytesIO()
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img.convert("RGBA").save(out, "PNG", optimize=True)
return out.getvalue(), "image/png"
img.convert("RGB").save(out, "JPEG", quality=88, optimize=True)
return out.getvalue(), "image/jpeg"
@router.put("/me/avatar")
async def upload_avatar(file: UploadFile = File(...), user: User = Depends(current_user), db: Session = Depends(get_db)):
data = await file.read(AVATAR_MAX_BYTES + 1)
if len(data) > AVATAR_MAX_BYTES:
raise HTTPException(413, "image_too_large")
user.avatar, user.avatar_type = resize_avatar(data)
user.avatar_updated_at = datetime.now(timezone.utc)
record(db, user, "user", user.id, "avatar_changed", user.email)
db.commit()
return user_out(user)
@router.delete("/me/avatar")
def delete_avatar(user: User = Depends(current_user), db: Session = Depends(get_db)):
user.avatar = user.avatar_type = user.avatar_updated_at = None
record(db, user, "user", user.id, "avatar_removed", user.email)
db.commit()
return user_out(user)