Use token based auth in invitation e-mails (#280)

* Use token based auth in invitation e-mails

Invitation e-mails no longer contain the clear
text project password

* Skip invite page after project creation

- Replace ``The project identifier is demo, remember it!``
by ``Invite other people to join this project!``
(linking to the invite page)
- Encourage users to share the project password via other
communication means in the reminder email
This commit is contained in:
0livd 2017-12-15 17:10:28 +01:00 committed by Alexis Metaireau
parent 2866c868d5
commit 8a68ac0d5b
13 changed files with 98 additions and 57 deletions

View file

@ -20,6 +20,7 @@ Changed
- Simpler and safer authentication logic (#270)
- Use token based auth to reset passwords (#269)
- Better install doc (#275)
- Use token based auth in invitation e-mails (#280)
Added
=====

View file

@ -5,8 +5,8 @@ from flask_sqlalchemy import SQLAlchemy, BaseQuery
from flask import g, current_app
from sqlalchemy import orm
from itsdangerous import (TimedJSONWebSignatureSerializer
as Serializer, BadSignature, SignatureExpired)
from itsdangerous import (TimedJSONWebSignatureSerializer, URLSafeSerializer,
BadSignature, SignatureExpired)
db = SQLAlchemy()
@ -201,22 +201,32 @@ class Project(db.Model):
db.session.delete(self)
db.session.commit()
def generate_token(self, expiration):
def generate_token(self, expiration=0):
"""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')
if expiration:
serializer = TimedJSONWebSignatureSerializer(
current_app.config['SECRET_KEY'],
expiration)
token = serializer.dumps({'project_id': self.id}).decode('utf-8')
else:
serializer = URLSafeSerializer(current_app.config['SECRET_KEY'])
token = serializer.dumps({'project_id': self.id})
return token
@staticmethod
def verify_token(token):
def verify_token(token, token_type="timed_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'])
if token_type == "timed_token":
serializer = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
else:
serializer = URLSafeSerializer(current_app.config['SECRET_KEY'])
try:
data = serializer.loads(token)
except SignatureExpired:

View file

@ -3,8 +3,9 @@
<h2>Authentication</h2>
{% if create_project %}
<p class="info">{{ _("The project you are trying to access do not exist, do you want
to") }} <a href="{{ url_for(".create_project", project_id=create_project) }}">{{ _("create it") }}</a>{{ _("?") }}
<p class="info">{{ _("The project you are trying to access do not exist, do you want to") }}
<a href="{{ url_for(".create_project", project_id=create_project) }}">
{{ _("create it") }}</a>{{ _("?") }}
</p>
{% endif %}
<form class="form-horizontal" method="POST" accept-charset="utf-8">

View file

@ -4,7 +4,9 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha
It's as simple as saying what did you paid for, for who, and how much did it cost you, we are caring about the rest.
You can access it here: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }} and the private code is "{{ g.project.password }}".
You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
Once logged in you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }}
If your cookie gets deleted or if you log out, you will need to log back in using the first link.
Enjoy,
Some weird guys (with beards)

View file

@ -4,6 +4,8 @@ Quelqu'un avec l'addresse email "{{ g.project.contact_email }}" vous à invité
C'est aussi simple que de dire qui à payé pour quoi, pour qui, et combien celà à coûté, on s'occuppe du reste.
Vous pouvez accéder à la page ici: {{ config['SITE_URL'] }}{{ url_for(".list_bills") }} et le code est "{{ g.project.password }}".
Vous pouvez vous authentifier avec le lien suivant: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
Une fois authentifié, vous pouvez utiliser le lien suivant qui est plus facile à mémoriser: {{ url_for(".list_bills", _external=True) }}
Si votre cookie est supprimé ou si vous vous déconnectez, voous devrez vous réauthentifier en utilisant le premier lien.
Have fun,

View file

@ -92,7 +92,7 @@
{% endblock %}
{% block content %}
<div class="identifier">{{ _("The project identifier is") }} <a href="{{ url_for(".list_bills") }}">{{ g.project.id }}</a>, {{ _("remember it!") }}</div>
<div class="identifier"><a href="{{ url_for(".invite") }}">{{ _("Invite people to join this project!") }}</a></div>
<a id="new-bill" href="{{ url_for(".add_bill") }}" class="btn btn-primary" data-toggle="modal" data-target="#bill-form">{{ _("Add a new bill") }}</a>
<div id="bill-form" class="modal fade show" role="dialog">

View file

@ -3,7 +3,9 @@ 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: {{ url_for(".list_bills", _external=True) }} (the identifier is {{ g.project.id }}),
and the private code is "{{ g.project.password }}".
and the shared password is "{{ g.project.password }}".
If you want to share this project with your friends, you can share the identifier and the shared password with them or send them invitations with the following link:
{{ url_for(".invite", _external=True) }}
Enjoy,
Some weird guys (with beards)

View file

@ -4,5 +4,7 @@ Vous venez de créer le projet "{{ g.project.name }}" pour partager vos dépense
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 }}".
Si vous voulez partager ce projet avec vos amis, vous pouvez partager son identifiant et son code d'accès avec eux ou leur envoyer une invitation avec le lien suivant :
{{ url_for(".invite", _external=True) }}
Faites en bon usage !

View file

@ -1,17 +1,15 @@
{% extends "layout.html" %}
{% block sidebar %}
<ol>
<li>{{ _("Create the project") }}</li>
<li><strong>{{ _("Invite people") }}</strong></li>
<li><a href="{{ url_for(".list_bills") }}">{{ _("Use it!") }}</a></li>
</ol>
{% endblock %}
{% block content %}
<h2>{{ _("Invite people to join this project") }}</h2>
<p>{{ _("Specify a (comma separated) list of email adresses you want to notify about the
creation of this budget management project and we will send them an email for you.") }}</p>
<p>{{ _("If you prefer, you can") }} <a href="{{ url_for(".list_bills") }}">{{ _("skip this step") }}</a> {{ _("and notify them yourself") }}</p>
<p>{{ _("If you prefer, you can share the project identifier and the shared
password by other communication means. Or even directly share the following link:") }}</br>
<a href="{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}">
{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}
</a>
</p>
{% include "display_errors.html" %}
<form class="invites form-horizontal" method="post" accept-charset="utf-8">

View file

@ -152,6 +152,29 @@ class BudgetTestCase(IhatemoneyTestCase):
# only one message is sent to multiple persons
self.assertEqual(len(outbox), 0)
def test_invite(self):
"""Test that invitation e-mails are sent properly
"""
self.login("raclette")
self.post_project("raclette")
with self.app.mail.record_messages() as outbox:
self.client.post("/raclette/invite",
data={"emails": 'toto@notmyidea.org'})
self.assertEqual(len(outbox), 1)
url_start = outbox[0].body.find('You can log in using this link: ') + 32
url_end = outbox[0].body.find('.\n', url_start)
url = outbox[0].body[url_start:url_end]
self.client.get("/exit")
# Test that we got a valid token
resp = self.client.get(url, follow_redirects=True)
self.assertIn('You probably want to <a href="/raclette/add"', resp.data.decode('utf-8'))
# Test empty and invalid tokens
self.client.get("/exit")
resp = self.client.get("/authenticate")
self.assertIn("You either provided a bad token", resp.data.decode('utf-8'))
resp = self.client.get("/authenticate?token=token")
self.assertIn("You either provided a bad token", resp.data.decode('utf-8'))
def test_password_reminder(self):
# test that it is possible to have an email cotaining the password of a
# project in case people forget it (and it happens!)

View file

@ -174,6 +174,10 @@ msgstr "remboursements"
msgid "Export file format"
msgstr "Format du fichier d'export"
#: web.py:95
msgid "You either provided a bad token or no project identifier."
msgstr "L'identifiant du projet ou le token fourni n'est pas correct."
#: web.py:95
msgid "This private code is not the right one"
msgstr "Le code que vous avez entré n'est pas correct"
@ -271,8 +275,7 @@ msgstr "Retourner à la liste"
#: templates/authenticate.html:6
msgid ""
"The project you are trying to access do not exist, do you want \n"
"to"
"The project you are trying to access do not exist, do you want to"
msgstr "Le projet auquel vous essayez d'acceder n'existe pas. Souhaitez vous"
#: templates/authenticate.html:7
@ -477,12 +480,12 @@ msgid "reactivate"
msgstr "ré-activer"
#: templates/list_bills.html:88
msgid "The project identifier is"
msgstr "L'identifiant de ce projet est"
msgid "Invite"
msgstr "Invitez"
#: templates/list_bills.html:88
msgid "remember it!"
msgstr "souvenez vous en !"
msgid "Invite people to join this project!"
msgstr "Invitez d'autres personnes à rejoindre ce projet !"
#: templates/list_bills.html:89
msgid "Add a new bill"
@ -536,14 +539,6 @@ msgstr "Vos projets"
msgid "Reset your password"
msgstr "Changez votre mot de passe"
#: templates/send_invites.html:6
msgid "Invite people"
msgstr "Invitez des gens"
#: templates/send_invites.html:7
msgid "Use it!"
msgstr "Utilisez le !"
#: templates/send_invites.html:11
msgid "Invite people to join this project"
msgstr "Invitez des personnes à rejoindre ce projet"
@ -551,7 +546,7 @@ msgstr "Invitez des personnes à rejoindre ce projet"
#: templates/send_invites.html:12
msgid ""
"Specify a (comma separated) list of email adresses you want to notify "
"about the \n"
"about the\n"
"creation of this budget management project and we will send them an email"
" for you."
msgstr ""
@ -559,16 +554,11 @@ msgstr ""
"par des virgules. On s'occupe de leur envoyer un email."
#: templates/send_invites.html:14
msgid "If you prefer, you can"
msgstr "Si vous préférez vous pouvez"
#: templates/send_invites.html:14
msgid "skip this step"
msgstr "sauter cette étape"
#: templates/send_invites.html:14
msgid "and notify them yourself"
msgstr "et les avertir vous même"
msgid "If you prefer, you can share the project identifier and the shared\n"
"password by other communication means. Or even directly share the following link:"
msgstr "Si vous préférez vous pouvez partager l'identifiant du projet et son mot "
"de passe par un autre moyen de communication. Ou directement partager le lien "
"suivant :"
#: templates/settle_bills.html:31
msgid "Who pays?"

View file

@ -151,12 +151,20 @@ def admin():
def authenticate(project_id=None):
"""Authentication form"""
form = AuthenticationForm()
if not form.id.data and request.args.get('project_id'):
form.id.data = request.args['project_id']
project_id = form.id.data
# Try to get project_id from token first
token = request.args.get('token')
if token:
project_id = Project.verify_token(token, token_type='non_timed_token')
token_auth = True
else:
if not form.id.data and request.args.get('project_id'):
form.id.data = request.args['project_id']
project_id = form.id.data
token_auth = False
if project_id is None:
# User doesn't provide project identifier, return to authenticate form
msg = _("You need to enter a project identifier")
# User doesn't provide project identifier or a valid token
# return to authenticate form
msg = _("You either provided a bad token or no project identifier.")
form.errors["id"] = [msg]
return render_template("authenticate.html", form=form)
@ -171,11 +179,10 @@ def authenticate(project_id=None):
setattr(g, 'project', project)
return redirect(url_for(".list_bills"))
if request.method == "POST" and form.validate():
if not form.password.data == project.password:
msg = _("This private code is not the right one")
form.errors['password'] = [msg]
return render_template("authenticate.html", form=form)
# else do form authentication or token authentication
is_post_auth = request.method == "POST" and form.validate()
is_valid_password = form.password.data == project.password
if is_post_auth and is_valid_password or token_auth:
# maintain a list of visited projects
if "projects" not in session:
session["projects"] = []
@ -185,6 +192,9 @@ def authenticate(project_id=None):
session.update()
setattr(g, 'project', project)
return redirect(url_for(".list_bills"))
if is_post_auth and not is_valid_password:
msg = _("This private code is not the right one")
form.errors['password'] = [msg]
return render_template("authenticate.html", form=form)
@ -250,7 +260,7 @@ def create_project():
# redirect the user to the next step (invite)
flash(_("%(msg_compl)sThe project identifier is %(project)s",
msg_compl=msg_compl, project=project.id))
return redirect(url_for(".invite", project_id=project.id))
return redirect(url_for(".list_bills", project_id=project.id))
return render_template("create_project.html", form=form)