Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/controllers/password_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions templates/password_reset/forgot.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
<input class="form-input" type="email" id="email" name="email" placeholder="your@email.com" required>
</div>
</div>
{% if RECAPTCHA_SITEKEY %}
<label class="form-label float-right" id="forgot-password-captcha-label">Human verification</label>
<div id="forgot-password-recaptcha" role="group" aria-labelledby="forgot-password-captcha-label" style="clear: both;">
<div class="g-recaptcha float-right" data-sitekey="{{ RECAPTCHA_SITEKEY }}"></div>
</div>
<div style="clear: both; margin-bottom: 1rem;"></div>
{% endif %}
<div style="display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem;">
<a href="/login" class="btn active">Back to Login</a>
<input type="submit" value="Send Reset Link" class="btn active">
Expand Down
40 changes: 40 additions & 0 deletions tests/test_password_reset_captcha.py
Original file line number Diff line number Diff line change
@@ -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'