feat: add captcha to auth modal

This commit is contained in:
hieptl 2025-12-22 15:27:02 +07:00
parent 5553d3ca2e
commit b49b309885
13 changed files with 1005 additions and 14 deletions

View File

@ -43,3 +43,4 @@ BLOCKED_EMAIL_DOMAINS = [
for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',')
if domain.strip()
]
RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY', '').strip()

View File

@ -159,6 +159,7 @@ class SetAuthCookieMiddleware:
'/api/billing/cancel',
'/api/billing/customer-setup-success',
'/api/billing/stripe-webhook',
'/api/verify-recaptcha',
'/oauth/device/authorize',
'/oauth/device/token',
)

View File

@ -3,15 +3,17 @@ from datetime import datetime, timezone
from typing import Annotated, Literal, Optional
from urllib.parse import quote
import httpx
import posthog
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import SecretStr
from pydantic import BaseModel, SecretStr
from server.auth.auth_utils import user_verifier
from server.auth.constants import (
KEYCLOAK_CLIENT_ID,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL_EXT,
RECAPTCHA_SECRET_KEY,
ROLE_CHECK_ENABLED,
)
from server.auth.domain_blocker import domain_blocker
@ -32,6 +34,7 @@ from openhands.server.services.conversation_service import create_provider_token
from openhands.server.shared import config
from openhands.server.user_auth import get_access_token
from openhands.server.user_auth.user_auth import get_user_auth
from openhands.utils.http_session import httpx_verify_option
with warnings.catch_warnings():
warnings.simplefilter('ignore')
@ -457,3 +460,72 @@ async def refresh_tokens(
)
return TokenResponse(token=token.get_secret_value())
class RecaptchaVerifyRequest(BaseModel):
token: str
@api_router.post('/verify-recaptcha')
async def verify_recaptcha(request_data: RecaptchaVerifyRequest):
"""
Verify a reCAPTCHA token with Google's reCAPTCHA API.
This endpoint is public and does not require authentication.
"""
if not RECAPTCHA_SECRET_KEY:
logger.warning('reCAPTCHA secret key not configured')
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={'success': False, 'error': 'reCAPTCHA not configured'},
)
if not request_data.token:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'success': False, 'error': 'Token is required'},
)
try:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
'https://www.google.com/recaptcha/api/siteverify',
data={
'secret': RECAPTCHA_SECRET_KEY,
'response': request_data.token,
},
timeout=10.0,
)
response.raise_for_status()
result = response.json()
if result.get('success', False):
logger.debug('reCAPTCHA verification successful')
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'success': True},
)
else:
error_codes = result.get('error-codes', [])
logger.warning(
f'reCAPTCHA verification failed: {error_codes}',
extra={'error_codes': error_codes},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'success': False,
'error_codes': error_codes,
},
)
except httpx.HTTPError as e:
logger.error(f'Error calling reCAPTCHA API: {str(e)}')
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={'success': False, 'error': 'Failed to verify reCAPTCHA'},
)
except Exception as e:
logger.error(f'Unexpected error during reCAPTCHA verification: {str(e)}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'success': False, 'error': 'Internal server error'},
)

View File

@ -8,11 +8,13 @@ from pydantic import SecretStr
from server.auth.auth_error import AuthError
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import (
RecaptchaVerifyRequest,
authenticate,
keycloak_callback,
keycloak_offline_callback,
logout,
set_response_cookie,
verify_recaptcha,
)
from openhands.integrations.service_types import ProviderType
@ -635,3 +637,155 @@ async def test_keycloak_callback_missing_email(mock_request):
assert isinstance(result, RedirectResponse)
mock_domain_blocker.is_domain_blocked.assert_not_called()
mock_token_manager.disable_keycloak_user.assert_not_called()
class TestVerifyRecaptcha:
"""Test reCAPTCHA verification endpoint."""
@pytest.mark.asyncio
async def test_verify_recaptcha_success(self):
"""Test successful reCAPTCHA verification with valid token."""
# Arrange
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
mock_google_response = MagicMock()
mock_google_response.json.return_value = {'success': True}
mock_google_response.raise_for_status = MagicMock()
with (
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
patch('httpx.AsyncClient') as mock_client,
):
mock_client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_client_instance
mock_client_instance.post = AsyncMock(return_value=mock_google_response)
# Act
result = await verify_recaptcha(request_data)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
result_data = result.body.decode()
assert '"success":true' in result_data
mock_client_instance.post.assert_called_once_with(
'https://www.google.com/recaptcha/api/siteverify',
data={'secret': 'test_secret_key', 'response': 'valid_token_12345'},
timeout=10.0,
)
@pytest.mark.asyncio
async def test_verify_recaptcha_invalid_token(self):
"""Test reCAPTCHA verification with invalid token."""
# Arrange
request_data = RecaptchaVerifyRequest(token='invalid_token_12345')
mock_google_response = MagicMock()
mock_google_response.json.return_value = {
'success': False,
'error-codes': ['invalid-input-response'],
}
mock_google_response.raise_for_status = MagicMock()
with (
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
patch('httpx.AsyncClient') as mock_client,
):
mock_client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_client_instance
mock_client_instance.post = AsyncMock(return_value=mock_google_response)
# Act
result = await verify_recaptcha(request_data)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
result_data = result.body.decode()
assert '"success":false' in result_data
assert '"error_codes"' in result_data
assert 'invalid-input-response' in result_data
@pytest.mark.asyncio
async def test_verify_recaptcha_missing_token(self):
"""Test reCAPTCHA verification with empty token."""
# Arrange
request_data = RecaptchaVerifyRequest(token='')
with patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'):
# Act
result = await verify_recaptcha(request_data)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_400_BAD_REQUEST
result_data = result.body.decode()
assert '"success":false' in result_data
assert 'Token is required' in result_data
@pytest.mark.asyncio
async def test_verify_recaptcha_secret_key_not_configured(self):
"""Test reCAPTCHA verification when secret key is not configured."""
# Arrange
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
with patch('server.routes.auth.RECAPTCHA_SECRET_KEY', ''):
# Act
result = await verify_recaptcha(request_data)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
result_data = result.body.decode()
assert '"success":false' in result_data
assert 'reCAPTCHA not configured' in result_data
@pytest.mark.asyncio
async def test_verify_recaptcha_http_error(self):
"""Test reCAPTCHA verification when Google API returns HTTP error."""
# Arrange
import httpx
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
with (
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
patch('httpx.AsyncClient') as mock_client,
):
mock_client_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_client_instance
mock_client_instance.post = AsyncMock(
side_effect=httpx.HTTPError('Network error')
)
# Act
result = await verify_recaptcha(request_data)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
result_data = result.body.decode()
assert '"success":false' in result_data
assert 'Failed to verify reCAPTCHA' in result_data
@pytest.mark.asyncio
async def test_verify_recaptcha_unexpected_exception(self):
"""Test reCAPTCHA verification when unexpected exception occurs."""
# Arrange
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
with (
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
patch('httpx.AsyncClient') as mock_client,
):
mock_client.return_value.__aenter__.side_effect = ValueError(
'Unexpected error'
)
# Act
result = await verify_recaptcha(request_data)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
result_data = result.body.decode()
assert '"success":false' in result_data
assert 'Internal server error' in result_data

View File

@ -1,7 +1,9 @@
import { render, screen } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { AuthModal } from "#/components/features/waitlist/auth-modal";
import AuthService from "#/api/auth-service/auth-service.api";
import { renderWithProviders } from "test-utils";
// Mock the useAuthUrl hook
vi.mock("#/hooks/use-auth-url", () => ({
@ -15,9 +17,30 @@ vi.mock("#/hooks/use-tracking", () => ({
}),
}));
// Mock the useRecaptcha hook
const mockGetRecaptchaResponse = vi.hoisted(() => vi.fn());
const mockUseRecaptcha = vi.hoisted(() =>
vi.fn(() => ({
recaptchaLoaded: true,
recaptchaError: false,
widgetId: 1,
recaptchaRef: { current: null },
getRecaptchaResponse: mockGetRecaptchaResponse,
resetRecaptcha: vi.fn(),
})),
);
vi.mock("#/hooks/use-recaptcha", () => ({
useRecaptcha: mockUseRecaptcha,
}));
describe("AuthModal", () => {
let verifyRecaptchaSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
verifyRecaptchaSpy = vi.spyOn(AuthService, "verifyRecaptcha");
mockGetRecaptchaResponse.mockReturnValue("");
});
afterEach(() => {
@ -26,7 +49,7 @@ describe("AuthModal", () => {
});
it("should render the GitHub and GitLab buttons", () => {
render(
renderWithProviders(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
@ -46,9 +69,11 @@ describe("AuthModal", () => {
});
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
// Arrange
const user = userEvent.setup();
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", undefined);
const mockUrl = "https://github.com/login/oauth/authorize";
render(
renderWithProviders(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
@ -56,16 +81,18 @@ describe("AuthModal", () => {
/>,
);
// Act
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
// Assert
expect(window.location.href).toBe(mockUrl);
});
it("should render Terms of Service and Privacy Policy text with correct links", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
renderWithProviders(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
// Find the terms of service section using data-testid
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
@ -106,7 +133,7 @@ describe("AuthModal", () => {
});
it("should open Terms of Service link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
renderWithProviders(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
@ -115,11 +142,167 @@ describe("AuthModal", () => {
});
it("should open Privacy Policy link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
renderWithProviders(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toHaveAttribute("target", "_blank");
});
describe("reCAPTCHA integration", () => {
it("should allow auth when reCAPTCHA is not configured", async () => {
// Arrange
const user = userEvent.setup();
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", undefined);
const mockUrl = "https://github.com/login/oauth/authorize";
renderWithProviders(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
// Act
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
// Assert
expect(window.location.href).toBe(mockUrl);
expect(verifyRecaptchaSpy).not.toHaveBeenCalled();
});
it("should block auth and show error when reCAPTCHA is not completed", async () => {
// Arrange
const user = userEvent.setup();
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key");
const mockUrl = "https://github.com/login/oauth/authorize";
mockGetRecaptchaResponse.mockReturnValue("");
renderWithProviders(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
// Act
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
// Assert
expect(window.location.href).toBe("");
expect(screen.getByText(/AUTH\$RECAPTCHA_REQUIRED/i)).toBeInTheDocument();
expect(verifyRecaptchaSpy).not.toHaveBeenCalled();
});
it("should block auth when reCAPTCHA verification fails", async () => {
// Arrange
const user = userEvent.setup();
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key");
const mockUrl = "https://github.com/login/oauth/authorize";
const mockToken = "recaptcha-token-123";
mockGetRecaptchaResponse.mockReturnValue(mockToken);
verifyRecaptchaSpy.mockResolvedValue({ success: false });
renderWithProviders(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
// Act
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
// Assert
await waitFor(() => {
expect(window.location.href).toBe("");
});
expect(screen.getByText(/AUTH\$RECAPTCHA_REQUIRED/i)).toBeInTheDocument();
expect(verifyRecaptchaSpy).toHaveBeenCalledWith(mockToken);
});
it("should allow auth when reCAPTCHA verification succeeds", async () => {
// Arrange
const user = userEvent.setup();
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key");
const mockUrl = "https://github.com/login/oauth/authorize";
const mockToken = "recaptcha-token-123";
mockGetRecaptchaResponse.mockReturnValue(mockToken);
verifyRecaptchaSpy.mockResolvedValue({ success: true });
renderWithProviders(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
// Act
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
// Assert
await waitFor(() => {
expect(window.location.href).toBe(mockUrl);
});
expect(verifyRecaptchaSpy).toHaveBeenCalledWith(mockToken);
expect(
screen.queryByText(/AUTH\$RECAPTCHA_REQUIRED/i),
).not.toBeInTheDocument();
});
it("should render reCAPTCHA widget container when site key is configured", () => {
// Arrange
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key");
// Act
renderWithProviders(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github"]}
/>,
);
// Assert
expect(mockUseRecaptcha).toHaveBeenCalledWith({
siteKey: "test-site-key",
enabled: true,
});
});
it("should not render reCAPTCHA widget when site key is not configured", () => {
// Arrange
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", undefined);
// Act
renderWithProviders(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github"]}
/>,
);
// Assert
const recaptchaContainer = document.querySelector("div[ref]");
expect(recaptchaContainer).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,280 @@
import { act, renderHook, waitFor, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRecaptcha } from "#/hooks/use-recaptcha";
import React from "react";
describe("useRecaptcha", () => {
let mockGrecaptcha: {
ready: (callback: () => void) => void;
render: (
element: HTMLElement,
options: {
sitekey: string;
callback?: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
},
) => number;
getResponse: (widgetId?: number) => string;
reset: (widgetId?: number) => void;
};
let mockScript: HTMLScriptElement;
beforeEach(() => {
// Mock grecaptcha API
mockGrecaptcha = {
ready: vi.fn((callback: () => void) => callback()),
render: vi.fn(() => 1),
getResponse: vi.fn(() => ""),
reset: vi.fn(),
};
// Mock script element - create a real script element so it can be appended to DOM
const originalCreateElement = document.createElement.bind(document);
mockScript = originalCreateElement("script") as HTMLScriptElement;
// Mock DOM methods
vi.stubGlobal("grecaptcha", undefined);
vi.spyOn(document, "createElement").mockImplementation(
(tagName: string) => {
if (tagName === "script") {
return mockScript;
}
return originalCreateElement(tagName);
},
);
vi.spyOn(document.head, "appendChild").mockImplementation(
vi.fn((node: Node) => node),
);
vi.spyOn(document, "querySelector").mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("should return initial state when siteKey is undefined", () => {
// Arrange & Act
const { result } = renderHook(() => useRecaptcha({ siteKey: undefined }));
// Assert
expect(result.current.recaptchaLoaded).toBe(false);
expect(result.current.recaptchaError).toBe(false);
expect(result.current.widgetId).toBe(null);
expect(result.current.recaptchaRef.current).toBe(null);
expect(document.createElement).not.toHaveBeenCalledWith("script");
});
it("should not load script when enabled is false", () => {
// Arrange & Act
const { result } = renderHook(() =>
useRecaptcha({ siteKey: "test-site-key", enabled: false }),
);
// Assert
expect(result.current.recaptchaLoaded).toBe(false);
expect(document.createElement).not.toHaveBeenCalledWith("script");
});
it("should load script when siteKey is provided and grecaptcha not available", () => {
// Arrange
vi.stubGlobal("grecaptcha", undefined);
// Act
renderHook(() => useRecaptcha({ siteKey: "test-site-key" }));
// Assert
expect(document.createElement).toHaveBeenCalledWith("script");
expect(mockScript.src).toBe(
"https://www.google.com/recaptcha/api.js?render=explicit",
);
expect(mockScript.async).toBe(true);
expect(mockScript.defer).toBe(true);
expect(document.head.appendChild).toHaveBeenCalledWith(mockScript);
});
it("should render widget when grecaptcha is already available", async () => {
// Arrange
vi.stubGlobal("grecaptcha", mockGrecaptcha);
const TestComponent = () => {
const { recaptchaRef, recaptchaLoaded, widgetId } = useRecaptcha({
siteKey: "test-site-key",
});
return <div ref={recaptchaRef} data-testid="recaptcha-container" />;
};
// Act
render(<TestComponent />);
// Assert
await waitFor(() => {
expect(mockGrecaptcha.render).toHaveBeenCalled();
});
const renderCall = vi.mocked(mockGrecaptcha.render).mock.calls[0];
expect(renderCall[1]).toMatchObject({
sitekey: "test-site-key",
callback: expect.any(Function),
"expired-callback": expect.any(Function),
"error-callback": expect.any(Function),
});
});
it("should set error state when script fails to load", async () => {
// Arrange
vi.stubGlobal("grecaptcha", undefined);
const { result } = renderHook(() =>
useRecaptcha({ siteKey: "test-site-key" }),
);
// Act - simulate script error
act(() => {
if (mockScript.onerror) {
mockScript.onerror(new Event("error"));
}
});
// Assert
await waitFor(() => {
expect(result.current.recaptchaError).toBe(true);
});
expect(result.current.recaptchaLoaded).toBe(false);
});
it("should return response token when widget is ready", async () => {
// Arrange
const mockToken = "recaptcha-token-123";
mockGrecaptcha.getResponse = vi.fn(() => mockToken);
vi.stubGlobal("grecaptcha", mockGrecaptcha);
const TestComponent = () => {
const { recaptchaRef, getRecaptchaResponse } = useRecaptcha({
siteKey: "test-site-key",
});
return (
<div>
<div ref={recaptchaRef} data-testid="recaptcha-container" />
<button
onClick={() => {
const response = getRecaptchaResponse();
// Store response for testing
(window as any).testResponse = response;
}}
>
Get Response
</button>
</div>
);
};
// Act
const { getByRole } = render(<TestComponent />);
await waitFor(() => {
expect(mockGrecaptcha.render).toHaveBeenCalled();
});
act(() => {
getByRole("button").click();
});
// Assert
expect((window as any).testResponse).toBe(mockToken);
expect(mockGrecaptcha.getResponse).toHaveBeenCalledWith(1);
delete (window as any).testResponse;
});
it("should return null when widget is not ready", () => {
// Arrange
vi.stubGlobal("grecaptcha", undefined);
const { result } = renderHook(() =>
useRecaptcha({ siteKey: "test-site-key" }),
);
// Act
const response = result.current.getRecaptchaResponse();
// Assert
expect(response).toBe(null);
});
it("should reset widget when resetRecaptcha is called", async () => {
// Arrange
vi.stubGlobal("grecaptcha", mockGrecaptcha);
const TestComponent = () => {
const { recaptchaRef, resetRecaptcha } = useRecaptcha({
siteKey: "test-site-key",
});
return (
<div>
<div ref={recaptchaRef} data-testid="recaptcha-container" />
<button onClick={resetRecaptcha}>Reset</button>
</div>
);
};
// Act
const { getByRole } = render(<TestComponent />);
await waitFor(() => {
expect(mockGrecaptcha.render).toHaveBeenCalled();
});
act(() => {
getByRole("button").click();
});
// Assert
expect(mockGrecaptcha.reset).toHaveBeenCalledWith(1);
});
it("should handle widget render error gracefully", async () => {
// Arrange
mockGrecaptcha.render = vi.fn(() => {
throw new Error("Render failed");
});
vi.stubGlobal("grecaptcha", mockGrecaptcha);
const TestComponent = () => {
const { recaptchaRef, recaptchaError } = useRecaptcha({
siteKey: "test-site-key",
});
return (
<div>
<div ref={recaptchaRef} data-testid="recaptcha-container" />
{recaptchaError && <div data-testid="error">Error occurred</div>}
</div>
);
};
// Act
const { getByTestId } = render(<TestComponent />);
// Assert
await waitFor(() => {
expect(getByTestId("error")).toBeInTheDocument();
});
});
it("should cleanup script reference on unmount", () => {
// Arrange
const existingScript = document.createElement("script");
vi.spyOn(document, "querySelector").mockReturnValue(existingScript);
const { unmount } = renderHook(() =>
useRecaptcha({ siteKey: "test-site-key" }),
);
// Act
unmount();
// Assert
expect(document.querySelector).toHaveBeenCalledWith(
'script[src*="recaptcha/api.js"]',
);
});
});

16
frontend/global.d.ts vendored
View File

@ -5,7 +5,7 @@ interface Window {
init: (config: { clientID: string }) => void;
identify: (identity: {
username: string;
type: "github" |"email";
type: "github" | "email";
other_identities?: Array<{
username: string;
type: "github" | "email";
@ -15,4 +15,18 @@ interface Window {
company?: string;
}) => void;
};
grecaptcha?: {
ready: (callback: () => void) => void;
render: (
element: HTMLElement,
options: {
sitekey: string;
callback?: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
},
) => number;
reset: (widgetId?: number) => void;
getResponse: (widgetId?: number) => string;
};
}

View File

@ -47,6 +47,26 @@ class AuthService {
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);
}
/**
* Verify reCAPTCHA token with the backend
* @param token The reCAPTCHA token to verify
* @returns Response indicating if verification was successful
*/
static async verifyRecaptcha(token: string): Promise<{
success: boolean;
error?: string;
error_codes?: string[];
}> {
const { data } = await openHands.post<{
success: boolean;
error?: string;
error_codes?: string[];
}>("/api/verify-recaptcha", {
token,
});
return data;
}
}
export default AuthService;

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
@ -13,6 +13,8 @@ import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { useRecaptcha } from "#/hooks/use-recaptcha";
import { useVerifyRecaptcha } from "#/hooks/mutation/use-verify-recaptcha";
interface AuthModalProps {
githubAuthUrl: string | null;
@ -29,6 +31,23 @@ export function AuthModal({
}: AuthModalProps) {
const { t } = useTranslation();
const { trackLoginButtonClick } = useTracking();
const [recaptchaError, setRecaptchaError] = useState(false);
// Get reCAPTCHA site key from environment variable
const recaptchaSiteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY || undefined;
// Initialize reCAPTCHA
const {
recaptchaRef,
getRecaptchaResponse,
recaptchaError: recaptchaLoadError,
} = useRecaptcha({
siteKey: recaptchaSiteKey,
enabled: !!recaptchaSiteKey,
});
// Hook for verifying reCAPTCHA with backend
const { mutateAsync: verifyRecaptcha } = useVerifyRecaptcha();
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
@ -54,7 +73,43 @@ export function AuthModal({
authUrl,
});
const handleGitHubAuth = () => {
// Validate reCAPTCHA before proceeding with auth
const validateRecaptcha = async (): Promise<boolean> => {
if (!recaptchaSiteKey) {
// If reCAPTCHA is not configured, allow auth to proceed
return true;
}
const response = getRecaptchaResponse();
if (!response) {
setRecaptchaError(true);
return false;
}
try {
// Verify the token with the backend using the mutation hook
const verificationResult = await verifyRecaptcha(response);
if (!verificationResult.success) {
setRecaptchaError(true);
return false;
}
setRecaptchaError(false);
return true;
} catch (error) {
// Log error to console for debugging
console.error("reCAPTCHA verification failed", error);
setRecaptchaError(true);
return false;
}
};
const handleGitHubAuth = async () => {
const hasCaptchaVerified = await validateRecaptcha();
if (!hasCaptchaVerified) {
return;
}
if (githubAuthUrl) {
trackLoginButtonClick({ provider: "github" });
// Always start the OIDC flow, let the backend handle TOS check
@ -62,7 +117,12 @@ export function AuthModal({
}
};
const handleGitLabAuth = () => {
const handleGitLabAuth = async () => {
const hasCaptchaVerified = await validateRecaptcha();
if (!hasCaptchaVerified) {
return;
}
if (gitlabAuthUrl) {
trackLoginButtonClick({ provider: "gitlab" });
// Always start the OIDC flow, let the backend handle TOS check
@ -70,7 +130,12 @@ export function AuthModal({
}
};
const handleBitbucketAuth = () => {
const handleBitbucketAuth = async () => {
const hasCaptchaVerified = await validateRecaptcha();
if (!hasCaptchaVerified) {
return;
}
if (bitbucketAuthUrl) {
trackLoginButtonClick({ provider: "bitbucket" });
// Always start the OIDC flow, let the backend handle TOS check
@ -78,14 +143,24 @@ export function AuthModal({
}
};
const handleAzureDevOpsAuth = () => {
const handleAzureDevOpsAuth = async () => {
const hasCaptchaVerified = await validateRecaptcha();
if (!hasCaptchaVerified) {
return;
}
if (azureDevOpsAuthUrl) {
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = azureDevOpsAuthUrl;
}
};
const handleEnterpriseSsoAuth = () => {
const handleEnterpriseSsoAuth = async () => {
const hasCaptchaVerified = await validateRecaptcha();
if (!hasCaptchaVerified) {
return;
}
if (enterpriseSsoUrl) {
trackLoginButtonClick({ provider: "enterprise_sso" });
// Always start the OIDC flow, let the backend handle TOS check
@ -123,6 +198,11 @@ export function AuthModal({
<ModalBackdrop>
<ModalBody className="border border-tertiary">
<OpenHandsLogo width={68} height={46} />
{recaptchaError && (
<div className="text-sm text-red-500 text-center mt-2 mb-2">
{t(I18nKey.AUTH$RECAPTCHA_REQUIRED)}
</div>
)}
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{t(I18nKey.AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER)}
@ -194,6 +274,17 @@ export function AuthModal({
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</BrandButton>
)}
{recaptchaSiteKey && (
<div className="flex justify-center mt-2">
<div ref={recaptchaRef} />
{recaptchaLoadError && (
<div className="text-xs text-muted-foreground text-center mt-1">
{t(I18nKey.AUTH$RECAPTCHA_LOAD_ERROR)}
</div>
)}
</div>
)}
</>
)}
</div>

View File

@ -0,0 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import AuthService from "#/api/auth-service/auth-service.api";
export const useVerifyRecaptcha = () =>
useMutation({
mutationFn: (token: string) => AuthService.verifyRecaptcha(token),
});

View File

@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from "react";
interface UseRecaptchaOptions {
siteKey: string | undefined;
enabled?: boolean;
}
interface UseRecaptchaReturn {
recaptchaLoaded: boolean;
recaptchaError: boolean;
widgetId: number | null;
recaptchaRef: React.RefObject<HTMLDivElement | null>;
getRecaptchaResponse: () => string | null;
resetRecaptcha: () => void;
}
/**
* Hook to load and manage Google reCAPTCHA v2
* @param siteKey - The reCAPTCHA site key
* @param enabled - Whether to load reCAPTCHA (default: true)
* @returns Object with reCAPTCHA state and methods
*/
export function useRecaptcha({
siteKey,
enabled = true,
}: UseRecaptchaOptions): UseRecaptchaReturn {
const [recaptchaLoaded, setRecaptchaLoaded] = useState(false);
const [recaptchaError, setRecaptchaError] = useState(false);
const [widgetId, setWidgetId] = useState<number | null>(null);
const recaptchaRef = useRef<HTMLDivElement>(null);
const scriptLoadedRef = useRef(false);
useEffect(() => {
if (!enabled || !siteKey || scriptLoadedRef.current) {
return undefined;
}
// Check if script is already loaded
if (window.grecaptcha) {
setRecaptchaLoaded(true);
scriptLoadedRef.current = true;
return undefined;
}
// Load the reCAPTCHA script
const script = document.createElement("script");
script.src = "https://www.google.com/recaptcha/api.js?render=explicit";
script.async = true;
script.defer = true;
script.onload = () => {
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
setRecaptchaLoaded(true);
scriptLoadedRef.current = true;
});
}
};
script.onerror = () => {
setRecaptchaError(true);
};
document.head.appendChild(script);
return () => {
// Cleanup: remove script if component unmounts
const existingScript = document.querySelector(
'script[src*="recaptcha/api.js"]',
);
if (existingScript) {
// Don't remove script as it might be used elsewhere
// Just reset the state
setRecaptchaLoaded(false);
scriptLoadedRef.current = false;
}
};
}, [siteKey, enabled]);
// Render the reCAPTCHA widget when script is loaded
useEffect(() => {
if (
!recaptchaLoaded ||
!siteKey ||
!recaptchaRef.current ||
widgetId !== null
) {
return;
}
if (window.grecaptcha && recaptchaRef.current) {
try {
const id = window.grecaptcha.render(recaptchaRef.current, {
sitekey: siteKey,
callback: () => {
// CAPTCHA completed successfully
},
"expired-callback": () => {
// CAPTCHA expired
},
"error-callback": () => {
// CAPTCHA error
},
});
setWidgetId(id);
} catch (error) {
setRecaptchaError(true);
}
}
}, [recaptchaLoaded, siteKey, widgetId]);
const getRecaptchaResponse = (): string | null => {
if (!window.grecaptcha || widgetId === null) {
return null;
}
const response = window.grecaptcha.getResponse(widgetId);
return response || null;
};
const resetRecaptcha = () => {
if (window.grecaptcha && widgetId !== null) {
window.grecaptcha.reset(widgetId);
}
};
return {
recaptchaLoaded,
recaptchaError,
widgetId,
recaptchaRef,
getRecaptchaResponse,
resetRecaptcha,
};
}

View File

@ -730,6 +730,8 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$USE_MICROAGENTS = "MICROAGENT_MANAGEMENT$USE_MICROAGENTS",
AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR = "AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
AUTH$NO_PROVIDERS_CONFIGURED = "AUTH$NO_PROVIDERS_CONFIGURED",
AUTH$RECAPTCHA_REQUIRED = "AUTH$RECAPTCHA_REQUIRED",
AUTH$RECAPTCHA_LOAD_ERROR = "AUTH$RECAPTCHA_LOAD_ERROR",
COMMON$TERMS_OF_SERVICE = "COMMON$TERMS_OF_SERVICE",
COMMON$AND = "COMMON$AND",
COMMON$PRIVACY_POLICY = "COMMON$PRIVACY_POLICY",

View File

@ -11679,6 +11679,38 @@
"de": "Mindestens ein Identitätsanbieter muss konfiguriert werden (z.B. GitHub)",
"uk": "Принаймні один постачальник ідентифікації має бути налаштований (наприклад, GitHub)"
},
"AUTH$RECAPTCHA_REQUIRED": {
"en": "Please complete the reCAPTCHA verification to continue",
"ja": "続行するには、reCAPTCHAの確認を完了してください",
"zh-CN": "请完成 reCAPTCHA 验证以继续",
"zh-TW": "請完成 reCAPTCHA 驗證以繼續",
"ko-KR": "계속하려면 reCAPTCHA 확인을 완료하세요",
"no": "Vennligst fullfør reCAPTCHA-verifiseringen for å fortsette",
"it": "Completa la verifica reCAPTCHA per continuare",
"pt": "Por favor, complete a verificação reCAPTCHA para continuar",
"es": "Por favor, complete la verificación reCAPTCHA para continuar",
"ar": "يرجى إكمال التحقق من reCAPTCHA للمتابعة",
"fr": "Veuillez compléter la vérification reCAPTCHA pour continuer",
"tr": "Devam etmek için lütfen reCAPTCHA doğrulamasını tamamlayın",
"de": "Bitte vervollständigen Sie die reCAPTCHA-Überprüfung, um fortzufahren",
"uk": "Будь ласка, завершіть перевірку reCAPTCHA, щоб продовжити"
},
"AUTH$RECAPTCHA_LOAD_ERROR": {
"en": "Failed to load reCAPTCHA",
"ja": "reCAPTCHAの読み込みに失敗しました",
"zh-CN": "加载 reCAPTCHA 失败",
"zh-TW": "載入 reCAPTCHA 失敗",
"ko-KR": "reCAPTCHA 로드 실패",
"no": "Kunne ikke laste reCAPTCHA",
"it": "Impossibile caricare reCAPTCHA",
"pt": "Falha ao carregar reCAPTCHA",
"es": "Error al cargar reCAPTCHA",
"ar": "فشل تحميل reCAPTCHA",
"fr": "Échec du chargement de reCAPTCHA",
"tr": "reCAPTCHA yüklenemedi",
"de": "reCAPTCHA konnte nicht geladen werden",
"uk": "Не вдалося завантажити reCAPTCHA"
},
"COMMON$TERMS_OF_SERVICE": {
"en": "Terms of Service",
"ja": "利用規約",