diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index 7c596cd558..d6af1e90f0 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -34,6 +34,7 @@ from server.services.org_invitation_service import ( OrgInvitationService, UserAlreadyMemberError, ) +from server.utils.rate_limit_utils import check_rate_limit_by_user_id from sqlalchemy import select from storage.database import a_session_maker from storage.user import User @@ -326,12 +327,37 @@ async def keycloak_callback( # Check email verification status email_verified = user_info.email_verified or False if not email_verified: - # Send verification email + # Send verification email with rate limiting to prevent abuse + # Users who repeatedly login without verifying would otherwise trigger + # unlimited verification emails # Import locally to avoid circular import with email.py from server.routes.email import verify_email - await verify_email(request=request, user_id=user_id, is_auth_flow=True) + # Rate limit verification emails during auth flow (60 seconds per user) + # This is separate from the manual resend rate limit which uses 30 seconds + rate_limited = False + try: + await check_rate_limit_by_user_id( + request=request, + key_prefix='auth_verify_email', + user_id=user_id, + user_rate_limit_seconds=60, + ip_rate_limit_seconds=120, + ) + await verify_email(request=request, user_id=user_id, is_auth_flow=True) + except HTTPException as e: + if e.status_code == status.HTTP_429_TOO_MANY_REQUESTS: + # Rate limited - still redirect to verification page but don't send email + rate_limited = True + logger.info( + f'Rate limited verification email for user {user_id} during auth flow' + ) + else: + raise + verification_redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}' + if rate_limited: + verification_redirect_url = f'{verification_redirect_url}&rate_limited=true' # Preserve invitation token so it can be included in OAuth state after verification if invitation_token: verification_redirect_url = ( diff --git a/enterprise/tests/unit/test_auth_routes.py b/enterprise/tests/unit/test_auth_routes.py index 0d1ed3760c..43a6f348f5 100644 --- a/enterprise/tests/unit/test_auth_routes.py +++ b/enterprise/tests/unit/test_auth_routes.py @@ -249,10 +249,12 @@ async def test_keycloak_callback_email_not_verified( """Test keycloak_callback when email is not verified.""" # Arrange mock_verify_email = AsyncMock() + mock_rate_limit = AsyncMock() with ( patch('server.routes.auth.token_manager') as mock_token_manager, patch('server.routes.auth.user_verifier') as mock_verifier, patch('server.routes.email.verify_email', mock_verify_email), + patch('server.routes.auth.check_rate_limit_by_user_id', mock_rate_limit), patch('server.routes.auth.UserStore') as mock_user_store, ): mock_token_manager.get_keycloak_tokens = AsyncMock( @@ -291,6 +293,14 @@ async def test_keycloak_callback_email_not_verified( mock_verify_email.assert_called_once_with( request=mock_request, user_id='test_user_id', is_auth_flow=True ) + # Verify rate limit was checked + mock_rate_limit.assert_called_once_with( + request=mock_request, + key_prefix='auth_verify_email', + user_id='test_user_id', + user_rate_limit_seconds=60, + ip_rate_limit_seconds=120, + ) @pytest.mark.asyncio @@ -300,10 +310,12 @@ async def test_keycloak_callback_email_not_verified_missing_field( """Test keycloak_callback when email_verified field is missing (defaults to False).""" # Arrange mock_verify_email = AsyncMock() + mock_rate_limit = AsyncMock() with ( patch('server.routes.auth.token_manager') as mock_token_manager, patch('server.routes.auth.user_verifier') as mock_verifier, patch('server.routes.email.verify_email', mock_verify_email), + patch('server.routes.auth.check_rate_limit_by_user_id', mock_rate_limit), patch('server.routes.auth.UserStore') as mock_user_store, ): mock_token_manager.get_keycloak_tokens = AsyncMock( @@ -344,6 +356,73 @@ async def test_keycloak_callback_email_not_verified_missing_field( ) +@pytest.mark.asyncio +async def test_keycloak_callback_email_verification_rate_limited( + mock_request, create_keycloak_user_info +): + """Test keycloak_callback when email verification is rate limited. + + Users who repeatedly try to login without completing email verification + should not trigger unlimited verification emails. + """ + from fastapi import HTTPException + + # Arrange + mock_verify_email = AsyncMock() + mock_rate_limit = AsyncMock( + side_effect=HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail='Too many requests. Please wait 1 minute before trying again.', + ) + ) + with ( + patch('server.routes.auth.token_manager') as mock_token_manager, + patch('server.routes.auth.user_verifier') as mock_verifier, + patch('server.routes.email.verify_email', mock_verify_email), + patch('server.routes.auth.check_rate_limit_by_user_id', mock_rate_limit), + patch('server.routes.auth.UserStore') as mock_user_store, + ): + mock_token_manager.get_keycloak_tokens = AsyncMock( + return_value=('test_access_token', 'test_refresh_token') + ) + mock_token_manager.get_user_info = AsyncMock( + return_value=create_keycloak_user_info( + sub='test_user_id', + preferred_username='test_user', + identity_provider='github', + email_verified=False, + ) + ) + mock_token_manager.store_idp_tokens = AsyncMock() + mock_verifier.is_active.return_value = False + + # Mock the user creation + mock_user = MagicMock() + mock_user.id = 'test_user_id' + mock_user.current_org_id = 'test_org_id' + mock_user_store.get_user_by_id = AsyncMock(return_value=mock_user) + mock_user_store.create_user = AsyncMock(return_value=mock_user) + mock_user_store.backfill_contact_name = AsyncMock() + mock_user_store.backfill_user_email = AsyncMock() + + # Act + result = await keycloak_callback( + code='test_code', state='test_state', request=mock_request + ) + + # Assert - should still redirect to verification page but NOT send email + assert isinstance(result, RedirectResponse) + assert result.status_code == 302 + assert 'email_verification_required=true' in result.headers['location'] + assert 'user_id=test_user_id' in result.headers['location'] + # When rate limited, the redirect URL should include rate_limited=true + # so the frontend can show an appropriate message + assert 'rate_limited=true' in result.headers['location'] + # verify_email should NOT have been called due to rate limit + mock_verify_email.assert_not_called() + mock_rate_limit.assert_called_once() + + @pytest.mark.asyncio async def test_keycloak_callback_success_without_offline_token( mock_request, create_keycloak_user_info diff --git a/frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx b/frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx index c62f85036b..12ff0398a3 100644 --- a/frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx +++ b/frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx @@ -36,6 +36,21 @@ describe("EmailVerificationModal", () => { ).toBeInTheDocument(); }); + it("should render the rate limited message when wasRateLimited is true", () => { + // Arrange & Act + renderWithRouter( + , + ); + + // Assert - should show the rate limited message instead of the default one + expect( + screen.getByText("AUTH$CHECK_INBOX_FOR_VERIFICATION_EMAIL"), + ).toBeInTheDocument(); + expect( + screen.queryByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"), + ).not.toBeInTheDocument(); + }); + it("should render the TermsAndPrivacyNotice component", () => { // Arrange & Act renderWithRouter(); diff --git a/frontend/src/components/features/waitlist/email-verification-modal.tsx b/frontend/src/components/features/waitlist/email-verification-modal.tsx index fafd11ee4e..751a0fa106 100644 --- a/frontend/src/components/features/waitlist/email-verification-modal.tsx +++ b/frontend/src/components/features/waitlist/email-verification-modal.tsx @@ -10,11 +10,13 @@ import { useEmailVerification } from "#/hooks/use-email-verification"; interface EmailVerificationModalProps { onClose: () => void; userId?: string | null; + wasRateLimited?: boolean; } export function EmailVerificationModal({ onClose, userId, + wasRateLimited = false, }: EmailVerificationModalProps) { const { t } = useTranslation(); const { @@ -33,14 +35,18 @@ export function EmailVerificationModal({ resendButtonLabel = t(I18nKey.SETTINGS$RESEND_VERIFICATION); } + // Show different message when rate limited - user should check their inbox + // for the verification email sent earlier + const headerMessage = wasRateLimited + ? t(I18nKey.AUTH$CHECK_INBOX_FOR_VERIFICATION_EMAIL) + : t(I18nKey.AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY); + return (
-

- {t(I18nKey.AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY)} -

+

{headerMessage}

diff --git a/frontend/src/hooks/use-email-verification.ts b/frontend/src/hooks/use-email-verification.ts index ac919fc8ec..f9cf4fe29c 100644 --- a/frontend/src/hooks/use-email-verification.ts +++ b/frontend/src/hooks/use-email-verification.ts @@ -21,6 +21,7 @@ import { useResendEmailVerification } from "#/hooks/mutation/use-resend-email-ve * - isCooldownActive: boolean indicating if cooldown is currently active * - cooldownRemaining: number of milliseconds remaining in cooldown * - formattedCooldownTime: string formatted as "M:SS" for display + * - wasRateLimited: boolean indicating if the user was rate limited during OAuth flow */ export function useEmailVerification() { const [searchParams, setSearchParams] = useSearchParams(); @@ -29,6 +30,7 @@ export function useEmailVerification() { const [emailVerified, setEmailVerified] = React.useState(false); const [hasDuplicatedEmail, setHasDuplicatedEmail] = React.useState(false); const [recaptchaBlocked, setRecaptchaBlocked] = React.useState(false); + const [wasRateLimited, setWasRateLimited] = React.useState(false); const [userId, setUserId] = React.useState(null); const [lastSentTimestamp, setLastSentTimestamp] = React.useState< number | null @@ -85,6 +87,13 @@ export function useEmailVerification() { shouldUpdate = true; } + const rateLimitedParam = searchParams.get("rate_limited"); + if (rateLimitedParam === "true") { + setWasRateLimited(true); + searchParams.delete("rate_limited"); + shouldUpdate = true; + } + if (userIdParam) { setUserId(userIdParam); searchParams.delete("user_id"); @@ -136,6 +145,7 @@ export function useEmailVerification() { setEmailVerified, hasDuplicatedEmail, recaptchaBlocked, + wasRateLimited, userId, resendEmailVerification: resendEmailVerificationMutation.mutate, isResendingVerification: resendEmailVerificationMutation.isPending, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index a42047bb84..44ff4dcc38 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -767,6 +767,7 @@ export enum I18nKey { AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR = "AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR", AUTH$NO_PROVIDERS_CONFIGURED = "AUTH$NO_PROVIDERS_CONFIGURED", AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY = "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY", + AUTH$CHECK_INBOX_FOR_VERIFICATION_EMAIL = "AUTH$CHECK_INBOX_FOR_VERIFICATION_EMAIL", AUTH$EMAIL_VERIFIED_PLEASE_LOGIN = "AUTH$EMAIL_VERIFIED_PLEASE_LOGIN", AUTH$DUPLICATE_EMAIL_ERROR = "AUTH$DUPLICATE_EMAIL_ERROR", AUTH$RECAPTCHA_BLOCKED = "AUTH$RECAPTCHA_BLOCKED", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 868eee4a94..0306c04244 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -12275,6 +12275,22 @@ "de": "Bitte überprüfen Sie Ihre E-Mail, um Ihr Konto zu verifizieren.", "uk": "Будь ласка, перевірте свою електронну пошту, щоб підтвердити свій обліковий запис." }, + "AUTH$CHECK_INBOX_FOR_VERIFICATION_EMAIL": { + "en": "Please check your inbox for the verification email we sent earlier.", + "ja": "先ほど送信した確認メールを受信トレイでご確認ください。", + "zh-CN": "请检查您的收件箱,查收我们之前发送的验证邮件。", + "zh-TW": "請檢查您的收件箱,查收我們之前發送的驗證郵件。", + "ko-KR": "이전에 보내드린 인증 이메일을 받은 편지함에서 확인해 주세요.", + "no": "Vennligst sjekk innboksen din for bekreftelsese-posten vi sendte tidligere.", + "it": "Controlla la tua casella di posta per l'email di verifica che ti abbiamo inviato in precedenza.", + "pt": "Por favor, verifique sua caixa de entrada para o e-mail de verificação que enviamos anteriormente.", + "es": "Por favor, revisa tu bandeja de entrada para el correo de verificación que te enviamos anteriormente.", + "ar": "يرجى التحقق من صندوق الوارد للبريد الإلكتروني الذي أرسلناه لك سابقًا.", + "fr": "Veuillez vérifier votre boîte de réception pour l'e-mail de vérification que nous vous avons envoyé précédemment.", + "tr": "Lütfen daha önce gönderdiğimiz doğrulama e-postası için gelen kutunuzu kontrol edin.", + "de": "Bitte überprüfen Sie Ihren Posteingang auf die Bestätigungs-E-Mail, die wir Ihnen zuvor gesendet haben.", + "uk": "Будь ласка, перевірте вашу поштову скриньку на наявність листа підтвердження, який ми надіслали раніше." + }, "AUTH$EMAIL_VERIFIED_PLEASE_LOGIN": { "en": "Your email has been verified. Please login below.", "ja": "メールアドレスが確認されました。下記からログインしてください。", diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 874743aa6e..0b357909b6 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -19,6 +19,7 @@ export default function LoginPage() { emailVerified, hasDuplicatedEmail, recaptchaBlocked, + wasRateLimited, emailVerificationModalOpen, setEmailVerificationModalOpen, userId, @@ -83,6 +84,7 @@ export default function LoginPage() { setEmailVerificationModalOpen(false); }} userId={userId} + wasRateLimited={wasRateLimited} /> )}