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
This commit is contained in:
0livd 2017-10-26 19:46:34 +02:00 committed by Alexis Metaireau
parent b4961f646a
commit b94bad829c
15 changed files with 156 additions and 16 deletions

View file

@ -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

View file

@ -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()])

View file

@ -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

View file

@ -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 %}

View file

@ -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)

View file

@ -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 !

View file

@ -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,

View file

@ -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 !

View file

@ -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 %}

View file

@ -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:

View file

@ -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"

View file

@ -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()

View file

@ -10,3 +10,4 @@ jinja2>=2.6
raven
blinker
six>=1.10
itsdangerous>=0.24

View file

@ -29,6 +29,7 @@ REQUIREMENTS = [
'raven',
'blinker',
'six>=1.10',
'itsdangerous>=0.24',
]
DEPENDENCY_LINKS = [