Plain is headed towards 1.0! Subscribe for development updates →

  1from datetime import datetime
  2
  3from plain import signing
  4from plain.auth import get_user_model
  5from plain.auth.sessions import login as auth_login
  6from plain.auth.sessions import update_session_auth_hash
  7from plain.exceptions import BadRequest
  8from plain.http import (
  9    ResponseRedirect,
 10)
 11from plain.urls import reverse
 12from plain.utils.cache import add_never_cache_headers
 13from plain.utils.crypto import constant_time_compare
 14from plain.views import CreateView, FormView
 15
 16from .forms import (
 17    PasswordChangeForm,
 18    PasswordLoginForm,
 19    PasswordResetForm,
 20    PasswordSetForm,
 21    PasswordSignupForm,
 22)
 23
 24
 25class PasswordForgotView(FormView):
 26    form_class = PasswordResetForm
 27    reset_confirm_url_name: str
 28
 29    def generate_password_reset_token(self, user):
 30        return signing.dumps(
 31            {
 32                "pk": user.pk,
 33                "email": user.email,
 34                "password": user.password,  # Hashed password
 35                "timestamp": datetime.now().timestamp(),  # Makes each token unique
 36            },
 37            salt="password-reset",
 38            compress=True,
 39        )
 40
 41    def generate_password_reset_url(self, user):
 42        token = self.generate_password_reset_token(user)
 43        url = reverse(self.reset_confirm_url_name) + f"?token={token}"
 44        return self.request.build_absolute_uri(url)
 45
 46    def form_valid(self, form):
 47        form.save(
 48            generate_reset_url=self.generate_password_reset_url,
 49        )
 50        return super().form_valid(form)
 51
 52
 53class PasswordResetView(FormView):
 54    form_class = PasswordSetForm
 55    reset_token_max_age = 60 * 60  # 1 hour
 56    _reset_token_session_key = "_password_reset_token"
 57
 58    def check_password_reset_token(self, token):
 59        max_age = self.reset_token_max_age
 60
 61        try:
 62            data = signing.loads(token, salt="password-reset", max_age=max_age)
 63        except signing.SignatureExpired:
 64            return
 65        except signing.BadSignature:
 66            return
 67
 68        UserModel = get_user_model()
 69        try:
 70            user = UserModel._default_manager.get(pk=data["pk"])
 71        except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist):
 72            return
 73
 74        # If the password has changed since the token was generated, the token is invalid.
 75        # (These are the hashed passwords, not the raw passwords.)
 76        if not constant_time_compare(user.password, data["password"]):
 77            return
 78
 79        # If the email has changed since the token was generated, the token is invalid.
 80        if not constant_time_compare(user.email, data["email"]):
 81            return
 82
 83        return user
 84
 85    def get(self):
 86        if self.request.user:
 87            # Redirect if the user is already logged in
 88            return ResponseRedirect(self.success_url)
 89
 90        # Tokens are initially passed as GET parameters and we
 91        # immediately store them in the session and remove it from the URL.
 92        if token := self.request.query_params.get("token", ""):
 93            # Store the token in the session and redirect to the
 94            # password reset form at a URL without the token. That
 95            # avoids the possibility of leaking the token in the
 96            # HTTP Referer header.
 97            self.request.session[self._reset_token_session_key] = token
 98            # Redirect to the path itself, without the GET parameters
 99            response = ResponseRedirect(self.request.path)
100            add_never_cache_headers(response)
101            return response
102
103        return super().get()
104
105    def get_user(self):
106        session_token = self.request.session.get(self._reset_token_session_key, "")
107        if not session_token:
108            # No token in the session, so we can't check the password reset token.
109            raise BadRequest("No password reset token found.")
110
111        user = self.check_password_reset_token(session_token)
112        if not user:
113            # Remove it from the session if it is invalid.
114            del self.request.session[self._reset_token_session_key]
115            raise BadRequest("Password reset token is no longer valid.")
116
117        return user
118
119    def get_form_kwargs(self):
120        kwargs = super().get_form_kwargs()
121        kwargs["user"] = self.get_user()
122        return kwargs
123
124    def form_valid(self, form):
125        form.save()
126        del self.request.session[self._reset_token_session_key]
127        # If you wanted, you could log in the user here so they don't have to
128        # go through the log in form again.
129        return super().form_valid(form)
130
131
132class PasswordChangeView(FormView):
133    # Change to PasswordSetForm if you want to set new passwords
134    # without confirming the old one.
135    form_class = PasswordChangeForm
136
137    def get_form_kwargs(self):
138        kwargs = super().get_form_kwargs()
139        kwargs["user"] = self.request.user
140        return kwargs
141
142    def form_valid(self, form):
143        form.save()
144        # Updating the password logs out all other sessions for the user
145        # except the current one.
146        update_session_auth_hash(self.request, form.user)
147        return super().form_valid(form)
148
149
150class PasswordLoginView(FormView):
151    form_class = PasswordLoginForm
152    success_url = "/"
153
154    def get(self):
155        # Redirect if the user is already logged in
156        if self.request.user:
157            return ResponseRedirect(self.success_url)
158
159        return super().get()
160
161    def form_valid(self, form):
162        # Log the user in and redirect
163        auth_login(self.request, form.get_user())
164
165        return super().form_valid(form)
166
167
168class PasswordSignupView(CreateView):
169    form_class = PasswordSignupForm
170    success_url = "/"
171
172    def form_valid(self, form):
173        # # Log the user in and redirect
174        # auth_login(self.request, form.save())
175
176        return super().form_valid(form)