diff --git a/contentcuration/contentcuration/frontend/accounts/vuex/__tests__/module.spec.js b/contentcuration/contentcuration/frontend/accounts/vuex/__tests__/module.spec.js index 38867a0536..7c27ef720e 100644 --- a/contentcuration/contentcuration/frontend/accounts/vuex/__tests__/module.spec.js +++ b/contentcuration/contentcuration/frontend/accounts/vuex/__tests__/module.spec.js @@ -41,7 +41,6 @@ describe('account store', () => { const passwordData = { new_password1: 'testing password', new_password2: 'testing password', - token: 'testing token', uidb64: 'testing uidb64', }; return store.dispatch('account/setPassword', passwordData).then(() => { diff --git a/contentcuration/contentcuration/frontend/accounts/vuex/index.js b/contentcuration/contentcuration/frontend/accounts/vuex/index.js index f64df2778a..8f9dae3c52 100644 --- a/contentcuration/contentcuration/frontend/accounts/vuex/index.js +++ b/contentcuration/contentcuration/frontend/accounts/vuex/index.js @@ -16,12 +16,14 @@ export default { sendPasswordResetLink(context, email) { return client.post(window.Urls.auth_password_reset(), { email }); }, - setPassword(context, { uidb64, token, new_password1, new_password2 }) { + setPassword(context, { uidb64, new_password1, new_password2 }) { const data = { new_password1, new_password2, }; - return client.post(window.Urls.auth_password_reset_confirm(uidb64, token), data, { + // Django's PasswordResetConfirmView.reset_url_token + // https://docs.djangoproject.com/en/3.2/topics/auth/default/#django.contrib.auth.views.PasswordResetConfirmView + return client.post(window.Urls.auth_password_reset_confirm(uidb64, 'set-password'), data, { headers: { 'Content-type': 'application/form-url-encode', }, diff --git a/contentcuration/contentcuration/tests/views/test_users.py b/contentcuration/contentcuration/tests/views/test_users.py index 4c5f635204..dfb00b88b2 100644 --- a/contentcuration/contentcuration/tests/views/test_users.py +++ b/contentcuration/contentcuration/tests/views/test_users.py @@ -1,11 +1,15 @@ import json +from django.contrib.auth.tokens import default_token_generator from django.db import IntegrityError from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseForbidden from django.http.response import HttpResponseNotAllowed from django.http.response import HttpResponseRedirectBase +from django.urls import reverse from django.urls import reverse_lazy +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode from mock import mock from mock import patch @@ -15,6 +19,7 @@ from contentcuration.tests.base import StudioAPITestCase from contentcuration.views.users import login from contentcuration.views.users import UserActivationView +from contentcuration.views.users import UserPasswordResetConfirmView class LoginTestCase(StudioAPITestCase): @@ -179,6 +184,83 @@ def test_post__handles_integrity_error_gracefully(self, mock_register): self.assertIn("email", error_data) +class UserPasswordResetConfirmViewTestCase(StudioAPITestCase): + def setUp(self): + super(UserPasswordResetConfirmViewTestCase, self).setUp() + self.user = testdata.user(email="tester@tester.com") + self.user.set_password("old_password") + self.user.save() + self.uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) + self.token = default_token_generator.make_token(self.user) + + def _url(self, token): + return reverse( + "auth_password_reset_confirm", + kwargs=dict(uidb64=self.uidb64, token=token), + ) + + def _get(self, token): + response = self.client.get(self._url(token)) + if response.url == self._url(UserPasswordResetConfirmView.reset_url_token): + response = self.client.get(response.url) + return response + + def _post(self, password="new_password", confirm=None): + data = dict(new_password1=password, new_password2=confirm or password) + return self.client.post( + self._url(UserPasswordResetConfirmView.reset_url_token), + data, + format="json", + ) + + def _assert_password(self, password): + self.user.refresh_from_db() + self.assertTrue(self.user.check_password(password)) + + def test_get__valid_token(self): + response = self._get(self.token) + self.assertEqual( + response.url, "/accounts/#/reset-password?uidb64={}".format(self.uidb64) + ) + + def test_get__invalid_token(self): + response = self._get("invalid-token") + self.assertEqual(response.url, "/accounts/#/reset-expired") + + def test_post__valid_session(self): + self._get(self.token) + response = self._post() + self._assert_password("new_password") + self.assertEqual(response.url, "/accounts/#/password-reset-success") + + def test_post__no_session(self): + response = self._post() + self._assert_password("old_password") + self.assertIsInstance(response, HttpResponseForbidden) + + def test_post__invalid_token_in_url(self): + self.client.post( + self._url("invalid-token"), + dict(new_password1="new_password", new_password2="new_password"), + format="json", + ) + self._assert_password("old_password") + + def test_post__invalid_form(self): + self._get(self.token) + response = self._post("new_password", "other_password") + self._assert_password("old_password") + self.assertIsInstance(response, HttpResponseForbidden) + + def test_post__reused_token(self): + self._get(self.token) + self._post() + self._get(self.token) + response = self._post("second_password") + self._assert_password("new_password") + self.assertIsInstance(response, HttpResponseForbidden) + + class UserActivationViewTestCase(StudioAPITestCase): def setUp(self): super(UserActivationViewTestCase, self).setUp() diff --git a/contentcuration/contentcuration/views/users.py b/contentcuration/contentcuration/views/users.py index 2d5b1236b5..df7e32799d 100644 --- a/contentcuration/contentcuration/views/users.py +++ b/contentcuration/contentcuration/views/users.py @@ -19,9 +19,6 @@ from django.shortcuts import redirect from django.template.loader import render_to_string from django.urls import reverse_lazy -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from django.views.decorators.debug import sensitive_post_parameters from django_registration.backends.activation.views import ActivationView from django_registration.backends.activation.views import RegistrationView from rest_framework.authentication import BasicAuthentication @@ -318,37 +315,25 @@ def post(self, request): class UserPasswordResetConfirmView(PasswordResetConfirmView): http_method_names = ["get", "post"] + success_url = "/accounts/#/password-reset-success" - @method_decorator(sensitive_post_parameters()) - @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): - response = super(UserPasswordResetConfirmView, self).dispatch( - request, *args, **kwargs - ) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + if self.request.method == "POST": + kwargs["data"] = json.loads(self.request.body) + return kwargs - if request.method == "POST": - return self.post(request, *args, **kwargs) + def get(self, request, *args, **kwargs): + return redirect("/accounts/#/reset-password?uidb64={}".format(kwargs["uidb64"])) - # Token is valid, redirect to password reset page - if response.status_code == 302: - return redirect( - "/accounts/#/reset-password?uidb64={}&token={}".format( - kwargs["uidb64"], kwargs["token"] - ) - ) + def form_invalid(self, form): + return HttpResponseForbidden() + def render_to_response(self, context, **response_kwargs): + if self.request.method == "POST": + return HttpResponseForbidden() return redirect("/accounts/#/reset-expired") - def get_success_url(self): - return "/accounts/#/password-reset-success" - - def post(self, request, *args, **kwargs): - form = self.form_class(self.user, json.loads(request.body)) - - if form.is_valid(): - return self.form_valid(form) - return HttpResponseForbidden() - def request_activation_link(request): if request.method != "POST":