diff --git a/ihatemoney/run.py b/ihatemoney/run.py index c8fc5b25..7beedc78 100644 --- a/ihatemoney/run.py +++ b/ihatemoney/run.py @@ -19,6 +19,7 @@ from ihatemoney.models import db from ihatemoney.utils import ( IhmJSONEncoder, PrefixedWSGI, + RegexConverter, em_surround, locale_from_iso, localize_list, @@ -126,6 +127,8 @@ def create_app( instance_relative_config=instance_relative_config, ) + app.url_map.converters["regex"] = RegexConverter + # If a configuration object is passed, use it. Otherwise try to find one. load_configuration(app, configuration) app.wsgi_app = PrefixedWSGI(app) diff --git a/ihatemoney/templates/invitation_mail.en.j2 b/ihatemoney/templates/invitation_mail.en.j2 index 2b3157b1..8523eb6b 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, project_id=g.project.id, token=g.project.generate_token()) }}. +You can log in using this link: {{ url_for(".invitation", _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 d095cfdb..828c3338 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, project_id=g.project.id, token=g.project.generate_token()) }}. +Vous pouvez vous connecter grâce à ce lien : {{ url_for(".invitation", _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 8b73b175..1a952f2b 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, project_id=g.project.id, token=g.project.generate_token()) }} + + {{ url_for(".invitation", _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 83d5aa2a..63956d6b 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -213,9 +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( - f"/authenticate?token={decoded_resp['token']}&project_id=raclette" - ) + resp = self.client.get(f"/raclette/{decoded_resp['token']}") # 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 af33197a..15f9a671 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -4,7 +4,7 @@ import json import re from time import sleep import unittest -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import urlparse, urlunparse from flask import session import pytest @@ -91,16 +91,20 @@ class BudgetTestCase(IhatemoneyTestCase): 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))) + urlunparse( + parsed_url._replace( + path=parsed_url.path.replace("raclette/", "invalid_project/") + ) + ), + follow_redirects=True, ) - assert "You either provided a bad token" in resp.data.decode("utf-8") + assert "Create a new project" 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") + # A token MUST have a point between payload and signature + resp = self.client.get("/raclette/token.invalid", follow_redirects=True) self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) def test_invite_code_invalidation(self): diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index 66f5b6a4..cc1f4911 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -16,7 +16,7 @@ from flask import current_app, escape, redirect, render_template from flask_babel import get_locale, lazy_gettext as _ import jinja2 from markupsafe import Markup -from werkzeug.routing import HTTPException, RoutingException +from werkzeug.routing import BaseConverter, HTTPException, RoutingException def slugify(value): @@ -416,3 +416,10 @@ def format_form_errors(form, prefix): errors = f"" # I18N: Form error with a list of errors return Markup(_("{prefix}:
{errors}").format(prefix=prefix, errors=errors)) + + +# Taken from https://stackoverflow.com/a/5872904 +class RegexConverter(BaseConverter): + def __init__(self, url_map, *items): + super(RegexConverter, self).__init__(url_map) + self.regex = items[0] diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 5af15e08..dd743d6a 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -143,7 +143,8 @@ def pull_project(endpoint, values): raise Redirect303(url_for(".create_project", project_id=project_id)) is_admin = session.get("is_admin") - if session.get(project.id) or is_admin: + is_invitation = endpoint == "main.invitation" + if session.get(project.id) or is_admin or is_invitation: # add project into kwargs and call the original function g.project = project else: @@ -195,6 +196,32 @@ def admin(): ) +# To avoid matching other endpoint with a malformed token, +# ensure that it has a point in the middle, since it's the +# default separator between payload and signature. +@main.route("//", methods=["GET"]) +def invitation(token): + project_id = g.project.id + verified_project_id = Project.verify_token( + token, token_type="auth", project_id=project_id + ) + if verified_project_id != project_id: + # User doesn't provide project identifier or a valid token + # redirect to authenticate form + return redirect(url_for(".authenticate", project_id=project_id, bad_token=1)) + + # maintain a list of visited projects + if "projects" not in session: + session["projects"] = [] + # add the project on the top of the list + session["projects"].insert(0, (project_id, g.project.name)) + session[project_id] = True + # Set session to permanent to make language choice persist + session.permanent = True + session.update() + return redirect(url_for(".list_bills")) + + @main.route("/authenticate", methods=["GET", "POST"]) def authenticate(project_id=None): """Authentication form""" @@ -203,19 +230,8 @@ def authenticate(project_id=None): 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: - 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: - token_auth = False - if project_id is None: + + if project_id is None or request.args.get("bad_token") is not None: # 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.") @@ -235,13 +251,9 @@ def authenticate(project_id=None): setattr(g, "project", project) return redirect(url_for(".list_bills")) - # else do form authentication or token authentication + # else do form authentication authentication is_post_auth = request.method == "POST" and form.validate() - if ( - is_post_auth - and check_password_hash(project.password, form.password.data) - or token_auth - ): + if is_post_auth and check_password_hash(project.password, form.password.data): # maintain a list of visited projects if "projects" not in session: session["projects"] = []