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)
- Use token based auth to reset passwords (#269)
- Better install doc (#275)
- Use token based auth in invitation e-mails
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

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

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)