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,