Flatten budget-app contents into repo root
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user