487 lines
16 KiB
Python
487 lines
16 KiB
Python
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)
|
||
summary = []
|
||
if not value or value.startswith(UNUSABLE_PASSWORD_PREFIX):
|
||
summary.append({"label": gettext("No password set.")})
|
||
else:
|
||
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_})
|
||
context["summary"] = summary
|
||
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):
|
||
return unicodedata.normalize("NFKC", super().to_python(value))
|
||
|
||
def widget_attrs(self, widget):
|
||
return {
|
||
**super().widget_attrs(widget),
|
||
"autocapitalize": "none",
|
||
"autocomplete": "username",
|
||
}
|
||
|
||
|
||
class UserCreationForm(forms.ModelForm):
|
||
"""
|
||
A form that creates a user, with no privileges, from the given username and
|
||
password.
|
||
"""
|
||
|
||
error_messages = {
|
||
"password_mismatch": _("The two password fields didn’t match."),
|
||
}
|
||
password1 = forms.CharField(
|
||
label=_("Password"),
|
||
strip=False,
|
||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||
help_text=password_validation.password_validators_help_text_html(),
|
||
)
|
||
password2 = forms.CharField(
|
||
label=_("Password confirmation"),
|
||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||
strip=False,
|
||
help_text=_("Enter the same password as before, for verification."),
|
||
)
|
||
|
||
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_password2(self):
|
||
password1 = self.cleaned_data.get("password1")
|
||
password2 = self.cleaned_data.get("password2")
|
||
if password1 and password2 and password1 != password2:
|
||
raise ValidationError(
|
||
self.error_messages["password_mismatch"],
|
||
code="password_mismatch",
|
||
)
|
||
return password2
|
||
|
||
def _post_clean(self):
|
||
super()._post_clean()
|
||
# Validate the password after self.instance is updated with form data
|
||
# by super().
|
||
password = self.cleaned_data.get("password2")
|
||
if password:
|
||
try:
|
||
password_validation.validate_password(password, self.instance)
|
||
except ValidationError as error:
|
||
self.add_error("password2", error)
|
||
|
||
def save(self, commit=True):
|
||
user = super().save(commit=False)
|
||
user.set_password(self.cleaned_data["password1"])
|
||
if commit:
|
||
user.save()
|
||
return user
|
||
|
||
|
||
class UserChangeForm(forms.ModelForm):
|
||
password = ReadOnlyPasswordHashField(
|
||
label=_("Password"),
|
||
help_text=_(
|
||
"Raw passwords are not stored, so there is no way to see this "
|
||
"user’s password, but you can change the password using "
|
||
'<a href="{}">this form</a>.'
|
||
),
|
||
)
|
||
|
||
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:
|
||
password.help_text = password.help_text.format("../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(forms.Form):
|
||
"""
|
||
A form that lets a user change set their password without entering the old
|
||
password
|
||
"""
|
||
|
||
error_messages = {
|
||
"password_mismatch": _("The two password fields didn’t match."),
|
||
}
|
||
new_password1 = forms.CharField(
|
||
label=_("New password"),
|
||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||
strip=False,
|
||
help_text=password_validation.password_validators_help_text_html(),
|
||
)
|
||
new_password2 = forms.CharField(
|
||
label=_("New password confirmation"),
|
||
strip=False,
|
||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||
)
|
||
|
||
def __init__(self, user, *args, **kwargs):
|
||
self.user = user
|
||
super().__init__(*args, **kwargs)
|
||
|
||
def clean_new_password2(self):
|
||
password1 = self.cleaned_data.get("new_password1")
|
||
password2 = self.cleaned_data.get("new_password2")
|
||
if password1 and password2:
|
||
if password1 != password2:
|
||
raise ValidationError(
|
||
self.error_messages["password_mismatch"],
|
||
code="password_mismatch",
|
||
)
|
||
password_validation.validate_password(password2, self.user)
|
||
return password2
|
||
|
||
def save(self, commit=True):
|
||
password = self.cleaned_data["new_password1"]
|
||
self.user.set_password(password)
|
||
if commit:
|
||
self.user.save()
|
||
return self.user
|
||
|
||
|
||
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(forms.Form):
|
||
"""
|
||
A form used to change the password of a user in the admin interface.
|
||
"""
|
||
|
||
error_messages = {
|
||
"password_mismatch": _("The two password fields didn’t match."),
|
||
}
|
||
required_css_class = "required"
|
||
password1 = forms.CharField(
|
||
label=_("Password"),
|
||
widget=forms.PasswordInput(
|
||
attrs={"autocomplete": "new-password", "autofocus": True}
|
||
),
|
||
strip=False,
|
||
help_text=password_validation.password_validators_help_text_html(),
|
||
)
|
||
password2 = forms.CharField(
|
||
label=_("Password (again)"),
|
||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||
strip=False,
|
||
help_text=_("Enter the same password as before, for verification."),
|
||
)
|
||
|
||
def __init__(self, user, *args, **kwargs):
|
||
self.user = user
|
||
super().__init__(*args, **kwargs)
|
||
|
||
def clean_password2(self):
|
||
password1 = self.cleaned_data.get("password1")
|
||
password2 = self.cleaned_data.get("password2")
|
||
if password1 and password2 and password1 != password2:
|
||
raise ValidationError(
|
||
self.error_messages["password_mismatch"],
|
||
code="password_mismatch",
|
||
)
|
||
password_validation.validate_password(password2, self.user)
|
||
return password2
|
||
|
||
def save(self, commit=True):
|
||
"""Save the new password."""
|
||
password = self.cleaned_data["password1"]
|
||
self.user.set_password(password)
|
||
if commit:
|
||
self.user.save()
|
||
return self.user
|
||
|
||
@property
|
||
def changed_data(self):
|
||
data = super().changed_data
|
||
for name in self.fields:
|
||
if name not in data:
|
||
return []
|
||
return ["password"]
|