Include project code into project authentication token (#802)

Fix #780 

This a breaking change, the API for authentication is different, as it now requires `project_id`. Token is generated with only the project_id (so it's shorter than before), and signature is done by mixing password with secret key. Thus, it expires on every project code change.
This commit is contained in:
Glandos 2021-10-10 14:43:40 +02:00 committed by GitHub
parent c13c4c7e3c
commit bbe00ebb57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 114 additions and 30 deletions

View file

@ -35,7 +35,9 @@ def need_auth(f):
auth_token = auth_header.split(" ")[1]
except IndexError:
abort(401)
project_id = Project.verify_token(auth_token, token_type="non_timed_token")
project_id = Project.verify_token(
auth_token, token_type="auth", project_id=project_id
)
if auth_token and project_id:
project = Project.query.get(project_id)
if project:

View file

@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy
from itsdangerous import (
BadSignature,
SignatureExpired,
TimedJSONWebSignatureSerializer,
URLSafeSerializer,
URLSafeTimedSerializer,
)
import sqlalchemy
from sqlalchemy import orm
@ -339,41 +339,61 @@ class Project(db.Model):
db.session.delete(self)
db.session.commit()
def generate_token(self, expiration=0):
def generate_token(self, token_type="auth"):
"""Generate a timed and serialized JsonWebToken
:param expiration: Token expiration time (in seconds)
:param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration)
"""
if expiration:
serializer = TimedJSONWebSignatureSerializer(
current_app.config["SECRET_KEY"], expiration
if token_type == "reset":
serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
)
token = serializer.dumps({"project_id": self.id}).decode("utf-8")
token = serializer.dumps([self.id])
else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
token = serializer.dumps({"project_id": self.id})
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"] + self.password, salt=token_type
)
token = serializer.dumps([self.id])
return token
@staticmethod
def verify_token(token, token_type="timed_token"):
def verify_token(token, token_type="auth", project_id=None, max_age=3600):
"""Return the project id associated to the provided token,
None if the provided token is expired or not valid.
:param token: Serialized TimedJsonWebToken
:param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration)
:param project_id: Project ID. Used for token_type "auth" to use the password as serializer
secret key.
:param max_age: Token expiration time (in seconds). Only used with token_type "reset"
"""
if token_type == "timed_token":
serializer = TimedJSONWebSignatureSerializer(
current_app.config["SECRET_KEY"]
loads_kwargs = {}
if token_type == "reset":
serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
)
loads_kwargs["max_age"] = max_age
else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
project = Project.query.get(project_id) if project_id is not None else None
password = project.password if project is not None else ""
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"] + password, salt=token_type
)
try:
data = serializer.loads(token)
data = serializer.loads(token, **loads_kwargs)
except SignatureExpired:
return None
except BadSignature:
return None
return data["project_id"]
data_project = data[0] if isinstance(data, list) else None
return (
data_project if project_id is None or data_project == project_id else None
)
def __str__(self):
return self.name

View file

@ -4,7 +4,7 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha
It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest.
You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
You can log in using this link: {{ url_for(".authenticate", _external=True, project_id=g.project.id, 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.

View file

@ -4,7 +4,7 @@ Quelqu'un dont l'adresse email est {{ g.project.contact_email }} vous a invité
Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on soccupe du reste.
Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}.
Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }}
Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien.

View file

@ -1,7 +1,7 @@
Hi,
You requested to reset the password of the following project: "{{ project.name }}".
You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}.
You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
This link is only valid for one hour.
Hope this helps,

View file

@ -1,7 +1,7 @@
Salut,
Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}".
Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}.
Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
Ce lien est seulement valide pendant 1 heure.
Faites-en bon usage !

View file

@ -21,8 +21,8 @@
</td>
<td>
{{ _("You can directly share the following link via your prefered medium") }}</br>
<a href="{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}">
{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}
<a href="{{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}">
{{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
</a>
</td>
</tr>

View file

@ -213,7 +213,9 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette/token", headers=self.get_auth("raclette")
)
decoded_resp = json.loads(resp.data.decode("utf-8"))
resp = self.client.get("/authenticate?token={}".format(decoded_resp["token"]))
resp = self.client.get(
f"/authenticate?token={decoded_resp['token']}&project_id=raclette"
)
# Test that we are redirected.
self.assertEqual(302, resp.status_code)

View file

@ -4,6 +4,7 @@ import json
import re
from time import sleep
import unittest
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from flask import session
import pytest
@ -11,6 +12,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.versioning import LoggingMode
@ -87,11 +89,52 @@ class BudgetTestCase(IhatemoneyTestCase):
)
# Test empty and invalid tokens
self.client.get("/exit")
# Use another project_id
parsed_url = urlparse(url)
query = parse_qs(parsed_url.query)
query["project_id"] = "invalid"
resp = self.client.get(
urlunparse(parsed_url._replace(query=urlencode(query, doseq=True)))
)
assert "You either provided a bad token" in resp.data.decode("utf-8")
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_invite_code_invalidation(self):
"""Test that invitation link expire after code change"""
self.login("raclette")
self.post_project("raclette")
response = self.client.get("/raclette/invite").data.decode("utf-8")
link = extract_link(response, "share the following link")
self.client.get("/exit")
response = self.client.get(link)
# Link is valid
assert response.status_code == 302
# Change password to invalidate token
# Other data are required, but useless for the test
response = self.client.post(
"/raclette/edit",
data={
"name": "raclette",
"contact_email": "zorglub@notmyidea.org",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)
assert response.status_code == 200
assert "alert-danger" not in response.data.decode("utf-8")
self.client.get("/exit")
response = self.client.get(link, follow_redirects=True)
# Link is invalid
self.assertIn("You either provided a bad token", response.data.decode("utf-8"))
def test_password_reminder(self):
# test that it is possible to have an email containing the password of a
# project in case people forget it (and it happens!)

View file

@ -1,5 +1,16 @@
from markupsafe import Markup
def em_surround(string, regex_escape=False):
if regex_escape:
return r'<em class="font-italic">%s<\/em>' % string
else:
return '<em class="font-italic">%s</em>' % string
def extract_link(data, start_prefix):
base_index = data.find(start_prefix)
start = data.find('href="', base_index) + 6
end = data.find('">', base_index)
link = Markup(data[start:end]).unescape()
return link

View file

@ -199,15 +199,21 @@ 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
verified_project_id = Project.verify_token(
token, token_type="auth", project_id=project_id
)
if verified_project_id == project_id:
token_auth = True
else:
project_id = None
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 or a valid token
@ -381,7 +387,7 @@ def reset_password():
return render_template(
"reset_password.html", form=form, error=_("No token provided")
)
project_id = Project.verify_token(token)
project_id = Project.verify_token(token, token_type="reset")
if not project_id:
return render_template(
"reset_password.html", form=form, error=_("Invalid token")