feat: implement reCAPTCHA enterprise risk-based non-interactive (#12288)

This commit is contained in:
Hiep Le
2026-01-10 22:04:35 +07:00
committed by GitHub
parent 175117e8b5
commit d773dd6514
17 changed files with 1507 additions and 13 deletions

24
enterprise/poetry.lock generated
View File

@@ -2884,6 +2884,28 @@ google-auth = ">=1.25.0,<3.0dev"
[package.extras]
grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"]
[[package]]
name = "google-cloud-recaptcha-enterprise"
version = "1.29.0"
description = "Google Cloud Recaptcha Enterprise API client library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "google_cloud_recaptcha_enterprise-1.29.0-py3-none-any.whl", hash = "sha256:d3332f3ab9c586404c187d111326670bc745b4cbb64cad0a1c16259356c43c6d"},
{file = "google_cloud_recaptcha_enterprise-1.29.0.tar.gz", hash = "sha256:60cb3e8fb5860733c3c2c0b69d3a0aca6cf1ece2738ad7b5952183f1fb77e3e7"},
]
[package.dependencies]
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
grpcio = ">=1.33.2,<2.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0"},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
[[package]]
name = "google-cloud-resource-manager"
version = "1.14.2"
@@ -14492,4 +14514,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "ab703edc73639f22f498894d16bf7170fe3ab9c2697761cdd494587caee77973"
content-hash = "b5cbb1e25176845ac9f95650a802667e2f8be1a536e3e55a9269b5af5a42e3fc"

View File

@@ -43,6 +43,7 @@ coredis = "^4.22.0"
httpx = "*"
scikit-learn = "^1.7.0"
shap = "^0.48.0"
google-cloud-recaptcha-enterprise = "^1.24.0"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"

View File

@@ -38,3 +38,16 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
'y',
'on',
)
# reCAPTCHA Enterprise
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
# Account Defender labels that indicate suspicious activity
SUSPICIOUS_LABELS = {
'SUSPICIOUS_LOGIN_ACTIVITY',
'SUSPICIOUS_ACCOUNT_CREATION',
'RELATED_ACCOUNTS_NUMBER_HIGH',
}

View File

@@ -0,0 +1,153 @@
import hashlib
import hmac
from dataclasses import dataclass
from google.cloud import recaptchaenterprise_v1
from server.auth.constants import (
RECAPTCHA_BLOCK_THRESHOLD,
RECAPTCHA_HMAC_SECRET,
RECAPTCHA_PROJECT_ID,
RECAPTCHA_SITE_KEY,
SUSPICIOUS_LABELS,
)
from openhands.core.logger import openhands_logger as logger
@dataclass
class AssessmentResult:
"""Result of a reCAPTCHA Enterprise assessment."""
score: float
valid: bool
action_valid: bool
reason_codes: list[str]
account_defender_labels: list[str]
allowed: bool
class RecaptchaService:
"""Service for creating reCAPTCHA Enterprise assessments."""
def __init__(self):
self._client = None
self.project_id = RECAPTCHA_PROJECT_ID
self.site_key = RECAPTCHA_SITE_KEY
@property
def client(self):
"""Lazily initialize the reCAPTCHA client to avoid credential errors at import time."""
if self._client is None:
self._client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
return self._client
def hash_account_id(self, email: str) -> str:
"""Hash email using SHA256-HMAC for Account Defender.
Args:
email: The user's email address.
Returns:
Hex-encoded HMAC-SHA256 hash of the lowercase email.
"""
return hmac.new(
RECAPTCHA_HMAC_SECRET.encode(),
email.lower().encode(),
hashlib.sha256,
).hexdigest()
def create_assessment(
self,
token: str,
action: str,
user_ip: str,
user_agent: str,
email: str | None = None,
) -> AssessmentResult:
"""Create a reCAPTCHA Enterprise assessment.
Args:
token: The reCAPTCHA token from the frontend.
action: The expected action name (e.g., 'LOGIN').
user_ip: The user's IP address.
user_agent: The user's browser user agent.
email: Optional email for Account Defender hashing.
Returns:
AssessmentResult with score, validity, and allowed status.
"""
event = recaptchaenterprise_v1.Event()
event.site_key = self.site_key
event.token = token
event.user_ip_address = user_ip
event.user_agent = user_agent
event.expected_action = action
# Account Defender: use user_info.account_id (hashed_account_id is deprecated)
if email:
user_info = recaptchaenterprise_v1.UserInfo()
user_info.account_id = self.hash_account_id(email)
# Also include email as a user identifier for better fraud detection
user_info.user_ids.append(recaptchaenterprise_v1.UserId(email=email))
event.user_info = user_info
assessment = recaptchaenterprise_v1.Assessment()
assessment.event = event
request = recaptchaenterprise_v1.CreateAssessmentRequest()
request.assessment = assessment
request.parent = f'projects/{self.project_id}'
response = self.client.create_assessment(request)
token_properties = response.token_properties
risk_analysis = response.risk_analysis
score = risk_analysis.score
valid = token_properties.valid
action_valid = token_properties.action == action
reason_codes = [str(r) for r in risk_analysis.reasons]
# Extract Account Defender labels
account_defender_labels = []
if response.account_defender_assessment:
account_defender_labels = [
str(label) for label in response.account_defender_assessment.labels
]
# Check if any suspicious labels are present
has_suspicious_labels = bool(set(account_defender_labels) & SUSPICIOUS_LABELS)
# Block if: invalid token, wrong action, low score, OR suspicious Account Defender labels
allowed = (
valid
and action_valid
and score >= RECAPTCHA_BLOCK_THRESHOLD
and not has_suspicious_labels
)
logger.info(
'recaptcha_assessment',
extra={
'score': score,
'valid': valid,
'action_valid': action_valid,
'reasons': reason_codes,
'account_defender_labels': account_defender_labels,
'has_suspicious_labels': has_suspicious_labels,
'allowed': allowed,
'user_ip': user_ip,
},
)
return AssessmentResult(
score=score,
valid=valid,
action_valid=action_valid,
reason_codes=reason_codes,
account_defender_labels=account_defender_labels,
allowed=allowed,
)
recaptcha_service = RecaptchaService()

View File

@@ -17,6 +17,7 @@ from server.auth.constants import (
GITHUB_APP_PRIVATE_KEY,
GITHUB_APP_WEBHOOK_SECRET,
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from openhands.core.config.utils import load_openhands_config
@@ -187,4 +188,7 @@ class SaaSServerConfig(ServerConfig):
if self.auth_url:
config['AUTH_URL'] = self.auth_url
if RECAPTCHA_SITE_KEY:
config['RECAPTCHA_SITE_KEY'] = RECAPTCHA_SITE_KEY
return config

View File

@@ -1,3 +1,5 @@
import base64
import json
import warnings
from datetime import datetime, timezone
from typing import Annotated, Literal, Optional
@@ -12,10 +14,12 @@ from server.auth.constants import (
KEYCLOAK_CLIENT_ID,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL_EXT,
RECAPTCHA_SITE_KEY,
ROLE_CHECK_ENABLED,
)
from server.auth.domain_blocker import domain_blocker
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.recaptcha_service import recaptcha_service
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.config import get_config, sign_token
@@ -98,6 +102,24 @@ def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
)
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
"""Extract redirect URL and reCAPTCHA token from OAuth state.
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
if not state:
return '', None
try:
# Try to decode as JSON (new format with reCAPTCHA)
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
return state_data.get('redirect_url', ''), state_data.get('recaptcha_token')
except Exception:
# Old format - state is just the redirect URL
return state, None
@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
request: Request,
@@ -106,7 +128,11 @@ async def keycloak_callback(
error: Optional[str] = None,
error_description: Optional[str] = None,
):
redirect_url: str = state if state else str(request.base_url)
# Extract redirect URL and reCAPTCHA token from state
redirect_url, recaptcha_token = _extract_recaptcha_state(state)
if not redirect_url:
redirect_url = str(request.base_url)
if not code:
# check if this is a forward from the account linking page
if (
@@ -149,8 +175,43 @@ async def keycloak_callback(
email = user_info.get('email')
user_id = user_info['sub']
# reCAPTCHA verification with Account Defender
if RECAPTCHA_SITE_KEY and recaptcha_token:
user_ip = request.client.host if request.client else 'unknown'
user_agent = request.headers.get('User-Agent', '')
# Handle X-Forwarded-For for proxied requests
forwarded_for = request.headers.get('X-Forwarded-For')
if forwarded_for:
user_ip = forwarded_for.split(',')[0].strip()
try:
result = recaptcha_service.create_assessment(
token=recaptcha_token,
action='LOGIN',
user_ip=user_ip,
user_agent=user_agent,
email=email,
)
if not result.allowed:
logger.warning(
'recaptcha_blocked_at_callback',
extra={
'user_ip': user_ip,
'score': result.score,
'user_id': user_id,
},
)
# Redirect to home with error parameter
error_url = f'{request.base_url}login?recaptcha_blocked=true'
return RedirectResponse(error_url, status_code=302)
except Exception as e:
logger.exception(f'reCAPTCHA verification error at callback: {e}')
# Fail open - continue with login if reCAPTCHA service unavailable
# Check if email domain is blocked
email = user_info.get('email')
if email and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'

View File

@@ -1,3 +1,5 @@
import base64
import json
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
@@ -8,6 +10,7 @@ from pydantic import SecretStr
from server.auth.auth_error import AuthError
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import (
_extract_recaptcha_state,
authenticate,
keycloak_callback,
keycloak_offline_callback,
@@ -934,3 +937,764 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
assert result.status_code == 302
# Should not check for duplicate when email is missing
mock_token_manager.check_duplicate_base_email.assert_not_called()
class TestExtractRecaptchaState:
"""Tests for _extract_recaptcha_state() helper function."""
def test_should_extract_redirect_url_and_token_from_new_json_format(self):
"""Test extraction from new base64-encoded JSON format."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == 'https://example.com'
assert token == 'test-token'
def test_should_handle_old_format_plain_redirect_url(self):
"""Test handling of old format (plain redirect URL string)."""
# Arrange
state = 'https://example.com'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == 'https://example.com'
assert token is None
def test_should_handle_none_state(self):
"""Test handling of None state."""
# Arrange
state = None
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == ''
assert token is None
def test_should_handle_invalid_base64_gracefully(self):
"""Test handling of invalid base64/JSON (fallback to old format)."""
# Arrange
state = 'not-valid-base64!!!'
# Act
redirect_url, token = _extract_recaptcha_state(state)
# Assert
assert redirect_url == state
assert token is None
def test_should_handle_missing_redirect_url_in_json(self):
"""Test handling when redirect_url is missing in JSON."""
# Arrange
state_data = {'recaptcha_token': 'test-token'}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
# Act
redirect_url, token = _extract_recaptcha_state(encoded_state)
# Assert
assert redirect_url == ''
assert token == 'test-token'
class TestKeycloakCallbackRecaptcha:
"""Tests for reCAPTCHA integration in keycloak_callback()."""
@pytest.mark.asyncio
async def test_should_verify_recaptcha_and_allow_login_when_score_is_high(
self, mock_request
):
"""Test that login proceeds when reCAPTCHA score is high."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
mock_assessment_result.score = 0.9
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
result = await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
mock_recaptcha_service.create_assessment.assert_called_once()
@pytest.mark.asyncio
async def test_should_block_login_when_recaptcha_score_is_low(self, mock_request):
"""Test that login is blocked and redirected when reCAPTCHA score is low."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = False
mock_assessment_result.score = 0.2
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
):
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
}
)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
result = await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
assert 'recaptcha_blocked=true' in result.headers['location']
@pytest.mark.asyncio
async def test_should_extract_ip_from_x_forwarded_for_header(self, mock_request):
"""Test that IP is extracted from X-Forwarded-For header when present."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_request.headers = {'X-Forwarded-For': '192.168.1.1, 10.0.0.1'}
mock_request.client = None
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['user_ip'] == '192.168.1.1'
@pytest.mark.asyncio
async def test_should_use_client_host_when_x_forwarded_for_missing(
self, mock_request
):
"""Test that client.host is used when X-Forwarded-For is missing."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_request.headers = {}
mock_request.client = MagicMock()
mock_request.client.host = '192.168.1.2'
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['user_ip'] == '192.168.1.2'
@pytest.mark.asyncio
async def test_should_use_unknown_ip_when_client_is_none(self, mock_request):
"""Test that 'unknown' IP is used when client is None."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_request.headers = {}
mock_request.client = None
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['user_ip'] == 'unknown'
@pytest.mark.asyncio
async def test_should_include_email_in_assessment_when_available(
self, mock_request
):
"""Test that email is included in assessment when available."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = True
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
call_args = mock_recaptcha_service.create_assessment.call_args
assert call_args[1]['email'] == 'user@example.com'
@pytest.mark.asyncio
async def test_should_skip_recaptcha_when_site_key_not_configured(
self, mock_request
):
"""Test that reCAPTCHA is skipped when RECAPTCHA_SITE_KEY is not configured."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', ''),
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
mock_recaptcha_service.create_assessment.assert_not_called()
@pytest.mark.asyncio
async def test_should_skip_recaptcha_when_token_is_missing(self, mock_request):
"""Test that reCAPTCHA is skipped when token is missing from state."""
# Arrange
state = 'https://example.com' # Old format without token
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
# Act
await keycloak_callback(code='test_code', state=state, request=mock_request)
# Assert
mock_recaptcha_service.create_assessment.assert_not_called()
@pytest.mark.asyncio
async def test_should_fail_open_when_recaptcha_service_throws_exception(
self, mock_request
):
"""Test that login proceeds (fail open) when reCAPTCHA service throws exception."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.user_verifier') as mock_verifier,
patch('server.routes.auth.session_maker') as mock_session_maker,
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.set_response_cookie'),
patch('server.routes.auth.posthog'),
patch('server.routes.auth.logger') as mock_logger,
):
mock_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_session
mock_query = MagicMock()
mock_session.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_user_settings = MagicMock()
mock_user_settings.accepted_tos = '2025-01-01'
mock_query.first.return_value = mock_user_settings
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
'identity_provider': 'github',
'email_verified': True,
}
)
mock_token_manager.store_idp_tokens = AsyncMock()
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
mock_recaptcha_service.create_assessment.side_effect = Exception(
'Service error'
)
# Act
result = await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
assert isinstance(result, RedirectResponse)
# Check that reCAPTCHA error was logged (may be called multiple times due to other errors)
recaptcha_error_calls = [
call
for call in mock_logger.exception.call_args_list
if 'reCAPTCHA verification error' in str(call)
]
assert len(recaptcha_error_calls) > 0
@pytest.mark.asyncio
async def test_should_log_warning_when_recaptcha_blocks_user(self, mock_request):
"""Test that warning is logged when reCAPTCHA blocks user."""
# Arrange
state_data = {
'redirect_url': 'https://example.com',
'recaptcha_token': 'test-token',
}
encoded_state = base64.urlsafe_b64encode(
json.dumps(state_data).encode()
).decode()
mock_assessment_result = MagicMock()
mock_assessment_result.allowed = False
mock_assessment_result.score = 0.2
with (
patch('server.routes.auth.token_manager') as mock_token_manager,
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
patch('server.routes.auth.logger') as mock_logger,
patch('server.routes.email.verify_email', new_callable=AsyncMock),
):
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
)
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': 'test_user_id',
'preferred_username': 'test_user',
'email': 'user@example.com',
}
)
mock_token_manager.check_duplicate_base_email = AsyncMock(
return_value=False
)
mock_domain_blocker.is_domain_blocked.return_value = False
# Patch the module-level recaptcha_service instance
mock_recaptcha_service.create_assessment.return_value = (
mock_assessment_result
)
# Act
await keycloak_callback(
code='test_code', state=encoded_state, request=mock_request
)
# Assert
mock_logger.warning.assert_called_once()
call_kwargs = mock_logger.warning.call_args
assert call_kwargs[0][0] == 'recaptcha_blocked_at_callback'
assert call_kwargs[1]['extra']['score'] == 0.2
assert call_kwargs[1]['extra']['user_id'] == 'test_user_id'

View File

@@ -0,0 +1,293 @@
"""Tests for RecaptchaService."""
import hashlib
import hmac
from unittest.mock import MagicMock, patch
import pytest
from server.auth.recaptcha_service import AssessmentResult, RecaptchaService
@pytest.fixture
def mock_gcp_client():
"""Mock GCP reCAPTCHA Enterprise client."""
with patch(
'server.auth.recaptcha_service.recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient'
) as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
yield mock_client
@pytest.fixture
def recaptcha_service(mock_gcp_client):
"""Create RecaptchaService instance with mocked dependencies."""
with (
patch('server.auth.recaptcha_service.RECAPTCHA_PROJECT_ID', 'test-project'),
patch('server.auth.recaptcha_service.RECAPTCHA_SITE_KEY', 'test-site-key'),
patch('server.auth.recaptcha_service.RECAPTCHA_HMAC_SECRET', 'test-secret'),
patch('server.auth.recaptcha_service.RECAPTCHA_BLOCK_THRESHOLD', 0.3),
):
# Create new instance - constants are imported at module level, so we patch the imported names
return RecaptchaService()
class TestRecaptchaServiceHashAccountId:
"""Tests for RecaptchaService.hash_account_id()."""
def test_should_hash_email_with_hmac_sha256(self, recaptcha_service):
"""Test that hash_account_id produces correct HMAC-SHA256 hash."""
# Arrange
email = 'user@example.com'
# The service reads RECAPTCHA_HMAC_SECRET from the imported constants
# We need to verify it uses the constant correctly
from server.auth.recaptcha_service import RECAPTCHA_HMAC_SECRET
# Act
result = recaptcha_service.hash_account_id(email)
# Assert
# Verify the hash is correct using the actual secret from the patched constant
expected_hash = hmac.new(
RECAPTCHA_HMAC_SECRET.encode(),
email.lower().encode(),
hashlib.sha256,
).hexdigest()
assert result == expected_hash
assert len(result) == 64 # SHA256 produces 64 hex characters
def test_should_normalize_email_to_lowercase(self, recaptcha_service):
"""Test that hash_account_id normalizes email to lowercase."""
# Arrange
email1 = 'User@Example.com'
email2 = 'user@example.com'
# Act
hash1 = recaptcha_service.hash_account_id(email1)
hash2 = recaptcha_service.hash_account_id(email2)
# Assert
assert hash1 == hash2
def test_should_produce_different_hashes_for_different_emails(
self, recaptcha_service
):
"""Test that different emails produce different hashes."""
# Arrange
email1 = 'user1@example.com'
email2 = 'user2@example.com'
# Act
hash1 = recaptcha_service.hash_account_id(email1)
hash2 = recaptcha_service.hash_account_id(email2)
# Assert
assert hash1 != hash2
class TestRecaptchaServiceCreateAssessment:
"""Tests for RecaptchaService.create_assessment()."""
def test_should_create_assessment_and_allow_when_score_is_high(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment allows request when score is above threshold."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert isinstance(result, AssessmentResult)
assert result.allowed is True
assert result.score == 0.9
assert result.valid is True
assert result.action_valid is True
mock_gcp_client.create_assessment.assert_called_once()
def test_should_block_when_score_is_below_threshold(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment blocks request when score is below threshold."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.2
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert result.allowed is False
assert result.score == 0.2
def test_should_block_when_token_is_invalid(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment blocks request when token is invalid."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = False
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='invalid-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert result.allowed is False
assert result.valid is False
def test_should_block_when_action_does_not_match(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment blocks request when action doesn't match."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'SIGNUP'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
result = recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
assert result.allowed is False
assert result.action_valid is False
def test_should_include_email_in_user_info_when_provided(
self, recaptcha_service, mock_gcp_client
):
"""Test that email is included in user_info when provided."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
email='user@example.com',
)
# Assert
call_args = mock_gcp_client.create_assessment.call_args
assessment = call_args[0][0].assessment
assert assessment.event.user_info is not None
assert assessment.event.user_info.account_id is not None
assert len(assessment.event.user_info.user_ids) == 1
assert assessment.event.user_info.user_ids[0].email == 'user@example.com'
def test_should_not_include_user_info_when_email_is_none(
self, recaptcha_service, mock_gcp_client
):
"""Test that user_info is not included when email is None."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
email=None,
)
# Assert
call_args = mock_gcp_client.create_assessment.call_args
assessment = call_args[0][0].assessment
# When email is None, user_info should not be set
# Check that user_info was not explicitly set (protobuf objects may have default empty values)
# The key is that account_id should not be set when email is None
if hasattr(assessment.event, 'user_info') and assessment.event.user_info:
# If user_info exists, verify account_id is empty (not set)
assert not assessment.event.user_info.account_id
def test_should_log_assessment_details(self, recaptcha_service, mock_gcp_client):
"""Test that assessment details are logged."""
# Arrange
mock_response = MagicMock()
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = ['AUTOMATION']
mock_gcp_client.create_assessment.return_value = mock_response
with patch('server.auth.recaptcha_service.logger') as mock_logger:
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
mock_logger.info.assert_called_once()
call_kwargs = mock_logger.info.call_args
assert call_kwargs[0][0] == 'recaptcha_assessment'
assert call_kwargs[1]['extra']['score'] == 0.9
assert call_kwargs[1]['extra']['valid'] is True
assert call_kwargs[1]['extra']['action_valid'] is True
assert call_kwargs[1]['extra']['user_ip'] == '192.168.1.1'
def test_should_raise_exception_when_gcp_client_fails(
self, recaptcha_service, mock_gcp_client
):
"""Test that exceptions from GCP client are propagated."""
# Arrange
mock_gcp_client.create_assessment.side_effect = Exception('GCP error')
# Act & Assert
with pytest.raises(Exception, match='GCP error'):
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
@@ -27,6 +27,25 @@ vi.mock("#/hooks/use-tracking", () => ({
}),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: undefined,
}),
}));
vi.mock("#/hooks/use-recaptcha", () => ({
useRecaptcha: () => ({
isReady: false,
isLoading: false,
error: null,
executeRecaptcha: vi.fn().mockResolvedValue(null),
}),
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(),
}));
describe("LoginContent", () => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
@@ -131,7 +150,10 @@ describe("LoginContent", () => {
});
await user.click(githubButton);
expect(window.location.href).toBe(mockUrl);
// Wait for async handleAuthRedirect to complete
await waitFor(() => {
expect(window.location.href).toBe(mockUrl);
});
});
it("should display email verified message when emailVerified is true", () => {

11
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,13 @@ interface Window {
company?: string;
}) => void;
};
grecaptcha?: {
enterprise: {
ready: (callback: () => void) => void;
execute: (
siteKey: string,
options: { action: string },
) => Promise<string>;
};
};
}

View File

@@ -7,6 +7,7 @@ export interface GetConfigResponse {
POSTHOG_CLIENT_KEY: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
RECAPTCHA_SITE_KEY?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;

View File

@@ -9,6 +9,9 @@ import { GetConfigResponse } from "#/api/option-service/option.types";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
import { useRecaptcha } from "#/hooks/use-recaptcha";
import { useConfig } from "#/hooks/query/use-config";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
export interface LoginContentProps {
githubAuthUrl: string | null;
@@ -17,6 +20,7 @@ export interface LoginContentProps {
providersConfigured?: Provider[];
emailVerified?: boolean;
hasDuplicatedEmail?: boolean;
recaptchaBlocked?: boolean;
}
export function LoginContent({
@@ -26,9 +30,16 @@ export function LoginContent({
providersConfigured,
emailVerified = false,
hasDuplicatedEmail = false,
recaptchaBlocked = false,
}: LoginContentProps) {
const { t } = useTranslation();
const { trackLoginButtonClick } = useTracking();
const { data: config } = useConfig();
// reCAPTCHA - only need token generation, verification happens at backend callback
const { isReady: recaptchaReady, executeRecaptcha } = useRecaptcha({
siteKey: config?.RECAPTCHA_SITE_KEY,
});
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
@@ -42,24 +53,54 @@ export function LoginContent({
authUrl,
});
const handleAuthRedirect = async (
redirectUrl: string,
provider: Provider,
) => {
trackLoginButtonClick({ provider });
if (!config?.RECAPTCHA_SITE_KEY || !recaptchaReady) {
// No reCAPTCHA or token generation failed - redirect normally
window.location.href = redirectUrl;
return;
}
// If reCAPTCHA is configured, encode token in OAuth state
try {
const token = await executeRecaptcha("LOGIN");
if (token) {
const url = new URL(redirectUrl);
const currentState =
url.searchParams.get("state") || window.location.origin;
// Encode state with reCAPTCHA token for backend verification
const stateData = {
redirect_url: currentState,
recaptcha_token: token,
};
url.searchParams.set("state", btoa(JSON.stringify(stateData)));
window.location.href = url.toString();
}
} catch (err) {
displayErrorToast(t(I18nKey.AUTH$RECAPTCHA_BLOCKED));
}
};
const handleGitHubAuth = () => {
if (githubAuthUrl) {
trackLoginButtonClick({ provider: "github" });
window.location.href = githubAuthUrl;
handleAuthRedirect(githubAuthUrl, "github");
}
};
const handleGitLabAuth = () => {
if (gitlabAuthUrl) {
trackLoginButtonClick({ provider: "gitlab" });
window.location.href = gitlabAuthUrl;
handleAuthRedirect(gitlabAuthUrl, "gitlab");
}
};
const handleBitbucketAuth = () => {
if (bitbucketAuthUrl) {
trackLoginButtonClick({ provider: "bitbucket" });
window.location.href = bitbucketAuthUrl;
handleAuthRedirect(bitbucketAuthUrl, "bitbucket");
}
};
@@ -105,6 +146,11 @@ export function LoginContent({
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
</p>
)}
{recaptchaBlocked && (
<p className="text-sm text-danger text-center max-w-125">
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)}
</p>
)}
<div className="flex flex-col items-center gap-3">
{noProvidersConfigured ? (

View File

@@ -14,6 +14,7 @@ import { useResendEmailVerification } from "#/hooks/mutation/use-resend-email-ve
* - emailVerified: boolean state for email verification status
* - setEmailVerified: function to control email verification status
* - hasDuplicatedEmail: boolean state for duplicate email error status
* - recaptchaBlocked: boolean state for reCAPTCHA blocked error status
* - userId: string | null for the user ID from the redirect URL
* - resendEmailVerification: function to resend verification email
* - isResendingVerification: boolean indicating if resend is in progress
@@ -27,6 +28,7 @@ export function useEmailVerification() {
React.useState(false);
const [emailVerified, setEmailVerified] = React.useState(false);
const [hasDuplicatedEmail, setHasDuplicatedEmail] = React.useState(false);
const [recaptchaBlocked, setRecaptchaBlocked] = React.useState(false);
const [userId, setUserId] = React.useState<string | null>(null);
const [lastSentTimestamp, setLastSentTimestamp] = React.useState<
number | null
@@ -48,13 +50,14 @@ export function useEmailVerification() {
},
});
// Check for email verification query parameters
// Check for email verification and reCAPTCHA query parameters
React.useEffect(() => {
const emailVerificationRequired = searchParams.get(
"email_verification_required",
);
const emailVerifiedParam = searchParams.get("email_verified");
const duplicatedEmailParam = searchParams.get("duplicated_email");
const recaptchaBlockedParam = searchParams.get("recaptcha_blocked");
const userIdParam = searchParams.get("user_id");
let shouldUpdate = false;
@@ -76,6 +79,12 @@ export function useEmailVerification() {
shouldUpdate = true;
}
if (recaptchaBlockedParam === "true") {
setRecaptchaBlocked(true);
searchParams.delete("recaptcha_blocked");
shouldUpdate = true;
}
if (userIdParam) {
setUserId(userIdParam);
searchParams.delete("user_id");
@@ -126,6 +135,7 @@ export function useEmailVerification() {
emailVerified,
setEmailVerified,
hasDuplicatedEmail,
recaptchaBlocked,
userId,
resendEmailVerification: resendEmailVerificationMutation.mutate,
isResendingVerification: resendEmailVerificationMutation.isPending,

View File

@@ -0,0 +1,76 @@
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
const RECAPTCHA_SCRIPT_URL = "https://www.google.com/recaptcha/enterprise.js";
interface UseRecaptchaOptions {
siteKey?: string;
}
export interface UseRecaptchaReturn {
isReady: boolean;
isLoading: boolean;
error: Error | null;
executeRecaptcha: (action: string) => Promise<string | null>;
}
export function useRecaptcha({
siteKey,
}: UseRecaptchaOptions): UseRecaptchaReturn {
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!siteKey) return;
// Check if script is already loaded
if (window.grecaptcha?.enterprise) {
window.grecaptcha.enterprise.ready(() => setIsReady(true));
return;
}
setIsLoading(true);
const script = document.createElement("script");
script.src = `${RECAPTCHA_SCRIPT_URL}?render=${siteKey}`;
script.async = true;
script.defer = true;
script.onload = () => {
window.grecaptcha?.enterprise.ready(() => {
setIsReady(true);
setIsLoading(false);
});
};
script.onerror = () => {
setError(new Error("Failed to load reCAPTCHA script"));
setIsLoading(false);
};
document.head.appendChild(script);
}, [siteKey]);
const executeRecaptcha = useCallback(
async (action: string): Promise<string | null> => {
if (!siteKey || !isReady || !window.grecaptcha?.enterprise) return null;
try {
const token = await window.grecaptcha.enterprise.execute(siteKey, {
action,
});
return token;
} catch (err) {
displayErrorToast(t(I18nKey.AUTH$RECAPTCHA_BLOCKED));
return null;
}
},
[siteKey, isReady, t],
);
return { isReady, isLoading, error, executeRecaptcha };
}

View File

@@ -757,6 +757,7 @@ export enum I18nKey {
AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY = "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY",
AUTH$EMAIL_VERIFIED_PLEASE_LOGIN = "AUTH$EMAIL_VERIFIED_PLEASE_LOGIN",
AUTH$DUPLICATE_EMAIL_ERROR = "AUTH$DUPLICATE_EMAIL_ERROR",
AUTH$RECAPTCHA_BLOCKED = "AUTH$RECAPTCHA_BLOCKED",
AUTH$LETS_GET_STARTED = "AUTH$LETS_GET_STARTED",
COMMON$TERMS_OF_SERVICE = "COMMON$TERMS_OF_SERVICE",
COMMON$AND = "COMMON$AND",

View File

@@ -12111,6 +12111,22 @@
"de": "Für diese E-Mail-Adresse existiert bereits ein Konto.",
"uk": "Обліковий запис з цією електронною адресою вже існує."
},
"AUTH$RECAPTCHA_BLOCKED": {
"en": "Access blocked due to suspicious activity. If you believe this is an error, please contact contact@openhands.dev for assistance.",
"ja": "不審なアクティビティのためアクセスがブロックされました。これが誤りだと思われる場合は、contact@openhands.dev までお問い合わせください。",
"zh-CN": "由于可疑活动,访问已被阻止。如果您认为这是错误的,请联系 contact@openhands.dev 寻求帮助。",
"zh-TW": "由於可疑活動,存取已被封鎖。如果您認為這是錯誤的,請聯繫 contact@openhands.dev 尋求協助。",
"ko-KR": "의심스러운 활동으로 인해 접근이 차단되었습니다. 오류라고 생각되시면 contact@openhands.dev로 문의해 주세요.",
"no": "Tilgang blokkert på grunn av mistenkelig aktivitet. Hvis du mener dette er en feil, vennligst kontakt contact@openhands.dev for hjelp.",
"it": "Accesso bloccato a causa di attività sospetta. Se ritieni che si tratti di un errore, contatta contact@openhands.dev per assistenza.",
"pt": "Acesso bloqueado devido a atividade suspeita. Se você acredita que isso é um erro, entre em contato com contact@openhands.dev para obter ajuda.",
"es": "Acceso bloqueado debido a actividad sospechosa. Si cree que esto es un error, comuníquese con contact@openhands.dev para obtener ayuda.",
"ar": "تم حظر الوصول بسبب نشاط مشبوه. إذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بـ contact@openhands.dev للحصول على المساعدة.",
"fr": "Accès bloqué en raison d'une activité suspecte. Si vous pensez qu'il s'agit d'une erreur, veuillez contacter contact@openhands.dev pour obtenir de l'aide.",
"tr": "Şüpheli aktivite nedeniyle erişim engellendi. Bunun bir hata olduğunu düşünüyorsanız, yardım için lütfen contact@openhands.dev ile iletişime geçin.",
"de": "Zugriff aufgrund verdächtiger Aktivitäten blockiert. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich bitte an contact@openhands.dev.",
"uk": "Доступ заблоковано через підозрілу активність. Якщо ви вважаєте, що це помилка, зверніться до contact@openhands.dev за допомогою."
},
"AUTH$LETS_GET_STARTED": {
"en": "Let's get started",
"ja": "始めましょう",

View File

@@ -17,6 +17,7 @@ export default function LoginPage() {
const {
emailVerified,
hasDuplicatedEmail,
recaptchaBlocked,
emailVerificationModalOpen,
setEmailVerificationModalOpen,
} = useEmailVerification();
@@ -67,6 +68,7 @@ export default function LoginPage() {
providersConfigured={config.data?.PROVIDERS_CONFIGURED}
emailVerified={emailVerified}
hasDuplicatedEmail={hasDuplicatedEmail}
recaptchaBlocked={recaptchaBlocked}
/>
</main>