fix: Only send password reset email to known, active addresses (#5061)
* fix: Only send password reset email to known, active addresses Limits password reset to Users with a Person and at least one active address on file. Avoids the possibility of sending a password reset to a spoofed address as in CVE-2019-19844. * test: Use factory instead of explicit construction * test: Test that a User with no Person cannot reset password * fix: Fix handling of User.person field when it's null * test: Test that reset emails are sent to known, active addresses
This commit is contained in:
parent
afac1f8f19
commit
98d7b15dfb
|
@ -21,6 +21,7 @@ from ietf.person.models import Person, Email
|
||||||
from ietf.mailinglists.models import Allowlisted
|
from ietf.mailinglists.models import Allowlisted
|
||||||
from ietf.utils.text import isascii
|
from ietf.utils.text import isascii
|
||||||
|
|
||||||
|
|
||||||
class RegistrationForm(forms.Form):
|
class RegistrationForm(forms.Form):
|
||||||
email = forms.EmailField(label="Your email (lowercase)")
|
email = forms.EmailField(label="Your email (lowercase)")
|
||||||
|
|
||||||
|
@ -193,10 +194,18 @@ class ResetPasswordForm(forms.Form):
|
||||||
username = forms.EmailField(label="Your email (lowercase)")
|
username = forms.EmailField(label="Your email (lowercase)")
|
||||||
|
|
||||||
def clean_username(self):
|
def clean_username(self):
|
||||||
import ietf.ietfauth.views
|
"""Verify that the username is valid
|
||||||
|
|
||||||
|
In addition to EmailField's checks, verifies that a User matching the username exists.
|
||||||
|
"""
|
||||||
username = self.cleaned_data["username"]
|
username = self.cleaned_data["username"]
|
||||||
if not User.objects.filter(username=username).exists():
|
if not User.objects.filter(username=username).exists():
|
||||||
raise forms.ValidationError(mark_safe("Didn't find a matching account. If you don't have an account yet, you can <a href=\"{}\">create one</a>.".format(urlreverse(ietf.ietfauth.views.create_account))))
|
raise forms.ValidationError(mark_safe(
|
||||||
|
"Didn't find a matching account. "
|
||||||
|
"If you don't have an account yet, you can <a href=\"{}\">create one</a>.".format(
|
||||||
|
urlreverse('ietf.ietfauth.views.create_account')
|
||||||
|
)
|
||||||
|
))
|
||||||
return username
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -414,12 +414,10 @@ class IetfAuthTests(TestCase):
|
||||||
email = 'someone@example.com'
|
email = 'someone@example.com'
|
||||||
password = 'foobar'
|
password = 'foobar'
|
||||||
|
|
||||||
user = User.objects.create(username=email, email=email)
|
user = PersonFactory(user__email=email).user
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
user.save()
|
user.save()
|
||||||
p = Person.objects.create(name="Some One", ascii="Some One", user=user)
|
|
||||||
Email.objects.create(address=user.username, person=p, origin=user.username)
|
|
||||||
|
|
||||||
# get
|
# get
|
||||||
r = self.client.get(url)
|
r = self.client.get(url)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
@ -497,6 +495,39 @@ class IetfAuthTests(TestCase):
|
||||||
r = self.client.get(confirm_url)
|
r = self.client.get(confirm_url)
|
||||||
self.assertEqual(r.status_code, 404)
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_reset_password_without_person(self):
|
||||||
|
"""No password reset for account without a person"""
|
||||||
|
url = urlreverse('ietf.ietfauth.views.password_reset')
|
||||||
|
user = UserFactory()
|
||||||
|
user.set_password('some password')
|
||||||
|
user.save()
|
||||||
|
empty_outbox()
|
||||||
|
r = self.client.post(url, { 'username': user.username})
|
||||||
|
self.assertContains(r, 'No known active email addresses', status_code=200)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertTrue(len(q("form .is-invalid")) > 0)
|
||||||
|
self.assertEqual(len(outbox), 0)
|
||||||
|
|
||||||
|
def test_reset_password_address_handling(self):
|
||||||
|
"""Reset password links are only sent to known, active addresses"""
|
||||||
|
url = urlreverse('ietf.ietfauth.views.password_reset')
|
||||||
|
person = PersonFactory()
|
||||||
|
person.email_set.update(active=False)
|
||||||
|
empty_outbox()
|
||||||
|
r = self.client.post(url, { 'username': person.user.username})
|
||||||
|
self.assertContains(r, 'No known active email addresses', status_code=200)
|
||||||
|
q = PyQuery(r.content)
|
||||||
|
self.assertTrue(len(q("form .is-invalid")) > 0)
|
||||||
|
self.assertEqual(len(outbox), 0)
|
||||||
|
|
||||||
|
active_address = EmailFactory(person=person).address
|
||||||
|
r = self.client.post(url, {'username': person.user.username})
|
||||||
|
self.assertNotContains(r, 'No known active email addresses', status_code=200)
|
||||||
|
self.assertEqual(len(outbox), 1)
|
||||||
|
to = outbox[0].get('To')
|
||||||
|
self.assertIn(active_address, to)
|
||||||
|
self.assertNotIn(person.user.username, to)
|
||||||
|
|
||||||
def test_review_overview(self):
|
def test_review_overview(self):
|
||||||
review_req = ReviewRequestFactory()
|
review_req = ReviewRequestFactory()
|
||||||
assignment = ReviewAssignmentFactory(review_request=review_req,reviewer=EmailFactory(person__user__username='reviewer'))
|
assignment = ReviewAssignmentFactory(review_request=review_req,reviewer=EmailFactory(person__user__username='reviewer'))
|
||||||
|
|
|
@ -413,32 +413,39 @@ def password_reset(request):
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = ResetPasswordForm(request.POST)
|
form = ResetPasswordForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
username = form.cleaned_data['username']
|
submitted_username = form.cleaned_data['username']
|
||||||
|
# The form validation checks that a matching User exists. Add the person__isnull check
|
||||||
|
# because the OneToOne field does not gracefully handle checks for user.person is Null.
|
||||||
|
# If we don't get a User here, we know it's because there's no related Person.
|
||||||
|
user = User.objects.filter(username=submitted_username, person__isnull=False).first()
|
||||||
|
if not (user and user.person.email_set.filter(active=True).exists()):
|
||||||
|
form.add_error(
|
||||||
|
'username',
|
||||||
|
'No known active email addresses are associated with this account. '
|
||||||
|
'Please contact the secretariat for assistance.',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
'username': user.username,
|
||||||
|
'password': user.password and user.password[-4:],
|
||||||
|
'last_login': user.last_login.timestamp() if user.last_login else None,
|
||||||
|
}
|
||||||
|
auth = django.core.signing.dumps(data, salt="password_reset")
|
||||||
|
|
||||||
data = { 'username': username }
|
domain = Site.objects.get_current().domain
|
||||||
if User.objects.filter(username=username).exists():
|
subject = 'Confirm password reset at %s' % domain
|
||||||
user = User.objects.get(username=username)
|
from_email = settings.DEFAULT_FROM_EMAIL
|
||||||
data['password'] = user.password and user.password[-4:]
|
# Send email to addresses from the database, NOT to the address from the form.
|
||||||
if user.last_login:
|
# This prevents unicode spoofing tricks (https://nvd.nist.gov/vuln/detail/CVE-2019-19844).
|
||||||
data['last_login'] = user.last_login.timestamp()
|
to_emails = list(set(email.address for email in user.person.email_set.filter(active=True)))
|
||||||
else:
|
to_emails.sort()
|
||||||
data['last_login'] = None
|
send_mail(request, to_emails, from_email, subject, 'registration/password_reset_email.txt', {
|
||||||
|
'domain': domain,
|
||||||
auth = django.core.signing.dumps(data, salt="password_reset")
|
'auth': auth,
|
||||||
|
'username': submitted_username,
|
||||||
domain = Site.objects.get_current().domain
|
'expire': settings.MINUTES_TO_EXPIRE_RESET_PASSWORD_LINK,
|
||||||
subject = 'Confirm password reset at %s' % domain
|
})
|
||||||
from_email = settings.DEFAULT_FROM_EMAIL
|
success = True
|
||||||
to_email = username # form validation makes sure that this is an email address
|
|
||||||
|
|
||||||
send_mail(request, to_email, from_email, subject, 'registration/password_reset_email.txt', {
|
|
||||||
'domain': domain,
|
|
||||||
'auth': auth,
|
|
||||||
'username': username,
|
|
||||||
'expire': settings.MINUTES_TO_EXPIRE_RESET_PASSWORD_LINK,
|
|
||||||
})
|
|
||||||
|
|
||||||
success = True
|
|
||||||
else:
|
else:
|
||||||
form = ResetPasswordForm()
|
form = ResetPasswordForm()
|
||||||
return render(request, 'registration/password_reset.html', {
|
return render(request, 'registration/password_reset.html', {
|
||||||
|
|
Loading…
Reference in a new issue