Use token based auth in invitation e-mails

Invitation e-mails no longer contain the clear
text project password
This commit is contained in:
0livd 2017-10-27 17:57:11 +02:00 committed by 0livd
parent b94bad829c
commit e52fbbd239
9 changed files with 75 additions and 23 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
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

@ -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

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)