From d9a4389d424734df12de58b636b86b7f1c5001b6 Mon Sep 17 00:00:00 2001 From: Glandos Date: Sat, 17 Jul 2021 23:05:24 +0200 Subject: [PATCH] API BREAKING CHANGE the authenticate token now need project_id= param in URL This change is introduced to have the ability to invalidate auth token with password change. Token payload is still the same, but the key is the concatenation of SECRET_KEY project password. To have a clean verification, we need to have the project id before loading payload, to build the serializer with the correct key (including the password). --- ihatemoney/api/common.py | 2 +- ihatemoney/models.py | 20 +++++++++----------- ihatemoney/templates/invitation_mail.en.j2 | 2 +- ihatemoney/templates/invitation_mail.fr.j2 | 2 +- ihatemoney/templates/send_invites.html | 4 ++-- ihatemoney/tests/api_test.py | 2 +- ihatemoney/tests/budget_test.py | 3 ++- ihatemoney/web.py | 9 +++++---- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py index 70a706c4..c33ee70c 100644 --- a/ihatemoney/api/common.py +++ b/ihatemoney/api/common.py @@ -35,7 +35,7 @@ def need_auth(f): auth_token = auth_header.split(" ")[1] except IndexError: abort(401) - project_id = Project.verify_token(auth_token, token_type="auth") + 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: diff --git a/ihatemoney/models.py b/ihatemoney/models.py index aa64e4ac..ddf9b714 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -353,20 +353,21 @@ class Project(db.Model): token = serializer.dumps({"project_id": self.id}) else: serializer = URLSafeSerializer( - current_app.config["SECRET_KEY"], salt=token_type + current_app.config["SECRET_KEY"] + self.password, salt=token_type ) - token = serializer.dumps({"project_id": self.id, "password": self.password}) + token = serializer.dumps({"project_id": self.id}) return token @staticmethod - def verify_token(token, token_type="auth", max_age=3600): + 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" """ loads_kwargs = {} @@ -376,8 +377,10 @@ class Project(db.Model): ) loads_kwargs["max_age"] = max_age else: + project = Project.query.get(project_id) + password = project.password if project is not None else '' serializer = URLSafeSerializer( - current_app.config["SECRET_KEY"], salt=token_type + current_app.config["SECRET_KEY"] + password, salt=token_type ) try: data = serializer.loads(token, **loads_kwargs) @@ -386,13 +389,8 @@ class Project(db.Model): except BadSignature: return None - password = data.get("password", None) - project_id = data["project_id"] - if password is not None: - project = Project.query.get(project_id) - if project is None or project.password != password: - return None - return project_id + data_project = data.get("project_id") + return data_project if project_id is None or data_project == project_id else None def __str__(self): return self.name diff --git a/ihatemoney/templates/invitation_mail.en.j2 b/ihatemoney/templates/invitation_mail.en.j2 index 79fcc427..2b3157b1 100644 --- a/ihatemoney/templates/invitation_mail.en.j2 +++ b/ihatemoney/templates/invitation_mail.en.j2 @@ -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. diff --git a/ihatemoney/templates/invitation_mail.fr.j2 b/ihatemoney/templates/invitation_mail.fr.j2 index e57d7035..d095cfdb 100644 --- a/ihatemoney/templates/invitation_mail.fr.j2 +++ b/ihatemoney/templates/invitation_mail.fr.j2 @@ -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 s’occupe 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. diff --git a/ihatemoney/templates/send_invites.html b/ihatemoney/templates/send_invites.html index 53492c85..8b73b175 100644 --- a/ihatemoney/templates/send_invites.html +++ b/ihatemoney/templates/send_invites.html @@ -21,8 +21,8 @@ {{ _("You can directly share the following link via your prefered medium") }}
- - {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }} + + {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }} diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py index 41f5ab2d..0894b15b 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -213,7 +213,7 @@ 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) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index dc862f02..6c858124 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -6,6 +6,7 @@ from time import sleep import unittest from flask import session +from markupsafe import Markup import pytest from werkzeug.security import check_password_hash, generate_password_hash @@ -100,7 +101,7 @@ class BudgetTestCase(IhatemoneyTestCase): base_index = response.find("share the following link") start = response.find('href="', base_index) + 6 end = response.find('">', base_index) - link = response[start:end] + link = Markup(response[start:end]).unescape() self.client.get("/exit") response = self.client.get(link) diff --git a/ihatemoney/web.py b/ihatemoney/web.py index b2d72f55..0f75d640 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -199,15 +199,16 @@ 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="auth") + project_id = Project.verify_token(token, token_type="auth", project_id=project_id) 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 or a valid token