From b94bad829c1fd4b4325a4af280d33d50f164e05f Mon Sep 17 00:00:00 2001 From: 0livd Date: Thu, 26 Oct 2017 19:46:34 +0200 Subject: [PATCH] Use token based auth to reset passwords (#269) Send a mail containing a password reset token link instead of sending a clear text password. Ref #232 --- CHANGELOG.rst | 1 + ihatemoney/forms.py | 10 ++++- ihatemoney/models.py | 28 +++++++++++- ihatemoney/templates/forms.html | 10 ++++- ihatemoney/templates/password_reminder.en | 6 +-- ihatemoney/templates/password_reminder.fr | 6 +-- ihatemoney/templates/reminder_mail.en | 2 +- ihatemoney/templates/reminder_mail.fr | 2 +- ihatemoney/templates/reset_password.html | 12 ++++++ ihatemoney/tests/tests.py | 24 +++++++++++ .../translations/fr/LC_MESSAGES/messages.mo | Bin 9024 -> 9559 bytes .../translations/fr/LC_MESSAGES/messages.po | 40 +++++++++++++++++- ihatemoney/web.py | 29 +++++++++++-- requirements.txt | 1 + setup.py | 1 + 15 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 ihatemoney/templates/reset_password.html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 260e6ff2..2f3a0375 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,7 @@ Changed - Logged admin can see any project (#262) - Simpler and safer authentication logic (#270) +- Use token based auth to reset passwords (#269) - Better install doc (#275) Added diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index ead5586d..c5e0b54a 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -2,7 +2,7 @@ from flask_wtf.form import FlaskForm from wtforms.fields.core import SelectField, SelectMultipleField from wtforms.fields.html5 import DateField, DecimalField from wtforms.fields.simple import PasswordField, SubmitField, TextAreaField, StringField -from wtforms.validators import Email, Required, ValidationError +from wtforms.validators import Email, Required, ValidationError, EqualTo from flask_babel import lazy_gettext as _ from flask import request @@ -102,6 +102,14 @@ class PasswordReminder(FlaskForm): raise ValidationError(_("This project does not exists")) +class ResetPasswordForm(FlaskForm): + password_validators = [Required(), + EqualTo('password_confirmation', message=_("Password mismatch"))] + password = PasswordField(_("Password"), validators=password_validators) + password_confirmation = PasswordField(_("Password confirmation"), validators=[Required()]) + submit = SubmitField(_("Reset password")) + + class BillForm(FlaskForm): date = DateField(_("Date"), validators=[Required()], default=datetime.now) what = StringField(_("What?"), validators=[Required()]) diff --git a/ihatemoney/models.py b/ihatemoney/models.py index cd896f3c..c801b745 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -2,9 +2,11 @@ from collections import defaultdict from datetime import datetime from flask_sqlalchemy import SQLAlchemy, BaseQuery -from flask import g +from flask import g, current_app from sqlalchemy import orm +from itsdangerous import (TimedJSONWebSignatureSerializer + as Serializer, BadSignature, SignatureExpired) db = SQLAlchemy() @@ -199,6 +201,30 @@ class Project(db.Model): db.session.delete(self) db.session.commit() + def generate_token(self, expiration): + """Generate a timed and serialized JsonWebToken + + :param expiration: Token expiration time (in seconds) + """ + serializer = Serializer(current_app.config['SECRET_KEY'], expiration) + return serializer.dumps({'project_id': self.id}).decode('utf-8') + + @staticmethod + def verify_token(token): + """Return the project id associated to the provided token, + None if the provided token is expired or not valid. + + :param token: Serialized TimedJsonWebToken + """ + serializer = Serializer(current_app.config['SECRET_KEY']) + try: + data = serializer.loads(token) + except SignatureExpired: + return None + except BadSignature: + return None + return data['project_id'] + def __repr__(self): return "" % self.name diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html index ffdd165b..63d1c3c7 100644 --- a/ihatemoney/templates/forms.html +++ b/ihatemoney/templates/forms.html @@ -159,10 +159,18 @@ {% endmacro %} {% macro remind_password(form) %} - {% include "display_errors.html" %} {{ form.hidden_tag() }} {{ input(form.id) }} {{ submit(form.submit) }} {% endmacro %} + +{% macro reset_password(form) %} + {% include "display_errors.html" %} + {{ form.hidden_tag() }} + {{ input(form.password) }} + {{ input(form.password_confirmation) }} + {{ submit(form.submit) }} + +{% endmacro %} diff --git a/ihatemoney/templates/password_reminder.en b/ihatemoney/templates/password_reminder.en index 31210aab..bc7e609c 100644 --- a/ihatemoney/templates/password_reminder.en +++ b/ihatemoney/templates/password_reminder.en @@ -1,8 +1,8 @@ Hi, -You requested to be reminded about your password for "{{ project.name }}". - -You can access it here: {{ config['SITE_URL'] }}{{ url_for(".list_bills", project_id=project.id) }}, the private code is "{{ project.password }}". +You requested to reset the password of the following project: "{{ project.name }}". +You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. +This link is only valid for 1 hour. Hope this helps, Some weird guys (with beards) diff --git a/ihatemoney/templates/password_reminder.fr b/ihatemoney/templates/password_reminder.fr index 58f04e3e..d4fbc2d5 100644 --- a/ihatemoney/templates/password_reminder.fr +++ b/ihatemoney/templates/password_reminder.fr @@ -1,7 +1,7 @@ Salut, -Vous avez demandez des informations sur votre mot de passe pour "{{ project.name }}". - -Vous pouvez y accéder ici {{ config['SITE_URL'] }}{{ url_for(".list_bills", project_id=project.id) }}, le code d'accès est "{{ project.password }}". +Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}". +Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. +Ce lien est seulement valide pendant 1 heure. Faites en bon usage ! diff --git a/ihatemoney/templates/reminder_mail.en b/ihatemoney/templates/reminder_mail.en index fe57be2d..f13da5df 100644 --- a/ihatemoney/templates/reminder_mail.en +++ b/ihatemoney/templates/reminder_mail.en @@ -2,7 +2,7 @@ Hi, You have just (or someone else using your email address) created the project "{{ g.project.name }}" to share your expenses. -You can access it here: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }} (the identifier is {{ g.project.id }}), +You can access it here: {{ url_for(".list_bills", _external=True) }} (the identifier is {{ g.project.id }}), and the private code is "{{ g.project.password }}". Enjoy, diff --git a/ihatemoney/templates/reminder_mail.fr b/ihatemoney/templates/reminder_mail.fr index 81302181..86c00ff4 100644 --- a/ihatemoney/templates/reminder_mail.fr +++ b/ihatemoney/templates/reminder_mail.fr @@ -2,7 +2,7 @@ Hey, Vous venez de créer le projet "{{ g.project.name }}" pour partager vos dépenses. -Vous pouvez y accéder ici: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }} (l'identifieur est {{ g.project.id }}), +Vous pouvez y accéder ici: {{ url_for(".list_bills", _external=True) }} (l'identifieur est {{ g.project.id }}), et le code d'accès "{{ g.project.password }}". Faites en bon usage ! diff --git a/ihatemoney/templates/reset_password.html b/ihatemoney/templates/reset_password.html new file mode 100644 index 00000000..78b58530 --- /dev/null +++ b/ihatemoney/templates/reset_password.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} + +{% block content %} +{% if error %} +
{{ error }}
+{% else %} +

{{ _("Reset your password") }}

+
+{{ forms.reset_password(form) }} +
+{% endif %} +{% endblock %} diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 6c0ccb9f..f9187461 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -169,6 +169,30 @@ class BudgetTestCase(IhatemoneyTestCase): self.assertIn("raclette", outbox[0].body) self.assertIn("raclette@notmyidea.org", outbox[0].recipients) + def test_password_reset(self): + # test that a password can be changed using a link sent by mail + + self.create_project("raclette") + # Get password resetting link from mail + with self.app.mail.record_messages() as outbox: + self.client.post("/password-reminder", data={"id": "raclette"}) + self.assertEqual(len(outbox), 1) + url_start = outbox[0].body.find('You can reset it here: ') + 23 + url_end = outbox[0].body.find('.\n', url_start) + url = outbox[0].body[url_start:url_end] + # Test that we got a valid token + resp = self.client.get(url) + self.assertIn("Password confirmation", resp.data.decode('utf-8')) + # Test that password can be changed + self.client.post(url, data={'password': 'pass', 'password_confirmation': 'pass'}) + resp = self.login('raclette', password='pass') + self.assertIn("Account manager - raclette", resp.data.decode('utf-8')) + # Test empty and null tokens + resp = self.client.get("/reset-password") + self.assertIn("No token provided", resp.data.decode('utf-8')) + resp = self.client.get("/reset-password?token=token") + self.assertIn("Invalid token", resp.data.decode('utf-8')) + def test_project_creation(self): with self.app.test_client() as c: diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo index 56b50d39ce0cef5c3edde1ac2bc5ef78a69286fb..249c996139697aa01a3dbe6285be9f78c2d8d3d1 100644 GIT binary patch delta 2828 zcmZwHd2AGA9LDi?D@PA#OR*dU+G&v%3~W)XRRX9Kfnd>ch$g}x%kI>!-OkiKN?Rct zF(!hB02Pmt7=)N`YT~LWpwUz?ihmKn7zC|g0ujTJ;Dv(UXLmS^Htl|9-hJnNfA90Y z(@$r9GbeeXsLuw&&no^#@)zo(yBj|{vyB`31yjoHJ+AQ}#$COVFK@FZ%$vq&xGs%H*WYU06Ih9#))C*u&D zfdlag&oEYSzYI&T8}5{^SNAQS2Bs6r>B`qiVJdmL5p z5-i3>RKMk(YfuSnLM`xPR1!UyW%{SX!D5uAi4P)QUK zj}jP-jBRRB$<4ys@DYq)9M!KEmCPm7g0g5V#T--uW0T&6A9aZC@|=d++u1k;=b~P( z)u_t%qYmG3Bu(=-D#1K*S7Jj@6IA0UtU-;x0M%~^s-R>O7n;cRj3bLR9bShQQ7c@J z%wf7w1H6q|*+Pqto3{iRnR8X*1U%!a6eAPUR3`w;?P@Bg-T>9YHRDTn)yvV7ZYe$ zj(T7_@@wAZLjxZ~CG;IC>RznEbEu?-a^f_>IMn-pAF6#3YQiSeTjHP&Yo~X=0dtw( zyvc?3VkauH6e`N2sKfFDD&niCg7O(fiIt)H*P{-ji`AG!{mXp^snMiR<9&^j@F*(L zT;fWq(otNfV>K#)*{Hp3KyAqqyaPLtRLxFQVxOZ5zld7-AE<;1c}U}wqLQgZwNF9K zHwU$l#Usg|VoVDSis%K@N?%4zv;}oo_ImfR!5xiceBuT2e3z-g#M z6+!iH^X^xo3V8_?c^9gXji{~I=Cyx-Z1Ftv2^SjRDC$)Hh?@8kYQmf`nLRB+6hF}^^q&s0sDI0>Ge@}pv?|iN}AsOu0z9@m(-s%u1avZ}9yCHpHXW delta 2299 zcmYM!e@sKE=1twMrg%Eo0hAY&8baqa2h1so7{WRE4VAJ zKgekt-dRL##K;=e*xG4P3CO0SCR?l7GF`*1#l~c1niZ~Qy+3ib*!8@gbI$YQ`~5!8 z@zUnWjj@@mq*I2!ZvL(0Uulx={{QnWHRc{}E3g_jVHqC61RTUn9KsZIa0#A451z#g zyx@8necaFCGGk&UC)JqcJSav@P>so0i!1O^RAA5GUAP~!@gQd52~^B)BXgS5sQx3U zd8bhU{NUcta%wQa*`_@HymXf?PDP(|vvfRiTrp4Erz*hfx*$ z1eM5y`}`YJzZq0zt|PISGzP_Jn>-qtpoEL|x(Zd|&8Q6aU@^XcL)e1~z(?BJqH5HH z8(p79joXd+*odk?7v|wRsCCBDslQ6~9S*KNp}W{3O!MO3Eyk)Mfj(fAXn1Y>2v%!bH4<#BTmXE$rbFky*%4Q-IovO7!ace}u+59_&JOd;=M422m9l zMeXG{K8RDOiv5A=UrJq7F&}E;22@3!MU{9LmSYR5GQFtzhcH*)|7Y%tuTcyBi25q# zP>1uDd!NZFDxDWq`Z837Do~Ys3Ux+yq5_Gc509bxjUwmPTtOvr4VSRKDd1b6ZHiD6 zt;HIA3{}bqD)JswzkXEa4icL=g9_*(QWSFqmH7=+zkg5}r;v@F7oz5S0Aq@D9Svo? z6?GPNq9$%Z9lmb&e!zV{ifeg3h23}uHU0=2s`1BBnZAiSWFMdc`4siFO`^unda1uf zqmUD%h1R1E({3&*Wz@9~FK|DK8}KNd591fuhz)cm1~ZJ6cpl5}H&jImII-Hghwurk zLnZ#oD(bIO+0O%&W(4oUF+7B`NU&zFwfH|`7wU}kBCpKrsM3$3GMqroGlg2h=<*rrhn&!85(iQ3b@P=R_%7ALB~_~g=qPAGLgA>O*| zcB<8Gx3vVC?DqIrTBoxm{pTd-@vQR+KC8{&(rPvPL)P=Q9k4=nAZ&$$Rx}u~4zz@u zt#Gq#z2pyt4g}kq98dOJiAifJt32^9a|6!dyrYRuMnQc-{6lYk{HC|l=~=lq*;y#g zOkA@)VA=o8+!2g~EWdSbIDBr{vI8B#DEd3>Myu7f+Ja%LX>oqr_~V0lD;#fWUBbT+ C$^*jy diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po index 93a80a9d..5e030bff 100644 --- a/ihatemoney/translations/fr/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po @@ -65,6 +65,18 @@ msgstr "" msgid "Get in" msgstr "Entrer" +#: forms.py:107 +msgid "Password mismatch" +msgstr "Les mots de passe fournis ne sont pas les mêmes." + +#: forms.py:109 +msgid "Password confirmation" +msgstr "Confirmation du mot de passe" + +#: forms.py:107 +msgid "Password" +msgstr "Mot de passe" + #: forms.py:108 msgid "Send me the code by email" msgstr "Envoyez moi le code par email" @@ -185,8 +197,8 @@ msgid "%(msg_compl)sThe project identifier is %(project)s" msgstr "L'identifiant de ce projet est '%(project)s'" #: web.py:185 -msgid "a mail has been sent to you with the password" -msgstr "Un email vous a été envoyé avec le mot de passe" +msgid "A link to reset your password has been sent to your email." +msgstr "Un lien pour changer votre mot de passe vous a été envoyé par mail." #: web.py:211 msgid "Project successfully deleted" @@ -197,6 +209,26 @@ msgstr "Projet supprimé" msgid "You have been invited to share your expenses for %(project)s" msgstr "Vous avez été invité à partager vos dépenses pour %(project)s" +#: web.py:259 +#, python-format +msgid ""No token provided"" +msgstr "Aucun token n'a été fourni." + +#: web.py:259 +#, python-format +msgid "Unknown project" +msgstr "Project inconnu" + +#: web.py:261 +#, python-format +msgid "Invalid token" +msgstr "Token invalide" + +#: web.py:267 +#, python-format +msgid "Password successfully reset." +msgstr "Le mot de passe a été changé avec succès." + #: web.py:261 msgid "Your invitations have been sent" msgstr "Vos invitations ont bien été envoyées" @@ -500,6 +532,10 @@ msgstr "Rappel du code d'accès" msgid "Your projects" msgstr "Vos projets" +#: templates/reset_password.html:2 +msgid "Reset your password" +msgstr "Changez votre mot de passe" + #: templates/send_invites.html:6 msgid "Invite people" msgstr "Invitez des gens" diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 92b7ddc4..7e4c563b 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -24,7 +24,7 @@ from functools import wraps from ihatemoney.models import db, Project, Person, Bill from ihatemoney.forms import ( AdminAuthenticationForm, AuthenticationForm, EditProjectForm, - InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for, + InviteForm, MemberForm, PasswordReminder, ResetPasswordForm, ProjectForm, get_billform_for, ExportForm ) from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv, LoginThrottler @@ -263,17 +263,40 @@ def remind_password(): # get the project project = Project.query.get(form.id.data) - # send the password reminder + # send a link to reset the password password_reminder = "password_reminder.%s" % get_locale().language current_app.mail.send(Message( "password recovery", body=render_template(password_reminder, project=project), recipients=[project.contact_email])) - flash(_("a mail has been sent to you with the password")) + flash(_("A link to reset your password has been sent to your email.")) return render_template("password_reminder.html", form=form) +@main.route('/reset-password', methods=['GET', 'POST']) +def reset_password(): + form = ResetPasswordForm() + token = request.args.get('token') + if not token: + return render_template('reset_password.html', form=form, error=_("No token provided")) + project_id = Project.verify_token(token) + if not project_id: + return render_template('reset_password.html', form=form, error=_("Invalid token")) + project = Project.query.get(project_id) + if not project: + return render_template('reset_password.html', form=form, error=_("Unknown project")) + + if request.method == "POST": + if form.validate(): + project.password = form.password.data + db.session.add(project) + db.session.commit() + flash(_("Password successfully reset.")) + return redirect(url_for(".home")) + return render_template('reset_password.html', form=form) + + @main.route("//edit", methods=["GET", "POST"]) def edit_project(): edit_form = EditProjectForm() diff --git a/requirements.txt b/requirements.txt index be770048..4145851c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ jinja2>=2.6 raven blinker six>=1.10 +itsdangerous>=0.24 diff --git a/setup.py b/setup.py index 0ba37848..e90dea2b 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ REQUIREMENTS = [ 'raven', 'blinker', 'six>=1.10', + 'itsdangerous>=0.24', ] DEPENDENCY_LINKS = [