- 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
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
- 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). - Valid token → login attempt proceeds normally. Invalid/missing token →
429with{ captchaRequired: true }. - Successful login resets the failed-attempt counter for that IP.
- The hard 15-min IP block is removed (or raised to 1000+ as a last-resort DoS backstop only).
- A new env var
TURNSTILE_SECRET_KEY(server-side) andNEXT_PUBLIC_TURNSTILE_SITE_KEY(frontend) configure the integration. - If
TURNSTILE_SECRET_KEYis 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-responsein 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
- Go to dash.cloudflare.com → Turnstile → Add Site
- Domain:
dev.amn.gg(andamn.ggwhen live) - Widget type: Managed (invisible, automatic challenge)
- Copy Site Key →
NEXT_PUBLIC_TURNSTILE_SITE_KEY - 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/refreshor/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= |