From c38ade6e1b378340268acc210c6a4707453cde36 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Tue, 21 Feb 2023 10:01:03 -0600 Subject: [PATCH] feat: treat django auth username as case insensitive throughout the datatracker (#5165) * feat: insensitive username matching at django authentication * feat: use iexact when using the User object manager * fix: more places to ignore username case * fix: remove unused management command * fix: avoid get when probing for object existance * fix: force lowercase new usernames in secr/rolodex * fix: use explicit arguments when creating user --- ietf/api/views.py | 2 +- ietf/community/utils.py | 2 +- ietf/ietfauth/backends.py | 21 ++++ ietf/ietfauth/forms.py | 6 +- ietf/ietfauth/views.py | 10 +- ietf/nomcom/utils.py | 2 +- ietf/person/utils.py | 1 - ietf/secr/rolodex/forms.py | 10 +- ietf/secr/rolodex/views.py | 11 +- ietf/settings.py | 2 +- ietf/submit/views.py | 4 +- ietf/sync/views.py | 2 +- .../management/commands/import_htpasswd.py | 2 +- .../commands/send_gdpr_consent_request.py | 104 ------------------ 14 files changed, 47 insertions(+), 132 deletions(-) create mode 100644 ietf/ietfauth/backends.py delete mode 100644 ietf/utils/management/commands/send_gdpr_consent_request.py diff --git a/ietf/api/views.py b/ietf/api/views.py index e5fc3bac5..ea7af3caf 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -192,7 +192,7 @@ def api_new_meeting_registration(request): except ValueError as e: return err(400, "Unexpected POST data: %s" % e) response = "Accepted, New registration" if created else "Accepted, Updated registration" - if User.objects.filter(username=email).exists() or Email.objects.filter(address=email).exists(): + if User.objects.filter(username__iexact=email).exists() or Email.objects.filter(address=email).exists(): pass else: send_account_creation_email(request, email) diff --git a/ietf/community/utils.py b/ietf/community/utils.py index 06da50011..8130954b9 100644 --- a/ietf/community/utils.py +++ b/ietf/community/utils.py @@ -36,7 +36,7 @@ def lookup_community_list(username=None, acronym=None): group = get_object_or_404(Group, acronym=acronym) clist = CommunityList.objects.filter(group=group).first() or CommunityList(group=group) else: - user = get_object_or_404(User, username=username) + user = get_object_or_404(User, username__iexact=username) clist = CommunityList.objects.filter(user=user).first() or CommunityList(user=user) return clist diff --git a/ietf/ietfauth/backends.py b/ietf/ietfauth/backends.py new file mode 100644 index 000000000..ad34ca9a7 --- /dev/null +++ b/ietf/ietfauth/backends.py @@ -0,0 +1,21 @@ + +# From https://simpleisbetterthancomplex.com/tutorial/2017/02/06/how-to-implement-case-insensitive-username.html +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +class CaseInsensitiveModelBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + UserModel = get_user_model() + if username is None: + username = kwargs.get(UserModel.USERNAME_FIELD) + try: + case_insensitive_username_field = '{}__iexact'.format(UserModel.USERNAME_FIELD) + user = UserModel._default_manager.get(**{case_insensitive_username_field: username}) + except UserModel.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a non-existing user (#20760). + UserModel().set_password(password) + else: + if user.check_password(password) and self.user_can_authenticate(user): + return user diff --git a/ietf/ietfauth/forms.py b/ietf/ietfauth/forms.py index a27c955b9..2c2c57047 100644 --- a/ietf/ietfauth/forms.py +++ b/ietf/ietfauth/forms.py @@ -31,7 +31,7 @@ class RegistrationForm(forms.Form): return email if email.lower() != email: raise forms.ValidationError('The supplied address contained uppercase letters. Please use a lowercase email address.') - if User.objects.filter(username=email).exists(): + if User.objects.filter(username__iexact=email).exists(): raise forms.ValidationError('An account with the email address you provided already exists.') return email @@ -199,7 +199,7 @@ class ResetPasswordForm(forms.Form): In addition to EmailField's checks, verifies that a User matching the username exists. """ username = self.cleaned_data["username"] - if not User.objects.filter(username=username).exists(): + if not User.objects.filter(username__iexact=username).exists(): raise forms.ValidationError(mark_safe( "Didn't find a matching account. " "If you don't have an account yet, you can create one.".format( @@ -266,6 +266,6 @@ class ChangeUsernameForm(forms.Form): def clean_username(self): username = self.cleaned_data['username'] - if User.objects.filter(username=username).exists(): + if User.objects.filter(username__iexact=username).exists(): raise ValidationError("A login with that username already exists. Please contact the secretariat to get this resolved.") return username diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 01a43672d..9ba40b2e4 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -159,7 +159,7 @@ def confirm_account(request, auth): except django.core.signing.BadSignature: raise Http404("Invalid or expired auth") - if User.objects.filter(username=email).exists(): + if User.objects.filter(username__iexact=email).exists(): return redirect(profile) success = False @@ -390,7 +390,7 @@ def confirm_new_email(request, auth): except django.core.signing.BadSignature: raise Http404("Invalid or expired auth") - person = get_object_or_404(Person, user__username=username) + person = get_object_or_404(Person, user__username__iexact=username) # do another round of validation since the situation may have # changed since submitting the request @@ -417,7 +417,7 @@ def password_reset(request): # 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() + user = User.objects.filter(username__iexact=submitted_username, person__isnull=False).first() if not (user and user.person.email_set.filter(active=True).exists()): form.add_error( 'username', @@ -465,7 +465,7 @@ def confirm_password_reset(request, auth): except django.core.signing.BadSignature: raise Http404("Invalid or expired auth") - user = get_object_or_404(User, username=username, password__endswith=password, last_login=last_login) + user = get_object_or_404(User, username__iexact=username, password__endswith=password, last_login=last_login) if request.user.is_authenticated and request.user != user: return HttpResponseForbidden( f'This password reset link is not for the signed-in user. ' @@ -707,7 +707,7 @@ def login(request, extra_context=None): if request.method == "POST": form = AuthenticationForm(request, data=request.POST) username = form.data.get('username') - user = User.objects.filter(username=username).first() + user = User.objects.filter(username__iexact=username).first() # Consider _never_ actually looking for the User username and only looking at Email if not user: # try to find user ID from the email address email = Email.objects.filter(address=username).first() diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index 3227771af..fc990e247 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -95,7 +95,7 @@ def get_user_email(user): if emails: user._email_cache = emails[0] for email in emails: - if email.address == user.username: + if email.address.lower() == user.username.lower(): user._email_cache = email else: try: diff --git a/ietf/person/utils.py b/ietf/person/utils.py index 393587e8c..942c2aaab 100755 --- a/ietf/person/utils.py +++ b/ietf/person/utils.py @@ -47,7 +47,6 @@ def merge_persons(request, source, target, file=sys.stdout, verbose=False): # check for any remaining relationships and exit if more found objs = [source] -# request.user = User.objects.filter(is_superuser=True).first() deletable_objects = admin.utils.get_deleted_objects(objs, request, admin.site) deletable_objects_summary = deletable_objects[1] if len(deletable_objects_summary) > 1: # should only include one object (Person) diff --git a/ietf/secr/rolodex/forms.py b/ietf/secr/rolodex/forms.py index 8c95da88d..468fad0be 100644 --- a/ietf/secr/rolodex/forms.py +++ b/ietf/secr/rolodex/forms.py @@ -81,13 +81,13 @@ class EditPersonForm(forms.ModelForm): self.initial['user'] = self.instance.user.username def clean_user(self): - user = self.cleaned_data['user'] + user = self.cleaned_data['user'].lower() if user: # if Django User object exists return it, otherwise create one try: - user_obj = User.objects.get(username=user) + user_obj = User.objects.get(username__iexact=user) except User.DoesNotExist: - user_obj = User.objects.create_user(user,user) + user_obj = User.objects.create_user(username=user, email=user) return user_obj else: @@ -133,11 +133,11 @@ class NewPersonForm(forms.ModelForm): exclude = ('time','user') def clean_email(self): - email = self.cleaned_data['email'] + email = self.cleaned_data['email'].lower() # error if there is already an account (User, Person) associated with this email try: - user = User.objects.get(username=email) + user = User.objects.get(username__iexact=email) person = Person.objects.get(user=user) if user and person: raise forms.ValidationError("This account already exists. [name=%s, id=%s, email=%s]" % (person.name,person.id,email)) diff --git a/ietf/secr/rolodex/views.py b/ietf/secr/rolodex/views.py index be3cd5b02..7dd8201f0 100644 --- a/ietf/secr/rolodex/views.py +++ b/ietf/secr/rolodex/views.py @@ -1,6 +1,5 @@ from django.contrib import messages from django.contrib.auth.models import User -from django.db import IntegrityError from django.forms.models import inlineformset_factory from django.shortcuts import render, get_object_or_404, redirect from django.utils.http import urlencode @@ -91,10 +90,10 @@ def add_proceed(request): ) # in theory a user record could exist which wasn't associated with a Person - try: - user = User.objects.create_user(email, email) - except IntegrityError: - user = User.objects.get(username=email) + + user = User.objects.filter(username__iexact=email).first() + if not user: + user = User.objects.create_user(username=email, email=email) person.user = user person.save() @@ -179,7 +178,7 @@ def edit(request, id): if 'user' in person_form.changed_data and person_form.initial['user']: try: - source = User.objects.get(username=person_form.initial['user']) + source = User.objects.get(username__iexact=person_form.initial['user']) merge_users(source, person_form.cleaned_data['user']) source.is_active = False source.save() diff --git a/ietf/settings.py b/ietf/settings.py index 246d6bf6e..1c203ff16 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -168,7 +168,7 @@ STATICFILES_FINDERS = ( WSGI_APPLICATION = "ietf.wsgi.application" -AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) +AUTHENTICATION_BACKENDS = ( 'ietf.ietfauth.backends.CaseInsensitiveModelBackend', ) FILE_UPLOAD_PERMISSIONS = 0o644 diff --git a/ietf/submit/views.py b/ietf/submit/views.py index e875d1476..4aaafbeac 100644 --- a/ietf/submit/views.py +++ b/ietf/submit/views.py @@ -129,7 +129,7 @@ def api_submission(request): if form.is_valid(): log('got valid submission form for %s' % form.filename) username = form.cleaned_data['user'] - user = User.objects.filter(username=username) + user = User.objects.filter(username__iexact=username) if user.count() == 0: # See if a secondary login was being used email = Email.objects.filter(address=username, active=True) @@ -232,7 +232,7 @@ def api_submit(request): if form.is_valid(): log('got valid submission form for %s' % form.filename) username = form.cleaned_data['user'] - user = User.objects.filter(username=username) + user = User.objects.filter(username__iexact=username) if user.count() == 0: # See if a secondary login was being used email = Email.objects.filter(address=username, active=True) diff --git a/ietf/sync/views.py b/ietf/sync/views.py index 1d22c424c..431dd0a8f 100644 --- a/ietf/sync/views.py +++ b/ietf/sync/views.py @@ -58,7 +58,7 @@ def notify(request, org, notification): if not user.is_authenticated: try: - user = User.objects.get(username=username) + user = User.objects.get(username__iexact=username) except User.DoesNotExist: return HttpResponse("Invalid username/password") diff --git a/ietf/utils/management/commands/import_htpasswd.py b/ietf/utils/management/commands/import_htpasswd.py index ed19eea6f..c33a46b72 100644 --- a/ietf/utils/management/commands/import_htpasswd.py +++ b/ietf/utils/management/commands/import_htpasswd.py @@ -15,7 +15,7 @@ def import_htpasswd_file(filename, verbosity=1, overwrite=False): ' "%s"' % (file.name, line)) username, password = line.strip().split(':', 1) try: - user = User.objects.get(username=username) + user = User.objects.get(username__iexact=username) if overwrite == True or not user.password: if password.startswith('{SHA}'): user.password = "sha1$$%s" % password[len('{SHA}'):] diff --git a/ietf/utils/management/commands/send_gdpr_consent_request.py b/ietf/utils/management/commands/send_gdpr_consent_request.py deleted file mode 100644 index 152fc83fe..000000000 --- a/ietf/utils/management/commands/send_gdpr_consent_request.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright The IETF Trust 2018-2020, All Rights Reserved -# -*- coding: utf-8 -*- - - -import datetime -import time - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone - -import debug # pyflakes:ignore - -from ietf.person.models import Person, PersonEvent -from ietf.utils.mail import send_mail - -class Command(BaseCommand): - help = (""" - Send GDPR consent request emails to persons who have not indicated consent - to having their personal information stored. Each send is logged as a - PersonEvent. - - By default email sending happens at a rate of 1 message per second; the - rate can be adjusted with the -r option. At the start of a run, an estimate - is given of how many persons to send to, and how long the run will take. - - By default, emails are not sent out if there is less than 6 days since the - previous consent request email. The interval can be adjusted with the -m - option. One effect of this is that it is possible to break of a run and - re-start it with for instance a different rate, without having duplicate - messages go out to persons that were handled in the interrupted run. - """) - - def add_arguments(self, parser): - parser.add_argument('-n', '--dry-run', action='store_true', default=False, - help="Don't send email, just list recipients") - parser.add_argument('-d', '--date', help="Date of deletion (mentioned in message)") - parser.add_argument('-m', '--minimum-interval', type=int, default=6, - help="Minimum interval between re-sending email messages, default: %(default)s days") - parser.add_argument('-r', '--rate', type=float, default=1.0, - help='Rate of sending mail, default: %(default)s/s') - parser.add_argument('-R', '--reminder', action='store_true', default=False, - help='Preface the subject with "Reminder:"') - parser.add_argument('user', nargs='*') - - - def handle(self, *args, **options): - # Don't send copies of the whole bulk mailing to the debug mailbox - if settings.SERVER_MODE == 'production': - settings.EMAIL_COPY_TO = "Email Debug Copy " - # - event_type = 'gdpr_notice_email' - # Arguments - # --date - if 'date' in options and options['date'] != None: - try: - date = datetime.datetime.strptime(options['date'], "%Y-%m-%d").date() - except ValueError as e: - raise CommandError('%s' % e) - else: - date = datetime.date.today() + datetime.timedelta(days=30) - days = (date - datetime.date.today()).days - if days <= 1: - raise CommandError('date must be more than 1 day in the future') - # --rate - delay = 1.0/options['rate'] - # --minimum_interval - minimum_interval = options['minimum_interval'] - latest_previous = timezone.now() - datetime.timedelta(days=minimum_interval) - # user - self.stdout.write('Querying the database for matching person records ...') - if 'user' in options and options['user']: - persons = Person.objects.filter(user__username__in=options['user']) - else: - exclude = PersonEvent.objects.filter(time__gt=latest_previous, type=event_type) - persons = Person.objects.exclude(consent=True).exclude(personevent__in=exclude) - # Report the size of the run - runtime = persons.count() * delay - self.stdout.write('Sending to %d users; estimated time a bit more than %d:%02d hours' % (persons.count(), runtime//3600, runtime%3600//60)) - subject='Personal Information in the IETF Datatracker' - if options['reminder']: - subject = "Reminder: " + subject - for person in persons: - fields = ', '.join(person.needs_consent()) - if fields and person.email_set.exists(): - if options['dry_run']: - print(("%-32s %-32s %-32s %-32s %s" % (person.email(), person.name_from_draft or '', person.name, person.ascii, fields)).encode('utf8')) - else: - to = [ e.address for e in person.email_set.filter(active=True) ] # pyflakes:ignore - if not to: - to = [ e.address for e in person.email_set.all() ] # pyflakes:ignore - self.stdout.write("Sendimg email to %s" % to) - send_mail(None, to, "", - subject=subject, - template='utils/personal_information_notice.txt', - context={ - 'date': date, 'days': days, 'fields': fields, - 'person': person, 'settings': settings, - }, - ) - PersonEvent.objects.create(person=person, type='gdpr_notice_email', - desc="Sent GDPR notice email to %s with confirmation deadline %s" % (to, date)) - time.sleep(delay) -