MAESTRO: add captcha challenge modal

This commit is contained in:
Mariusz Banach
2026-02-18 04:16:01 +01:00
parent 98f2f8a656
commit 4d1a5299bf
2 changed files with 190 additions and 1 deletions

View File

@@ -31,7 +31,7 @@ This phase protects the analysis service from abuse with per-IP rate limiting an
- [x] T040 [US6] Write failing tests (TDD Red) in `backend/tests/api/test_rate_limiter.py` (rate limiting triggers at threshold, 429 response with CAPTCHA challenge), `backend/tests/api/test_captcha.py` (challenge generation, verification, bypass token), `backend/tests/api/test_health.py` (health endpoint returns correct status), and `frontend/src/__tests__/CaptchaChallenge.test.tsx` (render modal, display CAPTCHA image, submit answer, handle success/failure states, keyboard accessibility)
- [x] T041 [US6] Create `backend/app/middleware/rate_limiter.py` — per-IP sliding window rate limiter (async-safe in-memory). Configurable limit via `config.py`. Returns 429 with Retry-After header and CAPTCHA challenge token (NFR-11, NFR-12). Note: per-instance counters — acceptable for initial release; shared store upgradeable later. Verify `test_rate_limiter.py` passes (TDD Green)
- [x] T042 [US6] Create `backend/app/routers/captcha.py``POST /api/captcha/verify` endpoint. Server-generated visual noise CAPTCHA (randomly distorted text). Returns HMAC-signed bypass token (5-minute expiry) on success. Token exempts IP from rate limiting. Response schema in `backend/app/schemas/captcha.py`. Verify `test_captcha.py` passes (TDD Green)
- [ ] T043 [P] [US6] Create `frontend/src/components/CaptchaChallenge.tsx` — modal on 429 response. Displays CAPTCHA image, on verification stores bypass token and retries original request. FontAwesome lock/unlock icons. Keyboard accessible (NFR-02). Verify `CaptchaChallenge.test.tsx` passes (TDD Green)
- [x] T043 [P] [US6] Create `frontend/src/components/CaptchaChallenge.tsx` — modal on 429 response. Displays CAPTCHA image, on verification stores bypass token and retries original request. FontAwesome lock/unlock icons. Keyboard accessible (NFR-02). Verify `CaptchaChallenge.test.tsx` passes (TDD Green)
- [ ] T044 [US6] Create `backend/app/schemas/health.py` and `backend/app/routers/health.py``GET /api/health` returning status (up/degraded/down), version, uptime, scanner count (NFR-15). Verify `test_health.py` passes (TDD Green)
- [ ] T045 [US6] Register all routers and middleware in `backend/app/main.py` — CORS middleware (frontend origin), rate limiter, routers (analysis, tests, health, captcha). Verify stateless operation (NFR-16). Note: rate limiter per-instance state is accepted trade-off (see T041)

View File

@@ -0,0 +1,189 @@
"use client";
import { useCallback, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLock, faUnlock } from "@fortawesome/free-solid-svg-icons";
export type CaptchaChallengeData = {
challengeToken: string;
imageBase64: string;
};
export type CaptchaVerifyPayload = {
challengeToken: string;
answer: string;
};
type CaptchaChallengeProps = {
isOpen: boolean;
challenge?: CaptchaChallengeData | null;
onVerify: (payload: CaptchaVerifyPayload) => Promise<string>;
onSuccess: (bypassToken: string) => void;
onRetry: () => void | Promise<void>;
onClose: () => void;
};
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return "Verification failed. Please try again.";
};
export default function CaptchaChallenge({
isOpen,
challenge,
onVerify,
onSuccess,
onRetry,
onClose,
}: CaptchaChallengeProps) {
const [answer, setAnswer] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const titleId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const answerRef = useRef("");
useLayoutEffect(() => {
if (!isOpen) {
return;
}
setAnswer("");
setError(null);
setIsSubmitting(false);
answerRef.current = "";
}, [isOpen, challenge?.challengeToken]);
const commitAnswer = useCallback((value: string) => {
answerRef.current = value;
setAnswer(value);
}, []);
const handleSubmit = useCallback(async () => {
if (!challenge || isSubmitting) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const bypassToken = await onVerify({
challengeToken: challenge.challengeToken,
answer: (inputRef.current?.value ?? answerRef.current ?? answer).trim(),
});
onSuccess(bypassToken);
await Promise.resolve(onRetry());
onClose();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setIsSubmitting(false);
}
}, [answer, challenge, isSubmitting, onClose, onRetry, onSuccess, onVerify]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
},
[onClose],
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
void handleSubmit();
}
},
[handleSubmit],
);
if (!isOpen || !challenge) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4">
<div
className="w-full max-w-md rounded-2xl border border-info/20 bg-surface p-6 shadow-[0_0_45px_rgba(15,23,42,0.5)]"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="captcha-challenge"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="rounded-full border border-info/20 bg-background/50 p-2 text-xs text-info">
<FontAwesomeIcon icon={faLock} />
</span>
<div className="flex flex-col">
<h2 id={titleId} className="text-sm font-semibold text-text">
Security Check Required
</h2>
<p className="text-xs text-text/60">Solve the CAPTCHA to continue analysis.</p>
</div>
</div>
<button
type="button"
className="rounded-full border border-info/20 bg-background/40 px-3 py-2 text-xs text-text/70 transition hover:border-info/40 hover:text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
onClick={onClose}
data-testid="captcha-close"
aria-label="Close captcha challenge"
>
Close
</button>
</div>
<div className="mt-5 flex flex-col gap-4">
<div className="rounded-xl border border-info/20 bg-background/50 p-4">
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="CAPTCHA challenge"
className="w-full rounded-lg"
data-testid="captcha-image"
/>
</div>
<label className="flex flex-col gap-2 text-xs text-text/70">
Enter the text shown above
<input
type="text"
autoComplete="off"
value={answer}
onChange={(event) => commitAnswer(event.target.value)}
onInput={(event) => commitAnswer(event.currentTarget.value)}
onKeyDown={handleInputKeyDown}
data-testid="captcha-input"
ref={inputRef}
className="rounded-xl border border-info/20 bg-background/50 px-4 py-3 text-sm text-text outline-none transition focus:border-info/60 focus:ring-2 focus:ring-info/30"
/>
</label>
{error ? (
<p className="text-xs text-spam" data-testid="captcha-error">
{error}
</p>
) : null}
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-xl border border-info/30 bg-info/20 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-info transition hover:border-info/50 hover:text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void handleSubmit()}
disabled={isSubmitting}
data-testid="captcha-submit"
>
<FontAwesomeIcon icon={faUnlock} />
{isSubmitting ? "Verifying" : "Verify"}
</button>
</div>
</div>
</div>
);
}