from app.core.datetime_utils import ensure_utc, utc_now from fastapi import APIRouter, HTTPException, Request, Response, status from app.core.config import settings from app.core.redis import check_rate_limit, client_ip from app.modules.auth.schemas import ( ForgotPasswordIn, LoginIn, LoginOut, RegisterIn, ResendVerificationIn, ResetPasswordIn, VerifyEmailIn, ) from app.modules.auth.service import ( forgot_password, login, refresh, register, resend_verification, reset_password, revoke_refresh_token, verify_email_token, ) from app.modules.users import repository router = APIRouter() def _allowed_origins() -> set[str]: origins: set[str] = set() for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins): if not raw: continue normalized = raw.rstrip("/") origins.add(normalized) if "://localhost" in normalized: origins.add(normalized.replace("://localhost", "://127.0.0.1")) if "://127.0.0.1" in normalized: origins.add(normalized.replace("://127.0.0.1", "://localhost")) return origins def _enforce_origin(request: Request, *, require_header: bool = False) -> None: allowed = _allowed_origins() origin = (request.headers.get("Origin") or "").rstrip("/") referer = request.headers.get("Referer") or "" if require_header and not origin and not referer: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") if origin and origin not in allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") if not origin and referer: if not any(referer.startswith(base) for base in allowed): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") def _set_refresh_cookie(response: Response, refresh_token: str) -> None: response.set_cookie( key="refresh_token", value=refresh_token, httponly=True, secure=settings.cookie_secure, samesite="lax", path="/api/v1/auth", max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60, ) @router.post("/register") async def register_route(payload: RegisterIn, request: Request): _enforce_origin(request) check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600) user = register(payload.email, payload.password) _ = user return {"message": "If email is valid, verification has been sent."} @router.post("/verify-email") async def verify_email_route(payload: VerifyEmailIn): try: user = verify_email_token(payload.token) except ValueError as exc: detail = str(exc) if detail == "TOKEN_EXPIRED": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN") _ = user return {"status": "active"} @router.post("/login", response_model=LoginOut) async def login_route(payload: LoginIn, request: Request, response: Response): _enforce_origin(request) user = repository.get_user_by_email(payload.email) if user: locked_until = ensure_utc(user.locked_until) if locked_until and locked_until > utc_now(): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED", ) check_rate_limit( f"login:{client_ip(request)}:{payload.email.lower()}", limit=5, window_seconds=60, ) try: access_token, refresh_token, user = login(payload.email, payload.password) except ValueError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS") except PermissionError as exc: detail = str(exc) if detail == "EMAIL_NOT_VERIFIED": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED") if detail == "ACCOUNT_TEMPORARILY_LOCKED": raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED") _set_refresh_cookie(response, refresh_token) return { "access_token": access_token, "expires_in": settings.jwt_access_ttl_min * 60, "user": { "id": user.id, "email": user.email, "role": user.role, "is_superuser": user.is_superuser, "status": user.status, }, } @router.post("/refresh", response_model=LoginOut) async def refresh_route(request: Request, response: Response): refresh_token = request.cookies.get("refresh_token") if not refresh_token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING") _enforce_origin(request, require_header=True) check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60) try: access_token, new_refresh, user = refresh(refresh_token) except PermissionError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH") _set_refresh_cookie(response, new_refresh) return { "access_token": access_token, "expires_in": settings.jwt_access_ttl_min * 60, "user": { "id": user.id, "email": user.email, "role": user.role, "is_superuser": user.is_superuser, "status": user.status, }, } @router.post("/logout") async def logout_route(request: Request, response: Response): _enforce_origin(request, require_header=True) refresh_token = request.cookies.get("refresh_token") if refresh_token: revoke_refresh_token(refresh_token) response.delete_cookie( "refresh_token", path="/api/v1/auth", secure=settings.cookie_secure, samesite="lax", ) return {"message": "logged_out"} @router.post("/forgot-password") async def forgot_password_route(payload: ForgotPasswordIn, request: Request): _enforce_origin(request) check_rate_limit( f"forgot:{client_ip(request)}:{payload.email.lower()}", limit=3, window_seconds=3600, ) forgot_password(payload.email) return {"message": "If email is registered, reset instructions have been sent."} @router.post("/reset-password") async def reset_password_route(payload: ResetPasswordIn): try: reset_password(payload.token, payload.new_password) except ValueError as exc: detail = str(exc) if detail == "TOKEN_EXPIRED": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN") return {"message": "password_updated"} @router.post("/resend-verification") async def resend_verification_route(payload: ResendVerificationIn, request: Request): _enforce_origin(request) check_rate_limit( f"resend:{client_ip(request)}:{payload.email.lower()}", limit=3, window_seconds=3600, ) resend_verification(payload.email) return {"message": "If email is registered, verification has been sent."}