mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: implement reCAPTCHA enterprise risk-based non-interactive (#12288)
This commit is contained in:
24
enterprise/poetry.lock
generated
24
enterprise/poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
153
enterprise/server/auth/recaptcha_service.py
Normal file
153
enterprise/server/auth/recaptcha_service.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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'
|
||||
|
||||
293
enterprise/tests/unit/test_recaptcha_service.py
Normal file
293
enterprise/tests/unit/test_recaptcha_service.py
Normal 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',
|
||||
)
|
||||
@@ -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
11
frontend/global.d.ts
vendored
@@ -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>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
76
frontend/src/hooks/use-recaptcha.ts
Normal file
76
frontend/src/hooks/use-recaptcha.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "始めましょう",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user