diff --git a/enterprise/server/auth/constants.py b/enterprise/server/auth/constants.py index 242237e93d..07903baa4c 100644 --- a/enterprise/server/auth/constants.py +++ b/enterprise/server/auth/constants.py @@ -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() diff --git a/enterprise/server/middleware.py b/enterprise/server/middleware.py index 54e3319595..5e842b751b 100644 --- a/enterprise/server/middleware.py +++ b/enterprise/server/middleware.py @@ -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', ) diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index 2ee50bbd2d..15d7e9a421 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -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'}, + ) diff --git a/enterprise/tests/unit/test_auth_routes.py b/enterprise/tests/unit/test_auth_routes.py index d3e8f47fbe..0280757139 100644 --- a/enterprise/tests/unit/test_auth_routes.py +++ b/enterprise/tests/unit/test_auth_routes.py @@ -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 diff --git a/frontend/__tests__/components/features/auth-modal.test.tsx b/frontend/__tests__/components/features/auth-modal.test.tsx index 32b682d506..f0ccc7ac97 100644 --- a/frontend/__tests__/components/features/auth-modal.test.tsx +++ b/frontend/__tests__/components/features/auth-modal.test.tsx @@ -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; + 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( { }); 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( { />, ); + // 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(); + renderWithProviders(); // 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(); + renderWithProviders(); 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(); + renderWithProviders(); 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // Assert + const recaptchaContainer = document.querySelector("div[ref]"); + expect(recaptchaContainer).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/__tests__/hooks/use-recaptcha.test.tsx b/frontend/__tests__/hooks/use-recaptcha.test.tsx new file mode 100644 index 0000000000..e96a261166 --- /dev/null +++ b/frontend/__tests__/hooks/use-recaptcha.test.tsx @@ -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
; + }; + + // Act + render(); + + // 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 ( +
+
+ +
+ ); + }; + + // Act + const { getByRole } = render(); + + 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 ( +
+
+ +
+ ); + }; + + // Act + const { getByRole } = render(); + + 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 ( +
+
+ {recaptchaError &&
Error occurred
} +
+ ); + }; + + // Act + const { getByTestId } = render(); + + // 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"]', + ); + }); +}); diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 7317d3332d..24ef192d45 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -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; + }; } diff --git a/frontend/src/api/auth-service/auth-service.api.ts b/frontend/src/api/auth-service/auth-service.api.ts index 8ea758b944..6f4e0f93c5 100644 --- a/frontend/src/api/auth-service/auth-service.api.ts +++ b/frontend/src/api/auth-service/auth-service.api.ts @@ -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; diff --git a/frontend/src/components/features/waitlist/auth-modal.tsx b/frontend/src/components/features/waitlist/auth-modal.tsx index 2c431fbd95..e6f6cfff58 100644 --- a/frontend/src/components/features/waitlist/auth-modal.tsx +++ b/frontend/src/components/features/waitlist/auth-modal.tsx @@ -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 => { + 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({ + {recaptchaError && ( +
+ {t(I18nKey.AUTH$RECAPTCHA_REQUIRED)} +
+ )}

{t(I18nKey.AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER)} @@ -194,6 +274,17 @@ export function AuthModal({ {t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)} )} + + {recaptchaSiteKey && ( +
+
+ {recaptchaLoadError && ( +
+ {t(I18nKey.AUTH$RECAPTCHA_LOAD_ERROR)} +
+ )} +
+ )} )}
diff --git a/frontend/src/hooks/mutation/use-verify-recaptcha.ts b/frontend/src/hooks/mutation/use-verify-recaptcha.ts new file mode 100644 index 0000000000..9701dceea2 --- /dev/null +++ b/frontend/src/hooks/mutation/use-verify-recaptcha.ts @@ -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), + }); diff --git a/frontend/src/hooks/use-recaptcha.ts b/frontend/src/hooks/use-recaptcha.ts new file mode 100644 index 0000000000..475d6df945 --- /dev/null +++ b/frontend/src/hooks/use-recaptcha.ts @@ -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; + 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(null); + const recaptchaRef = useRef(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, + }; +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 1b330730d9..b6841b43eb 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index a421de5ddf..b05c419c5a 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "利用規約",