Files
nick-doc/PRD - Auth CAPTCHA (Cloudflare Turnstile).md
Siavash Sameni 67cfe4469b docs: sync from backend cdc8df1 + frontend a5dd48e + scanner 8fee27e — AMN Pay Scanner
- Activity Log: new entry for AMN Pay Scanner implementation
- Environment Variables: document AMN_SCANNER_URL, AMN_SCANNER_WEBHOOK_SECRET, AMN_SCANNER_DEFAULT
- PRD status table: mark all components implemented
2026-05-29 13:07:07 +04:00

198 lines
7.4 KiB
Markdown

# 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<string, { count: number; resetAt: number }>();
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 `<Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />` 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=` |