Commit 4a316541 by Jason Bau

Merge pull request #84 from edx/feature/jbau/activation-after-password-reset-confirm

Moves user activation from just clicking on reset password to following the link in the password reset email
parents fe82e865 734440f4
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
class PasswordResetFormNoActive(PasswordResetForm):
def clean_email(self):
"""
This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm
Except removing the requirement of active users
Validates that a user exists with the given email address.
"""
email = self.cleaned_data["email"]
#The line below contains the only change, removing is_active=True
self.users_cache = User.objects.filter(email__iexact=email)
if not len(self.users_cache):
raise forms.ValidationError(self.error_messages['unknown'])
if any((user.password == UNUSABLE_PASSWORD)
for user in self.users_cache):
raise forms.ValidationError(self.error_messages['unusable'])
return email
...@@ -5,18 +5,127 @@ when you run "manage.py test". ...@@ -5,18 +5,127 @@ when you run "manage.py test".
Replace this with more appropriate tests for your application. Replace this with more appropriate tests for your application.
""" """
import logging import logging
import json
import re
import unittest
from django import forms
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from mock import Mock from django.test.client import RequestFactory
from django.contrib.auth.models import User
from django.contrib.auth.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator
from django.template.loader import render_to_string, get_template, TemplateDoesNotExist
from django.core.urlresolvers import is_valid_path
from django.utils.http import int_to_base36
from student.models import unique_id_for_user
from student.views import process_survey_link, _cert_info
from mock import Mock, patch
from textwrap import dedent
from student.models import unique_id_for_user
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
from student.tests.factories import UserFactory
from student.tests.test_email import mock_render_to_string
COURSE_1 = 'edX/toy/2012_Fall' COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012' COURSE_2 = 'edx/full/6.002_Spring_2012'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
try:
get_template('registration/password_reset_email.html')
project_uses_password_reset = True
except TemplateDoesNotExist:
project_uses_password_reset = False
class ResetPasswordTests(TestCase):
""" Tests that clicking reset password sends email, and doesn't activate the user
"""
request_factory = RequestFactory()
def setUp(self):
self.user = UserFactory.create()
self.user.is_active = False
self.user.save()
self.token = default_token_generator.make_token(self.user)
self.uidb36 = int_to_base36(self.user.id)
self.user_bad_passwd = UserFactory.create()
self.user_bad_passwd.is_active = False
self.user_bad_passwd.password = UNUSABLE_PASSWORD
self.user_bad_passwd.save()
def test_user_bad_password_reset(self):
"""Tests password reset behavior for user with password marked UNUSABLE_PASSWORD"""
bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email})
bad_pwd_resp = password_reset(bad_pwd_req)
self.assertEquals(bad_pwd_resp.status_code, 200)
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False,
'error': 'Invalid e-mail or user'}))
def test_nonexist_email_password_reset(self):
"""Now test the exception cases with of reset_password called with invalid email."""
bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"})
bad_email_resp = password_reset(bad_email_req)
self.assertEquals(bad_email_resp.status_code, 200)
self.assertEquals(bad_email_resp.content, json.dumps({'success': False,
'error': 'Invalid e-mail or user'}))
@unittest.skipUnless(project_uses_password_reset,
dedent("""Skipping Test because CMS has not provided necessary templates for password reset.
If LMS tests print this message, that needs to be fixed."""))
@patch('django.core.mail.send_mail')
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_reset_password_email(self, send_email):
"""Tests contents of reset password email, and that user is not active"""
good_req = self.request_factory.post('/password_reset/', {'email': self.user.email})
good_resp = password_reset(good_req)
self.assertEquals(good_resp.status_code, 200)
self.assertEquals(good_resp.content,
json.dumps({'success': True,
'value': "('registration/password_reset_done.html', [])"}))
((subject, msg, from_addr, to_addrs), sm_kwargs) = send_email.call_args
self.assertIn("Password reset", subject)
self.assertIn("You're receiving this e-mail because you requested a password reset", msg)
self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL)
self.assertEquals(len(to_addrs), 1)
self.assertIn(self.user.email, to_addrs)
#test that the user is not active
self.user = User.objects.get(pk=self.user.pk)
self.assertFalse(self.user.is_active)
reset_match = re.search(r'password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', msg).groupdict()
@patch('student.views.password_reset_confirm')
def test_reset_password_bad_token(self, reset_confirm):
"""Tests bad token and uidb36 in password reset"""
bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/')
password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP')
(confirm_args, confirm_kwargs) = reset_confirm.call_args
self.assertEquals(confirm_kwargs['uidb36'], 'NO')
self.assertEquals(confirm_kwargs['token'], 'OP')
self.user = User.objects.get(pk=self.user.pk)
self.assertFalse(self.user.is_active)
@patch('student.views.password_reset_confirm')
def test_reset_password_good_token(self, reset_confirm):
"""Tests good token and uidb36 in password reset"""
good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token))
password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token)
(confirm_args, confirm_kwargs) = reset_confirm.call_args
self.assertEquals(confirm_kwargs['uidb36'], self.uidb36)
self.assertEquals(confirm_kwargs['token'], self.token)
self.user = User.objects.get(pk=self.user.pk)
self.assertTrue(self.user.is_active)
class CourseEndingTest(TestCase): class CourseEndingTest(TestCase):
"""Test things related to course endings: certificates, surveys, etc""" """Test things related to course endings: certificates, surveys, etc"""
......
...@@ -11,9 +11,9 @@ import time ...@@ -11,9 +11,9 @@ import time
from django.conf import settings from django.conf import settings
from django.contrib.auth import logout, authenticate, login from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import password_reset_confirm
from django.core.cache import cache from django.core.cache import cache
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django.core.mail import send_mail from django.core.mail import send_mail
...@@ -24,6 +24,7 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbid ...@@ -24,6 +24,7 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbid
from django.shortcuts import redirect from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date from django.utils.http import cookie_date
from django.utils.http import base36_to_int
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
...@@ -34,6 +35,8 @@ from student.models import (Registration, UserProfile, TestCenterUser, TestCente ...@@ -34,6 +35,8 @@ from student.models import (Registration, UserProfile, TestCenterUser, TestCente
CourseEnrollment, unique_id_for_user, CourseEnrollment, unique_id_for_user,
get_testcenter_registration, CourseEnrollmentAllowed) get_testcenter_registration, CourseEnrollmentAllowed)
from student.forms import PasswordResetFormNoActive
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -962,17 +965,7 @@ def password_reset(request): ...@@ -962,17 +965,7 @@ def password_reset(request):
if request.method != "POST": if request.method != "POST":
raise Http404 raise Http404
# By default, Django doesn't allow Users with is_active = False to reset their passwords, form = PasswordResetFormNoActive(request.POST)
# but this bites people who signed up a long time ago, never activated, and forgot their
# password. So for their sake, we'll auto-activate a user for whom password_reset is called.
try:
user = User.objects.get(email=request.POST['email'])
user.is_active = True
user.save()
except:
log.exception("Tried to auto-activate user to enable password reset, but failed.")
form = PasswordResetForm(request.POST)
if form.is_valid(): if form.is_valid():
form.save(use_https=request.is_secure(), form.save(use_https=request.is_secure(),
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
...@@ -982,7 +975,21 @@ def password_reset(request): ...@@ -982,7 +975,21 @@ def password_reset(request):
'value': render_to_string('registration/password_reset_done.html', {})})) 'value': render_to_string('registration/password_reset_done.html', {})}))
else: else:
return HttpResponse(json.dumps({'success': False, return HttpResponse(json.dumps({'success': False,
'error': 'Invalid e-mail'})) 'error': 'Invalid e-mail or user'}))
def password_reset_confirm_wrapper(request, uidb36=None, token=None):
''' A wrapper around django.contrib.auth.views.password_reset_confirm.
Needed because we want to set the user as active at this step.
'''
#cribbed from django.contrib.auth.views.password_reset_confirm
try:
uid_int = base36_to_int(uidb36)
user = User.objects.get(id=uid_int)
user.is_active = True
user.save()
except (ValueError, User.DoesNotExist):
pass
return password_reset_confirm(request, uidb36=uidb36, token=token)
def reactivation_email_for_user(user): def reactivation_email_for_user(user):
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
{% trans "Please go to the following page and choose a new password:" %} {% trans "Please go to the following page and choose a new password:" %}
{% block reset_link %} {% block reset_link %}
https://{{domain}}{% url 'django.contrib.auth.views.password_reset_confirm' uidb36=uid token=token %} https://{{domain}}{% url 'student.views.password_reset_confirm_wrapper' uidb36=uid token=token %}
{% endblock %} {% endblock %}
If you didn't request this change, you can disregard this email - we have not yet reset your password. If you didn't request this change, you can disregard this email - we have not yet reset your password.
......
...@@ -51,7 +51,7 @@ urlpatterns = ('', # nopep8 ...@@ -51,7 +51,7 @@ urlpatterns = ('', # nopep8
url(r'^password_change_done/$', django.contrib.auth.views.password_change_done, url(r'^password_change_done/$', django.contrib.auth.views.password_change_done,
name='auth_password_change_done'), name='auth_password_change_done'),
url(r'^password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$', url(r'^password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
django.contrib.auth.views.password_reset_confirm, 'student.views.password_reset_confirm_wrapper',
name='auth_password_reset_confirm'), name='auth_password_reset_confirm'),
url(r'^password_reset_complete/$', django.contrib.auth.views.password_reset_complete, url(r'^password_reset_complete/$', django.contrib.auth.views.password_reset_complete,
name='auth_password_reset_complete'), name='auth_password_reset_complete'),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment