mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
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:
parent
b4961f646a
commit
b94bad829c
15 changed files with 156 additions and 16 deletions
|
@ -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
|
||||
|
|
|
@ -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()])
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 !
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 !
|
||||
|
|
12
ihatemoney/templates/reset_password.html
Normal file
12
ihatemoney/templates/reset_password.html
Normal 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 %}
|
|
@ -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:
|
||||
|
||||
|
|
Binary file not shown.
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -10,3 +10,4 @@ jinja2>=2.6
|
|||
raven
|
||||
blinker
|
||||
six>=1.10
|
||||
itsdangerous>=0.24
|
||||
|
|
1
setup.py
1
setup.py
|
@ -29,6 +29,7 @@ REQUIREMENTS = [
|
|||
'raven',
|
||||
'blinker',
|
||||
'six>=1.10',
|
||||
'itsdangerous>=0.24',
|
||||
]
|
||||
|
||||
DEPENDENCY_LINKS = [
|
||||
|
|
Loading…
Reference in a new issue