Repository: akvo/akvo-rsr

Issue:
Better forgot password workflows especially for new users



Discussion:

Resolving patch:
diff --git a/akvo/rest/views/user_management.py b/akvo/rest/views/user_management.py
--- a/akvo/rest/views/user_management.py
+++ b/akvo/rest/views/user_management.py
@@ -47,7 +47,7 @@ def invite_user(request):
     # Check if all information is present, and if the organisation and group exist
     data, missing_data = request.data.get('user_data'), []
     if not data:
-        missing_data.append('email', 'organisation', 'group')
+        missing_data.extend(['email', 'organisation', 'group'])
         return Response({'missing_data': missing_data},
                         status=status.HTTP_400_BAD_REQUEST)
     else:
diff --git a/akvo/rsr/forms.py b/akvo/rsr/forms.py
--- a/akvo/rsr/forms.py
+++ b/akvo/rsr/forms.py
@@ -11,10 +11,10 @@
 import re
 
 from django import forms
-
 from django.contrib.auth import get_user_model
-from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
+from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm, PasswordResetForm as PRF
 from django.contrib.sites.shortcuts import get_current_site
+from django.db.models import F, Q
 from django.utils.translation import ugettext_lazy as _
 from django.utils.safestring import mark_safe
 
@@ -171,6 +171,32 @@ def save(self, request):
         return user
 
 
+class PasswordResetForm(PRF):
+
+    def get_users(self, email):
+        """Given an email, return matching user(s) who should receive a reset.
+
+        Overriden from the base class to allow users who didn't click on the
+        activation email or invite activation emails to get a new password, by
+        clicking on the forgot password email link.
+
+        We still ensure that users who have been explicitly deactivated are not
+        able to reset their account by requesting a new password. We check if
+        the user has logged in at least once, to differentiate such users from
+        other users.
+
+        """
+        User = get_user_model()
+        active_users = User.objects.filter(
+            # Active users should be allowed to reset passwords
+            Q(is_active=True, email__iexact=email) |
+            # Newly registered users should be allowed to ask for reset
+            # password, even if they didn't activate their account.
+            Q(last_login=F('date_joined'))
+        )
+        return active_users
+
+
 class ProfileForm(forms.Form):
     email = forms.EmailField(
         label='',
diff --git a/akvo/rsr/views/account.py b/akvo/rsr/views/account.py
--- a/akvo/rsr/views/account.py
+++ b/akvo/rsr/views/account.py
@@ -13,13 +13,13 @@
 from lxml import etree
 from tastypie.models import ApiKey
 
-from akvo.rsr.forms import RegisterForm, InvitedUserForm
+from akvo.rsr.forms import RegisterForm, InvitedUserForm, PasswordResetForm
 from akvo.rsr.models import Employment
 from akvo.utils import rsr_send_mail
 
 from django.conf import settings
 from django.contrib.auth import login, logout, authenticate, get_user_model
-from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm
+from django.contrib.auth.forms import AuthenticationForm
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.signing import TimestampSigner, BadSignature
 from django.http import (HttpResponse, HttpResponseRedirect,


Test patch:
diff --git a/akvo/rsr/tests/views/test_account.py b/akvo/rsr/tests/views/test_account.py
--- a/akvo/rsr/tests/views/test_account.py
+++ b/akvo/rsr/tests/views/test_account.py
@@ -10,15 +10,17 @@
 from __future__ import print_function
 
 import json
+from urlparse import urlparse
 
 from django.conf import settings
 from django.contrib.auth.models import Group
 from django.test import TestCase, Client
-from urlparse import urlparse
+from mock import patch
 
 from akvo.rsr.forms import PASSWORD_MINIMUM_LENGTH
 from akvo.rsr.models import Employment, Organisation, Partnership, Project, User
 from akvo.utils import check_auth_groups
+from akvo.rsr.tests.base import BaseTestCase
 
 
 class AccountTestCase(TestCase):
@@ -262,3 +264,78 @@ def test_registration_with_honeypot_filled_in(self):
         # Then
         self.assertEqual(response.status_code, 302)
         self.assertEqual(urlparse(response._headers['location'][1]).path, '/en/')
+
+
+class PasswordResetTestCase(BaseTestCase):
+    """Test password reset workflows."""
+
+    email = 'foo@example.com'
+    password = 'passwdpasswdA1$'
+
+    def test_normal_user_gets_password_reset_email(self):
+        user = self.create_user(self.email, self.password)
+        self.assertTrue(user.has_usable_password())
+        data = {'email': self.email}
+
+        with patch('django.contrib.auth.forms.PasswordResetForm.send_mail') as patched_send:
+            response = self.c.post('/en/sign_in/', data=data, follow=True)
+
+        self.assertEqual(200, response.status_code)
+        patched_send.assert_called_once()
+
+    def test_newly_registered_user_gets_reset_email(self):
+        register_data = dict(
+            first_name=self.email,
+            last_name=self.email,
+            email=self.email,
+            password1=self.password,
+            password2=self.password,
+        )
+        response = self.c.post('/en/register/', data=register_data, follow=True)
+        data = {'email': self.email}
+
+        with patch('django.contrib.auth.forms.PasswordResetForm.send_mail') as patched_send:
+            response = self.c.post('/en/sign_in/', data=data, follow=True)
+
+        self.assertEqual(200, response.status_code)
+        patched_send.assert_called_once()
+
+    def test_invited_user_gets_reset_email(self):
+        admin_email = 'admin@example.com'
+        self.create_user(email=admin_email, password=self.password, is_superuser=True)
+        user_group = Group.objects.get(name='Users')
+        org = self.create_organisation('Akvo')
+        invite_data = dict(
+            user_data=json.dumps(dict(
+                email=self.email,
+                organisation=org.id,
+                group=user_group.id,
+            ))
+        )
+        self.c.login(username=admin_email, password=self.password)
+        response = self.c.post('/rest/v1/invite_user/', data=invite_data, follow=True)
+        data = {'email': self.email}
+
+        with patch('django.contrib.auth.forms.PasswordResetForm.send_mail') as patched_send:
+            response = self.c.post('/en/sign_in/', data=data, follow=True)
+
+        self.assertEqual(200, response.status_code)
+        patched_send.assert_called_once()
+
+    def test_deactivated_users_donot_get_password_reset_email(self):
+        email = 'foo@example.com'
+        password = 'password'
+        user = self.create_user(email, password)
+        data = {'email': email}
+
+        self.c.login(username=email, password=password)
+        # Verify user has logged in at least once!
+        user.refresh_from_db()
+        self.assertNotEqual(user.last_login, user.date_joined)
+        user.is_active = False
+        user.save(update_fields=['is_active'])
+        with patch('django.contrib.auth.forms.PasswordResetForm.send_mail') as patched_send:
+            response = self.c.post('/en/sign_in/', data=data, follow=True)
+
+        self.assertEqual(200, response.status_code)
+        patched_send.assert_not_called()