Compare commits

...

4 Commits

Author SHA1 Message Date
Wenxi Onyx
d23d382e77 dont rely on next public cloud enabled 2026-02-13 14:36:23 -08:00
Wenxi Onyx
491567d4b3 make captcha v2 async 2026-02-13 14:31:40 -08:00
Wenxi Onyx
7b68707dba repurpose captcha v2 for cloud signup 2026-02-13 14:24:04 -08:00
Jessica Singh
a52ec6b9c1 captcha for cloud 2026-02-13 13:50:42 -08:00
8 changed files with 274 additions and 17 deletions

View File

@@ -4,9 +4,12 @@ import httpx
from pydantic import BaseModel
from pydantic import Field
from onyx.configs.app_configs import AUTH_TYPE
from onyx.configs.app_configs import CAPTCHA_ENABLED
from onyx.configs.app_configs import RECAPTCHA_SCORE_THRESHOLD
from onyx.configs.app_configs import RECAPTCHA_SECRET_KEY
from onyx.configs.app_configs import RECAPTCHA_V2_SECRET_KEY
from onyx.configs.constants import AuthType
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -105,3 +108,53 @@ async def verify_captcha_token(
# In case of API errors, we might want to allow registration
# to prevent blocking legitimate users. This is a policy decision.
raise CaptchaVerificationError("Captcha verification service unavailable")
def is_captcha_v2_enabled() -> bool:
"""Check if captcha v2 verification is enabled (cloud only)."""
return AUTH_TYPE == AuthType.CLOUD and bool(RECAPTCHA_V2_SECRET_KEY)
async def verify_captcha_v2_token(token: str) -> None:
"""
Verify a reCAPTCHA v2 token with Google's API.
Args:
token: The reCAPTCHA response token from the client
Raises:
CaptchaVerificationError: If verification fails
"""
if not RECAPTCHA_V2_SECRET_KEY:
raise CaptchaVerificationError("reCAPTCHA v2 secret key not configured")
if not token:
raise CaptchaVerificationError("Captcha token is required")
try:
async with httpx.AsyncClient() as client:
response = await client.post(
RECAPTCHA_VERIFY_URL,
data={
"secret": RECAPTCHA_V2_SECRET_KEY,
"response": token,
},
timeout=10.0,
)
response.raise_for_status()
data = response.json()
result = RecaptchaResponse(**data)
if not result.success:
error_codes = result.error_codes or ["unknown-error"]
logger.warning(f"Captcha v2 verification failed: {error_codes}")
raise CaptchaVerificationError(
f"Captcha verification failed: {', '.join(error_codes)}"
)
logger.debug("Captcha v2 verification passed")
except httpx.HTTPError as e:
logger.error(f"Captcha v2 API request failed: {e}")
raise CaptchaVerificationError("Captcha verification service unavailable")

View File

@@ -323,7 +323,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# Verify captcha if enabled (for cloud signup protection)
from onyx.auth.captcha import CaptchaVerificationError
from onyx.auth.captcha import is_captcha_enabled
from onyx.auth.captcha import is_captcha_v2_enabled
from onyx.auth.captcha import verify_captcha_token
from onyx.auth.captcha import verify_captcha_v2_token
if is_captcha_enabled() and request is not None:
# Get captcha token from request body or headers
@@ -345,6 +347,22 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
detail={"reason": str(e)},
)
# Verify reCAPTCHA v2 if enabled (cloud only)
if is_captcha_v2_enabled() and request is not None:
captcha_v2_token = request.headers.get("X-Captcha-V2-Token")
if not captcha_v2_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"reason": "CAPTCHA verification required"},
)
try:
await verify_captcha_v2_token(captcha_v2_token)
except CaptchaVerificationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"reason": str(e)},
)
# We verify the password here to make sure it's valid before we proceed
await self.validate_password(
user_create.password, cast(schemas.UC, user_create)

View File

@@ -1010,6 +1010,9 @@ RECAPTCHA_SECRET_KEY = os.environ.get("RECAPTCHA_SECRET_KEY", "")
# 0.5 is the recommended default
RECAPTCHA_SCORE_THRESHOLD = float(os.environ.get("RECAPTCHA_SCORE_THRESHOLD", "0.5"))
# Google reCAPTCHA v2 secret key (for user registration protection in cloud)
RECAPTCHA_V2_SECRET_KEY = os.environ.get("RECAPTCHA_V2_SECRET_KEY", "")
MOCK_CONNECTOR_FILE_PATH = os.environ.get("MOCK_CONNECTOR_FILE_PATH")
# Set to true to mock LLM responses for testing purposes

View File

@@ -6,7 +6,7 @@ import Button from "@/refresh-components/buttons/Button";
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { requestEmailVerification } from "../lib";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Spinner } from "@/components/Spinner";
import Link from "next/link";
import { useUser } from "@/providers/UserProvider";
@@ -18,6 +18,8 @@ import { validateInternalRedirect } from "@/lib/auth/redirectValidation";
import { APIFormFieldState } from "@/refresh-components/form/types";
import { SvgArrowRightCircle } from "@opal/icons";
import { useCaptcha } from "@/lib/hooks/useCaptcha";
import { useCaptchaV2 } from "@/lib/hooks/useCaptchaV2";
import { Section } from "@/layouts/general-layouts";
interface EmailPasswordFormProps {
isSignup?: boolean;
@@ -43,6 +45,26 @@ export default function EmailPasswordForm({
const [showApiMessage, setShowApiMessage] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>("");
const { getCaptchaToken } = useCaptcha();
const {
isCaptchaEnabled: isCaptchaV2Enabled,
isLoaded: isCaptchaV2Loaded,
token: captchaV2Token,
renderCaptcha,
resetCaptcha,
} = useCaptchaV2();
const captchaRendered = useRef(false);
useEffect(() => {
if (
isSignup &&
isCaptchaV2Enabled &&
isCaptchaV2Loaded &&
!captchaRendered.current
) {
renderCaptcha("signup-captcha-container");
captchaRendered.current = true;
}
}, [isSignup, isCaptchaV2Enabled, isCaptchaV2Loaded, renderCaptcha]);
const apiMessages = useMemo(
() => ({
@@ -100,11 +122,13 @@ export default function EmailPasswordForm({
email,
values.password,
referralSource,
captchaToken
captchaToken,
captchaV2Token ?? undefined
);
if (!response.ok) {
setIsWorking(false);
resetCaptcha();
const errorDetail: any = (await response.json()).detail;
let errorMsg: string = "Unknown error";
@@ -239,10 +263,21 @@ export default function EmailPasswordForm({
)}
/>
<Section>
{isSignup && isCaptchaV2Enabled && (
<div id="signup-captcha-container" />
)}
</Section>
<Button
type="submit"
className="w-full mt-1"
disabled={isSubmitting || !isValid || !dirty}
disabled={
isSubmitting ||
!isValid ||
!dirty ||
(isSignup && isCaptchaV2Enabled && !captchaV2Token)
}
rightIcon={SvgArrowRightCircle}
>
{isJoin ? "Join" : isSignup ? "Create Account" : "Sign In"}

View File

@@ -19,19 +19,6 @@
import { useCallback, useEffect, useState } from "react";
// Declare the global grecaptcha object
declare global {
interface Window {
grecaptcha?: {
ready: (callback: () => void) => void;
execute: (
siteKey: string,
options: { action: string }
) => Promise<string>;
};
}
}
const RECAPTCHA_SITE_KEY = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY || "";
export function useCaptcha() {

View File

@@ -0,0 +1,123 @@
/**
* Hook for Google reCAPTCHA v2 checkbox integration.
*
* Usage:
* 1. Add NEXT_PUBLIC_RECAPTCHA_V2_SITE_KEY to your environment
* 2. Use the hook to render a captcha widget and get the token
*
* Example:
* ```tsx
* const { isCaptchaEnabled, isLoaded, token, renderCaptcha, resetCaptcha } = useCaptchaV2();
*
* useEffect(() => {
* if (isCaptchaEnabled && isLoaded) {
* renderCaptcha("captcha-container");
* }
* }, [isCaptchaEnabled, isLoaded, renderCaptcha]);
*
* // In JSX:
* {isCaptchaEnabled && <div id="captcha-container" />}
* <button disabled={isCaptchaEnabled && !token}>Submit</button>
* ```
*/
import { useCallback, useEffect, useState, useRef } from "react";
import { useTheme } from "next-themes";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
const RECAPTCHA_V2_SITE_KEY =
process.env.NEXT_PUBLIC_RECAPTCHA_V2_SITE_KEY || "";
export function useCaptchaV2() {
const { resolvedTheme } = useTheme();
const [isLoaded, setIsLoaded] = useState(false);
const [token, setToken] = useState<string | null>(null);
const widgetIdRef = useRef<number | null>(null);
const isCaptchaEnabled = Boolean(RECAPTCHA_V2_SITE_KEY);
useEffect(() => {
if (!isCaptchaEnabled) {
return;
}
const scriptSrc =
"https://www.google.com/recaptcha/api.js?onload=onRecaptchaV2Load&render=explicit";
// Check if already loaded
if (window.grecaptcha?.render) {
setIsLoaded(true);
return;
}
// Check if script already exists
const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
if (existingScript) {
existingScript.addEventListener("load", () => {
if (window.grecaptcha?.ready) {
window.grecaptcha.ready(() => setIsLoaded(true));
} else {
setIsLoaded(true);
}
});
return;
}
window.onRecaptchaV2Load = () => {
if (window.grecaptcha?.ready) {
window.grecaptcha.ready(() => setIsLoaded(true));
} else {
setIsLoaded(true);
}
};
const script = document.createElement("script");
script.src = scriptSrc;
script.async = true;
script.defer = true;
document.head.appendChild(script);
return () => {
window.onRecaptchaV2Load = undefined;
};
}, [isCaptchaEnabled]);
const renderCaptcha = useCallback(
(containerId: string) => {
if (!isLoaded || !isCaptchaEnabled || widgetIdRef.current !== null) {
return;
}
const container = document.getElementById(containerId);
if (!container || !window.grecaptcha?.render) {
return;
}
const id = window.grecaptcha.render(containerId, {
sitekey: RECAPTCHA_V2_SITE_KEY,
theme: resolvedTheme === "dark" ? "dark" : "light",
size: "normal",
callback: (response: string) => setToken(response),
"expired-callback": () => setToken(null),
"error-callback": () => setToken(null),
});
widgetIdRef.current = id;
},
[isLoaded, isCaptchaEnabled, resolvedTheme]
);
const resetCaptcha = useCallback(() => {
if (widgetIdRef.current !== null && window.grecaptcha) {
window.grecaptcha.reset(widgetIdRef.current);
setToken(null);
}
}, []);
return {
isCaptchaEnabled,
isLoaded,
token,
renderCaptcha,
resetCaptcha,
};
}

View File

@@ -47,7 +47,8 @@ export const basicSignup = async (
email: string,
password: string,
referralSource?: string,
captchaToken?: string
captchaToken?: string,
captchaV2Token?: string
) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -57,6 +58,9 @@ export const basicSignup = async (
if (captchaToken) {
headers["X-Captcha-Token"] = captchaToken;
}
if (captchaV2Token) {
headers["X-Captcha-V2-Token"] = captchaV2Token;
}
const response = await fetch("/api/auth/register", {
method: "POST",

34
web/src/types/grecaptcha.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
/**
* Type declarations for Google reCAPTCHA.
* Used by both v2 and v3 captcha hooks.
*/
declare global {
interface Window {
grecaptcha?: {
// v3 methods
ready: (callback: () => void) => void;
execute: (
siteKey: string,
options: { action: string }
) => Promise<string>;
// v2 methods
render: (
container: string | HTMLElement,
options: {
sitekey: string;
theme?: "light" | "dark";
size?: "normal" | "compact";
callback: (response: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
}
) => number;
reset: (widgetId: number) => void;
};
// v2 onload callback
onRecaptchaV2Load?: () => void;
}
}
export {};