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