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) - Simpler and safer authentication logic (#270)
- Use token based auth to reset passwords (#269) - Use token based auth to reset passwords (#269)
- Better install doc (#275) - Better install doc (#275)
- Use token based auth in invitation e-mails (#280)
Added Added
===== =====

View file

@ -5,8 +5,8 @@ from flask_sqlalchemy import SQLAlchemy, BaseQuery
from flask import g, current_app from flask import g, current_app
from sqlalchemy import orm from sqlalchemy import orm
from itsdangerous import (TimedJSONWebSignatureSerializer from itsdangerous import (TimedJSONWebSignatureSerializer, URLSafeSerializer,
as Serializer, BadSignature, SignatureExpired) BadSignature, SignatureExpired)
db = SQLAlchemy() db = SQLAlchemy()
@ -201,22 +201,32 @@ class Project(db.Model):
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
def generate_token(self, expiration): def generate_token(self, expiration=0):
"""Generate a timed and serialized JsonWebToken """Generate a timed and serialized JsonWebToken
:param expiration: Token expiration time (in seconds) :param expiration: Token expiration time (in seconds)
""" """
serializer = Serializer(current_app.config['SECRET_KEY'], expiration) if expiration:
return serializer.dumps({'project_id': self.id}).decode('utf-8') 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 @staticmethod
def verify_token(token): def verify_token(token, token_type="timed_token"):
"""Return the project id associated to the provided token, """Return the project id associated to the provided token,
None if the provided token is expired or not valid. None if the provided token is expired or not valid.
:param token: Serialized TimedJsonWebToken :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: try:
data = serializer.loads(token) data = serializer.loads(token)
except SignatureExpired: except SignatureExpired:

View file

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

View file

@ -92,7 +92,7 @@
{% endblock %} {% endblock %}
{% block content %} {% 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> <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"> <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 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 }}), 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, Enjoy,
Some weird guys (with beards) 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 }}), 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 }}".
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 ! Faites en bon usage !

View file

@ -1,17 +1,15 @@
{% extends "layout.html" %} {% 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 %} {% block content %}
<h2>{{ _("Invite people to join this project") }}</h2> <h2>{{ _("Invite people to join this project") }}</h2>
<p>{{ _("Specify a (comma separated) list of email adresses you want to notify about the <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> 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" %} {% include "display_errors.html" %}
<form class="invites form-horizontal" method="post" accept-charset="utf-8"> <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 # only one message is sent to multiple persons
self.assertEqual(len(outbox), 0) 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): def test_password_reminder(self):
# test that it is possible to have an email cotaining the password of a # test that it is possible to have an email cotaining the password of a
# project in case people forget it (and it happens!) # project in case people forget it (and it happens!)

View file

@ -174,6 +174,10 @@ msgstr "remboursements"
msgid "Export file format" msgid "Export file format"
msgstr "Format du fichier d'export" 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 #: web.py:95
msgid "This private code is not the right one" msgid "This private code is not the right one"
msgstr "Le code que vous avez entré n'est pas correct" msgstr "Le code que vous avez entré n'est pas correct"
@ -271,8 +275,7 @@ msgstr "Retourner à la liste"
#: templates/authenticate.html:6 #: templates/authenticate.html:6
msgid "" msgid ""
"The project you are trying to access do not exist, do you want \n" "The project you are trying to access do not exist, do you want to"
"to"
msgstr "Le projet auquel vous essayez d'acceder n'existe pas. Souhaitez vous" msgstr "Le projet auquel vous essayez d'acceder n'existe pas. Souhaitez vous"
#: templates/authenticate.html:7 #: templates/authenticate.html:7
@ -477,12 +480,12 @@ msgid "reactivate"
msgstr "ré-activer" msgstr "ré-activer"
#: templates/list_bills.html:88 #: templates/list_bills.html:88
msgid "The project identifier is" msgid "Invite"
msgstr "L'identifiant de ce projet est" msgstr "Invitez"
#: templates/list_bills.html:88 #: templates/list_bills.html:88
msgid "remember it!" msgid "Invite people to join this project!"
msgstr "souvenez vous en !" msgstr "Invitez d'autres personnes à rejoindre ce projet !"
#: templates/list_bills.html:89 #: templates/list_bills.html:89
msgid "Add a new bill" msgid "Add a new bill"
@ -536,14 +539,6 @@ msgstr "Vos projets"
msgid "Reset your password" msgid "Reset your password"
msgstr "Changez votre mot de passe" 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 #: templates/send_invites.html:11
msgid "Invite people to join this project" msgid "Invite people to join this project"
msgstr "Invitez des personnes à rejoindre ce projet" msgstr "Invitez des personnes à rejoindre ce projet"
@ -559,16 +554,11 @@ msgstr ""
"par des virgules. On s'occupe de leur envoyer un email." "par des virgules. On s'occupe de leur envoyer un email."
#: templates/send_invites.html:14 #: templates/send_invites.html:14
msgid "If you prefer, you can" msgid "If you prefer, you can share the project identifier and the shared\n"
msgstr "Si vous préférez vous pouvez" "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 "
#: templates/send_invites.html:14 "de passe par un autre moyen de communication. Ou directement partager le lien "
msgid "skip this step" "suivant :"
msgstr "sauter cette étape"
#: templates/send_invites.html:14
msgid "and notify them yourself"
msgstr "et les avertir vous même"
#: templates/settle_bills.html:31 #: templates/settle_bills.html:31
msgid "Who pays?" msgid "Who pays?"

View file

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