import unicodedata from django import forms from django.contrib.auth import authenticate, get_user_model, password_validation from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives from django.template import loader from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode from django.utils.text import capfirst from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ UserModel = get_user_model() def _unicode_ci_compare(s1, s2): """ Perform case-insensitive comparison of two identifiers, using the recommended algorithm from Unicode Technical Report 36, section 2.11.2(B)(2). """ return ( unicodedata.normalize("NFKC", s1).casefold() == unicodedata.normalize("NFKC", s2).casefold() ) class ReadOnlyPasswordHashWidget(forms.Widget): template_name = "auth/widgets/read_only_password_hash.html" read_only = True def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) usable_password = value and not value.startswith(UNUSABLE_PASSWORD_PREFIX) summary = [] if usable_password: try: hasher = identify_hasher(value) except ValueError: summary.append( { "label": gettext( "Invalid password format or unknown hashing algorithm." ) } ) else: for key, value_ in hasher.safe_summary(value).items(): summary.append({"label": gettext(key), "value": value_}) else: summary.append({"label": gettext("No password set.")}) context["summary"] = summary context["button_label"] = ( _("Reset password") if usable_password else _("Set password") ) return context def id_for_label(self, id_): return None class ReadOnlyPasswordHashField(forms.Field): widget = ReadOnlyPasswordHashWidget def __init__(self, *args, **kwargs): kwargs.setdefault("required", False) kwargs.setdefault("disabled", True) super().__init__(*args, **kwargs) class UsernameField(forms.CharField): def to_python(self, value): value = super().to_python(value) if self.max_length is not None and len(value) > self.max_length: # Normalization can increase the string length (e.g. # "ff" -> "ff", "½" -> "1⁄2") but cannot reduce it, so there is no # point in normalizing invalid data. Moreover, Unicode # normalization is very slow on Windows and can be a DoS attack # vector. return value return unicodedata.normalize("NFKC", value) def widget_attrs(self, widget): return { **super().widget_attrs(widget), "autocapitalize": "none", "autocomplete": "username", } class SetPasswordMixin: """ Form mixin that validates and sets a password for a user. This mixin also support setting an unusable password for a user. """ error_messages = { "password_mismatch": _("The two password fields didn’t match."), } usable_password_help_text = _( "Whether the user will be able to authenticate using a password or not. " "If disabled, they may still be able to authenticate using other backends, " "such as Single Sign-On or LDAP." ) @staticmethod def create_password_fields(label1=_("Password"), label2=_("Password confirmation")): password1 = forms.CharField( label=label1, required=False, strip=False, widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), help_text=password_validation.password_validators_help_text_html(), ) password2 = forms.CharField( label=label2, required=False, widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), strip=False, help_text=_("Enter the same password as before, for verification."), ) return password1, password2 @staticmethod def create_usable_password_field(help_text=usable_password_help_text): return forms.ChoiceField( label=_("Password-based authentication"), required=False, initial="true", choices={"true": _("Enabled"), "false": _("Disabled")}, widget=forms.RadioSelect(attrs={"class": "radiolist inline"}), help_text=help_text, ) def validate_passwords( self, password1_field_name="password1", password2_field_name="password2", usable_password_field_name="usable_password", ): usable_password = ( self.cleaned_data.pop(usable_password_field_name, None) != "false" ) self.cleaned_data["set_usable_password"] = usable_password password1 = self.cleaned_data.get(password1_field_name) password2 = self.cleaned_data.get(password2_field_name) if not usable_password: return self.cleaned_data if not password1 and password1_field_name not in self.errors: error = ValidationError( self.fields[password1_field_name].error_messages["required"], code="required", ) self.add_error(password1_field_name, error) if not password2 and password2_field_name not in self.errors: error = ValidationError( self.fields[password2_field_name].error_messages["required"], code="required", ) self.add_error(password2_field_name, error) if password1 and password2 and password1 != password2: error = ValidationError( self.error_messages["password_mismatch"], code="password_mismatch", ) self.add_error(password2_field_name, error) def validate_password_for_user(self, user, password_field_name="password2"): password = self.cleaned_data.get(password_field_name) if password and self.cleaned_data["set_usable_password"]: try: password_validation.validate_password(password, user) except ValidationError as error: self.add_error(password_field_name, error) def set_password_and_save(self, user, password_field_name="password1", commit=True): if self.cleaned_data["set_usable_password"]: user.set_password(self.cleaned_data[password_field_name]) else: user.set_unusable_password() if commit: user.save() return user class BaseUserCreationForm(SetPasswordMixin, forms.ModelForm): """ A form that creates a user, with no privileges, from the given username and password. """ password1, password2 = SetPasswordMixin.create_password_fields() usable_password = SetPasswordMixin.create_usable_password_field() class Meta: model = User fields = ("username",) field_classes = {"username": UsernameField} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self._meta.model.USERNAME_FIELD in self.fields: self.fields[self._meta.model.USERNAME_FIELD].widget.attrs[ "autofocus" ] = True def clean(self): self.validate_passwords() return super().clean() def _post_clean(self): super()._post_clean() # Validate the password after self.instance is updated with form data # by super(). self.validate_password_for_user(self.instance) def save(self, commit=True): user = super().save(commit=False) user = self.set_password_and_save(user, commit=commit) if commit and hasattr(self, "save_m2m"): self.save_m2m() return user class UserCreationForm(BaseUserCreationForm): def clean_username(self): """Reject usernames that differ only in case.""" username = self.cleaned_data.get("username") if ( username and self._meta.model.objects.filter(username__iexact=username).exists() ): self._update_errors( ValidationError( { "username": self.instance.unique_error_message( self._meta.model, ["username"] ) } ) ) else: return username class UserChangeForm(forms.ModelForm): password = ReadOnlyPasswordHashField( label=_("Password"), help_text=_( "Raw passwords are not stored, so there is no way to see " "the user’s password." ), ) class Meta: model = User fields = "__all__" field_classes = {"username": UsernameField} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) password = self.fields.get("password") if password: if self.instance and not self.instance.has_usable_password(): password.help_text = _( "Enable password-based authentication for this user by setting a " "password." ) user_permissions = self.fields.get("user_permissions") if user_permissions: user_permissions.queryset = user_permissions.queryset.select_related( "content_type" ) class AuthenticationForm(forms.Form): """ Base class for authenticating users. Extend this to get a form that accepts username/password logins. """ username = UsernameField(widget=forms.TextInput(attrs={"autofocus": True})) password = forms.CharField( label=_("Password"), strip=False, widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}), ) error_messages = { "invalid_login": _( "Please enter a correct %(username)s and password. Note that both " "fields may be case-sensitive." ), "inactive": _("This account is inactive."), } def __init__(self, request=None, *args, **kwargs): """ The 'request' parameter is set for custom auth use by subclasses. The form data comes in via the standard 'data' kwarg. """ self.request = request self.user_cache = None super().__init__(*args, **kwargs) # Set the max length and label for the "username" field. self.username_field = UserModel._meta.get_field(UserModel.USERNAME_FIELD) username_max_length = self.username_field.max_length or 254 self.fields["username"].max_length = username_max_length self.fields["username"].widget.attrs["maxlength"] = username_max_length if self.fields["username"].label is None: self.fields["username"].label = capfirst(self.username_field.verbose_name) def clean(self): username = self.cleaned_data.get("username") password = self.cleaned_data.get("password") if username is not None and password: self.user_cache = authenticate( self.request, username=username, password=password ) if self.user_cache is None: raise self.get_invalid_login_error() else: self.confirm_login_allowed(self.user_cache) return self.cleaned_data def confirm_login_allowed(self, user): """ Controls whether the given User may log in. This is a policy setting, independent of end-user authentication. This default behavior is to allow login by active users, and reject login by inactive users. If the given user cannot log in, this method should raise a ``ValidationError``. If the given user may log in, this method should return None. """ if not user.is_active: raise ValidationError( self.error_messages["inactive"], code="inactive", ) def get_user(self): return self.user_cache def get_invalid_login_error(self): return ValidationError( self.error_messages["invalid_login"], code="invalid_login", params={"username": self.username_field.verbose_name}, ) class PasswordResetForm(forms.Form): email = forms.EmailField( label=_("Email"), max_length=254, widget=forms.EmailInput(attrs={"autocomplete": "email"}), ) def send_mail( self, subject_template_name, email_template_name, context, from_email, to_email, html_email_template_name=None, ): """ Send a django.core.mail.EmailMultiAlternatives to `to_email`. """ subject = loader.render_to_string(subject_template_name, context) # Email subject *must not* contain newlines subject = "".join(subject.splitlines()) body = loader.render_to_string(email_template_name, context) email_message = EmailMultiAlternatives(subject, body, from_email, [to_email]) if html_email_template_name is not None: html_email = loader.render_to_string(html_email_template_name, context) email_message.attach_alternative(html_email, "text/html") email_message.send() def get_users(self, email): """Given an email, return matching user(s) who should receive a reset. This allows subclasses to more easily customize the default policies that prevent inactive users and users with unusable passwords from resetting their password. """ email_field_name = UserModel.get_email_field_name() active_users = UserModel._default_manager.filter( **{ "%s__iexact" % email_field_name: email, "is_active": True, } ) return ( u for u in active_users if u.has_usable_password() and _unicode_ci_compare(email, getattr(u, email_field_name)) ) def save( self, domain_override=None, subject_template_name="registration/password_reset_subject.txt", email_template_name="registration/password_reset_email.html", use_https=False, token_generator=default_token_generator, from_email=None, request=None, html_email_template_name=None, extra_email_context=None, ): """ Generate a one-use only link for resetting password and send it to the user. """ email = self.cleaned_data["email"] if not domain_override: current_site = get_current_site(request) site_name = current_site.name domain = current_site.domain else: site_name = domain = domain_override email_field_name = UserModel.get_email_field_name() for user in self.get_users(email): user_email = getattr(user, email_field_name) context = { "email": user_email, "domain": domain, "site_name": site_name, "uid": urlsafe_base64_encode(force_bytes(user.pk)), "user": user, "token": token_generator.make_token(user), "protocol": "https" if use_https else "http", **(extra_email_context or {}), } self.send_mail( subject_template_name, email_template_name, context, from_email, user_email, html_email_template_name=html_email_template_name, ) class SetPasswordForm(SetPasswordMixin, forms.Form): """ A form that lets a user set their password without entering the old password """ new_password1, new_password2 = SetPasswordMixin.create_password_fields( label1=_("New password"), label2=_("New password confirmation") ) def __init__(self, user, *args, **kwargs): self.user = user super().__init__(*args, **kwargs) def clean(self): self.validate_passwords("new_password1", "new_password2") self.validate_password_for_user(self.user, "new_password2") return super().clean() def save(self, commit=True): return self.set_password_and_save(self.user, "new_password1", commit=commit) class PasswordChangeForm(SetPasswordForm): """ A form that lets a user change their password by entering their old password. """ error_messages = { **SetPasswordForm.error_messages, "password_incorrect": _( "Your old password was entered incorrectly. Please enter it again." ), } old_password = forms.CharField( label=_("Old password"), strip=False, widget=forms.PasswordInput( attrs={"autocomplete": "current-password", "autofocus": True} ), ) field_order = ["old_password", "new_password1", "new_password2"] def clean_old_password(self): """ Validate that the old_password field is correct. """ old_password = self.cleaned_data["old_password"] if not self.user.check_password(old_password): raise ValidationError( self.error_messages["password_incorrect"], code="password_incorrect", ) return old_password class AdminPasswordChangeForm(SetPasswordMixin, forms.Form): """ A form used to change the password of a user in the admin interface. """ required_css_class = "required" usable_password_help_text = SetPasswordMixin.usable_password_help_text + ( '
" ) password1, password2 = SetPasswordMixin.create_password_fields() def __init__(self, user, *args, **kwargs): self.user = user super().__init__(*args, **kwargs) self.fields["password1"].widget.attrs["autofocus"] = True if self.user.has_usable_password(): self.fields["usable_password"] = ( SetPasswordMixin.create_usable_password_field( self.usable_password_help_text ) ) def clean(self): self.validate_passwords() self.validate_password_for_user(self.user) return super().clean() def save(self, commit=True): """Save the new password.""" return self.set_password_and_save(self.user, commit=commit) @property def changed_data(self): data = super().changed_data if "set_usable_password" in data or "password1" in data and "password2" in data: return ["password"] return []