Skip to content

Commit 0faa2e4

Browse files
authored
fix: Don't expose existing emails via reset password and account creation forms (ietf-tools#5288)
* Rebase to feat/postgres * Attempt to address further review comments
1 parent 4b4e876 commit 0faa2e4

5 files changed

Lines changed: 140 additions & 74 deletions

File tree

ietf/ietfauth/forms.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
from django.core.exceptions import ValidationError
1111
from django.db import models
1212
from django.contrib.auth.models import User
13-
from django.utils.html import mark_safe # type:ignore
14-
from django.urls import reverse as urlreverse
1513

1614
from django_password_strength.widgets import PasswordStrengthInput, PasswordConfirmationInput
1715

@@ -31,8 +29,6 @@ def clean_email(self):
3129
return email
3230
if email.lower() != email:
3331
raise forms.ValidationError('The supplied address contained uppercase letters. Please use a lowercase email address.')
34-
if User.objects.filter(username__iexact=email).exists():
35-
raise forms.ValidationError('An account with the email address you provided already exists.')
3632
return email
3733

3834

@@ -164,11 +160,6 @@ class NewEmailForm(forms.Form):
164160

165161
def clean_new_email(self):
166162
email = self.cleaned_data.get("new_email", "")
167-
if email:
168-
existing = Email.objects.filter(address=email).first()
169-
if existing:
170-
raise forms.ValidationError("Email address '%s' is already assigned to account '%s' (%s)" % (existing, existing.person and existing.person.user, existing.person))
171-
172163
for pat in settings.EXCLUDED_PERSONAL_EMAIL_REGEX_PATTERNS:
173164
if re.search(pat, email):
174165
raise ValidationError("This email address is not valid in a datatracker account")
@@ -193,21 +184,6 @@ def __init__(self, role, *args, **kwargs):
193184
class ResetPasswordForm(forms.Form):
194185
username = forms.EmailField(label="Your email (lowercase)")
195186

196-
def clean_username(self):
197-
"""Verify that the username is valid
198-
199-
In addition to EmailField's checks, verifies that a User matching the username exists.
200-
"""
201-
username = self.cleaned_data["username"]
202-
if not User.objects.filter(username__iexact=username).exists():
203-
raise forms.ValidationError(mark_safe(
204-
"Didn't find a matching account. "
205-
"If you don't have an account yet, you can <a href=\"{}\">create one</a>.".format(
206-
urlreverse('ietf.ietfauth.views.create_account')
207-
)
208-
))
209-
return username
210-
211187

212188
class TestEmailForm(forms.Form):
213189
email = forms.EmailField(required=False)

ietf/ietfauth/tests.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def test_create_account_failure_template(self):
165165
r = render_to_string('registration/manual.html', { 'account_request_email': settings.ACCOUNT_REQUEST_EMAIL })
166166
self.assertTrue("Additional Assistance Required" in r)
167167

168-
def register_and_verify(self, email):
168+
def register(self, email):
169169
url = urlreverse(ietf.ietfauth.views.create_account)
170170

171171
# register email
@@ -175,6 +175,9 @@ def register_and_verify(self, email):
175175
self.assertContains(r, "Account request received")
176176
self.assertEqual(len(outbox), 1)
177177

178+
def register_and_verify(self, email):
179+
self.register(email)
180+
178181
# go to confirm page
179182
confirm_url = self.extract_confirm_url(outbox[-1])
180183
r = self.client.get(confirm_url)
@@ -229,6 +232,20 @@ def test_create_subscribed_account(self):
229232
self.register_and_verify(email)
230233
settings.LIST_ACCOUNT_DELAY = saved_delay
231234

235+
def test_create_existing_account(self):
236+
# create account once
237+
email = "new-account@example.com"
238+
self.register_and_verify(email)
239+
240+
# create account again
241+
self.register(email)
242+
243+
# check notification
244+
note = get_payload_text(outbox[-1])
245+
self.assertIn(email, note)
246+
self.assertIn("A datatracker account for that email already exists", note)
247+
self.assertIn(urlreverse(ietf.ietfauth.views.password_reset), note)
248+
232249
def test_ietfauth_profile(self):
233250
EmailFactory(person__user__username='plain')
234251
GroupFactory(acronym='mars')
@@ -317,11 +334,14 @@ def test_ietfauth_profile(self):
317334
self.assertEqual(r.status_code, 200)
318335
self.assertEqual(Email.objects.filter(address=new_email_address, person__user__username=username, active=1).count(), 1)
319336

320-
# check that we can't re-add it - that would give a duplicate
321-
r = self.client.get(confirm_url)
337+
# try and add it again
338+
empty_outbox()
339+
r = self.client.post(url, with_new_email_address)
322340
self.assertEqual(r.status_code, 200)
323-
q = PyQuery(r.content)
324-
self.assertEqual(len(q('[name="action"][value="confirm"]')), 0)
341+
self.assertEqual(len(outbox), 1)
342+
note = get_payload_text(outbox[-1])
343+
self.assertIn(new_email_address, note)
344+
self.assertIn("already associated with your account", note)
325345

326346
pronoundish = base_data.copy()
327347
pronoundish["pronouns_freetext"] = "baz/boom"
@@ -395,7 +415,7 @@ def test_email_case_insensitive_protection(self):
395415
"new_email": "testaddress@example.net",
396416
}
397417
r = self.client.post(url, data)
398-
self.assertContains(r, "Email address &#39;TestAddress@example.net&#39; is already assigned", status_code=200)
418+
self.assertContains(r, "A confirmation email has been sent to", status_code=200)
399419

400420
def test_nomcom_dressing_on_profile(self):
401421
url = urlreverse('ietf.ietfauth.views.profile')
@@ -437,11 +457,11 @@ def test_reset_password(self):
437457
r = self.client.get(url)
438458
self.assertEqual(r.status_code, 200)
439459

440-
# ask for reset, wrong username
460+
# ask for reset, wrong username (form should not fail)
441461
r = self.client.post(url, { 'username': "nobody@example.com" })
442462
self.assertEqual(r.status_code, 200)
443463
q = PyQuery(r.content)
444-
self.assertTrue(len(q("form .is-invalid")) > 0)
464+
self.assertTrue(len(q("form .is-invalid")) == 0)
445465

446466
# ask for reset
447467
empty_outbox()
@@ -518,9 +538,9 @@ def test_reset_password_without_person(self):
518538
user.save()
519539
empty_outbox()
520540
r = self.client.post(url, { 'username': user.username})
521-
self.assertContains(r, 'No known active email addresses', status_code=200)
541+
self.assertContains(r, 'We have sent you an email with instructions', status_code=200)
522542
q = PyQuery(r.content)
523-
self.assertTrue(len(q("form .is-invalid")) > 0)
543+
self.assertTrue(len(q("form .is-invalid")) == 0)
524544
self.assertEqual(len(outbox), 0)
525545

526546
def test_reset_password_address_handling(self):
@@ -530,14 +550,14 @@ def test_reset_password_address_handling(self):
530550
person.email_set.update(active=False)
531551
empty_outbox()
532552
r = self.client.post(url, { 'username': person.user.username})
533-
self.assertContains(r, 'No known active email addresses', status_code=200)
553+
self.assertContains(r, 'We have sent you an email with instructions', status_code=200)
534554
q = PyQuery(r.content)
535-
self.assertTrue(len(q("form .is-invalid")) > 0)
555+
self.assertTrue(len(q("form .is-invalid")) == 0)
536556
self.assertEqual(len(outbox), 0)
537557

538558
active_address = EmailFactory(person=person).address
539559
r = self.client.post(url, {'username': person.user.username})
540-
self.assertNotContains(r, 'No known active email addresses', status_code=200)
560+
self.assertContains(r, 'We have sent you an email with instructions', status_code=200)
541561
self.assertEqual(len(outbox), 1)
542562
to = outbox[0].get('To')
543563
self.assertIn(active_address, to)

ietf/ietfauth/views.py

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -112,33 +112,51 @@ def index(request):
112112
# redirect_to = settings.LOGIN_REDIRECT_URL
113113
# return HttpResponseRedirect(redirect_to)
114114

115+
115116
def create_account(request):
116-
to_email = None
117+
new_account_email = None
117118

118-
if request.method == 'POST':
119+
if request.method == "POST":
119120
form = RegistrationForm(request.POST)
120121
if form.is_valid():
121-
to_email = form.cleaned_data['email'] # This will be lowercase if form.is_valid()
122-
123-
# For the IETF 113 Registration period (at least) we are lowering the barriers for account creation
124-
# to the simple email round-trip check
125-
send_account_creation_email(request, to_email)
126-
127-
# The following is what to revert to should that lowered barrier prove problematic
128-
# existing = Subscribed.objects.filter(email__iexact=to_email).first()
129-
# ok_to_create = ( Allowlisted.objects.filter(email__iexact=to_email).exists()
130-
# or existing and (existing.time + TimeDelta(seconds=settings.LIST_ACCOUNT_DELAY)) < DateTime.now() )
131-
# if ok_to_create:
132-
# send_account_creation_email(request, to_email)
133-
# else:
134-
# return render(request, 'registration/manual.html', { 'account_request_email': settings.ACCOUNT_REQUEST_EMAIL })
122+
new_account_email = form.cleaned_data[
123+
"email"
124+
] # This will be lowercase if form.is_valid()
125+
126+
user = User.objects.filter(username__iexact=new_account_email)
127+
email = Email.objects.filter(address__iexact=new_account_email)
128+
if user.exists() or email.exists():
129+
person_to_contact = user.first().person if user else email.first().person
130+
to_email = person_to_contact.email_address()
131+
if to_email:
132+
send_account_creation_exists_email(request, new_account_email, to_email)
133+
else:
134+
raise ValidationError(f"Account for {{new_account_email}} exists, but cannot email it")
135+
else:
136+
# For the IETF 113 Registration period (at least) we are lowering the
137+
# barriers for account creation to the simple email round-trip check
138+
send_account_creation_email(request, new_account_email)
139+
140+
# The following is what to revert to should that lowered barrier prove problematic
141+
# existing = Subscribed.objects.filter(email__iexact=new_account_email).first()
142+
# ok_to_create = ( Allowlisted.objects.filter(email__iexact=new_account_email).exists()
143+
# or existing and (existing.time + TimeDelta(seconds=settings.LIST_ACCOUNT_DELAY)) < DateTime.now() )
144+
# if ok_to_create:
145+
# send_account_creation_email(request, new_account_email)
146+
# else:
147+
# return render(request, 'registration/manual.html', { 'account_request_email': settings.ACCOUNT_REQUEST_EMAIL })
135148
else:
136149
form = RegistrationForm()
137150

138-
return render(request, 'registration/create.html', {
139-
'form': form,
140-
'to_email': to_email,
141-
})
151+
return render(
152+
request,
153+
"registration/create.html",
154+
{
155+
"form": form,
156+
"to_email": new_account_email,
157+
},
158+
)
159+
142160

143161
def send_account_creation_email(request, to_email):
144162
auth = django.core.signing.dumps(to_email, salt="create_account")
@@ -153,6 +171,23 @@ def send_account_creation_email(request, to_email):
153171
})
154172

155173

174+
def send_account_creation_exists_email(request, new_account_email, to_email):
175+
domain = Site.objects.get_current().domain
176+
subject = "Attempted account creation at %s" % domain
177+
from_email = settings.DEFAULT_FROM_EMAIL
178+
send_mail(
179+
request,
180+
to_email,
181+
from_email,
182+
subject,
183+
"registration/creation_exists_email.txt",
184+
{
185+
"domain": domain,
186+
"username": new_account_email,
187+
},
188+
)
189+
190+
156191
def confirm_account(request, auth):
157192
try:
158193
email = django.core.signing.loads(auth, salt="create_account", max_age=settings.DAYS_TO_EXPIRE_REGISTRATION_LINK * 24 * 60 * 60)
@@ -255,17 +290,25 @@ def profile(request):
255290
auth = django.core.signing.dumps([person.user.username, to_email], salt="add_email")
256291

257292
domain = Site.objects.get_current().domain
258-
subject = 'Confirm email address for %s' % person.name
259293
from_email = settings.DEFAULT_FROM_EMAIL
260294

261-
send_mail(request, to_email, from_email, subject, 'registration/add_email_email.txt', {
262-
'domain': domain,
263-
'auth': auth,
264-
'email': to_email,
265-
'person': person,
266-
'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK,
267-
})
268-
295+
existing = Email.objects.filter(address=to_email).first()
296+
if existing:
297+
subject = 'Attempt to add your email address by %s' % person.name
298+
send_mail(request, to_email, from_email, subject, 'registration/add_email_exists_email.txt', {
299+
'domain': domain,
300+
'email': to_email,
301+
'person': person,
302+
})
303+
else:
304+
subject = 'Confirm email address for %s' % person.name
305+
send_mail(request, to_email, from_email, subject, 'registration/add_email_email.txt', {
306+
'domain': domain,
307+
'auth': auth,
308+
'email': to_email,
309+
'person': person,
310+
'expire': settings.DAYS_TO_EXPIRE_REGISTRATION_LINK,
311+
})
269312

270313
for r in roles:
271314
e = r.email_form.cleaned_data["email"]
@@ -417,14 +460,10 @@ def password_reset(request):
417460
# The form validation checks that a matching User exists. Add the person__isnull check
418461
# because the OneToOne field does not gracefully handle checks for user.person is Null.
419462
# If we don't get a User here, we know it's because there's no related Person.
463+
# We still report that the action succeeded, so we're not leaking the existence of user
464+
# email addresses.
420465
user = User.objects.filter(username__iexact=submitted_username, person__isnull=False).first()
421-
if not (user and user.person.email_set.filter(active=True).exists()):
422-
form.add_error(
423-
'username',
424-
'No known active email addresses are associated with this account. '
425-
'Please contact the secretariat for assistance.',
426-
)
427-
else:
466+
if user and user.person.email_set.filter(active=True).exists():
428467
data = {
429468
'username': user.username,
430469
'password': user.password and user.password[-4:],
@@ -445,7 +484,7 @@ def password_reset(request):
445484
'username': submitted_username,
446485
'expire': settings.MINUTES_TO_EXPIRE_RESET_PASSWORD_LINK,
447486
})
448-
success = True
487+
success = True
449488
else:
450489
form = ResetPasswordForm()
451490
return render(request, 'registration/password_reset.html', {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{% autoescape off %}{% load ietf_filters %}
2+
Hello,
3+
4+
{% filter wordwrap:78 %}We have received a request to add the email address {{ email }} to the user account '{{ person.user }}' at {{ domain }}.
5+
This email address {{ email }} is already associated with your account at {{ domain }} and cannot be associated with two accounts.{% endfilter %}
6+
7+
If you did not request this change, you may safely ignore this email,
8+
as no actions have been taken.
9+
10+
Best regards,
11+
12+
The datatracker login manager service
13+
(for the IETF Secretariat)
14+
{% endautoescape %}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{% autoescape off %}{% load ietf_filters %}
2+
Hello,
3+
4+
{% filter wordwrap:78 %}We have received an account creation request for {{ username }} at {{ domain }}.{% endfilter %}
5+
6+
{% filter wordwrap:78 %}A datatracker account for that email already exists. If you have forgotten the password for the {{ username }} account, please go to the following link and follow the instructions there:{% endfilter %}
7+
8+
https://{{ domain }}{% url "ietf.ietfauth.views.password_reset" %}
9+
10+
If you have not requested the account creation you can ignore this email, your
11+
credentials have been left untouched.
12+
13+
Best regards,
14+
15+
The datatracker login manager service
16+
(for the IETF Secretariat)
17+
{% endautoescape %}

0 commit comments

Comments
 (0)