diff --git a/app/controllers/password_reset.py b/app/controllers/password_reset.py
index 022a7c6..c92521c 100644
--- a/app/controllers/password_reset.py
+++ b/app/controllers/password_reset.py
@@ -13,6 +13,7 @@
from app.services.passhash import hash_string
from app.services.email import send_email, is_configured as email_is_configured
from app.services.discord import notify_password_reset_request, notify_password_reset_complete
+from app.services.recaptcha import verify as verify_recaptcha
from app.controllers.decorators import anonymous_required
password_reset_bp = Blueprint('password_reset', __name__)
@@ -45,6 +46,11 @@ def forgot_password_post():
flash('Please enter your email address', FLASH_ERROR)
return render_template('password_reset/forgot.html')
+ # Validate reCAPTCHA
+ if not verify_recaptcha(request):
+ flash('Verification failed. Please complete the challenge and try again.', FLASH_ERROR)
+ return render_template('password_reset/forgot.html')
+
# Check if email service is configured
if not email_is_configured():
flash('Password reset is currently unavailable. Please contact support.', FLASH_ERROR)
diff --git a/templates/password_reset/forgot.html b/templates/password_reset/forgot.html
index 0084cfd..d890e41 100644
--- a/templates/password_reset/forgot.html
+++ b/templates/password_reset/forgot.html
@@ -18,6 +18,13 @@
+ {% if RECAPTCHA_SITEKEY %}
+
+
+
+ {% endif %}
Back to Login
diff --git a/tests/test_password_reset_captcha.py b/tests/test_password_reset_captcha.py
new file mode 100644
index 0000000..86afa8e
--- /dev/null
+++ b/tests/test_password_reset_captcha.py
@@ -0,0 +1,40 @@
+"""
+Tests for forgot-password reCAPTCHA handling.
+"""
+
+from flask import Flask
+from unittest.mock import patch
+
+from app.models.errors import ErrNoResult
+from app.controllers.password_reset import forgot_password_post
+
+
+class TestPasswordResetCaptcha:
+ """Focused tests for forgot-password captcha flow."""
+
+ def setup_method(self):
+ self.app = Flask(__name__)
+ self.app.secret_key = 'test-secret'
+
+ def test_forgot_password_rejects_invalid_recaptcha(self):
+ """Forgot-password should reject invalid reCAPTCHA."""
+ with self.app.test_request_context('/forgot-password', method='POST', data={'email': 'test@example.com'}):
+ with patch('app.controllers.password_reset.quota_exceeded', return_value=False):
+ with patch('app.controllers.password_reset.verify_recaptcha', return_value=False):
+ with patch('app.controllers.password_reset.email_is_configured') as email_configured:
+ with patch('app.controllers.password_reset.render_template', return_value='password_reset/forgot.html'):
+ response = forgot_password_post()
+ assert response == 'password_reset/forgot.html'
+ email_configured.assert_not_called()
+
+ def test_forgot_password_accepts_valid_recaptcha(self):
+ """Forgot-password should continue flow when reCAPTCHA is valid."""
+ with self.app.test_request_context('/forgot-password', method='POST', data={'email': 'test@example.com'}):
+ with patch('app.controllers.password_reset.quota_exceeded', return_value=False):
+ with patch('app.controllers.password_reset.verify_recaptcha', return_value=True):
+ with patch('app.controllers.password_reset.email_is_configured', return_value=True):
+ with patch('app.controllers.password_reset.email_quota_exceeded', return_value=False):
+ with patch('app.controllers.password_reset.user_by_mail', side_effect=ErrNoResult('not found')):
+ with patch('app.controllers.password_reset.render_template', return_value='password_reset/email_sent.html'):
+ response = forgot_password_post()
+ assert response == 'password_reset/email_sent.html'