Browse Source

#362: User auth done

Rafał Pitoń 11 years ago
parent
commit
4ecb411f8d

+ 18 - 0
misago/templates/misago/emails/change_email_password.html

@@ -0,0 +1,18 @@
+{% extends "misago/emails/base.html" %}
+{% load i18n %}
+
+
+{% block content %}
+{% blocktrans trimmed with username=recipient.username %}
+{{ username }}, you are receiving this message because you have made changes in your account email and password.
+{% endblocktrans %}
+<br>
+<br>
+{% blocktrans trimmed %}
+To confirm those changes, click the link below:
+{% endblocktrans %}
+<br>
+<br>
+<a href="{{ SITE_ADDRESS }}{% url 'misago:usercp_confirm_email_password_change' token=credentials_token %}">{% trans "Save changes" %}</a>
+<br>
+{% endblock content %}

+ 14 - 0
misago/templates/misago/emails/change_email_password.txt

@@ -0,0 +1,14 @@
+{% extends "misago/emails/base.txt" %}
+{% load i18n %}
+
+
+{% block content %}
+{% blocktrans trimmed with username=recipient.username %}
+{{ username }}, you are receiving this message because you have made changes in your account email and password.
+{% endblocktrans %}
+
+{% blocktrans trimmed %}
+To confirm those changes, click the link below:
+{% endblocktrans %}
+{{ SITE_ADDRESS }}{% url 'misago:usercp_confirm_email_password_change' token=credentials_token %}
+{% endblock content %}

+ 1 - 1
misago/templates/misago/emails/change_password_form_link.html

@@ -1,5 +1,5 @@
 {% extends "misago/emails/base.html" %}
 {% extends "misago/emails/base.html" %}
-{% load i18n misago_capture %}
+{% load i18n %}
 
 
 
 
 {% block content %}
 {% block content %}

+ 78 - 0
misago/users/changedcredentials.py

@@ -0,0 +1,78 @@
+"""
+Changed credentials service
+
+Stores new e-mail and password in cache
+"""
+from hashlib import sha256
+
+from misago.conf import settings
+from misago.core.cache import cache
+from misago.users import tokens
+
+
+__all__ = ['cache_new_credentials', 'get_new_credentials']
+
+
+TOKEN_NAME = 'new_credentials'
+CACHE_PATTERN = 'new_credentials_%s'
+CACHE_TIMEOUT = 3600 * 48
+
+
+def cache_new_credentials(user, new_email, new_password):
+    new_credentials = {
+        'user_pk': user.pk,
+        'email': new_email,
+        'email_checksum': _make_checksum(user, new_email),
+        'password': new_password,
+        'password_checksum': _make_checksum(user, new_password),
+    }
+
+    cache.set(_make_cache_name(user), new_credentials, CACHE_TIMEOUT)
+    return _make_token(user)
+
+
+def get_new_credentials(user, token):
+    if token != _make_token(user):
+        return None
+
+    new_credentials = cache.get(_make_cache_name(user), 'nada')
+
+    if new_credentials == 'nada':
+        raise Exception('CACHE NOT FOUND')
+        return None
+
+    if new_credentials['user_pk'] != user.pk:
+        return None
+
+    email_checksum = _make_checksum(user, new_credentials['email'])
+    if new_credentials['email_checksum'] != email_checksum:
+        raise Exception('MAIL CHECKSUM FAIL')
+        return None
+
+    password_checksum = _make_checksum(user, new_credentials['password'])
+    if new_credentials['password_checksum'] != password_checksum:
+        raise Exception('PASS CHECKSUM FAIL')
+        return None
+
+    return new_credentials
+
+
+def _make_token(user):
+    return tokens.make(user, TOKEN_NAME)
+
+
+def _make_cache_name(user):
+    return CACHE_PATTERN % _make_token(user)
+
+
+def _make_checksum(user, value):
+    seeds = (
+        user.pk,
+        user.email,
+        user.password,
+        user.last_login.replace(microsecond=0, tzinfo=None),
+        settings.SECRET_KEY,
+        unicode(value)
+    )
+
+    return sha256('+'.join([unicode(s) for s in seeds])).hexdigest()

+ 14 - 3
misago/users/forms/usercp.py

@@ -3,7 +3,8 @@ from django.utils.translation import ugettext_lazy as _
 
 
 from misago.core import forms, timezones
 from misago.core import forms, timezones
 from misago.users.models import AUTO_SUBSCRIBE_CHOICES
 from misago.users.models import AUTO_SUBSCRIBE_CHOICES
-from misago.users.validators import validate_username
+from misago.users.validators import (validate_email, validate_password,
+                                     validate_username)
 
 
 
 
 class ChangeForumOptionsBaseForm(forms.ModelForm):
 class ChangeForumOptionsBaseForm(forms.ModelForm):
@@ -101,9 +102,19 @@ class ChangeEmailPasswordForm(forms.Form):
             raise forms.ValidationError(message)
             raise forms.ValidationError(message)
 
 
         if not self.user.check_password(current_password):
         if not self.user.check_password(current_password):
-            message = _("Entered password is invalid.")
+            raise forms.ValidationError(_("Entered password is invalid."))
+
+        if not (new_email or new_password):
+            message = _("You have to enter new e-mail or password.")
             raise forms.ValidationError(message)
             raise forms.ValidationError(message)
 
 
-        raise NotImplementedError("change email/pass form is incomplete")
+        if new_email:
+            if new_email.lower() == self.user.email.lower():
+                message = _("New e-mail is same as current one.")
+                raise forms.ValidationError(message)
+            validate_email(new_email)
+
+        if new_password:
+            validate_password(new_password)
 
 
         return data
         return data

+ 20 - 0
misago/users/tests/test_changedcredentials.py

@@ -0,0 +1,20 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.users import changedcredentials
+
+
+class ChangedCredentialsTests(TestCase):
+    def test_credentials_change(self):
+        """changedcredentials module allows for credentials change"""
+        User = get_user_model()
+        test_user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+
+        credentials_token = changedcredentials.cache_new_credentials(
+            test_user, 'newbob@test.com', 'newpass123')
+
+        new_credentials = changedcredentials.get_new_credentials(
+            test_user, credentials_token)
+
+        self.assertEqual(new_credentials['email'], 'newbob@test.com')
+        self.assertEqual(new_credentials['password'], 'newpass123')

+ 1 - 1
misago/users/tests/test_forgottenpassword_views.py

@@ -96,7 +96,7 @@ class ForgottenPasswordViewsTests(TestCase):
         self.assertTrue(not mail.outbox)
         self.assertTrue(not mail.outbox)
 
 
     def test_successful_change(self):
     def test_successful_change(self):
-        """change allright user password"""
+        """change user password"""
         User = get_user_model()
         User = get_user_model()
         test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         test_user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
         old_password = test_user.password
         old_password = test_user.password

+ 53 - 0
misago/users/tests/test_usercp_views.py

@@ -1,4 +1,5 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
+from django.core import mail
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 
 
 from misago.admin.testutils import AdminTestCase
 from misago.admin.testutils import AdminTestCase
@@ -56,3 +57,55 @@ class ChangeUsernameTests(AdminTestCase):
         response = self.client.get(self.view_link)
         response = self.client.get(self.view_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertIn(test_user.username, response.content)
         self.assertIn(test_user.username, response.content)
+
+
+class ChangeEmailPasswordTests(AdminTestCase):
+    def setUp(self):
+        super(ChangeEmailPasswordTests, self).setUp()
+        self.view_link = reverse('misago:usercp_change_email_password')
+
+    def _link_from_mail(self, mail_body):
+        for line in mail.outbox[0].body.splitlines():
+            if line.strip().startswith('http://testserver/'):
+                return line.strip()[len('http://testserver'):]
+        return ''
+
+    def test_change_email_password_get(self):
+        """GET to usercp change email/pass view returns 200"""
+        response = self.client.get(self.view_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Change email or password', response.content)
+
+    def test_change_email(self):
+        """POST to usercp change email view returns 302"""
+        response = self.client.post(self.view_link,
+                                    data={'new_email': 'newmail@test.com',
+                                          'current_password': 'Pass.123'})
+        self.assertEqual(response.status_code, 302)
+
+        self.assertIn('Confirm changes to', mail.outbox[0].subject)
+        confirmation_link = self._link_from_mail(mail.outbox[0].body)
+
+        response = self.client.get(confirmation_link)
+        self.assertEqual(response.status_code, 302)
+
+        User = get_user_model()
+        test_user = User.objects.get(email='newmail@test.com')
+
+    def test_change_password(self):
+        """POST to usercp change password view returns 302"""
+        response = self.client.post(self.view_link,
+                                    data={'new_password': 'newpass123',
+                                          'current_password': 'Pass.123'})
+        self.assertEqual(response.status_code, 302)
+
+        self.assertIn('Confirm changes to', mail.outbox[0].subject)
+        confirmation_link = self._link_from_mail(mail.outbox[0].body)
+
+        response = self.client.get(confirmation_link)
+        self.assertEqual(response.status_code, 302)
+
+        User = get_user_model()
+        test_user = User.objects.get(pk=self.test_admin.pk)
+        self.assertFalse(test_user.check_password('Pass.123'))
+        self.assertTrue(test_user.check_password('newpass123'))

+ 23 - 23
misago/users/tokens.py

@@ -15,17 +15,34 @@ Token is base encoded string containing three values:
 - token checksum for discovering manipulations
 - token checksum for discovering manipulations
 """
 """
 def make(user, token_type):
 def make(user, token_type):
-    user_hash = make_hash(user, token_type)
-    creation_day = days_since_epoch()
+    user_hash = _make_hash(user, token_type)
+    creation_day = _days_since_epoch()
 
 
     obfuscated = base64.b64encode('%s%s' % (user_hash, creation_day))
     obfuscated = base64.b64encode('%s%s' % (user_hash, creation_day))
     obfuscated = obfuscated.rstrip('=')
     obfuscated = obfuscated.rstrip('=')
-    checksum = make_checksum(obfuscated)
+    checksum = _make_checksum(obfuscated)
 
 
     return '%s%s' % (checksum, obfuscated)
     return '%s%s' % (checksum, obfuscated)
 
 
 
 
-def make_hash(user, token_type):
+def is_valid(user, token_type, token):
+    checksum = token[:8]
+    obfuscated = token[8:]
+
+    if checksum != _make_checksum(obfuscated):
+        return False
+
+    unobfuscated = base64.b64decode(obfuscated + '=' * (-len(obfuscated) % 4))
+    user_hash = unobfuscated[:8]
+
+    if user_hash != _make_hash(user, token_type):
+        return False
+
+    creation_day = int(unobfuscated[8:])
+    return creation_day + 5 >= _days_since_epoch()
+
+
+def _make_hash(user, token_type):
     seeds = (
     seeds = (
         user.pk,
         user.pk,
         user.email,
         user.email,
@@ -38,31 +55,14 @@ def make_hash(user, token_type):
     return sha256('+'.join([unicode(s) for s in seeds])).hexdigest()[:8]
     return sha256('+'.join([unicode(s) for s in seeds])).hexdigest()[:8]
 
 
 
 
-def days_since_epoch():
+def _days_since_epoch():
     return int(time() / (25 * 3600))
     return int(time() / (25 * 3600))
 
 
 
 
-def make_checksum(obfuscated):
+def _make_checksum(obfuscated):
     return sha256('%s:%s' % (settings.SECRET_KEY, obfuscated)).hexdigest()[:8]
     return sha256('%s:%s' % (settings.SECRET_KEY, obfuscated)).hexdigest()[:8]
 
 
 
 
-def is_valid(user, token_type, token):
-    checksum = token[:8]
-    obfuscated = token[8:]
-
-    if checksum != make_checksum(obfuscated):
-        return False
-
-    unobfuscated = base64.b64decode(obfuscated + '=' * (-len(obfuscated) % 4))
-    user_hash = unobfuscated[:8]
-
-    if user_hash != make_hash(user, token_type):
-        return False
-
-    creation_day = int(unobfuscated[8:])
-    return creation_day + 5 >= days_since_epoch()
-
-
 """
 """
 Convenience functions for activation token
 Convenience functions for activation token
 """
 """

+ 1 - 0
misago/users/urls.py

@@ -40,4 +40,5 @@ urlpatterns += patterns('misago.users.views.usercp',
     url(r'^usercp/forum-options/$', 'change_forum_options', name="usercp_change_forum_options"),
     url(r'^usercp/forum-options/$', 'change_forum_options', name="usercp_change_forum_options"),
     url(r'^usercp/change-username/$', 'change_username', name="usercp_change_username"),
     url(r'^usercp/change-username/$', 'change_username', name="usercp_change_username"),
     url(r'^usercp/change-email-password/$', 'change_email_password', name="usercp_change_email_password"),
     url(r'^usercp/change-email-password/$', 'change_email_password', name="usercp_change_email_password"),
+    url(r'^usercp/change-email-password/(?P<token>[a-zA-Z0-9]+)/$', 'confirm_email_password_change', name='usercp_confirm_email_password_change'),
 )
 )

+ 5 - 5
misago/users/views/forgottenpassword.py

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
 from django.http import Http404
 from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
+from django.views.decorators.debug import sensitive_post_parameters
 
 
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
@@ -42,10 +43,9 @@ def request_reset(request):
 
 
             confirmation_token = make_password_reset_token(requesting_user)
             confirmation_token = make_password_reset_token(requesting_user)
 
 
-            mail_user(
-                request, requesting_user, mail_subject,
-                'misago/emails/change_password_form_link',
-                {'confirmation_token': confirmation_token})
+            mail_user(request, requesting_user, mail_subject,
+                      'misago/emails/change_password_form_link',
+                      {'confirmation_token': confirmation_token})
 
 
             return redirect('misago:reset_password_link_sent')
             return redirect('misago:reset_password_link_sent')
 
 
@@ -66,7 +66,6 @@ def link_sent(request):
                   {'requesting_user': requesting_user})
                   {'requesting_user': requesting_user})
 
 
 
 
-
 class ResetStopped(Exception):
 class ResetStopped(Exception):
     pass
     pass
 
 
@@ -75,6 +74,7 @@ class ResetError(Exception):
     pass
     pass
 
 
 
 
+@sensitive_post_parameters()
 @reset_view
 @reset_view
 def reset_password_form(request, user_id, token):
 def reset_password_form(request, user_id, token):
     User = get_user_model()
     User = get_user_model()

+ 72 - 2
misago/users/views/usercp.py

@@ -1,13 +1,20 @@
 from django.contrib import messages
 from django.contrib import messages
-from django.db import transaction
+from django.contrib.auth import update_session_auth_hash
+from django.db import IntegrityError, transaction
 from django.shortcuts import redirect, render as django_render
 from django.shortcuts import redirect, render as django_render
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
+from django.views.decorators.debug import sensitive_post_parameters
+
+from misago.conf import settings
+from misago.core.mail import mail_user
 
 
 from misago.users.decorators import deny_guests
 from misago.users.decorators import deny_guests
 from misago.users.forms.usercp import (ChangeForumOptionsForm,
 from misago.users.forms.usercp import (ChangeForumOptionsForm,
                                        ChangeUsernameForm,
                                        ChangeUsernameForm,
                                        ChangeEmailPasswordForm)
                                        ChangeEmailPasswordForm)
 from misago.users.sites import usercp
 from misago.users.sites import usercp
+from misago.users.changedcredentials import (cache_new_credentials,
+                                             get_new_credentials)
 from misago.users.namechanges import UsernameChanges
 from misago.users.namechanges import UsernameChanges
 
 
 
 
@@ -64,13 +71,76 @@ def change_username(request):
         })
         })
 
 
 
 
+@sensitive_post_parameters()
 @deny_guests
 @deny_guests
 def change_email_password(request):
 def change_email_password(request):
     form = ChangeEmailPasswordForm()
     form = ChangeEmailPasswordForm()
     if request.method == 'POST':
     if request.method == 'POST':
         form = ChangeEmailPasswordForm(request.POST, user=request.user)
         form = ChangeEmailPasswordForm(request.POST, user=request.user)
         if form.is_valid():
         if form.is_valid():
-            pass
+            new_email = ''
+            new_password = ''
+
+            # Store original data
+            old_email = request.user.email
+            old_password = request.user.password
+
+            # Assign new creds to user temporarily
+            if form.cleaned_data['new_email']:
+                request.user.set_email(form.cleaned_data['new_email'])
+                new_email = request.user.email
+            if form.cleaned_data['new_password']:
+                request.user.set_password(form.cleaned_data['new_password'])
+                new_password = request.user.password
+
+            request.user.email = old_email
+            request.user.password = old_password
+
+            credentials_token = cache_new_credentials(
+                request.user, new_email, new_password)
+
+            mail_subject = _("Confirm changes to %(username)s account "
+                             "on %(forum_title)s forums")
+            subject_formats = {'username': request.user.username,
+                               'forum_title': settings.forum_name}
+            mail_subject = mail_subject % subject_formats
+
+            if new_email:
+                # finally override email before sending message
+                request.user.email = new_email
+
+            mail_user(request, request.user, mail_subject,
+                      'misago/emails/change_email_password',
+                      {'credentials_token': credentials_token})
+
+            message = _("E-mail was sent to %(email)s with a link that "
+                        "you have to click to confirm changes.")
+            messages.info(request, message % {'email': request.user.email})
+            return redirect('misago:usercp_change_email_password')
 
 
     return render(request, 'misago/usercp/change_email_password.html',
     return render(request, 'misago/usercp/change_email_password.html',
                   {'form': form})
                   {'form': form})
+
+
+@deny_guests
+def confirm_email_password_change(request, token):
+    new_credentials = get_new_credentials(request.user, token)
+    if not new_credentials:
+        messages.error(request, _("Confirmation link is invalid."))
+    else:
+        changes_made = []
+        if new_credentials['email']:
+            request.user.set_email(new_credentials['email'])
+            changes_made.extend(['email', 'email_hash'])
+        if new_credentials['password']:
+            request.user.password = new_credentials['password']
+            update_session_auth_hash(request, request.user)
+            changes_made.append('password')
+
+        try:
+            request.user.save(update_fields=changes_made)
+            message = _("Changes in e-mail and password have been saved.")
+            messages.success(request, message)
+        except IntegrityError:
+            messages.error(request, _("Confirmation link is invalid."))
+    return redirect('misago:usercp_change_email_password')