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
This commit is contained in:
Robert Sparks 2023-02-21 10:01:03 -06:00 committed by GitHub
parent a10464a250
commit c38ade6e1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 47 additions and 132 deletions

View file

@ -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)

View file

@ -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

21
ietf/ietfauth/backends.py Normal file
View file

@ -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

View file

@ -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 <a href=\"{}\">create one</a>.".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

View file

@ -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()

View file

@ -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:

View file

@ -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)

View file

@ -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))

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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}'):]

View file

@ -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 <outbound@ietf.org>"
#
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, "<gdprnoreply@ietf.org>",
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)