mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +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)
|
||||
- Use token based auth to reset passwords (#269)
|
||||
- Better install doc (#275)
|
||||
- Use token based auth in invitation e-mails (#280)
|
||||
|
||||
Added
|
||||
=====
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 !
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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!)
|
||||
|
|
Binary file not shown.
|
@ -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?"
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
Loading…
Reference in a new issue