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