admin / Apex
publicBid Management and Orchestration Tool with AI Capability
Apex / Synapse-Apexv2 / backend / app / routers / auth.py
4637 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | import io import secrets import uuid from datetime import datetime, timedelta, timezone import pyotp import qrcode import qrcode.image.svg from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session from app.core.config import settings from app.core.database import get_db from app.core.deps import audit, get_current_user from app.core.security import ( create_access_token, hash_password, verify_password, ) from app.models import SystemSetting, User from app.schemas import ( ChangePasswordIn, LoginIn, MfaEnrollOut, MfaVerifyIn, TokenOut, UserOut, ) router = APIRouter(prefix="/api/auth", tags=["auth"]) LOCK_THRESHOLD = 5 LOCK_MINUTES = 15 def mfa_enforced(db: Session) -> bool: row = db.get(SystemSetting, "enforce_mfa") return bool(row and row.value.get("enabled")) def _qr_svg(uri: str) -> str: img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgImage) buf = io.BytesIO() img.save(buf) return buf.getvalue().decode("utf-8") @router.post("/login", response_model=TokenOut) def login(body: LoginIn, db: Session = Depends(get_db)): user = db.execute(select(User).where(User.email == body.email)).scalar_one_or_none() if not user or not user.is_active: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials") now = datetime.now(timezone.utc) if user.locked_until and user.locked_until > now: raise HTTPException(status.HTTP_423_LOCKED, "Account temporarily locked") if not verify_password(body.password, user.password_hash): user.failed_logins += 1 if user.failed_logins >= LOCK_THRESHOLD: user.locked_until = now + timedelta(minutes=LOCK_MINUTES) user.failed_logins = 0 db.commit() audit(db, user, "login_failed", "user", user.id) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials") user.failed_logins = 0 user.locked_until = None db.commit() # MFA gate if user.mfa_enrolled: if not body.otp: return TokenOut(access_token="", mfa_required=True) if not pyotp.TOTP(user.mfa_secret).verify(body.otp, valid_window=1): if body.otp not in user.recovery_codes: audit(db, user, "mfa_failed", "user", user.id) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid MFA code") user.recovery_codes = [c for c in user.recovery_codes if c != body.otp] db.commit() audit(db, user, "login_success", "user", user.id) token = create_access_token(user.id) return TokenOut( access_token=token, mfa_required=False, must_change_password=user.must_change_password, ) @router.get("/me", response_model=UserOut) def me(user: User = Depends(get_current_user)): return user @router.get("/mfa/status") def mfa_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)): return {"enrolled": user.mfa_enrolled, "enforced": mfa_enforced(db)} @router.post("/mfa/enroll", response_model=MfaEnrollOut) def mfa_enroll(db: Session = Depends(get_db), user: User = Depends(get_current_user)): secret = pyotp.random_base32() uri = pyotp.TOTP(secret).provisioning_uri(name=user.email, issuer_name="Synapse-Apex") codes = [secrets.token_hex(4) for _ in range(10)] user.mfa_secret = secret user.recovery_codes = codes db.commit() return MfaEnrollOut(secret=secret, otpauth_uri=uri, qr_svg=_qr_svg(uri), recovery_codes=codes) @router.post("/mfa/verify") def mfa_verify(body: MfaVerifyIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)): if not user.mfa_secret or not pyotp.TOTP(user.mfa_secret).verify(body.otp, valid_window=1): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid code") user.mfa_enrolled = True db.commit() audit(db, user, "mfa_enrolled", "user", user.id) return {"ok": True} @router.post("/change-password") def change_password(body: ChangePasswordIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)): if not verify_password(body.current_password, user.password_hash): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Current password incorrect") if len(body.new_password) < 8: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Password too short") user.password_hash = hash_password(body.new_password) user.must_change_password = False db.commit() audit(db, user, "password_changed", "user", user.id) return {"ok": True} |