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