diff options
-rw-r--r-- | mediagoblin/auth/forms.py | 32 | ||||
-rw-r--r-- | mediagoblin/auth/lib.py | 40 | ||||
-rw-r--r-- | mediagoblin/auth/routing.py | 18 | ||||
-rw-r--r-- | mediagoblin/auth/views.py | 133 | ||||
-rw-r--r-- | mediagoblin/db/migrations.py | 15 | ||||
-rw-r--r-- | mediagoblin/db/models.py | 2 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/auth/change_fp.html | 37 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/auth/forgot_password.html | 37 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/auth/fp_changed_success.html | 27 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/auth/fp_email_sent.html | 28 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/auth/fp_verification_email.txt | 30 | ||||
-rw-r--r-- | mediagoblin/templates/mediagoblin/auth/login.html | 6 | ||||
-rw-r--r-- | mediagoblin/tests/test_auth.py | 88 |
13 files changed, 482 insertions, 11 deletions
diff --git a/mediagoblin/auth/forms.py b/mediagoblin/auth/forms.py index 1dfaf095..6339b4a3 100644 --- a/mediagoblin/auth/forms.py +++ b/mediagoblin/auth/forms.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import wtforms +import re from mediagoblin.util import fake_ugettext_passthrough as _ @@ -51,3 +52,34 @@ class LoginForm(wtforms.Form): password = wtforms.PasswordField( _('Password'), [wtforms.validators.Required()]) + + +class ForgotPassForm(wtforms.Form): + username = wtforms.TextField( + 'Username or email', + [wtforms.validators.Required()]) + + def validate_username(form,field): + if not (re.match(r'^\w+$',field.data) or + re.match(r'^.+@[^.].*\.[a-z]{2,10}$',field.data, re.IGNORECASE)): + raise wtforms.ValidationError(u'Incorrect input') + + +class ChangePassForm(wtforms.Form): + password = wtforms.PasswordField( + 'Password', + [wtforms.validators.Required(), + wtforms.validators.Length(min=6, max=30), + wtforms.validators.EqualTo( + 'confirm_password', + 'Passwords must match.')]) + confirm_password = wtforms.PasswordField( + 'Confirm password', + [wtforms.validators.Required()]) + userid = wtforms.HiddenField( + '', + [wtforms.validators.Required()]) + token = wtforms.HiddenField( + '', + [wtforms.validators.Required()]) + diff --git a/mediagoblin/auth/lib.py b/mediagoblin/auth/lib.py index 89cfb6ff..d7d351a5 100644 --- a/mediagoblin/auth/lib.py +++ b/mediagoblin/auth/lib.py @@ -47,7 +47,7 @@ def bcrypt_check_password(raw_pass, stored_hash, extra_salt=None): # number (thx to zooko on this advice, which I hopefully # incorporated right.) # - # See also: + # See also: rand_salt = bcrypt.gensalt(5) randplus_stored_hash = bcrypt.hashpw(stored_hash, rand_salt) randplus_hashed_pass = bcrypt.hashpw(hashed_pass, rand_salt) @@ -99,7 +99,7 @@ def send_verification_email(user, request): Args: - user: a user object - - request: the request + - request: the request """ rendered_email = render_template( request, 'mediagoblin/auth/verification_email.txt', @@ -116,8 +116,38 @@ def send_verification_email(user, request): [user['email']], # TODO # Due to the distributed nature of GNU MediaGoblin, we should - # find a way to send some additional information about the - # specific GNU MediaGoblin instance in the subject line. For - # example "GNU MediaGoblin @ Wandborg - [...]". + # find a way to send some additional information about the + # specific GNU MediaGoblin instance in the subject line. For + # example "GNU MediaGoblin @ Wandborg - [...]". 'GNU MediaGoblin - Verify your email!', rendered_email) + + +EMAIL_FP_VERIFICATION_TEMPLATE = ( + u"http://{host}{uri}?" + u"userid={userid}&token={fp_verification_key}") + +def send_fp_verification_email(user, request): + """ + Send the verification email to users to change their password. + + Args: + - user: a user object + - request: the request + """ + rendered_email = render_template( + request, 'mediagoblin/auth/fp_verification_email.txt', + {'username': user['username'], + 'verification_url': EMAIL_FP_VERIFICATION_TEMPLATE.format( + host=request.host, + uri=request.urlgen('mediagoblin.auth.verify_forgot_password'), + userid=unicode(user['_id']), + fp_verification_key=user['fp_verification_key'])}) + + # TODO: There is no error handling in place + send_email( + mg_globals.app_config['email_sender_address'], + [user['email']], + 'GNU MediaGoblin - Change forgotten password!', + rendered_email) + diff --git a/mediagoblin/auth/routing.py b/mediagoblin/auth/routing.py index edd21be7..912d89fa 100644 --- a/mediagoblin/auth/routing.py +++ b/mediagoblin/auth/routing.py @@ -26,4 +26,20 @@ auth_routes = [ Route('mediagoblin.auth.verify_email', '/verify_email/', controller='mediagoblin.auth.views:verify_email'), Route('mediagoblin.auth.resend_verification', '/resend_verification/', - controller='mediagoblin.auth.views:resend_activation')] + controller='mediagoblin.auth.views:resend_activation'), + Route('mediagoblin.auth.resend_verification_success', + '/resend_verification_success/', + template='mediagoblin/auth/resent_verification_email.html', + controller='mediagoblin.views:simple_template_render'), + Route('mediagoblin.auth.forgot_password', '/forgot_password/', + controller='mediagoblin.auth.views:forgot_password'), + Route('mediagoblin.auth.verify_forgot_password', '/forgot_password/verify/', + controller='mediagoblin.auth.views:verify_forgot_password'), + Route('mediagoblin.auth.fp_changed_success', + '/forgot_password/changed_success/', + template='mediagoblin/auth/fp_changed_success.html', + controller='mediagoblin.views:simple_template_render'), + Route('mediagoblin.auth.fp_email_sent', + '/forgot_password/email_sent/', + template='mediagoblin/auth/fp_email_sent.html', + controller='mediagoblin.views:simple_template_render')] diff --git a/mediagoblin/auth/views.py b/mediagoblin/auth/views.py index 1b942280..f67f0588 100644 --- a/mediagoblin/auth/views.py +++ b/mediagoblin/auth/views.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import uuid +import datetime from webob import exc @@ -22,10 +23,11 @@ from mediagoblin import messages from mediagoblin import mg_globals from mediagoblin.util import render_to_response, redirect, render_404 from mediagoblin.util import pass_to_ugettext as _ -from mediagoblin.db.util import ObjectId +from mediagoblin.db.util import ObjectId, InvalidId from mediagoblin.auth import lib as auth_lib from mediagoblin.auth import forms as auth_forms -from mediagoblin.auth.lib import send_verification_email +from mediagoblin.auth.lib import send_verification_email, \ + send_fp_verification_email def register(request): @@ -151,9 +153,12 @@ def verify_email(request): {'_id': ObjectId(unicode(request.GET['userid']))}) if user and user['verification_key'] == unicode(request.GET['token']): - user['status'] = u'active' - user['email_verified'] = True + user[u'status'] = u'active' + user[u'email_verified'] = True + user[u'verification_key'] = None + user.save() + messages.add_message( request, messages.SUCCESS, @@ -176,7 +181,7 @@ def resend_activation(request): Resend the activation email. """ - request.user['verification_key'] = unicode(uuid.uuid4()) + request.user[u'verification_key'] = unicode(uuid.uuid4()) request.user.save() send_verification_email(request.user, request) @@ -188,3 +193,121 @@ def resend_activation(request): return redirect( request, 'mediagoblin.user_pages.user_home', user=request.user['username']) + + +def forgot_password(request): + """ + Forgot password view + + Sends an email whit an url to renew forgoten password + """ + fp_form = auth_forms.ForgotPassForm(request.POST) + + if request.method == 'POST' and fp_form.validate(): + # '$or' not available till mongodb 1.5.3 + user = request.db.User.find_one( + {'username': request.POST['username']}) + if not user: + user = request.db.User.find_one( + {'email': request.POST['username']}) + + if user: + if user['email_verified'] and user['status'] == 'active': + user[u'fp_verification_key'] = unicode(uuid.uuid4()) + user[u'fp_token_expire'] = datetime.datetime.now() + \ + datetime.timedelta(days=10) + user.save() + + send_fp_verification_email(user, request) + else: + # special case... we can't send the email because the + # username is inactive / hasn't verified their email + messages.add_message( + request, + messages.WARNING, + _("Could not send password recovery email as " + "your username is inactive or your account's " + "email address has not been verified.")) + + return redirect( + request, 'mediagoblin.user_pages.user_home', + user=user['username']) + + + # do not reveal whether or not there is a matching user, just move along + return redirect(request, 'mediagoblin.auth.fp_email_sent') + + return render_to_response( + request, + 'mediagoblin/auth/forgot_password.html', + {'fp_form': fp_form}) + + +def verify_forgot_password(request): + """ + Check the forgot-password verification and possibly let the user + change their password because of it. + """ + # get form data variables, and specifically check for presence of token + formdata = _process_for_token(request) + if not formdata['has_userid_and_token']: + return render_404(request) + + formdata_token = formdata['vars']['token'] + formdata_userid = formdata['vars']['userid'] + formdata_vars = formdata['vars'] + + # check if it's a valid Id + try: + user = request.db.User.find_one( + {'_id': ObjectId(unicode(formdata_userid))}) + except InvalidId: + return render_404(request) + + # check if we have a real user and correct token + if ((user and user['fp_verification_key'] and + user['fp_verification_key'] == unicode(formdata_token) and + datetime.datetime.now() < user['fp_token_expire'] + and user['email_verified'] and user['status'] == 'active')): + + cp_form = auth_forms.ChangePassForm(formdata_vars) + + if request.method == 'POST' and cp_form.validate(): + user[u'pw_hash'] = auth_lib.bcrypt_gen_password_hash( + request.POST['password']) + user[u'fp_verification_key'] = None + user[u'fp_token_expire'] = None + user.save() + + return redirect(request, 'mediagoblin.auth.fp_changed_success') + else: + return render_to_response( + request, + 'mediagoblin/auth/change_fp.html', + {'cp_form': cp_form}) + + # in case there is a valid id but no user whit that id in the db + # or the token expired + else: + return render_404(request) + + +def _process_for_token(request): + """ + Checks for tokens in formdata without prior knowledge of request method + + For now, returns whether the userid and token formdata variables exist, and + the formdata variables in a hash. Perhaps an object is warranted? + """ + # retrieve the formdata variables + if request.method == 'GET': + formdata_vars = request.GET + else: + formdata_vars = request.POST + + formdata = { + 'vars': formdata_vars, + 'has_userid_and_token': + formdata_vars.has_key('userid') and formdata_vars.has_key('token')} + + return formdata diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index b07681a6..755f49c5 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -92,3 +92,18 @@ def mediaentry_add_fail_error_and_metadata(database): {'fail_metadata': {'$exists': False}}, {'$set': {'fail_metadata': {}}}, multi=True) + + +@RegisterMigration(6) +def user_add_forgot_password_token_and_expires(database): + """ + Add token and expiration fields to help recover forgotten passwords + """ + database['users'].update( + {'fp_verification_key': {'$exists': False}}, + {'$set': {'fp_verification_key': None}}, + multi=True) + database['users'].update( + {'fp_token_expire': {'$exists': False}}, + {'$set': {'fp_token_expire': None}}, + multi=True) diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 792a515e..bbddada6 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -78,6 +78,8 @@ class User(Document): 'url' : unicode, 'bio' : unicode, # May contain markdown 'bio_html': unicode, # May contain plaintext, or HTML + 'fp_verification_key': unicode, # forgotten password verification key + 'fp_token_expire': datetime.datetime } required_fields = ['username', 'created', 'pw_hash', 'email'] diff --git a/mediagoblin/templates/mediagoblin/auth/change_fp.html b/mediagoblin/templates/mediagoblin/auth/change_fp.html new file mode 100644 index 00000000..4be7e065 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/auth/change_fp.html @@ -0,0 +1,37 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011 Free Software Foundation, Inc +# +# 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/>. +#} +{% extends "mediagoblin/base.html" %} + +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} + + <form action="{{ request.urlgen('mediagoblin.auth.verify_forgot_password') }}" + method="POST" enctype="multipart/form-data"> + <div class="grid_6 prefix_1 suffix_1 form_box"> + <h1>{% trans %}Enter your new password{% endtrans %}</h1> + + {{ wtforms_util.render_divs(cp_form) }} + <div class="form_submit_buttons"> + <input type="submit" value="submit" class="button"/> + </div> + + </div> + </form> +{% endblock %} + diff --git a/mediagoblin/templates/mediagoblin/auth/forgot_password.html b/mediagoblin/templates/mediagoblin/auth/forgot_password.html new file mode 100644 index 00000000..23fa9eb5 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/auth/forgot_password.html @@ -0,0 +1,37 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011 Free Software Foundation, Inc +# +# 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/>. +#} +{% extends "mediagoblin/base.html" %} + +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} + + <form action="{{ request.urlgen('mediagoblin.auth.forgot_password') }}" + method="POST" enctype="multipart/form-data"> + <div class="grid_6 prefix_1 suffix_1 form_box"> + <h1>{% trans %}Enter your username or email{% endtrans %}</h1> + + {{ wtforms_util.render_divs(fp_form) }} + <div class="form_submit_buttons"> + <input type="submit" value="submit" class="button"/> + </div> + + </div> + </form> +{% endblock %} + diff --git a/mediagoblin/templates/mediagoblin/auth/fp_changed_success.html b/mediagoblin/templates/mediagoblin/auth/fp_changed_success.html new file mode 100644 index 00000000..d6633ec6 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/auth/fp_changed_success.html @@ -0,0 +1,27 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011 Free Software Foundation, Inc +# +# 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/>. +#} +{% extends "mediagoblin/base.html" %} + +{% block mediagoblin_content %} + <p> + {% trans %} + Your password has been changed. Try to log in now. + {% endtrans %} + </p> +{% endblock %} + diff --git a/mediagoblin/templates/mediagoblin/auth/fp_email_sent.html b/mediagoblin/templates/mediagoblin/auth/fp_email_sent.html new file mode 100644 index 00000000..bc79b970 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/auth/fp_email_sent.html @@ -0,0 +1,28 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011 Free Software Foundation, Inc +# +# 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/>. +#} +{% extends "mediagoblin/base.html" %} + +{% block mediagoblin_content %} + <p> + {% trans %} + Check your inbox. We sent an email with a URL for changing your password. + {% endtrans %} + </p> + +{% endblock %} + diff --git a/mediagoblin/templates/mediagoblin/auth/fp_verification_email.txt b/mediagoblin/templates/mediagoblin/auth/fp_verification_email.txt new file mode 100644 index 00000000..fb5e1674 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/auth/fp_verification_email.txt @@ -0,0 +1,30 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011 Free Software Foundation, Inc +# +# 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 {{ username }}, + +to change your GNU MediaGoblin password, open the following URL in +your web browser: + +{{ verification_url }} + +If you think this is an error, just ignore this email and continue being +a happy goblin! +{%- endtrans %} + diff --git a/mediagoblin/templates/mediagoblin/auth/login.html b/mediagoblin/templates/mediagoblin/auth/login.html index 958cf9ea..3926a1df 100644 --- a/mediagoblin/templates/mediagoblin/auth/login.html +++ b/mediagoblin/templates/mediagoblin/auth/login.html @@ -44,6 +44,12 @@ <a href="{{ request.urlgen('mediagoblin.auth.register') }}"> {%- trans %}Create one here!{% endtrans %}</a> </p> + <p> + {% trans %}Forgot your password?{% endtrans %} + <br /> + <a href="{{ request.urlgen('mediagoblin.auth.forgot_password') }}"> + {%- trans %}Change it!{% endtrans %}</a> + </p> {% endif %} </div> </form> diff --git a/mediagoblin/tests/test_auth.py b/mediagoblin/tests/test_auth.py index eed418c6..fbbe1613 100644 --- a/mediagoblin/tests/test_auth.py +++ b/mediagoblin/tests/test_auth.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import urlparse +import datetime from nose.tools import assert_equal @@ -237,6 +238,93 @@ def test_register_views(test_app): ## TODO: Also check for double instances of an email address? + ### Oops, forgot the password + # ------------------- + util.clear_test_template_context() + response = test_app.post( + '/auth/forgot_password/', + {'username': 'happygirl'}) + response.follow() + + ## Did we redirect to the proper page? Use the right template? + assert_equal( + urlparse.urlsplit(response.location)[2], + '/auth/forgot_password/email_sent/') + assert util.TEMPLATE_TEST_CONTEXT.has_key( + 'mediagoblin/auth/fp_email_sent.html') + + ## Make sure link to change password is sent by email + assert len(util.EMAIL_TEST_INBOX) == 1 + message = util.EMAIL_TEST_INBOX.pop() + assert message['To'] == 'happygrrl@example.org' + email_context = util.TEMPLATE_TEST_CONTEXT[ + 'mediagoblin/auth/fp_verification_email.txt'] + #TODO - change the name of verification_url to something forgot-password-ish + assert email_context['verification_url'] in message.get_payload(decode=True) + + path = urlparse.urlsplit(email_context['verification_url'])[2] + get_params = urlparse.urlsplit(email_context['verification_url'])[3] + assert path == u'/auth/forgot_password/verify/' + parsed_get_params = urlparse.parse_qs(get_params) + + # user should have matching parameters + new_user = mg_globals.database.User.find_one({'username': 'happygirl'}) + assert parsed_get_params['userid'] == [unicode(new_user['_id'])] + assert parsed_get_params['token'] == [new_user['fp_verification_key']] + + ### The forgotten password token should be set to expire in ~ 10 days + # A few ticks have expired so there are only 9 full days left... + assert (new_user['fp_token_expire'] - datetime.datetime.now()).days == 9 + + ## Try using a bs password-changing verification key, shouldn't work + util.clear_test_template_context() + response = test_app.get( + "/auth/forgot_password/verify/?userid=%s&token=total_bs" % unicode( + new_user['_id']), status=400) + assert response.status == '400 Bad Request' + + ## Try using an expired token to change password, shouldn't work + util.clear_test_template_context() + real_token_expiration = new_user['fp_token_expire'] + new_user['fp_token_expire'] = datetime.datetime.now() + new_user.save() + response = test_app.get("%s?%s" % (path, get_params), status=400) + assert response.status == '400 Bad Request' + new_user['fp_token_expire'] = real_token_expiration + new_user.save() + + ## Verify step 1 of password-change works -- can see form to change password + util.clear_test_template_context() + response = test_app.get("%s?%s" % (path, get_params)) + assert util.TEMPLATE_TEST_CONTEXT.has_key('mediagoblin/auth/change_fp.html') + + ## Verify step 2.1 of password-change works -- report success to user + util.clear_test_template_context() + response = test_app.post( + '/auth/forgot_password/verify/', { + 'userid': parsed_get_params['userid'], + 'password': 'iamveryveryhappy', + 'confirm_password': 'iamveryveryhappy', + 'token': parsed_get_params['token']}) + response.follow() + assert util.TEMPLATE_TEST_CONTEXT.has_key( + 'mediagoblin/auth/fp_changed_success.html') + + ## Verify step 2.2 of password-change works -- login w/ new password success + util.clear_test_template_context() + response = test_app.post( + '/auth/login/', { + 'username': u'happygirl', + 'password': 'iamveryveryhappy'}) + + # User should be redirected + response.follow() + assert_equal( + urlparse.urlsplit(response.location)[2], + '/') + assert util.TEMPLATE_TEST_CONTEXT.has_key( + 'mediagoblin/root.html') + @setup_fresh_app def test_authentication_views(test_app): |