From 8e4c91c4ac81fe567b50647233c465932ee716e1 Mon Sep 17 00:00:00 2001 From: pranavshankar1221 Date: Fri, 5 Jun 2026 23:21:59 +0530 Subject: [PATCH 1/2] feat(auth): add Cloudflare Turnstile bot protection and rate limiting --- .env.example | 3 + .gitignore | 25 ++++++- README.md | 6 ++ backend/.env.example | 3 + backend/main.py | 50 +++++++++++-- backend/requirements-base.txt | 1 + backend/turnstile.py | 46 ++++++++++++ package-lock.json | 4 +- src/lib/api.ts | 12 ++- src/lib/useTurnstile.ts | 136 ++++++++++++++++++++++++++++++++++ src/pages/AuthPage.tsx | 52 ++++++++++--- 11 files changed, 319 insertions(+), 19 deletions(-) create mode 100644 backend/turnstile.py create mode 100644 src/lib/useTurnstile.ts diff --git a/.env.example b/.env.example index 598cb3a..ec4371a 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ VITE_API_URL= # Set to "true" locally. NEVER set in Vercel env vars. VITE_DEV_MODE=true +# Cloudflare Turnstile +VITE_TURNSTILE_SITE_KEY= + # ── PostHog Analytics ───────────────────────────────────────────────────────── # Leave blank locally — PostHog is silently disabled when this key is missing. # Set both vars in your Vercel / production environment dashboard. diff --git a/.gitignore b/.gitignore index 96ca793..e525b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,27 @@ Models/ *.ntvs* *.njsproj *.sln -*.sw? \ No newline at end of file +*.sw? + +# Additional local / secret ignores +# Local build outputs +build/ + +# Alternate virtualenv names +myenv/ + +# Docker local overrides and runtime artifacts +docker-compose.override.yml +.docker/ + +# Hugging Face / ML cache +.hf/ +.cache/ + +# Secrets files +secrets.env +*.secret + +# Local DB / artifacts +*.sqlite3 +*.db \ No newline at end of file diff --git a/README.md b/README.md index f6436e4..3bf85e7 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ The setup script detects your environment automatically: ```env VITE_API_URL= # leave blank for local dev; Vite proxy handles /api/* VITE_DEV_MODE=true # enables DEV LOGIN button; never set true in production +VITE_TURNSTILE_SITE_KEY= # Cloudflare Turnstile site key ``` **Backend** — copy `backend/.env.example` to `backend/.env`: @@ -109,9 +110,14 @@ SUPABASE_KEY= SUPABASE_SERVICE_KEY= FRONTEND_URL=http://localhost:5173 API_BASE_URL=http://localhost:8000 +TURNSTILE_SECRET_KEY= DEV_BYPASS_AUTH=true # never set true in production ``` +> Note: When using production auth flows, set `VITE_TURNSTILE_SITE_KEY` in the frontend and `TURNSTILE_SECRET_KEY` in the backend. This enables Cloudflare Turnstile protection for auth endpoint requests. + +> Authentication start requests are rate limited in the backend to 5 requests per minute. Invalid or missing Turnstile tokens are rejected with a standard 400 response. + ### Available Scripts | Script | Description | diff --git a/backend/.env.example b/backend/.env.example index 2e3bc79..bbbeaa6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -23,6 +23,9 @@ CORS_ALLOW_ALL=true DEV_BYPASS_AUTH=true DEV_BYPASS_TOKEN=dev-local-bypass-token +# ── Cloudflare Turnstile — protect auth endpoints from automated abuse +TURNSTILE_SECRET_KEY= + # ── ML Models (optional) ────────────────────────────────────────────────────── # Leave unset → demo mode (random scores, no PyTorch needed). # Uncomment to enable real ML inference: diff --git a/backend/main.py b/backend/main.py index 2a9ed14..298253d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,9 +15,12 @@ except ImportError: pass -from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Depends, Query +from fastapi import Body, FastAPI, File, UploadFile, Form, HTTPException, Depends, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.middleware import SlowAPIMiddleware +from slowapi.util import get_remote_address from supabase import create_client, Client from PIL import Image @@ -32,6 +35,7 @@ print("WARNING: PyTorch not installed. Scan endpoints will return 503.") from auth import get_current_user, get_google_oauth_url, exchange_code_for_session +from turnstile import TURNSTILE_SECRET_KEY, verify_turnstile_token # ── Configuration ───────────────────────────────────────────────────────────── # All secrets MUST come from environment variables — no hardcoded fallbacks. @@ -110,7 +114,10 @@ async def lifespan(app: FastAPI): allow_methods=["*"], allow_headers=["*"], ) - +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler(429, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) # ── Domain helpers ──────────────────────────────────────────────────────────── @@ -328,12 +335,43 @@ async def _upload_image(image_bytes: bytes, user_id: str, scan_id: str) -> Optio # ── AUTH ────────────────────────────────────────────────────────────────────── -@app.get("/api/v1/auth/login/google") -async def login_google(): +def _auth_redirect_url() -> str: callback_url = f"{API_BASE_URL}/api/v1/auth/callback" + return get_google_oauth_url(redirect_to=callback_url) + + +async def _verify_turnstile(turnstile_token: str | None, request: Request) -> None: + if TURNSTILE_SECRET_KEY: + await verify_turnstile_token(turnstile_token, request.client.host) + + +@app.get("/api/v1/auth/login/google") +@limiter.limit("5/minute") +async def login_google_get( + request: Request, + turnstile_token: str | None = Query(None, alias="turnstile_token"), +): + try: + await _verify_turnstile(turnstile_token, request) + return RedirectResponse(url=_auth_redirect_url()) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not generate OAuth URL: {exc}") + + +@app.post("/api/v1/auth/login/google") +@limiter.limit("5/minute") +async def login_google_post( + request: Request, + payload: dict | None = Body(None), +): + turnstile_token = payload.get("turnstile_token") if payload else None try: - url = get_google_oauth_url(redirect_to=callback_url) - return RedirectResponse(url=url) + await _verify_turnstile(turnstile_token, request) + return {"redirect_url": _auth_redirect_url()} + except HTTPException: + raise except Exception as exc: raise HTTPException(status_code=500, detail=f"Could not generate OAuth URL: {exc}") diff --git a/backend/requirements-base.txt b/backend/requirements-base.txt index dc40008..dda6406 100644 --- a/backend/requirements-base.txt +++ b/backend/requirements-base.txt @@ -6,3 +6,4 @@ numpy>=2.4.6 python-dotenv>=1.0.0 python-multipart>=0.0.29 httpx>=0.27.0 +slowapi>=0.1.4 diff --git a/backend/turnstile.py b/backend/turnstile.py new file mode 100644 index 0000000..075f7b6 --- /dev/null +++ b/backend/turnstile.py @@ -0,0 +1,46 @@ +import os +from typing import Optional + +import httpx +from fastapi import HTTPException + +TURNSTILE_SECRET_KEY = os.environ.get('TURNSTILE_SECRET_KEY', '') + +async def verify_turnstile_token(turnstile_token: Optional[str], remote_ip: Optional[str] = None) -> None: + if not TURNSTILE_SECRET_KEY: + raise HTTPException( + status_code=500, + detail='Turnstile secret key is not configured. Set TURNSTILE_SECRET_KEY.', + ) + + if not turnstile_token: + raise HTTPException(status_code=400, detail='Turnstile token is required.') + + payload = { + 'secret': TURNSTILE_SECRET_KEY, + 'response': turnstile_token, + } + if remote_ip: + payload['remoteip'] = remote_ip + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + data=payload, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + ) + response.raise_for_status() + data = response.json() + except Exception as exc: + raise HTTPException( + status_code=502, + detail=f'Turnstile verification failed: {exc}', + ) + + if not data.get('success'): + errors = data.get('error-codes', []) + raise HTTPException( + status_code=400, + detail=f"Turnstile verification failed: {', '.join(errors) or 'invalid token'}", + ) diff --git a/package-lock.json b/package-lock.json index 884076d..2a7c95e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@tailwindcss/vite": "^4.2.2", "framer-motion": "^12.38.0", diff --git a/src/lib/api.ts b/src/lib/api.ts index 84ae3d0..1ff8c09 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -92,7 +92,17 @@ export interface GradcamResponse { gradcam_image: string; predicted_class: strin // ── API surface ─────────────────────────────────────────────────────────────── export const api = { - loginUrl: (): string => `${API_BASE}/api/v1/auth/login/google`, + loginUrl: async (turnstileToken?: string): Promise => { + if (turnstileToken) { + const response = await apiFetch<{ redirect_url: string }>('/api/v1/auth/login/google', { + method: 'POST', + body: JSON.stringify({ turnstile_token: turnstileToken }), + }); + return response.redirect_url; + } + + return `${API_BASE}/api/v1/auth/login/google`; + }, getMe: (): Promise => apiFetch('/api/v1/auth/me'), diff --git a/src/lib/useTurnstile.ts b/src/lib/useTurnstile.ts new file mode 100644 index 0000000..512cc65 --- /dev/null +++ b/src/lib/useTurnstile.ts @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from 'react'; + +declare global { + interface Window { + turnstile?: { + render: (container: HTMLElement, options: Record) => number; + execute: (widgetId: number) => void; + reset: (widgetId: number) => void; + }; + } +} + +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + +async function loadTurnstileScript(): Promise { + if (window.turnstile) { + return; + } + + return new Promise((resolve, reject) => { + const existingScript = document.querySelector(`script[src="${SCRIPT_SRC}"]`); + if (existingScript) { + if ((existingScript as HTMLScriptElement).dataset.loaded === 'true') { + return resolve(); + } + existingScript.addEventListener('load', () => resolve()); + existingScript.addEventListener('error', () => reject(new Error('Failed to load Turnstile script.'))); + return; + } + + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + script.async = true; + script.defer = true; + script.onload = () => { + script.dataset.loaded = 'true'; + resolve(); + }; + script.onerror = () => reject(new Error('Failed to load Turnstile script.')); + document.head.appendChild(script); + }); +} + +export default function useTurnstile(siteKey: string | undefined) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const pendingRef = useRef<{ + resolve: (token: string) => void; + reject: (error: Error) => void; + } | null>(null); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!siteKey) { + setReady(false); + return; + } + + let canceled = false; + + async function setup() { + try { + await loadTurnstileScript(); + if (canceled) return; + if (!window.turnstile || !containerRef.current) { + throw new Error('Turnstile is unavailable in this environment.'); + } + + if (widgetIdRef.current === null) { + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: siteKey, + size: 'normal', + + callback: (token: string) => { + const pending = pendingRef.current; + pending?.resolve(token); + pendingRef.current = null; + }, + + 'error-callback': () => { + const pending = pendingRef.current; + pending?.reject(new Error('Turnstile verification failed.')); + pendingRef.current = null; + }, + + 'expired-callback': () => { + const pending = pendingRef.current; + pending?.reject( + new Error('Turnstile token expired. Please verify again.') + ); + pendingRef.current = null; + }, + }); + } + setReady(true); + } catch (err) { + if (!canceled) { + const loadError = err instanceof Error ? err : new Error('Unknown Turnstile load error'); + setError(loadError); + setReady(false); + } + } + } + + setup(); + + return () => { + canceled = true; + }; + }, [siteKey]); + + const execute = async (): Promise => { + if (!siteKey) { + throw new Error('Turnstile site key is not configured.'); + } + if (error) { + throw error; + } + if (!ready || widgetIdRef.current === null) { + throw new Error('Turnstile is not ready yet. Please wait and try again.'); + } + + return new Promise((resolve, reject) => { + pendingRef.current = { resolve, reject }; + try { + window.turnstile?.execute(widgetIdRef.current as number); + } catch (exc) { + pendingRef.current = null; + reject(exc instanceof Error ? exc : new Error('Turnstile execution failed.')); + } + }); + }; + + return { containerRef, ready, execute, error }; +} diff --git a/src/pages/AuthPage.tsx b/src/pages/AuthPage.tsx index 616e2ec..78671a6 100644 --- a/src/pages/AuthPage.tsx +++ b/src/pages/AuthPage.tsx @@ -3,16 +3,19 @@ import { useNavigate } from 'react-router-dom'; import { usePostHog } from 'posthog-js/react'; import StatusTerminal from '../components/StatusTerminal'; import { api, setToken, isAuthenticated } from '../lib/api'; +import useTurnstile from '../lib/useTurnstile'; // Bypass token must match DEV_BYPASS_TOKEN in backend/.env const DEV_BYPASS_TOKEN = 'dev-local-bypass-token'; const IS_DEV_MODE = import.meta.env.VITE_DEV_MODE === 'true'; +const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; export default function AuthPage() { const navigate = useNavigate(); const posthog = usePostHog(); const [status, setStatus] = useState<'idle' | 'processing' | 'error'>('idle'); const [errorMsg, setErrorMsg] = useState(''); + const { containerRef, ready: turnstileReady, execute: executeTurnstile, error: turnstileError } = useTurnstile(TURNSTILE_SITE_KEY); // Handle redirect from backend OAuth callback useEffect(() => { @@ -40,21 +43,35 @@ export default function AuthPage() { } }, [navigate, posthog]); - const handleGoogleLogin = () => { + const handleGoogleLogin = async () => { try { setStatus('processing'); - const loginUrl = api.loginUrl(); - + let turnstileToken: string | undefined; + + if (TURNSTILE_SITE_KEY) { + if (!turnstileReady) { + throw new Error('Turnstile is still loading. Please wait and try again.'); + } + if (turnstileError) { + throw turnstileError; + } + turnstileToken = await executeTurnstile(); + } + + const loginUrl = await api.loginUrl(turnstileToken); if (!loginUrl) { - throw new Error("Login URL configuration missing"); + throw new Error('Login URL configuration missing'); } - - // Force full browser navigation for OAuth + window.location.href = loginUrl; } catch (err) { setStatus('error'); - setErrorMsg('Could not initiate Google Login. Please check your network connection.'); - console.error("Auth initiation failed:", err); + setErrorMsg( + err instanceof Error + ? err.message + : 'Could not initiate Google Login. Please check your network connection.' + ); + console.error('Auth initiation failed:', err); } }; @@ -102,12 +119,27 @@ export default function AuthPage() { Sign in to view your live Trust Map and sync biomarker data across devices.

)} + + {TURNSTILE_SITE_KEY && !turnstileReady && ( +

+ Loading verification challenge... +

+ )} + + {turnstileError && ( +

+ {turnstileError.message} +

+ )}
+ +
); } \ No newline at end of file From 163f389df3bde80e7ba1b03157385459e90c142c Mon Sep 17 00:00:00 2001 From: pranavshankar1221 Date: Sat, 6 Jun 2026 08:25:40 +0530 Subject: [PATCH 2/2] style(auth): fix Ruff line length violation --- backend/turnstile.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/turnstile.py b/backend/turnstile.py index 075f7b6..49c8e54 100644 --- a/backend/turnstile.py +++ b/backend/turnstile.py @@ -6,7 +6,10 @@ TURNSTILE_SECRET_KEY = os.environ.get('TURNSTILE_SECRET_KEY', '') -async def verify_turnstile_token(turnstile_token: Optional[str], remote_ip: Optional[str] = None) -> None: +async def verify_turnstile_token( + turnstile_token: Optional[str], + remote_ip: Optional[str] = None, +) -> None: if not TURNSTILE_SECRET_KEY: raise HTTPException( status_code=500,