diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py index ede76e46..fa097dec 100644 --- a/ihatemoney/api/common.py +++ b/ihatemoney/api/common.py @@ -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: diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 68d1fd84..b3d1cba2 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -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 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/password_reminder.en.j2 b/ihatemoney/templates/password_reminder.en.j2 index c6543546..845ff790 100644 --- a/ihatemoney/templates/password_reminder.en.j2 +++ b/ihatemoney/templates/password_reminder.en.j2 @@ -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, diff --git a/ihatemoney/templates/password_reminder.fr.j2 b/ihatemoney/templates/password_reminder.fr.j2 index 17c52c4d..4603a963 100644 --- a/ihatemoney/templates/password_reminder.fr.j2 +++ b/ihatemoney/templates/password_reminder.fr.j2 @@ -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 ! 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..83d5aa2a 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -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) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 75a2dc35..af33197a 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -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!) diff --git a/ihatemoney/tests/common/help_functions.py b/ihatemoney/tests/common/help_functions.py index e9c4dcd1..5a401059 100644 --- a/ihatemoney/tests/common/help_functions.py +++ b/ihatemoney/tests/common/help_functions.py @@ -1,5 +1,16 @@ +from markupsafe import Markup + + def em_surround(string, regex_escape=False): if regex_escape: return r'%s<\/em>' % string else: return '%s' % 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 diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 712d2b0e..5af15e08 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -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")