auth.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """
  2. app/auth.py
  3. ───────────
  4. Security layer:
  5. · Argon2id password hashing via passlib
  6. · HS256 JWT creation / verification (2-hour expiry)
  7. · TOTP MFA setup, verification, and activation via pyotp
  8. · Login, MFA-verify, MFA-setup, /me endpoints
  9. · get_current_user FastAPI dependency (used as auth guard on every route)
  10. · seed_admin() helper called at app startup to bootstrap the first admin
  11. """
  12. from __future__ import annotations
  13. from datetime import datetime, timedelta
  14. from typing import Optional
  15. import pyotp
  16. from fastapi import APIRouter, Depends, HTTPException, status
  17. from fastapi.security import OAuth2PasswordBearer
  18. from jose import JWTError, jwt
  19. from passlib.context import CryptContext
  20. from sqlalchemy.orm import Session
  21. from app.config import settings
  22. from app.database import get_db
  23. from app.models import (
  24. AdminUser,
  25. LoginRequest, MFASetupResponse, MFAVerifyRequest,
  26. TokenResponse, UserRead,
  27. )
  28. router = APIRouter(prefix="/api/auth", tags=["auth"])
  29. # ─────────────────────────────────────────────────────────────────────────────
  30. # Passlib CryptContext — Argon2id
  31. # ─────────────────────────────────────────────────────────────────────────────
  32. pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
  33. # ─────────────────────────────────────────────────────────────────────────────
  34. # OAuth2 Bearer token extraction
  35. # ─────────────────────────────────────────────────────────────────────────────
  36. oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
  37. # ═══════════════════════════════════════════════════════════════════════════════
  38. # Password helpers
  39. # ═══════════════════════════════════════════════════════════════════════════════
  40. def hash_password(plain: str) -> str:
  41. """Hash a plaintext password with Argon2id."""
  42. return pwd_context.hash(plain)
  43. def verify_password(plain: str, hashed: str) -> bool:
  44. """Constant-time comparison using Argon2id verify."""
  45. return pwd_context.verify(plain, hashed)
  46. # ═══════════════════════════════════════════════════════════════════════════════
  47. # JWT helpers
  48. # ═══════════════════════════════════════════════════════════════════════════════
  49. def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
  50. payload = data.copy()
  51. expire = datetime.utcnow() + (
  52. expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
  53. )
  54. payload["exp"] = expire
  55. return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
  56. def _decode_token(token: str) -> dict:
  57. try:
  58. return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
  59. except JWTError:
  60. raise HTTPException(
  61. status_code=status.HTTP_401_UNAUTHORIZED,
  62. detail="Token is invalid or has expired.",
  63. headers={"WWW-Authenticate": "Bearer"},
  64. )
  65. # ═══════════════════════════════════════════════════════════════════════════════
  66. # DB helpers
  67. # ═══════════════════════════════════════════════════════════════════════════════
  68. def _get_user(db: Session, username: str) -> Optional[AdminUser]:
  69. return db.query(AdminUser).filter(AdminUser.username == username).first()
  70. # ═══════════════════════════════════════════════════════════════════════════════
  71. # FastAPI dependency: authenticated current user
  72. # ═══════════════════════════════════════════════════════════════════════════════
  73. def get_current_user(
  74. token: str = Depends(oauth2_scheme),
  75. db: Session = Depends(get_db),
  76. ) -> AdminUser:
  77. payload = _decode_token(token)
  78. username = payload.get("sub")
  79. # Reject partial MFA-pending tokens on protected routes
  80. if payload.get("mfa_pending"):
  81. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
  82. detail="MFA verification required.")
  83. if not username:
  84. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
  85. detail="Invalid token payload.")
  86. user = _get_user(db, username)
  87. if not user:
  88. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
  89. detail="User account not found.")
  90. return user
  91. # ═══════════════════════════════════════════════════════════════════════════════
  92. # First-boot seed
  93. # ═══════════════════════════════════════════════════════════════════════════════
  94. def seed_admin(db: Session) -> None:
  95. """Create a default admin account if the admin_users table is empty."""
  96. if db.query(AdminUser).first():
  97. return
  98. admin = AdminUser(
  99. username=settings.ADMIN_USERNAME,
  100. hashed_password=hash_password(settings.ADMIN_PASSWORD),
  101. )
  102. db.add(admin)
  103. db.commit()
  104. # ═══════════════════════════════════════════════════════════════════════════════
  105. # Auth routes
  106. # ═══════════════════════════════════════════════════════════════════════════════
  107. @router.post("/login", response_model=TokenResponse, summary="Authenticate with username + password")
  108. def login(payload: LoginRequest, db: Session = Depends(get_db)):
  109. user = _get_user(db, payload.username)
  110. if not user or not verify_password(payload.password, user.hashed_password):
  111. raise HTTPException(
  112. status_code=status.HTTP_401_UNAUTHORIZED,
  113. detail="Incorrect username or password.",
  114. )
  115. if user.mfa_enabled and user.totp_secret:
  116. # Issue a short-lived, MFA-pending partial token; client must call /mfa/verify
  117. partial = create_access_token(
  118. {"sub": user.username, "mfa_pending": True},
  119. expires_delta=timedelta(minutes=5),
  120. )
  121. return TokenResponse(access_token=partial, mfa_required=True)
  122. access_token = create_access_token({"sub": user.username})
  123. return TokenResponse(access_token=access_token)
  124. @router.post("/mfa/verify", response_model=TokenResponse, summary="Complete MFA login with TOTP code")
  125. def verify_mfa(payload: MFAVerifyRequest, db: Session = Depends(get_db)):
  126. user = _get_user(db, payload.username)
  127. if not user or not user.totp_secret:
  128. raise HTTPException(status_code=400, detail="MFA not configured for this account.")
  129. totp = pyotp.TOTP(user.totp_secret)
  130. if not totp.verify(payload.totp_code, valid_window=1):
  131. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code.")
  132. access_token = create_access_token({"sub": user.username})
  133. return TokenResponse(access_token=access_token)
  134. @router.post(
  135. "/mfa/setup",
  136. response_model=MFASetupResponse,
  137. summary="Generate a new TOTP secret and QR provisioning URI",
  138. )
  139. def setup_mfa(
  140. current_user: AdminUser = Depends(get_current_user),
  141. db: Session = Depends(get_db),
  142. ):
  143. """
  144. Generates a 32-character base32 TOTP secret and stores it on the account
  145. (MFA is NOT enabled yet until /mfa/enable is called with a valid code).
  146. Returns the secret and an otpauth:// URI compatible with Google Authenticator.
  147. """
  148. secret = pyotp.random_base32(length=32)
  149. totp = pyotp.TOTP(secret)
  150. qr_uri = totp.provisioning_uri(name=current_user.username, issuer_name="PMDI")
  151. # Persist the tentative secret; activation requires /mfa/enable
  152. current_user.totp_secret = secret
  153. db.commit()
  154. return MFASetupResponse(secret=secret, qr_uri=qr_uri)
  155. @router.post("/mfa/enable", summary="Confirm TOTP code and activate MFA on the account")
  156. def enable_mfa(
  157. payload: MFAVerifyRequest,
  158. db: Session = Depends(get_db),
  159. ):
  160. user = _get_user(db, payload.username)
  161. if not user or not user.totp_secret:
  162. raise HTTPException(status_code=400, detail="Call /mfa/setup first.")
  163. totp = pyotp.TOTP(user.totp_secret)
  164. if not totp.verify(payload.totp_code, valid_window=1):
  165. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code.")
  166. user.mfa_enabled = True
  167. db.commit()
  168. return {"detail": "MFA enabled successfully."}
  169. @router.post("/mfa/disable", summary="Disable MFA on the account (requires valid TOTP)")
  170. def disable_mfa(
  171. payload: MFAVerifyRequest,
  172. current_user: AdminUser = Depends(get_current_user),
  173. db: Session = Depends(get_db),
  174. ):
  175. if not current_user.totp_secret:
  176. raise HTTPException(status_code=400, detail="MFA not configured.")
  177. totp = pyotp.TOTP(current_user.totp_secret)
  178. if not totp.verify(payload.totp_code, valid_window=1):
  179. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code.")
  180. current_user.mfa_enabled = False
  181. current_user.totp_secret = None
  182. db.commit()
  183. return {"detail": "MFA disabled."}
  184. @router.get("/me", response_model=UserRead, summary="Return the current authenticated user")
  185. def me(current_user: AdminUser = Depends(get_current_user)):
  186. return current_user