aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--mediagoblin/auth/tools.py25
-rw-r--r--mediagoblin/edit/forms.py14
-rw-r--r--mediagoblin/edit/routing.py2
-rw-r--r--mediagoblin/edit/views.py116
-rw-r--r--mediagoblin/templates/mediagoblin/edit/edit_account.html2
-rw-r--r--mediagoblin/templates/mediagoblin/edit/verification.txt29
-rw-r--r--mediagoblin/tests/test_edit.py106
7 files changed, 265 insertions, 29 deletions
diff --git a/mediagoblin/auth/tools.py b/mediagoblin/auth/tools.py
index db6b6e37..d86235b1 100644
--- a/mediagoblin/auth/tools.py
+++ b/mediagoblin/auth/tools.py
@@ -66,7 +66,8 @@ EMAIL_VERIFICATION_TEMPLATE = (
u"userid={userid}&token={verification_key}")
-def send_verification_email(user, request):
+def send_verification_email(user, request, email=None,
+ rendered_email=None):
"""
Send the verification email to users to activate their accounts.
@@ -74,19 +75,23 @@ def send_verification_email(user, request):
- user: a user object
- request: the request
"""
- rendered_email = render_template(
- request, 'mediagoblin/auth/verification_email.txt',
- {'username': user.username,
- 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
- host=request.host,
- uri=request.urlgen('mediagoblin.auth.verify_email'),
- userid=unicode(user.id),
- verification_key=user.verification_key)})
+ if not email:
+ email = user.email
+
+ if not rendered_email:
+ rendered_email = render_template(
+ request, 'mediagoblin/auth/verification_email.txt',
+ {'username': user.username,
+ 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
+ host=request.host,
+ uri=request.urlgen('mediagoblin.auth.verify_email'),
+ userid=unicode(user.id),
+ verification_key=user.verification_key)})
# TODO: There is no error handling in place
send_email(
mg_globals.app_config['email_sender_address'],
- [user.email],
+ [email],
# TODO
# Due to the distributed nature of GNU MediaGoblin, we should
# find a way to send some additional information about the
diff --git a/mediagoblin/edit/forms.py b/mediagoblin/edit/forms.py
index 3b2486de..3a502263 100644
--- a/mediagoblin/edit/forms.py
+++ b/mediagoblin/edit/forms.py
@@ -16,9 +16,11 @@
import wtforms
-from mediagoblin.tools.text import tag_length_validator, TOO_LONG_TAG_WARNING
+from mediagoblin.tools.text import tag_length_validator
from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
from mediagoblin.tools.licenses import licenses_as_choices
+from mediagoblin.auth.forms import normalize_user_or_email_field
+
class EditForm(wtforms.Form):
title = wtforms.TextField(
@@ -59,6 +61,16 @@ class EditProfileForm(wtforms.Form):
class EditAccountForm(wtforms.Form):
+ new_email = wtforms.TextField(
+ _('New email address'),
+ [wtforms.validators.Optional(),
+ normalize_user_or_email_field(allow_user=False)])
+ password = wtforms.PasswordField(
+ _('Password'),
+ [wtforms.validators.Optional(),
+ wtforms.validators.Length(min=5, max=1024)],
+ description=_(
+ 'Enter your old password to prove you own this account.'))
license_preference = wtforms.SelectField(
_('License preference'),
[
diff --git a/mediagoblin/edit/routing.py b/mediagoblin/edit/routing.py
index 622729ac..3592f708 100644
--- a/mediagoblin/edit/routing.py
+++ b/mediagoblin/edit/routing.py
@@ -26,3 +26,5 @@ add_route('mediagoblin.edit.delete_account', '/edit/account/delete/',
'mediagoblin.edit.views:delete_account')
add_route('mediagoblin.edit.pass', '/edit/password/',
'mediagoblin.edit.views:change_pass')
+add_route('mediagoblin.edit.verify_email', '/edit/verify_email/',
+ 'mediagoblin.edit.views:verify_email')
diff --git a/mediagoblin/edit/views.py b/mediagoblin/edit/views.py
index 508c380d..249fb8ba 100644
--- a/mediagoblin/edit/views.py
+++ b/mediagoblin/edit/views.py
@@ -16,6 +16,7 @@
from datetime import datetime
+from itsdangerous import BadSignature
from werkzeug.exceptions import Forbidden
from werkzeug.utils import secure_filename
@@ -23,18 +24,22 @@ from mediagoblin import messages
from mediagoblin import mg_globals
from mediagoblin.auth import lib as auth_lib
+from mediagoblin.auth.views import email_debug_message
from mediagoblin.edit import forms
from mediagoblin.edit.lib import may_edit_media
from mediagoblin.decorators import (require_active_login, active_user_from_url,
- get_media_entry_by_id,
- user_may_alter_collection, get_user_collection)
-from mediagoblin.tools.response import render_to_response, \
- redirect, redirect_obj
+ get_media_entry_by_id, user_may_alter_collection,
+ get_user_collection)
+from mediagoblin.tools.crypto import get_timed_signer_url
+from mediagoblin.tools.response import (render_to_response,
+ redirect, redirect_obj, render_404)
from mediagoblin.tools.translate import pass_to_ugettext as _
+from mediagoblin.tools.template import render_template
from mediagoblin.tools.text import (
convert_to_tag_list_of_dicts, media_tags_as_string)
from mediagoblin.tools.url import slugify
from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used
+from mediagoblin.db.models import User
import mimetypes
@@ -212,6 +217,10 @@ def edit_profile(request, url_user=None):
{'user': user,
'form': form})
+EMAIL_VERIFICATION_TEMPLATE = (
+ u'{uri}?'
+ u'token={verification_key}')
+
@require_active_login
def edit_account(request):
@@ -220,27 +229,57 @@ def edit_account(request):
wants_comment_notification=user.wants_comment_notification,
license_preference=user.license_preference)
- if request.method == 'POST':
- form_validated = form.validate()
-
- if form_validated and \
- form.wants_comment_notification.validate(form):
+ if request.method == 'POST' and form.validate():
+ if form.wants_comment_notification.validate(form):
user.wants_comment_notification = \
form.wants_comment_notification.data
- if form_validated and \
- form.license_preference.validate(form):
+ if form.license_preference.validate(form):
user.license_preference = \
form.license_preference.data
- if form_validated and not form.errors:
+ if form.new_email.data:
+ if not form.password.data:
+ form.password.errors.append(
+ _('This field is required.'))
+ elif not auth_lib.bcrypt_check_password(
+ form.password.data, user.pw_hash):
+ form.password.errors.append(
+ _('Wrong password.'))
+ else:
+ new_email = form.new_email.data
+ users_with_email = User.query.filter_by(
+ email=new_email).count()
+ if users_with_email:
+ form.new_email.errors.append(
+ _('Sorry, a user with that email address'
+ ' already exists.'))
+ else:
+ verification_key = get_timed_signer_url(
+ 'mail_verification_token').dumps({
+ 'user': user.id,
+ 'email': new_email})
+
+ rendered_email = render_template(
+ request, 'mediagoblin/edit/verification.txt',
+ {'username': user.username,
+ 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
+ uri=request.urlgen('mediagoblin.edit.verify_email',
+ qualified=True),
+ verification_key=verification_key)})
+
+ email_debug_message(request)
+ auth_lib.send_verification_email(user, request, new_email,
+ rendered_email)
+
+ if not form.errors:
user.save()
messages.add_message(request,
- messages.SUCCESS,
- _("Account settings saved"))
+ messages.SUCCESS,
+ _("Account settings saved"))
return redirect(request,
- 'mediagoblin.user_pages.user_home',
- user=user.username)
+ 'mediagoblin.user_pages.user_home',
+ user=user.username)
return render_to_response(
request,
@@ -369,3 +408,48 @@ def change_pass(request):
'mediagoblin/edit/change_pass.html',
{'form': form,
'user': user})
+
+
+def verify_email(request):
+ """
+ Email verification view for changing email address
+ """
+ # If no token, we can't do anything
+ if not 'token' in request.GET:
+ return render_404(request)
+
+ # Catch error if token is faked or expired
+ token = None
+ try:
+ token = get_timed_signer_url("mail_verification_token") \
+ .loads(request.GET['token'], max_age=10*24*3600)
+ except BadSignature:
+ messages.add_message(
+ request,
+ messages.ERROR,
+ _('The verification key or user id is incorrect.'))
+
+ return redirect(
+ request,
+ 'index')
+
+ user = User.query.filter_by(id=int(token['user'])).first()
+
+ if user:
+ user.email = token['email']
+ user.save()
+
+ messages.add_message(
+ request,
+ messages.SUCCESS,
+ _('Your email address has been verified.'))
+
+ else:
+ messages.add_message(
+ request,
+ messages.ERROR,
+ _('The verification key or user id is incorrect.'))
+
+ return redirect(
+ request, 'mediagoblin.user_pages.user_home',
+ user=user.username)
diff --git a/mediagoblin/templates/mediagoblin/edit/edit_account.html b/mediagoblin/templates/mediagoblin/edit/edit_account.html
index 4c4aaf95..d56b3ba0 100644
--- a/mediagoblin/templates/mediagoblin/edit/edit_account.html
+++ b/mediagoblin/templates/mediagoblin/edit/edit_account.html
@@ -46,6 +46,8 @@
{% trans %}Change your password.{% endtrans %}
</a>
</p>
+ {{ wtforms_util.render_field_div(form.new_email) }}
+ {{ wtforms_util.render_field_div(form.password) }}
<div class="form_field_input">
<p>{{ form.wants_comment_notification }}
{{ wtforms_util.render_label(form.wants_comment_notification) }}</p>
diff --git a/mediagoblin/templates/mediagoblin/edit/verification.txt b/mediagoblin/templates/mediagoblin/edit/verification.txt
new file mode 100644
index 00000000..d53cd5e8
--- /dev/null
+++ b/mediagoblin/templates/mediagoblin/edit/verification.txt
@@ -0,0 +1,29 @@
+{#
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+-#}
+
+{% trans username=username, verification_url=verification_url|safe -%}
+Hi,
+
+We wanted to verify that you are {{ username }}. If this is the case, then
+please follow the link below to verify your new email address.
+
+{{ verification_url }}
+
+If you are not {{ username }} or didn't request an email change, you can ignore
+this email.
+{%- endtrans %}
diff --git a/mediagoblin/tests/test_edit.py b/mediagoblin/tests/test_edit.py
index 08b4f8cf..76fd5ee9 100644
--- a/mediagoblin/tests/test_edit.py
+++ b/mediagoblin/tests/test_edit.py
@@ -15,14 +15,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import urlparse
-import pytest
from mediagoblin import mg_globals
from mediagoblin.db.models import User
from mediagoblin.tests.tools import fixture_add_user
-from mediagoblin.tools import template
+from mediagoblin.tools import template, mail
from mediagoblin.auth.lib import bcrypt_check_password
+
class TestUserEdit(object):
def setup(self):
# set up new user
@@ -141,4 +141,106 @@ class TestUserEdit(object):
assert form.url.errors == [
u'This address contains errors']
+ def test_email_change(self, test_app):
+ self.login(test_app)
+
+ # Test email change without password
+ template.clear_test_template_context()
+ test_app.post(
+ '/edit/account/', {
+ 'new_email': 'new@example.com'})
+
+ # Check form errors
+ context = template.TEMPLATE_TEST_CONTEXT[
+ 'mediagoblin/edit/edit_account.html']
+ assert context['form'].password.errors == [
+ u'This field is required.']
+
+ # Test email change with wrong password
+ template.clear_test_template_context()
+ test_app.post(
+ '/edit/account/', {
+ 'new_email': 'new@example.com',
+ 'password': 'wrong'})
+
+ # Check form errors
+ context = template.TEMPLATE_TEST_CONTEXT[
+ 'mediagoblin/edit/edit_account.html']
+ assert context['form'].password.errors == [
+ u'Wrong password.']
+
+ # Test email already in db
+ template.clear_test_template_context()
+ test_app.post(
+ '/edit/account/', {
+ 'new_email': 'chris@example.com',
+ 'password': 'toast'})
+
+ # Check form errors
+ context = template.TEMPLATE_TEST_CONTEXT[
+ 'mediagoblin/edit/edit_account.html']
+ assert context['form'].new_email.errors == [
+ u'Sorry, a user with that email address already exists.']
+
+ # Test password is too short
+ template.clear_test_template_context()
+ test_app.post(
+ '/edit/account/', {
+ 'new_email': 'new@example.com',
+ 'password': 't'})
+
+ # Check form errors
+ context = template.TEMPLATE_TEST_CONTEXT[
+ 'mediagoblin/edit/edit_account.html']
+ assert context['form'].password.errors == [
+ u'Field must be between 5 and 1024 characters long.']
+
+ # Test successful email change
+ template.clear_test_template_context()
+ res = test_app.post(
+ '/edit/account/', {
+ 'new_email': 'new@example.com',
+ 'password': 'toast'})
+ res.follow()
+
+ # Correct redirect?
+ assert urlparse.urlsplit(res.location)[2] == '/u/chris/'
+
+ # Make sure we get email verification and try verifying
+ assert len(mail.EMAIL_TEST_INBOX) == 1
+ message = mail.EMAIL_TEST_INBOX.pop()
+ assert message['To'] == 'new@example.com'
+ email_context = template.TEMPLATE_TEST_CONTEXT[
+ 'mediagoblin/edit/verification.txt']
+ assert email_context['verification_url'] in \
+ message.get_payload(decode=True)
+
+ path = urlparse.urlsplit(email_context['verification_url'])[2]
+ assert path == u'/edit/verify_email/'
+
+ ## Try verifying with bs verification key, shouldn't work
+ template.clear_test_template_context()
+ res = test_app.get(
+ "/edit/verify_email/?token=total_bs")
+ res.follow()
+
+ # Correct redirect?
+ assert urlparse.urlsplit(res.location)[2] == '/'
+
+ # Email shouldn't be saved
+ email_in_db = mg_globals.database.User.find_one(
+ {'email': 'new@example.com'})
+ email = User.query.filter_by(username='chris').first().email
+ assert email_in_db is None
+ assert email == 'chris@example.com'
+
+ # Verify email activation works
+ template.clear_test_template_context()
+ get_params = urlparse.urlsplit(email_context['verification_url'])[3]
+ res = test_app.get('%s?%s' % (path, get_params))
+ res.follow()
+
+ # New email saved?
+ email = User.query.filter_by(username='chris').first().email
+ assert email == 'new@example.com'
# test changing the url inproperly