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,  # type: ignore[attr-defined]
 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        # password2 must exist at this point (required field)
105        assert isinstance(password2, str), "new_password2 must be a string"
106
107        # Clean it as if it were being put into the model directly
108        self.user._model_meta.get_field("password").clean(password2, self.user)  # type: ignore[union-attr]
109
110        return password2
111
112    def save(self, commit: bool = True) -> Model:
113        # User model has password attribute
114        self.user.password = self.cleaned_data["new_password1"]  # type: ignore[assignment]
115        if commit:
116            self.user.save()
117        return self.user
118
119
120class PasswordChangeForm(PasswordSetForm):
121    """
122    A form that lets a user change their password by entering their old
123    password.
124    """
125
126    current_password = forms.CharField(strip=False)
127
128    def clean_current_password(self) -> str:
129        """
130        Validate that the current_password field is correct.
131        """
132        current_password = self.cleaned_data["current_password"]
133        if not check_user_password(self.user, current_password):
134            raise ValidationError(
135                "Your old password was entered incorrectly. Please enter it again.",
136                code="password_incorrect",
137            )
138        return current_password
139
140
141class PasswordLoginForm(forms.Form):
142    email = forms.EmailField(max_length=150)
143    password = forms.CharField(strip=False)
144
145    def clean(self) -> dict[str, Any]:
146        User = get_user_model()
147
148        email = self.cleaned_data.get("email")
149        password = self.cleaned_data.get("password")
150
151        if email and password:
152            try:
153                # The vast majority of users won't have a case-sensitive email, so we act that way
154                user = User.query.get(email__iexact=email)
155            except User.DoesNotExist:
156                # Run the default password hasher once to reduce the timing
157                # difference between an existing and a nonexistent user (django #20760).
158                check_password(password, "")
159
160                raise ValidationError(
161                    "Please enter a correct email and password. Note that both fields may be case-sensitive.",
162                    code="invalid_login",
163                )
164
165            if not check_user_password(user, password):
166                raise ValidationError(
167                    "Please enter a correct email and password. Note that both fields may be case-sensitive.",
168                    code="invalid_login",
169                )
170
171            self._user = user
172
173        return self.cleaned_data
174
175    def get_user(self) -> Model:
176        return self._user
177
178
179class PasswordSignupForm(ModelForm):
180    confirm_password = forms.CharField(strip=False)
181
182    class Meta:
183        model = get_user_model()
184        fields = ("email", "password")
185
186    def clean(self) -> dict[str, Any]:
187        cleaned_data = super().clean()
188        password = cleaned_data.get("password")
189        confirm_password = cleaned_data.get("confirm_password")
190        if password and confirm_password and password != confirm_password:
191            raise ValidationError(
192                "The two password fields didn't match.",
193                code="password_mismatch",
194            )
195        return cleaned_data