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