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

7.4 KiB

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

// 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:

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):

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 → Turnstile → Add Site
  2. Domain: dev.amn.gg (and amn.gg when live)
  3. Widget type: Managed (invisible, automatic challenge)
  4. Copy Site KeyNEXT_PUBLIC_TURNSTILE_SITE_KEY
  5. Copy Secret KeyTURNSTILE_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=