Skip to content

Commit 68af2ff

Browse files
committed
Replace cache with session to stop links from sending multiple emails.
1 parent c1a65bb commit 68af2ff

2 files changed

Lines changed: 36 additions & 18 deletions

File tree

‎hypha/apply/users/tests/test_email_change.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,31 @@ def test_signed_value_reusable_if_sending_fails(self):
222222
)
223223
self.assertEqual(len(mail.outbox), 2)
224224

225+
def test_used_value_stays_used_when_cache_is_cleared(self):
226+
from django.core import mail
227+
228+
target = url_with_value(make_signed_value(self.user, NEW_EMAIL))
229+
self.client.get(target)
230+
sent = len(mail.outbox)
231+
# The database cache culls entries when full, so the guard must not
232+
# depend on it.
233+
cache.clear()
234+
response = self.client.get(target, follow=False)
235+
self.assertRedirects(
236+
response, CONFIRM_LINK_SENT_URL, fetch_redirect_response=False
237+
)
238+
self.assertEqual(len(mail.outbox), sent)
239+
240+
def test_new_value_after_used_one_still_sends(self):
241+
from django.core import mail
242+
243+
self.client.get(url_with_value(make_signed_value(self.user, NEW_EMAIL)))
244+
mail.outbox.clear()
245+
other_email = "other@example.com"
246+
self.client.get(url_with_value(make_signed_value(self.user, other_email)))
247+
self.assertEqual(len(mail.outbox), 2)
248+
self.assertIn(other_email, [addr for msg in mail.outbox for addr in msg.to])
249+
225250
def test_confirmation_token_is_not_accepted_as_value(self):
226251
from django.core import mail
227252

‎hypha/apply/users/views.py‎

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import datetime
2-
import hashlib
32
import time
43
from typing import Any
54
from urllib.parse import urlencode
@@ -17,7 +16,6 @@
1716
from django.contrib.auth.views import PasswordResetView as DjPasswordResetView
1817
from django.contrib.messages.views import SuccessMessageMixin
1918
from django.contrib.sites.shortcuts import get_current_site
20-
from django.core.cache import cache
2119
from django.core.exceptions import PermissionDenied
2220
from django.core.signing import TimestampSigner
2321
from django.db import IntegrityError
@@ -88,6 +86,8 @@
8886
# never accepted by the other.
8987
EMAIL_CHANGE_REQUEST_SALT = "hypha.users.email_change.request"
9088
EMAIL_CHANGE_CONFIRM_SALT = "hypha.users.email_change.confirm"
89+
# Session key holding the last email change request that mails were sent for.
90+
EMAIL_CHANGE_SENT_SESSION_KEY = "email_change_sent"
9191

9292

9393
@method_decorator(
@@ -229,24 +229,17 @@ def account_email_change(request):
229229
if updated_email == current_email:
230230
return redirect("users:account")
231231

232-
# The value can only be used once, so reloading the page cannot be used to
233-
# send the same mails over and over. cache.add() is an atomic check-and-set,
234-
# so concurrent requests with the same value cannot both get through.
235-
used_cache_key = (
236-
f"email-change-request:{hashlib.sha256(signed_value.encode()).hexdigest()}"
237-
)
238-
if not cache.add(used_cache_key, True, timeout=settings.PASSWORD_RESET_TIMEOUT):
239-
# Already handled, e.g. a reload or the back button: the mails are on
240-
# their way, so show the same page as the first request did.
232+
# Reloading, or going back through the elevate page, must not send the same
233+
# mails again. The session is kept per user and, unlike the cache, is not
234+
# culled, so it reliably remembers the request handled last.
235+
if request.session.get(EMAIL_CHANGE_SENT_SESSION_KEY) == signed_value:
236+
# The mails are on their way, so show the same page as the first
237+
# request did.
241238
return redirect("users:confirm_link_sent")
242239

243-
try:
244-
_send_email_change_mails(request, current_email, updated_email)
245-
except Exception:
246-
# Release the value so the user can retry instead of being left with a
247-
# used link and no mail.
248-
cache.delete(used_cache_key)
249-
raise
240+
_send_email_change_mails(request, current_email, updated_email)
241+
# Marked only once sent, so a request that failed can be retried.
242+
request.session[EMAIL_CHANGE_SENT_SESSION_KEY] = signed_value
250243
return redirect("users:confirm_link_sent")
251244

252245

0 commit comments

Comments
 (0)