mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-29 09:52:36 +02:00
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:
parent
2866c868d5
commit
8a68ac0d5b
13 changed files with 98 additions and 57 deletions
|
@ -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
|
||||||
=====
|
=====
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 !
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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!)
|
||||||
|
|
Binary file not shown.
|
@ -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"
|
||||||
|
@ -551,7 +546,7 @@ msgstr "Invitez des personnes à rejoindre ce projet"
|
||||||
#: templates/send_invites.html:12
|
#: templates/send_invites.html:12
|
||||||
msgid ""
|
msgid ""
|
||||||
"Specify a (comma separated) list of email adresses you want to notify "
|
"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"
|
"creation of this budget management project and we will send them an email"
|
||||||
" for you."
|
" for you."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
@ -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?"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue