Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3from collections.abc import Callable, Generator
  4from typing import TYPE_CHECKING, Any
  5
  6from plain import forms
  7from plain.auth import get_user_model
  8from plain.exceptions import ValidationError
  9from plain.models.forms import ModelForm
 10
 11from .core import check_user_password
 12from .hashers import check_password
 13from .utils import unicode_ci_compare
 14
 15if TYPE_CHECKING:
 16    from plain.models import Model
 17
 18
 19class PasswordResetForm(forms.Form):
 20    email = forms.EmailField(max_length=254)
 21
 22    def send_mail(
 23        self,
 24        *,
 25        template_name: str,
 26        context: dict[str, Any],
 27        from_email: str,
 28        to_email: str,
 29    ) -> None:
 30        from plain.email import TemplateEmail
 31
 32        email = TemplateEmail(
 33            template=template_name,
 34            context=context,
 35            from_email=from_email,
 36            to=[to_email],
 37            headers={
 38                "X-Auto-Response-Suppress": "All",
 39            },
 40        )
 41
 42        email.send()
 43
 44    def get_users(self, email: str) -> Generator[Model, None, None]:
 45        """Given an email, return matching user(s) who should receive a reset.
 46
 47        This allows subclasses to more easily customize the default policies
 48        that prevent inactive users and users with unusable passwords from
 49        resetting their password.
 50        """
 51        active_users = get_user_model().query.filter(email__iexact=email)
 52        return (u for u in active_users if unicode_ci_compare(email, u.email))
 53
 54    def save(
 55        self,
 56        *,
 57        generate_reset_url: Callable[[Model], str],
 58        email_template_name: str = "password_reset",
 59        from_email: str = "",
 60        extra_email_context: dict[str, Any] | None = None,
 61    ) -> None:
 62        """
 63        Generate a one-use only link for resetting password and send it to the
 64        user.
 65        """
 66        email = self.cleaned_data["email"]
 67        for user in self.get_users(email):
 68            context = {
 69                "email": email,
 70                "user": user,
 71                "url": generate_reset_url(user),
 72                **(extra_email_context or {}),
 73            }
 74            self.send_mail(
 75                template_name=email_template_name,
 76                context=context,
 77                from_email=from_email,
 78                to_email=user.email,
 79            )
 80
 81
 82class PasswordSetForm(forms.Form):
 83    """
 84    A form that lets a user set their password without entering the old
 85    password
 86    """
 87
 88    new_password1 = forms.CharField(strip=False)
 89    new_password2 = forms.CharField(strip=False)
 90
 91    def __init__(self, user: Model, *args: Any, **kwargs: Any) -> None:
 92        self.user = user
 93        super().__init__(*args, **kwargs)
 94
 95    def clean_new_password2(self) -> str:
 96        password1 = self.cleaned_data.get("new_password1")
 97        password2 = self.cleaned_data.get("new_password2")
 98        if password1 and password2 and password1 != password2:
 99            raise ValidationError(
100                "The two password fields didn't match.",
101                code="password_mismatch",
102            )
103
104        # Clean it as if it were being put into the model directly
105        self.user._model_meta.get_field("password").clean(password2, self.user)
106
107        return password2
108
109    def save(self, commit: bool = True) -> Model:
110        self.user.password = self.cleaned_data["new_password1"]
111        if commit:
112            self.user.save()
113        return self.user
114
115
116class PasswordChangeForm(PasswordSetForm):
117    """
118    A form that lets a user change their password by entering their old
119    password.
120    """
121
122    current_password = forms.CharField(strip=False)
123
124    def clean_current_password(self) -> str:
125        """
126        Validate that the current_password field is correct.
127        """
128        current_password = self.cleaned_data["current_password"]
129        if not check_user_password(self.user, current_password):
130            raise ValidationError(
131                "Your old password was entered incorrectly. Please enter it again.",
132                code="password_incorrect",
133            )
134        return current_password
135
136
137class PasswordLoginForm(forms.Form):
138    email = forms.EmailField(max_length=150)
139    password = forms.CharField(strip=False)
140
141    def clean(self) -> dict[str, Any]:
142        User = get_user_model()
143
144        email = self.cleaned_data.get("email")
145        password = self.cleaned_data.get("password")
146
147        if email and password:
148            try:
149                # The vast majority of users won't have a case-sensitive email, so we act that way
150                user = User.query.get(email__iexact=email)
151            except User.DoesNotExist:
152                # Run the default password hasher once to reduce the timing
153                # difference between an existing and a nonexistent user (django #20760).
154                check_password(password, "")
155
156                raise ValidationError(
157                    "Please enter a correct email and password. Note that both fields may be case-sensitive.",
158                    code="invalid_login",
159                )
160
161            if not check_user_password(user, password):
162                raise ValidationError(
163                    "Please enter a correct email and password. Note that both fields may be case-sensitive.",
164                    code="invalid_login",
165                )
166
167            self._user = user
168
169        return self.cleaned_data
170
171    def get_user(self) -> Model:
172        return self._user
173
174
175class PasswordSignupForm(ModelForm):
176    confirm_password = forms.CharField(strip=False)
177
178    class Meta:
179        model = get_user_model()
180        fields = ("email", "password")
181
182    def clean(self) -> dict[str, Any]:
183        cleaned_data = super().clean()
184        password = cleaned_data.get("password")
185        confirm_password = cleaned_data.get("confirm_password")
186        if password and confirm_password and password != confirm_password:
187            raise ValidationError(
188                "The two password fields didn't match.",
189                code="password_mismatch",
190            )
191        return cleaned_data