"""JWT Authentication + OTP routes.""" from datetime import datetime, timedelta from typing import Optional import secrets import bcrypt from fastapi import APIRouter, HTTPException, Header from jose import jwt from sqlalchemy.exc import IntegrityError from core.config import ( JWT_SECRET_KEY, JWT_ALGORITHM, JWT_ACCESS_TOKEN_EXPIRE_MINUTES, BREVO_API_KEY, OTP_EXPIRE_MINUTES, OTP_MAX_ATTEMPTS, OTP_EMAIL_REQUIRED, ) from core.schemas import ( UserCreate, UserLogin, TokenResponse, RegisterSendOtpRequest, VerifyOtpRequest, OtpActionResponse, ) from database import SessionLocal, User, PendingOTPRegistration from services.email_service import send_otp_email router = APIRouter(prefix="/api/auth", tags=["auth"]) VALID_ROLES = ["noc_engineer", "network_planner", "executive"] def _create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() expire = datetime.utcnow() + (expires_delta or timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({"exp": expire}) return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) def _hash_password(password: str) -> str: if len(password.encode("utf-8")) > 72: raise HTTPException(status_code=400, detail="Password is too long (max 72 bytes)") return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def _verify_password(password: str, hashed_password: str) -> bool: try: return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8")) except (ValueError, TypeError): return False @router.post("/register", response_model=TokenResponse) async def register(req: UserCreate): db = SessionLocal() try: existing = db.query(User).filter(User.username == req.username).first() if existing: raise HTTPException(status_code=400, detail="Username already exists") if req.role not in VALID_ROLES: raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(VALID_ROLES)}") hashed = _hash_password(req.password) user = User(username=req.username, hashed_password=hashed, role=req.role) db.add(user) db.commit() db.refresh(user) token = _create_access_token({"sub": user.username, "role": user.role}) return TokenResponse( access_token=token, username=user.username, role=user.role, ) finally: db.close() @router.post("/register-send-otp", response_model=OtpActionResponse) async def register_send_otp(req: RegisterSendOtpRequest): db = SessionLocal() try: if req.role not in VALID_ROLES: raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(VALID_ROLES)}") existing_user = db.query(User).filter(User.username == req.username).first() if existing_user: raise HTTPException(status_code=400, detail="Username already exists") existing_email = db.query(User).filter(User.email == req.email).first() if existing_email: raise HTTPException(status_code=400, detail="An account with this email already exists. Please log in instead.") otp_code = f"{secrets.randbelow(10**6):06d}" expires_at = datetime.utcnow() + timedelta(minutes=OTP_EXPIRE_MINUTES) hashed = _hash_password(req.password) pending = db.query(PendingOTPRegistration).filter(PendingOTPRegistration.email == req.email).first() if pending: pending.username = req.username pending.hashed_password = hashed pending.role = req.role pending.otp_code = otp_code pending.otp_expires_at = expires_at pending.attempts = 0 else: pending = PendingOTPRegistration( email=req.email, username=req.username, hashed_password=hashed, role=req.role, otp_code=otp_code, otp_expires_at=expires_at, ) db.add(pending) db.commit() if OTP_EMAIL_REQUIRED and not BREVO_API_KEY: raise HTTPException( status_code=500, detail="OTP email is required but BREVO_API_KEY is missing in backend .env", ) # FIX: only send email if OTP_EMAIL_REQUIRED is true if OTP_EMAIL_REQUIRED: try: send_otp_email(req.email, otp_code) except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to send OTP email: {e}") return OtpActionResponse( success=True, message="Verification code sent. Please check your email." if OTP_EMAIL_REQUIRED else "Registration pending. Use any code to verify (OTP email disabled).", ) finally: db.close() @router.post("/verify-otp", response_model=TokenResponse) async def verify_otp(req: VerifyOtpRequest): db = SessionLocal() try: pending = db.query(PendingOTPRegistration).filter(PendingOTPRegistration.email == req.email).first() if not pending: raise HTTPException(status_code=404, detail="No pending registration found for this email") if pending.otp_expires_at < datetime.utcnow(): db.delete(pending) db.commit() raise HTTPException(status_code=400, detail="OTP has expired. Please request a new code.") if pending.attempts >= OTP_MAX_ATTEMPTS: db.delete(pending) db.commit() raise HTTPException(status_code=400, detail="Maximum OTP attempts reached. Please request a new code.") if req.otp_code != pending.otp_code: pending.attempts += 1 db.commit() raise HTTPException(status_code=400, detail="Invalid OTP code") existing_user = db.query(User).filter(User.username == pending.username).first() if existing_user: db.delete(pending) db.commit() raise HTTPException(status_code=400, detail="Username already exists") existing_email = db.query(User).filter(User.email == pending.email).first() if existing_email: db.delete(pending) db.commit() raise HTTPException(status_code=400, detail="An account with this email already exists. Please log in instead.") user = User( username=pending.username, email=pending.email, hashed_password=pending.hashed_password, role=pending.role, ) db.add(user) db.delete(pending) try: db.commit() except IntegrityError: db.rollback() raise HTTPException( status_code=400, detail="An account with this username or email already exists. Please log in instead.", ) db.refresh(user) token = _create_access_token({"sub": user.username, "role": user.role}) return TokenResponse( access_token=token, username=user.username, role=user.role, ) finally: db.close() @router.post("/login", response_model=TokenResponse) async def login(req: UserLogin): db = SessionLocal() try: user = db.query(User).filter(User.username == req.username).first() if not user or not _verify_password(req.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid credentials") token = _create_access_token({"sub": user.username, "role": user.role}) return TokenResponse( access_token=token, username=user.username, role=user.role, ) finally: db.close() @router.get("/me") async def me(authorization: str = Header(default=None)): """Return current user info from JWT token.""" from routes.deps import get_current_user_required user = await get_current_user_required(authorization) return {"username": user["username"], "role": user["role"]}