mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-29 01:42:37 +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)
|
- Logged admin can see any project (#262)
|
||||||
- Simpler and safer authentication logic (#270)
|
- Simpler and safer authentication logic (#270)
|
||||||
|
- Use token based auth to reset passwords (#269)
|
||||||
- Better install doc (#275)
|
- Better install doc (#275)
|
||||||
|
|
||||||
Added
|
Added
|
||||||
|
|
|
@ -2,7 +2,7 @@ from flask_wtf.form import FlaskForm
|
||||||
from wtforms.fields.core import SelectField, SelectMultipleField
|
from wtforms.fields.core import SelectField, SelectMultipleField
|
||||||
from wtforms.fields.html5 import DateField, DecimalField
|
from wtforms.fields.html5 import DateField, DecimalField
|
||||||
from wtforms.fields.simple import PasswordField, SubmitField, TextAreaField, StringField
|
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_babel import lazy_gettext as _
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
|
@ -102,6 +102,14 @@ class PasswordReminder(FlaskForm):
|
||||||
raise ValidationError(_("This project does not exists"))
|
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):
|
class BillForm(FlaskForm):
|
||||||
date = DateField(_("Date"), validators=[Required()], default=datetime.now)
|
date = DateField(_("Date"), validators=[Required()], default=datetime.now)
|
||||||
what = StringField(_("What?"), validators=[Required()])
|
what = StringField(_("What?"), validators=[Required()])
|
||||||
|
|
|
@ -2,9 +2,11 @@ from collections import defaultdict
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask_sqlalchemy import SQLAlchemy, BaseQuery
|
from flask_sqlalchemy import SQLAlchemy, BaseQuery
|
||||||
from flask import g
|
from flask import g, current_app
|
||||||
|
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
from itsdangerous import (TimedJSONWebSignatureSerializer
|
||||||
|
as Serializer, BadSignature, SignatureExpired)
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
@ -199,6 +201,30 @@ class Project(db.Model):
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
db.session.commit()
|
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):
|
def __repr__(self):
|
||||||
return "<Project %s>" % self.name
|
return "<Project %s>" % self.name
|
||||||
|
|
||||||
|
|
|
@ -159,10 +159,18 @@
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro remind_password(form) %}
|
{% macro remind_password(form) %}
|
||||||
|
|
||||||
{% include "display_errors.html" %}
|
{% include "display_errors.html" %}
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{{ input(form.id) }}
|
{{ input(form.id) }}
|
||||||
{{ submit(form.submit) }}
|
{{ submit(form.submit) }}
|
||||||
|
|
||||||
{% endmacro %}
|
{% 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,
|
Hi,
|
||||||
|
|
||||||
You requested to be reminded about your password for "{{ project.name }}".
|
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)) }}.
|
||||||
You can access it here: {{ config['SITE_URL'] }}{{ url_for(".list_bills", project_id=project.id) }}, the private code is "{{ project.password }}".
|
This link is only valid for 1 hour.
|
||||||
|
|
||||||
Hope this helps,
|
Hope this helps,
|
||||||
Some weird guys (with beards)
|
Some weird guys (with beards)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
Salut,
|
Salut,
|
||||||
|
|
||||||
Vous avez demandez des informations sur votre mot de passe pour "{{ project.name }}".
|
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)) }}.
|
||||||
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 }}".
|
Ce lien est seulement valide pendant 1 heure.
|
||||||
|
|
||||||
Faites en bon usage !
|
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 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 }}".
|
and the private code is "{{ g.project.password }}".
|
||||||
|
|
||||||
Enjoy,
|
Enjoy,
|
||||||
|
|
|
@ -2,7 +2,7 @@ Hey,
|
||||||
|
|
||||||
Vous venez de créer le projet "{{ g.project.name }}" pour partager vos dépenses.
|
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 }}".
|
et le code d'accès "{{ g.project.password }}".
|
||||||
|
|
||||||
Faites en bon usage !
|
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", outbox[0].body)
|
||||||
self.assertIn("raclette@notmyidea.org", outbox[0].recipients)
|
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):
|
def test_project_creation(self):
|
||||||
with self.app.test_client() as c:
|
with self.app.test_client() as c:
|
||||||
|
|
||||||
|
|
Binary file not shown.
|
@ -65,6 +65,18 @@ msgstr ""
|
||||||
msgid "Get in"
|
msgid "Get in"
|
||||||
msgstr "Entrer"
|
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
|
#: forms.py:108
|
||||||
msgid "Send me the code by email"
|
msgid "Send me the code by email"
|
||||||
msgstr "Envoyez moi le code par 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'"
|
msgstr "L'identifiant de ce projet est '%(project)s'"
|
||||||
|
|
||||||
#: web.py:185
|
#: web.py:185
|
||||||
msgid "a mail has been sent to you with the password"
|
msgid "A link to reset your password has been sent to your email."
|
||||||
msgstr "Un email vous a été envoyé avec le mot de passe"
|
msgstr "Un lien pour changer votre mot de passe vous a été envoyé par mail."
|
||||||
|
|
||||||
#: web.py:211
|
#: web.py:211
|
||||||
msgid "Project successfully deleted"
|
msgid "Project successfully deleted"
|
||||||
|
@ -197,6 +209,26 @@ msgstr "Projet supprimé"
|
||||||
msgid "You have been invited to share your expenses for %(project)s"
|
msgid "You have been invited to share your expenses for %(project)s"
|
||||||
msgstr "Vous avez été invité à partager vos dépenses pour %(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
|
#: web.py:261
|
||||||
msgid "Your invitations have been sent"
|
msgid "Your invitations have been sent"
|
||||||
msgstr "Vos invitations ont bien été envoyées"
|
msgstr "Vos invitations ont bien été envoyées"
|
||||||
|
@ -500,6 +532,10 @@ msgstr "Rappel du code d'accès"
|
||||||
msgid "Your projects"
|
msgid "Your projects"
|
||||||
msgstr "Vos projets"
|
msgstr "Vos projets"
|
||||||
|
|
||||||
|
#: templates/reset_password.html:2
|
||||||
|
msgid "Reset your password"
|
||||||
|
msgstr "Changez votre mot de passe"
|
||||||
|
|
||||||
#: templates/send_invites.html:6
|
#: templates/send_invites.html:6
|
||||||
msgid "Invite people"
|
msgid "Invite people"
|
||||||
msgstr "Invitez des gens"
|
msgstr "Invitez des gens"
|
||||||
|
|
|
@ -24,7 +24,7 @@ from functools import wraps
|
||||||
from ihatemoney.models import db, Project, Person, Bill
|
from ihatemoney.models import db, Project, Person, Bill
|
||||||
from ihatemoney.forms import (
|
from ihatemoney.forms import (
|
||||||
AdminAuthenticationForm, AuthenticationForm, EditProjectForm,
|
AdminAuthenticationForm, AuthenticationForm, EditProjectForm,
|
||||||
InviteForm, MemberForm, PasswordReminder, ProjectForm, get_billform_for,
|
InviteForm, MemberForm, PasswordReminder, ResetPasswordForm, ProjectForm, get_billform_for,
|
||||||
ExportForm
|
ExportForm
|
||||||
)
|
)
|
||||||
from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv, LoginThrottler
|
from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv, LoginThrottler
|
||||||
|
@ -263,17 +263,40 @@ def remind_password():
|
||||||
# get the project
|
# get the project
|
||||||
project = Project.query.get(form.id.data)
|
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
|
password_reminder = "password_reminder.%s" % get_locale().language
|
||||||
current_app.mail.send(Message(
|
current_app.mail.send(Message(
|
||||||
"password recovery",
|
"password recovery",
|
||||||
body=render_template(password_reminder, project=project),
|
body=render_template(password_reminder, project=project),
|
||||||
recipients=[project.contact_email]))
|
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)
|
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"])
|
@main.route("/<project_id>/edit", methods=["GET", "POST"])
|
||||||
def edit_project():
|
def edit_project():
|
||||||
edit_form = EditProjectForm()
|
edit_form = EditProjectForm()
|
||||||
|
|
|
@ -10,3 +10,4 @@ jinja2>=2.6
|
||||||
raven
|
raven
|
||||||
blinker
|
blinker
|
||||||
six>=1.10
|
six>=1.10
|
||||||
|
itsdangerous>=0.24
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -29,6 +29,7 @@ REQUIREMENTS = [
|
||||||
'raven',
|
'raven',
|
||||||
'blinker',
|
'blinker',
|
||||||
'six>=1.10',
|
'six>=1.10',
|
||||||
|
'itsdangerous>=0.24',
|
||||||
]
|
]
|
||||||
|
|
||||||
DEPENDENCY_LINKS = [
|
DEPENDENCY_LINKS = [
|
||||||
|
|
Loading…
Reference in a new issue