aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author0livd <github@destras.fr>2017-10-26 19:46:34 +0200
committerAlexis Metaireau <alexis@notmyidea.org>2017-10-26 19:46:34 +0200
commitb94bad829c1fd4b4325a4af280d33d50f164e05f (patch)
treee1d090759cdd248f1511bd349d7ff26b180e1529
parentb4961f646a6e265451aa414df9fb0d58b552ffdf (diff)
downloadihatemoney-mirror-b94bad829c1fd4b4325a4af280d33d50f164e05f.zip
ihatemoney-mirror-b94bad829c1fd4b4325a4af280d33d50f164e05f.tar.gz
ihatemoney-mirror-b94bad829c1fd4b4325a4af280d33d50f164e05f.tar.bz2
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
-rw-r--r--CHANGELOG.rst1
-rw-r--r--ihatemoney/forms.py10
-rw-r--r--ihatemoney/models.py28
-rw-r--r--ihatemoney/templates/forms.html10
-rw-r--r--ihatemoney/templates/password_reminder.en6
-rw-r--r--ihatemoney/templates/password_reminder.fr6
-rw-r--r--ihatemoney/templates/reminder_mail.en2
-rw-r--r--ihatemoney/templates/reminder_mail.fr2
-rw-r--r--ihatemoney/templates/reset_password.html12
-rw-r--r--ihatemoney/tests/tests.py24
-rw-r--r--ihatemoney/translations/fr/LC_MESSAGES/messages.mobin9024 -> 9559 bytes
-rw-r--r--ihatemoney/translations/fr/LC_MESSAGES/messages.po40
-rw-r--r--ihatemoney/web.py29
-rw-r--r--requirements.txt1
-rw-r--r--setup.py1
15 files changed, 156 insertions, 16 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 260e6ff..2f3a037 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 ead5586..c5e0b54 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 cd896f3..c801b74 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 "<Project %s>" % self.name
diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html
index ffdd165..63d1c3c 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 31210aa..bc7e609 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 58f04e3..d4fbc2d 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 fe57be2..f13da5d 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 8130218..86c00ff 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 0000000..78b5853
--- /dev/null
+++ b/ihatemoney/templates/reset_password.html
@@ -0,0 +1,12 @@
+{% extends "layout.html" %}
+
+{% block content %}
+{% if error %}
+<div class="alert alert-danger">{{ error }}</div>
+{% else %}
+<h2>{{ _("Reset your password") }}</h2>
+<form class="form-horizontal" method="post">
+{{ forms.reset_password(form) }}
+</form>
+{% endif %}
+{% endblock %}
diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py
index 6c0ccb9..f918746 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</label>", 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("<title>Account manager - raclette</title>", 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 56b50d3..249c996 100644
--- a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo
+++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po
index 93a80a9..5e030bf 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 92b7ddc..7e4c563 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("/<project_id>/edit", methods=["GET", "POST"])
def edit_project():
edit_form = EditProjectForm()
diff --git a/requirements.txt b/requirements.txt
index be77004..4145851 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 0ba3784..e90dea2 100644
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,7 @@ REQUIREMENTS = [
'raven',
'blinker',
'six>=1.10',
+ 'itsdangerous>=0.24',
]
DEPENDENCY_LINKS = [