from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status from sqlalchemy.orm import Session import os from ..database import get_db from ..dependencies import get_current_user from ..models import User, UserRole, UserStatus from ..schemas import UserLogin, UserOut, UserRegister, UserApprove from ..security import ( ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, hash_password, verify_password, ) router = APIRouter(prefix="/auth", tags=["auth"]) _COOKIE_NAME = "access_token" _COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds _COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true" def _set_auth_cookie(response: Response, user_id: int) -> None: token = create_access_token(user_id) response.set_cookie( key=_COOKIE_NAME, value=token, httponly=True, secure=_COOKIE_SECURE, samesite="lax", max_age=_COOKIE_MAX_AGE, path="/", ) # ── POST /auth/register ─────────────────────────────────────────────────────── @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) def register(body: UserRegister, response: Response, db: Session = Depends(get_db)): if db.query(User).filter(User.username == body.username).first(): raise HTTPException(status_code=400, detail="Username already taken.") if db.query(User).filter(User.email == body.email).first(): raise HTTPException(status_code=400, detail="Email already registered.") # Admin registers as approved immediately; others start as pending initial_status = ( UserStatus.approved if body.role == UserRole.admin else UserStatus.pending ) user = User( username=body.username, email=body.email, password_hash=hash_password(body.password), role=body.role, status=initial_status, ) db.add(user) db.commit() db.refresh(user) # Only set auth cookie if immediately approved (admin self-registration) if user.status == UserStatus.approved: _set_auth_cookie(response, user.id) return user # ── POST /auth/login ────────────────────────────────────────────────────────── @router.post("/login", response_model=UserOut) def login(body: UserLogin, response: Response, db: Session = Depends(get_db)): user: User | None = db.query(User).filter(User.username == body.username).first() # Constant-time: always verify even when user is None to prevent timing attacks dummy_hash = "$2b$12$KIXyb/r7MBJ3z32Dc3y8YuNfj7DhpNcaYgU2q8uGiHjJ6YFe6RVxm" password_ok = verify_password(body.password, user.password_hash if user else dummy_hash) if not user or not password_ok: raise HTTPException(status_code=401, detail="Invalid username or password.") if user.status == UserStatus.pending: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Your account is pending admin approval.", ) if user.status == UserStatus.rejected: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Your account has been rejected.", ) _set_auth_cookie(response, user.id) return user # ── POST /auth/logout ───────────────────────────────────────────────────────── @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) def logout(response: Response): response.delete_cookie(key=_COOKIE_NAME, path="/") # ── GET /auth/me ────────────────────────────────────────────────────────────── @router.get("/me", response_model=UserOut) def me(current_user: User = Depends(get_current_user)): return current_user # ── GET /auth/token (return raw JWT for WebSocket auth) ───────────────────── @router.get("/token") def get_token(access_token: str | None = Cookie(default=None)): """ Returns the current JWT so the frontend can pass it as a WebSocket query param (browsers can't send cookies on WS upgrade in all cases). """ if not access_token: raise HTTPException(status_code=401, detail="Not authenticated.") return {"access_token": access_token}