Catalyst / admin/Apex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Apex

public

Bid Management and Orchestration Tool with AI Capability

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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}