# PRD — Auth CAPTCHA: Replace Hard Rate Limiter with Cloudflare Turnstile > Status: **Ready for implementation** > Task: #12 > Priority: Medium — blocking dev velocity (15-min lockouts during testing) > Effort estimate: ~3-4 hours --- ## Problem The current auth limiter (`authLimiter`) blocks all login attempts from an IP after 10 requests in 15 minutes. This: - Locks out legitimate users (and developers) for 15 minutes on any burst of requests - Is bypassable by rotating IPs anyway — provides no real security - Does not distinguish between a brute-force bot and a test script or user with a password manager **Stopgap already applied (2.6.54):** threshold raised to 100/15 min to unblock dev. This PRD replaces it with a real solution. --- ## Solution: Cloudflare Turnstile (invisible CAPTCHA) Cloudflare Turnstile is the right choice: - **Invisible** — no "click the traffic lights" for users; challenge runs silently in the background - **Server-side verification** — a simple POST to `https://challenges.cloudflare.com/turnstile/v0/siteverify` - **Free tier** covers our scale - **No Google dependency** (unlike reCAPTCHA) --- ## Acceptance Criteria 1. After **3 failed** login attempts from the same IP (within a 15-min window), subsequent attempts from that IP require a valid Turnstile token in the request body (`cf-turnstile-response`). 2. Valid token → login attempt proceeds normally. Invalid/missing token → `429` with `{ captchaRequired: true }`. 3. Successful login resets the failed-attempt counter for that IP. 4. The hard 15-min IP block is **removed** (or raised to 1000+ as a last-resort DoS backstop only). 5. A new env var `TURNSTILE_SECRET_KEY` (server-side) and `NEXT_PUBLIC_TURNSTILE_SITE_KEY` (frontend) configure the integration. 6. If `TURNSTILE_SECRET_KEY` is not set, the middleware logs a warning and skips CAPTCHA enforcement (safe for local dev without creds). --- ## Implementation Plan ### Backend (`backend/src/`) **1. New middleware: `src/shared/middleware/captchaMiddleware.ts`** ```typescript // Pseudo-code — agent should implement this properly import { Request, Response, NextFunction } from 'express'; const TURNSTILE_VERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; const failedAttempts = new Map(); export const captchaGate = async (req: Request, res: Response, next: NextFunction) => { const secret = process.env.TURNSTILE_SECRET_KEY; if (!secret) return next(); // local dev without creds: skip const ip = req.ip || req.headers['x-forwarded-for'] as string || 'unknown'; const now = Date.now(); const WINDOW = 15 * 60 * 1000; const THRESHOLD = 3; // Cleanup expired windows const state = failedAttempts.get(ip); if (state && now > state.resetAt) failedAttempts.delete(ip); const current = failedAttempts.get(ip); if (!current || current.count < THRESHOLD) { // Under threshold — proceed, but mark as "pending" to track outcome (req as any)._captchaIp = ip; return next(); } // Over threshold — require CAPTCHA token const token = req.body?.['cf-turnstile-response']; if (!token) { return res.status(429).json({ success: false, captchaRequired: true, message: 'Too many failed attempts. Please complete the CAPTCHA.', }); } // Verify with Cloudflare const formData = new FormData(); formData.append('secret', secret); formData.append('response', token); formData.append('remoteip', ip); const cfRes = await fetch(TURNSTILE_VERIFY, { method: 'POST', body: formData }); const cfJson = await cfRes.json() as { success: boolean }; if (!cfJson.success) { return res.status(429).json({ success: false, captchaRequired: true, message: 'CAPTCHA verification failed.', }); } (req as any)._captchaIp = ip; next(); }; // Call this on failed login export const recordFailedLogin = (ip: string) => { const now = Date.now(); const WINDOW = 15 * 60 * 1000; const state = failedAttempts.get(ip) || { count: 0, resetAt: now + WINDOW }; state.count += 1; failedAttempts.set(ip, state); }; // Call this on successful login export const clearFailedLogin = (ip: string) => { failedAttempts.delete(ip); }; ``` **2. Wire into `app.ts`** Replace `app.use("/api/auth", authLimiter)` with: ```typescript import { captchaGate } from './shared/middleware/captchaMiddleware'; app.use("/api/auth/login", captchaGate); app.use("/api/auth", authLimiter); // keep as DoS backstop at max: 1000 ``` **3. Wire `recordFailedLogin` / `clearFailedLogin` into the auth controller** In the login handler (wherever `401 Invalid credentials` is returned), call `recordFailedLogin(req.ip)`. On successful login, call `clearFailedLogin(req.ip)`. Find the login handler: `src/controllers/authController.ts` or similar — grep for `"Invalid credentials"` or `"incorrect password"`. ### Frontend (`frontend/src/`) **4. Add Turnstile widget to the login form** - Install: `@marsidev/react-turnstile` (lightweight React wrapper, ~3KB) - Render `` in the login form - The widget is **invisible by default** — it renders a hidden iframe, solves automatically, and calls `onSuccess(token)` with the token - Store the token in component state and include it as `cf-turnstile-response` in the login POST body - Only show the widget when the API returns `{ captchaRequired: true }` (progressive: invisible until needed) Find login form: `src/app/(auth)/login/page.tsx` or `src/components/auth/LoginForm.tsx` — grep for the login form component. **5. Handle `captchaRequired: true` in the API action** In `src/actions/auth.ts` (or wherever login is dispatched): ```typescript if (res.status === 429 && data.captchaRequired) { // Show the Turnstile widget — user needs to solve it setCaptchaRequired(true); return; } ``` --- ## Environment Variables | Variable | Where | Value | |---|---|---| | `TURNSTILE_SECRET_KEY` | Backend `.env` / deploy stack | From Cloudflare dashboard | | `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Frontend build arg (Woodpecker) | From Cloudflare dashboard | ### Getting Cloudflare Turnstile credentials 1. Go to [dash.cloudflare.com](https://dash.cloudflare.com) → Turnstile → Add Site 2. Domain: `dev.amn.gg` (and `amn.gg` when live) 3. Widget type: **Managed** (invisible, automatic challenge) 4. Copy **Site Key** → `NEXT_PUBLIC_TURNSTILE_SITE_KEY` 5. Copy **Secret Key** → `TURNSTILE_SECRET_KEY` --- ## What NOT to do - Do not use Google reCAPTCHA (data dependency, flaky v3 scores) - Do not block by IP permanently — Turnstile token clears the counter - Do not require CAPTCHA on first attempt — only after 3 failures - Do not add CAPTCHA to `/api/auth/refresh` or `/api/auth/logout` — only `/api/auth/login` - Do not store failed-attempt state in Redis for now — in-memory Map is fine for a single-instance deployment --- ## Files to touch (summary for agent) | File | Change | |---|---| | `backend/src/shared/middleware/captchaMiddleware.ts` | **CREATE** — full middleware | | `backend/src/app.ts` | Wire `captchaGate` on `/api/auth/login`; raise authLimiter max to 1000 | | `backend/src/controllers/authController.ts` (or equivalent) | Call `recordFailedLogin` on bad credentials, `clearFailedLogin` on success | | `frontend/src/` (login form + auth action) | Add Turnstile widget, handle `captchaRequired` response | | `backend/.env.example` | Add `TURNSTILE_SECRET_KEY=` | | `frontend/.env.example` | Add `NEXT_PUBLIC_TURNSTILE_SITE_KEY=` |