mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add captcha challenge modal
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
189
frontend/src/components/CaptchaChallenge.tsx
Normal file
189
frontend/src/components/CaptchaChallenge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user