Mercurial > kallithea
changeset 5457:f629e9a0c376
auth: secure password reset implementation
This is a better implementation of password reset function, which
doesn't involve sending a new password to the user's email address
in clear text, and at the same time is stateless.
The old implementation generated a new password and sent it
in clear text to whatever email assigned to the user currently,
so that any user, possibly unauthenticated, could request a reset
for any username or email. Apart from potential insecurity, this
made it possible for anyone to disrupt users' workflow by repeatedly
resetting their passwords.
The idea behind this implementation is to generate
an authentication token which is dependent on the user state
at the time before the password change takes place, so the token
is one-time and can't be reused, and also to bind the token to
the browser session.
The token is calculated as SHA1 hash of the following:
* user's identifier (number, not a name)
* timestamp
* hashed user's password
* session identifier
* per-application secret
We use numeric user's identifier, as it's fixed and doesn't change,
so renaming users doesn't affect the mechanism. Timestamp is added
to make it possible to limit the token's validness (currently hard
coded to 24h), and we don't want users to be able to fake that field
easily. Hashed user's password is needed to prevent using the token
again once the password has been changed. Session identifier is
an additional security measure to ensure someone else stealing the
token can't use it. Finally, per-application secret is just another
way to make it harder for an attacker to guess all values in an
attempt to generate a valid token.
When the token is generated, an anonymous user is directed to a
confirmation page where the timestamp and the usernames are already
preloaded, so the user needs to specify the token. User can either
click the link in the email if it's really them reading it, or to type
the token manually.
Using the right token in the same session as it was requested directs
the user to a password change form, where the user is supposed to
specify a new password (twice, of course). Upon completing the form
(which is POSTed) the password change happens and a notification
mail is sent.
The test is updated to test the basic functionality with a bad and
a good token, but it doesn't (yet) cover all code paths.
The original work from Andrew has been thorougly reviewed and heavily
modified by Søren Løvborg.
author | Andrew Shadura <andrew@shadura.me> |
---|---|
date | Sun, 17 May 2015 02:07:18 +0200 |
parents | 2577ddeab331 |
children | dd676aab3b4d |
files | kallithea/controllers/login.py kallithea/model/forms.py kallithea/model/user.py kallithea/templates/email_templates/password_reset.html kallithea/templates/email_templates/password_reset.txt kallithea/templates/password_reset.html kallithea/templates/password_reset_confirmation.html kallithea/tests/functional/test_login.py |
diffstat | 8 files changed, 277 insertions(+), 57 deletions(-) [+] |
line wrap: on
line diff
--- a/kallithea/controllers/login.py Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/controllers/login.py Sun May 17 02:07:18 2015 +0200 @@ -31,7 +31,7 @@ import urlparse from formencode import htmlfill -from webob.exc import HTTPFound +from webob.exc import HTTPFound, HTTPBadRequest from pylons.i18n.translation import _ from pylons.controllers.util import redirect from pylons import request, session, tmpl_context as c, url @@ -42,7 +42,8 @@ from kallithea.lib.exceptions import UserCreationError from kallithea.lib.utils2 import safe_str from kallithea.model.db import User, Setting -from kallithea.model.forms import LoginForm, RegisterForm, PasswordResetForm +from kallithea.model.forms import \ + LoginForm, RegisterForm, PasswordResetRequestForm, PasswordResetConfirmationForm from kallithea.model.user import UserModel from kallithea.model.meta import Session @@ -182,7 +183,7 @@ c.captcha_public_key = settings.get('captcha_public_key') if request.POST: - password_reset_form = PasswordResetForm()() + password_reset_form = PasswordResetRequestForm()() try: form_result = password_reset_form.to_python(dict(request.POST)) if c.captcha_active: @@ -197,10 +198,10 @@ error_dict = {'recaptcha_field': _msg} raise formencode.Invalid(_msg, _value, None, error_dict=error_dict) - UserModel().reset_password_link(form_result) - h.flash(_('Your password reset link was sent'), + redirect_link = UserModel().send_reset_password_email(form_result) + h.flash(_('A password reset confirmation code has been sent'), category='success') - return redirect(url('login_home')) + return redirect(redirect_link) except formencode.Invalid as errors: return htmlfill.render( @@ -214,18 +215,45 @@ return render('/password_reset.html') def password_reset_confirmation(self): - if request.GET and request.GET.get('key'): - try: - user = User.get_by_api_key(request.GET.get('key')) - data = dict(email=user.email) - UserModel().reset_password(data) - h.flash(_('Your password reset was successful, ' - 'new password has been sent to your email'), - category='success') - except Exception as e: - log.error(e) - return redirect(url('reset_password')) + # This controller handles both GET and POST requests, though we + # only ever perform the actual password change on POST (since + # GET requests are not allowed to have side effects, and do not + # receive automatic CSRF protection). + + # The template needs the email address outside of the form. + c.email = request.params.get('email') + + if not request.POST: + return htmlfill.render( + render('/password_reset_confirmation.html'), + defaults=dict(request.params), + encoding='UTF-8') + form = PasswordResetConfirmationForm()() + try: + form_result = form.to_python(dict(request.POST)) + except formencode.Invalid as errors: + return htmlfill.render( + render('/password_reset_confirmation.html'), + defaults=errors.value, + errors=errors.error_dict or {}, + prefix_error=False, + encoding='UTF-8') + + if not UserModel().verify_reset_password_token( + form_result['email'], + form_result['timestamp'], + form_result['token'], + ): + return htmlfill.render( + render('/password_reset_confirmation.html'), + defaults=form_result, + errors={'token': _('Invalid password reset token')}, + prefix_error=False, + encoding='UTF-8') + + UserModel().reset_password(form_result['email'], form_result['password']) + h.flash(_('Successfully updated password'), category='success') return redirect(url('login_home')) def logout(self):
--- a/kallithea/model/forms.py Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/model/forms.py Sun May 17 02:07:18 2015 +0200 @@ -205,13 +205,27 @@ return _RegisterForm -def PasswordResetForm(): - class _PasswordResetForm(formencode.Schema): +def PasswordResetRequestForm(): + class _PasswordResetRequestForm(formencode.Schema): allow_extra_fields = True filter_extra_fields = True email = v.Email(not_empty=True) - return _PasswordResetForm + return _PasswordResetRequestForm + +def PasswordResetConfirmationForm(): + class _PasswordResetConfirmationForm(formencode.Schema): + allow_extra_fields = True + filter_extra_fields = True + email = v.UnicodeString(strip=True, not_empty=True) + timestamp = v.Number(strip=True, not_empty=True) + token = v.UnicodeString(strip=True, not_empty=True) + password = All(v.ValidPassword(), v.UnicodeString(strip=False, min=6)) + password_confirm = All(v.ValidPassword(), v.UnicodeString(strip=False, min=6)) + + chained_validators = [v.ValidPasswordsMatch('password', + 'password_confirm')] + return _PasswordResetConfirmationForm def RepoForm(edit=False, old_data={}, supported_backends=BACKENDS.keys(), repo_groups=[], landing_revs=[]):
--- a/kallithea/model/user.py Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/model/user.py Sun May 17 02:07:18 2015 +0200 @@ -26,8 +26,12 @@ """ +import hashlib import logging +import time import traceback + +from pylons import config from pylons.i18n.translation import _ from sqlalchemy.exc import DatabaseError @@ -47,6 +51,8 @@ class UserModel(BaseModel): + password_reset_token_lifetime = 86400 # 24 hours + cls = User def get(self, user_id, cache=False): @@ -271,25 +277,73 @@ from kallithea.lib.hooks import log_delete_user log_delete_user(user.get_dict(), cur_user) - def reset_password_link(self, data): + def get_reset_password_token(self, user, timestamp, session_id): + """ + The token is calculated as SHA1 hash of the following: + + * user's identifier (number, not a name) + * timestamp + * hashed user's password + * session identifier + * per-application secret + + We use numeric user's identifier, as it's fixed and doesn't change, + so renaming users doesn't affect the mechanism. Timestamp is added + to make it possible to limit the token's validness (currently hard + coded to 24h), and we don't want users to be able to fake that field + easily. Hashed user's password is needed to prevent using the token + again once the password has been changed. Session identifier is + an additional security measure to ensure someone else stealing the + token can't use it. Finally, per-application secret is just another + way to make it harder for an attacker to guess all values in an + attempt to generate a valid token. + """ + return hashlib.sha1('\0'.join([ + str(user.user_id), + str(timestamp), + user.password, + session_id, + config.get('app_instance_uuid'), + ])).hexdigest() + + def send_reset_password_email(self, data): + """ + Sends email with a password reset token and link to the password + reset confirmation page with all information (including the token) + pre-filled. Also returns URL of that page, only without the token, + allowing users to copy-paste or manually enter the token from the + email. + """ from kallithea.lib.celerylib import tasks, run_task from kallithea.model.notification import EmailNotificationModel import kallithea.lib.helpers as h user_email = data['email'] user = User.get_by_email(user_email) + timestamp = int(time.time()) if user is not None: - log.debug('password reset user found %s', user) - link = h.canonical_url('reset_password_confirmation', - key=user.api_key) + log.debug('password reset user %s found', user) + token = self.get_reset_password_token(user, + timestamp, + h.authentication_token()) + # URL must be fully qualified; but since the token is locked to + # the current browser session, we must provide a URL with the + # current scheme and hostname, rather than the canonical_url. + link = h.url('reset_password_confirmation', qualified=True, + email=user_email, + timestamp=timestamp, + token=token) + reg_type = EmailNotificationModel.TYPE_PASSWORD_RESET body = EmailNotificationModel().get_email_tmpl( reg_type, 'txt', user=user.short_contact, + reset_token=token, reset_url=link) html_body = EmailNotificationModel().get_email_tmpl( reg_type, 'html', user=user.short_contact, + reset_token=token, reset_url=link) log.debug('sending email') run_task(tasks.send_email, [user_email], @@ -298,27 +352,52 @@ else: log.debug("password reset email %s not found", user_email) - return True + return h.url('reset_password_confirmation', + email=user_email, + timestamp=timestamp) - def reset_password(self, data): + def verify_reset_password_token(self, email, timestamp, token): from kallithea.lib.celerylib import tasks, run_task from kallithea.lib import auth - user_email = data['email'] + import kallithea.lib.helpers as h + user = User.get_by_email(email) + if user is None: + log.debug("user with email %s not found", email) + return False + + token_age = int(time.time()) - int(timestamp) + + if token_age < 0: + log.debug('timestamp is from the future') + return False + + if token_age > UserModel.password_reset_token_lifetime: + log.debug('password reset token expired') + return False + + expected_token = self.get_reset_password_token(user, + timestamp, + h.authentication_token()) + log.debug('computed password reset token: %s', expected_token) + log.debug('received password reset token: %s', token) + return expected_token == token + + def reset_password(self, user_email, new_passwd): + from kallithea.lib.celerylib import tasks, run_task + from kallithea.lib import auth user = User.get_by_email(user_email) - new_passwd = auth.PasswordGenerator().gen_password( - 8, auth.PasswordGenerator.ALPHABETS_BIG_SMALL) if user is not None: user.password = auth.get_crypt_password(new_passwd) Session().add(user) Session().commit() log.info('change password for %s', user_email) if new_passwd is None: - raise Exception('unable to generate new password') + raise Exception('unable to set new password') run_task(tasks.send_email, [user_email], - _('Your new password'), - _('Your new Kallithea password:%s') % (new_passwd,)) - log.info('send new password mail to %s', user_email) + _('Password reset notification'), + _('The password to your account %s has been changed using password reset form.') % (user.username,)) + log.info('send password reset mail to %s', user_email) return True
--- a/kallithea/templates/email_templates/password_reset.html Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/templates/email_templates/password_reset.html Sun May 17 02:07:18 2015 +0200 @@ -3,8 +3,10 @@ <h4>${_('Hello %s') % user}</h4> -<p>${_('We received a request to create a new password for your account.')}</p> -<p>${_('You can generate it by clicking following URL')}:</p> +<p>${_('We have received a request to reset the password for your account.')}</p> +<p>${_('To set a new password, click the following link')}:</p> <p><a href="${reset_url}">${reset_url}</a></p> -<p>${_("Please ignore this email if you did not request a new password .")}</p> +<p>${_("Should you not be able to use the link above, please type the following code into the password reset form")}: <code>${reset_token}</code></p> + +<p>${_("If it weren't you who requested the password reset, just disregard this message.")}</p>
--- a/kallithea/templates/email_templates/password_reset.txt Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/templates/email_templates/password_reset.txt Sun May 17 02:07:18 2015 +0200 @@ -3,9 +3,11 @@ ${_('Hello %s') % user|n,unicode} -${_('We received a request to create a new password for your account.')|n,unicode} -${_('You can generate it by clicking following URL')|n,unicode}: +${_('We have received a request to reset the password for your account..')|n,unicode} +${_('To set a new password, click the following link')|n,unicode}: ${reset_url|n,unicode} -${_("Please ignore this email if you did not request a new password .")|n,unicode} +${_("Should you not be able to use the link above, please type the following code into the password reset form")|n,unicode}: ${reset_token|n,unicode} + +${_("If it weren't you who requested the password reset, just disregard this message.")|n,unicode}
--- a/kallithea/templates/password_reset.html Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/templates/password_reset.html Sun May 17 02:07:18 2015 +0200 @@ -44,7 +44,7 @@ <div class="buttons"> <div class="nohighlight"> ${h.submit('send',_('Send Password Reset Email'),class_="btn")} - <div class="activation_msg">${_('Password reset link will be sent to the email address matching your username.')}</div> + <div class="activation_msg">${_('A password reset link will be sent to the specified email address if it is registered in the system.')}</div> </div> </div> </div>
--- a/kallithea/templates/password_reset_confirmation.html Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/templates/password_reset_confirmation.html Sun May 17 02:07:18 2015 +0200 @@ -0,0 +1,63 @@ +## -*- coding: utf-8 -*- +<%inherit file="base/root.html"/> + +<%block name="title"> + ${_('Reset Your Password')} +</%block> + +<div id="register"> + <%include file="/base/flash_msg.html"/> + <div class="title withlogo"> + %if c.site_name: + <h5>${_('Reset Your Password to %s') % c.site_name}</h5> + %else: + <h5>${_('Reset Your Password')}</h5> + %endif + </div> + <div class="inner"> + ${h.form(h.url('reset_password_confirmation'), method='post')} + <p>${_('You are about to set a new password for the email address %s.') % c.email}</p> + <p>${_('Note that you must use the same browser session for this as the one used to request the password reset.')}</p> + <div style="display: none;"> + ${h.hidden('email')} + ${h.hidden('timestamp')} + </div> + <div class="form"> + <!-- fields --> + <div class="fields"> + <div class="field"> + <div class="label"> + <label for="token">${_('Code you received in the email')}:</label> + </div> + <div class="input"> + ${h.text('token', class_='focus')} + </div> + </div> + + <div class="field"> + <div class="label"> + <label for="password">${_('New Password')}:</label> + </div> + <div class="input"> + ${h.password('password',class_='focus')} + </div> + </div> + + <div class="field"> + <div class="label"> + <label for="password_confirm">${_('Confirm New Password')}:</label> + </div> + <div class="input"> + ${h.password('password_confirm',class_='focus')} + </div> + </div> + <div class="buttons"> + <div class="nohighlight"> + ${h.submit('send',_('Confirm'),class_="btn")} + </div> + </div> + </div> + </div> + ${h.end_form()} + </div> + </div>
--- a/kallithea/tests/functional/test_login.py Thu Jul 09 22:17:26 2015 +0200 +++ b/kallithea/tests/functional/test_login.py Sun May 17 02:07:18 2015 +0200 @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import with_statement import re +import time import mock + from kallithea.tests import * from kallithea.tests.fixture import Fixture from kallithea.lib.utils2 import generate_api_key @@ -12,6 +14,7 @@ from kallithea.model import validators from kallithea.model.db import User, Notification from kallithea.model.meta import Session +from kallithea.model.user import UserModel fixture = Fixture() @@ -326,6 +329,10 @@ self.assertNotEqual(ret.api_key, None) self.assertEqual(ret.admin, False) + #========================================================================== + # PASSWORD RESET + #========================================================================== + def test_forgot_password_wrong_mail(self): bad_email = 'username%wrongmail.org' response = self.app.post( @@ -345,6 +352,7 @@ email = 'username@python-works.com' name = 'passwd' lastname = 'reset' + timestamp = int(time.time()) new = User() new.username = username @@ -360,34 +368,58 @@ action='password_reset'), {'email': email, }) - self.checkSessionFlash(response, 'Your password reset link was sent') + self.checkSessionFlash(response, 'A password reset confirmation code has been sent') response = response.follow() - # BAD KEY + # BAD TOKEN + + token = "bad" - key = "bad" + response = self.app.post(url(controller='login', + action='password_reset_confirmation'), + {'email': email, + 'timestamp': timestamp, + 'password': "p@ssw0rd", + 'password_confirm': "p@ssw0rd", + 'token': token, + }) + self.assertEqual(response.status, '200 OK') + response.mustcontain('Invalid password reset token') + + # GOOD TOKEN + + # TODO: The token should ideally be taken from the mail sent + # above, instead of being recalculated. + + token = UserModel().get_reset_password_token( + User.get_by_username(username), timestamp, self.authentication_token()) + response = self.app.get(url(controller='login', action='password_reset_confirmation', - key=key)) - self.assertEqual(response.status, '302 Found') - self.assertTrue(response.location.endswith(url('reset_password'))) - - # GOOD KEY + email=email, + timestamp=timestamp, + token=token)) + self.assertEqual(response.status, '200 OK') + response.mustcontain("You are about to set a new password for the email address %s" % email) - key = User.get_by_username(username).api_key - response = self.app.get(url(controller='login', - action='password_reset_confirmation', - key=key)) + response = self.app.post(url(controller='login', + action='password_reset_confirmation'), + {'email': email, + 'timestamp': timestamp, + 'password': "p@ssw0rd", + 'password_confirm': "p@ssw0rd", + 'token': token, + }) self.assertEqual(response.status, '302 Found') - self.assertTrue(response.location.endswith(url('login_home'))) - - self.checkSessionFlash(response, - ('Your password reset was successful, ' - 'new password has been sent to your email')) + self.checkSessionFlash(response, 'Successfully updated password') response = response.follow() + #========================================================================== + # API + #========================================================================== + def _get_api_whitelist(self, values=None): config = {'api_access_controllers_whitelist': values or []} return config