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"] = []