fix: validate form in login() (#7435)

* fix: validate form in login()

* refactor: custom LoginView subclass for logins

Preserves old behavior, but avoids some hacks.

* test: reverse with strings, not view refs

* chore: remove unused imports

* fix: restore logout() call
This commit is contained in:
Jennifer Richards 2024-05-24 18:36:58 -03:00 committed by GitHub
parent 96902bf3b8
commit 3c13db45fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 73 deletions

View file

@ -50,8 +50,6 @@ from ietf.utils.test_utils import TestCase, login_testing_unauthorized
from ietf.utils.timezone import date_today from ietf.utils.timezone import date_today
import ietf.ietfauth.views
if os.path.exists(settings.HTPASSWD_COMMAND): if os.path.exists(settings.HTPASSWD_COMMAND):
skip_htpasswd_command = False skip_htpasswd_command = False
skip_message = "" skip_message = ""
@ -83,30 +81,30 @@ class IetfAuthTests(TestCase):
super().tearDown() super().tearDown()
def test_index(self): def test_index(self):
self.assertEqual(self.client.get(urlreverse(ietf.ietfauth.views.index)).status_code, 200) self.assertEqual(self.client.get(urlreverse("ietf.ietfauth.views.index")).status_code, 200)
def test_login_and_logout(self): def test_login_and_logout(self):
PersonFactory(user__username='plain') PersonFactory(user__username='plain')
# try logging in without a next # try logging in without a next
r = self.client.get(urlreverse(ietf.ietfauth.views.login)) r = self.client.get(urlreverse("ietf.ietfauth.views.login"))
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
r = self.client.post(urlreverse(ietf.ietfauth.views.login), {"username":"plain", "password":"plain+password"}) r = self.client.post(urlreverse("ietf.ietfauth.views.login"), {"username":"plain", "password":"plain+password"})
self.assertEqual(r.status_code, 302) self.assertEqual(r.status_code, 302)
self.assertEqual(urlsplit(r["Location"])[2], urlreverse(ietf.ietfauth.views.profile)) self.assertEqual(urlsplit(r["Location"])[2], urlreverse("ietf.ietfauth.views.profile"))
# try logging out # try logging out
r = self.client.post(urlreverse('django.contrib.auth.views.logout'), {}) r = self.client.post(urlreverse('django.contrib.auth.views.logout'), {})
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertNotContains(r, "accounts/logout") self.assertNotContains(r, "accounts/logout")
r = self.client.get(urlreverse(ietf.ietfauth.views.profile)) r = self.client.get(urlreverse("ietf.ietfauth.views.profile"))
self.assertEqual(r.status_code, 302) self.assertEqual(r.status_code, 302)
self.assertEqual(urlsplit(r["Location"])[2], urlreverse(ietf.ietfauth.views.login)) self.assertEqual(urlsplit(r["Location"])[2], urlreverse("ietf.ietfauth.views.login"))
# try logging in with a next # try logging in with a next
r = self.client.post(urlreverse(ietf.ietfauth.views.login) + "?next=/foobar", {"username":"plain", "password":"plain+password"}) r = self.client.post(urlreverse("ietf.ietfauth.views.login") + "?next=/foobar", {"username":"plain", "password":"plain+password"})
self.assertEqual(r.status_code, 302) self.assertEqual(r.status_code, 302)
self.assertEqual(urlsplit(r["Location"])[2], "/foobar") self.assertEqual(urlsplit(r["Location"])[2], "/foobar")
@ -137,19 +135,19 @@ class IetfAuthTests(TestCase):
# try with a trivial next # try with a trivial next
_test_login("/") _test_login("/")
# try with a next that requires login # try with a next that requires login
_test_login(urlreverse(ietf.ietfauth.views.profile)) _test_login(urlreverse("ietf.ietfauth.views.profile"))
def test_login_with_different_email(self): def test_login_with_different_email(self):
person = PersonFactory(user__username='plain') person = PersonFactory(user__username='plain')
email = EmailFactory(person=person) email = EmailFactory(person=person)
# try logging in without a next # try logging in without a next
r = self.client.get(urlreverse(ietf.ietfauth.views.login)) r = self.client.get(urlreverse("ietf.ietfauth.views.login"))
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
r = self.client.post(urlreverse(ietf.ietfauth.views.login), {"username":email, "password":"plain+password"}) r = self.client.post(urlreverse("ietf.ietfauth.views.login"), {"username":email, "password":"plain+password"})
self.assertEqual(r.status_code, 302) self.assertEqual(r.status_code, 302)
self.assertEqual(urlsplit(r["Location"])[2], urlreverse(ietf.ietfauth.views.profile)) self.assertEqual(urlsplit(r["Location"])[2], urlreverse("ietf.ietfauth.views.profile"))
def extract_confirm_url(self, confirm_email): def extract_confirm_url(self, confirm_email):
# dig out confirm_email link # dig out confirm_email link
@ -176,7 +174,7 @@ class IetfAuthTests(TestCase):
# For the lowered barrier to account creation period, we are disabling this kind of failure # For the lowered barrier to account creation period, we are disabling this kind of failure
# def test_create_account_failure(self): # def test_create_account_failure(self):
# url = urlreverse(ietf.ietfauth.views.create_account) # url = urlreverse("ietf.ietfauth.views.create_account")
# # get # # get
# r = self.client.get(url) # r = self.client.get(url)
@ -195,7 +193,7 @@ class IetfAuthTests(TestCase):
self.assertTrue("Additional Assistance Required" in r) self.assertTrue("Additional Assistance Required" in r)
def register(self, email): def register(self, email):
url = urlreverse(ietf.ietfauth.views.create_account) url = urlreverse("ietf.ietfauth.views.create_account")
# register email # register email
empty_outbox() empty_outbox()
@ -240,7 +238,7 @@ class IetfAuthTests(TestCase):
note = get_payload_text(outbox[-1]) note = get_payload_text(outbox[-1])
self.assertIn(email, note) self.assertIn(email, note)
self.assertIn("A datatracker account for that email already exists", note) self.assertIn("A datatracker account for that email already exists", note)
self.assertIn(urlreverse(ietf.ietfauth.views.password_reset), note) self.assertIn(urlreverse("ietf.ietfauth.views.password_reset"), note)
def test_ietfauth_profile(self): def test_ietfauth_profile(self):
EmailFactory(person__user__username='plain') EmailFactory(person__user__username='plain')
@ -249,7 +247,7 @@ class IetfAuthTests(TestCase):
username = "plain" username = "plain"
email_address = Email.objects.filter(person__user__username=username).first().address email_address = Email.objects.filter(person__user__username=username).first().address
url = urlreverse(ietf.ietfauth.views.profile) url = urlreverse("ietf.ietfauth.views.profile")
login_testing_unauthorized(self, username, url) login_testing_unauthorized(self, username, url)
@ -400,7 +398,7 @@ class IetfAuthTests(TestCase):
def test_email_case_insensitive_protection(self): def test_email_case_insensitive_protection(self):
EmailFactory(address="TestAddress@example.net") EmailFactory(address="TestAddress@example.net")
person = PersonFactory() person = PersonFactory()
url = urlreverse(ietf.ietfauth.views.profile) url = urlreverse("ietf.ietfauth.views.profile")
login_testing_unauthorized(self, person.user.username, url) login_testing_unauthorized(self, person.user.username, url)
data = { data = {
@ -441,7 +439,7 @@ class IetfAuthTests(TestCase):
def test_reset_password(self): def test_reset_password(self):
url = urlreverse(ietf.ietfauth.views.password_reset) url = urlreverse("ietf.ietfauth.views.password_reset")
email = 'someone@example.com' email = 'someone@example.com'
password = 'foobar' password = 'foobar'
@ -507,7 +505,7 @@ class IetfAuthTests(TestCase):
self.assertEqual(len(outbox), 1) self.assertEqual(len(outbox), 1)
confirm_url = self.extract_confirm_url(outbox[-1]) confirm_url = self.extract_confirm_url(outbox[-1])
r = self.client.post(urlreverse(ietf.ietfauth.views.login), {'username': email, 'password': password}) r = self.client.post(urlreverse("ietf.ietfauth.views.login"), {'username': email, 'password': password})
r = self.client.get(confirm_url) r = self.client.get(confirm_url)
self.assertEqual(r.status_code, 404) self.assertEqual(r.status_code, 404)
@ -589,7 +587,7 @@ class IetfAuthTests(TestCase):
availability="unavailable", availability="unavailable",
) )
url = urlreverse(ietf.ietfauth.views.review_overview) url = urlreverse("ietf.ietfauth.views.review_overview")
login_testing_unauthorized(self, reviewer.user.username, url) login_testing_unauthorized(self, reviewer.user.username, url)
@ -633,10 +631,9 @@ class IetfAuthTests(TestCase):
def test_change_password(self): def test_change_password(self):
chpw_url = urlreverse("ietf.ietfauth.views.change_password")
chpw_url = urlreverse(ietf.ietfauth.views.change_password) prof_url = urlreverse("ietf.ietfauth.views.profile")
prof_url = urlreverse(ietf.ietfauth.views.profile) login_url = urlreverse("ietf.ietfauth.views.login")
login_url = urlreverse(ietf.ietfauth.views.login)
redir_url = '%s?next=%s' % (login_url, chpw_url) redir_url = '%s?next=%s' % (login_url, chpw_url)
# get without logging in # get without logging in
@ -681,9 +678,9 @@ class IetfAuthTests(TestCase):
def test_change_username(self): def test_change_username(self):
chun_url = urlreverse(ietf.ietfauth.views.change_username) chun_url = urlreverse("ietf.ietfauth.views.change_username")
prof_url = urlreverse(ietf.ietfauth.views.profile) prof_url = urlreverse("ietf.ietfauth.views.profile")
login_url = urlreverse(ietf.ietfauth.views.login) login_url = urlreverse("ietf.ietfauth.views.login")
redir_url = '%s?next=%s' % (login_url, chun_url) redir_url = '%s?next=%s' % (login_url, chun_url)
# get without logging in # get without logging in

View file

@ -14,7 +14,7 @@ urlpatterns = [
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email), url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
url(r'^create/$', views.create_account), url(r'^create/$', views.create_account),
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account), url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),
url(r'^login/$', views.login), url(r'^login/$', views.AnyEmailLoginView.as_view(), name="ietf.ietfauth.views.login"),
url(r'^logout/$', LogoutView.as_view(), name="django.contrib.auth.views.logout"), url(r'^logout/$', LogoutView.as_view(), name="django.contrib.auth.views.logout"),
url(r'^password/$', views.change_password), url(r'^password/$', views.change_password),
url(r'^profile/$', views.profile), url(r'^profile/$', views.profile),

View file

@ -45,7 +45,7 @@ import django.core.signing
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.conf import settings from django.conf import settings
from django.contrib.auth import update_session_auth_hash, logout, authenticate from django.contrib.auth import logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.hashers import identify_hasher from django.contrib.auth.hashers import identify_hasher
@ -752,53 +752,57 @@ def change_username(request):
return render(request, 'registration/change_username.html', {'form': form}) return render(request, 'registration/change_username.html', {'form': form})
class AnyEmailAuthenticationForm(AuthenticationForm):
def login(request, extra_context=None): """AuthenticationForm that allows any email address as the username
"""
This login function is a wrapper around django's login() for the purpose Also performs a check for a cleared password field and provides a helpful error message
of providing a notification if the user's password has been cleared. The if that applies to the user attempting to log in.
warning will be triggered if the password field has been set to something
which is not recognized as a valid password hash.
""" """
_unauthenticated_user = None
if request.method == "POST": def clean_username(self):
form = AuthenticationForm(request, data=request.POST) username = self.cleaned_data.get("username", None)
username = form.data.get('username') if username is None:
user = User.objects.filter(username__iexact=username).first() # Consider _never_ actually looking for the User username and only looking at Email raise self.get_invalid_login_error()
if not user: user = User.objects.filter(username__iexact=username).first()
# try to find user ID from the email address if user is None:
email = Email.objects.filter(address=username).first() email = Email.objects.filter(address=username).first()
if email and email.person and email.person.user: if email and email.person:
u2 = email.person.user user = email.person.user # might be None
# be conservative, only accept this if login is valid if user is None:
if u2: raise self.get_invalid_login_error()
pw = form.data.get('password') self._unauthenticated_user = user # remember this for the clean() method
au = authenticate(request, username=u2.username, password=pw) return user.username
if au:
# kludge to change the querydict def clean(self):
q2 = request.POST.copy() if self._unauthenticated_user is not None:
q2['username'] = u2.username try:
request.POST = q2 identify_hasher(self._unauthenticated_user.password)
user = u2
#
if user:
try:
identify_hasher(user.password)
except ValueError: except ValueError:
extra_context = {"alert": self.add_error(
"Note: Your password has been cleared because " "password",
"of possible password leakage. " 'Your password has been cleared because of possible password leakage. '
"Please use the password reset link below " 'Please use the "Forgot your password?" button below to set a new password '
"to set a new password for your account.", 'for your account.',
} )
response = LoginView.as_view(extra_context=extra_context)(request) return super().clean()
if isinstance(response, HttpResponseRedirect) and user and user.is_authenticated:
try:
user.person class AnyEmailLoginView(LoginView):
except Person.DoesNotExist: """LoginView that allows any email address as the username
logout(request)
response = render(request, 'registration/missing_person.html') Redirects to the missing_person page instead of logging in if the user does not have a Person
return response """
form_class = AnyEmailAuthenticationForm
def form_valid(self, form):
"""Security check complete. Log the user in if they have a Person."""
user = form.get_user() # user has authenticated at this point
if not hasattr(user, "person"):
logout(self.request) # should not be logged in yet, but just in case...
return render(self.request, "registration/missing_person.html")
return super().form_valid(form)
@login_required @login_required
@person_required @person_required