Change token path authentication to /PROJECT/token

This commit is contained in:
Glandos 2021-10-10 16:23:59 +02:00
parent bbe00ebb57
commit 044638d268
8 changed files with 58 additions and 34 deletions

View file

@ -19,6 +19,7 @@ from ihatemoney.models import db
from ihatemoney.utils import ( from ihatemoney.utils import (
IhmJSONEncoder, IhmJSONEncoder,
PrefixedWSGI, PrefixedWSGI,
RegexConverter,
em_surround, em_surround,
locale_from_iso, locale_from_iso,
localize_list, localize_list,
@ -126,6 +127,8 @@ def create_app(
instance_relative_config=instance_relative_config, 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. # If a configuration object is passed, use it. Otherwise try to find one.
load_configuration(app, configuration) load_configuration(app, configuration)
app.wsgi_app = PrefixedWSGI(app) app.wsgi_app = PrefixedWSGI(app)

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. 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) }} 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. 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. 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, 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) }} 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. Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien.

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import json
import re import re
from time import sleep from time import sleep
import unittest import unittest
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from flask import session from flask import session
import pytest import pytest
@ -91,16 +91,20 @@ class BudgetTestCase(IhatemoneyTestCase):
self.client.get("/exit") self.client.get("/exit")
# Use another project_id # Use another project_id
parsed_url = urlparse(url) parsed_url = urlparse(url)
query = parse_qs(parsed_url.query)
query["project_id"] = "invalid"
resp = self.client.get( 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/")
) )
assert "You either provided a bad token" in resp.data.decode("utf-8") ),
follow_redirects=True,
)
assert "Create a new project" in resp.data.decode("utf-8")
resp = self.client.get("/authenticate") resp = self.client.get("/authenticate")
self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) 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")) self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
def test_invite_code_invalidation(self): def test_invite_code_invalidation(self):

View file

@ -16,7 +16,7 @@ from flask import current_app, escape, redirect, render_template
from flask_babel import get_locale, lazy_gettext as _ from flask_babel import get_locale, lazy_gettext as _
import jinja2 import jinja2
from markupsafe import Markup from markupsafe import Markup
from werkzeug.routing import HTTPException, RoutingException from werkzeug.routing import BaseConverter, HTTPException, RoutingException
def slugify(value): def slugify(value):
@ -416,3 +416,10 @@ def format_form_errors(form, prefix):
errors = f"<ul><li>{error_list}</li></ul>" errors = f"<ul><li>{error_list}</li></ul>"
# I18N: Form error with a list of errors # I18N: Form error with a list of errors
return Markup(_("{prefix}:<br />{errors}").format(prefix=prefix, errors=errors)) return Markup(_("{prefix}:<br />{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]

View file

@ -143,7 +143,8 @@ def pull_project(endpoint, values):
raise Redirect303(url_for(".create_project", project_id=project_id)) raise Redirect303(url_for(".create_project", project_id=project_id))
is_admin = session.get("is_admin") 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 # add project into kwargs and call the original function
g.project = project g.project = project
else: 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("/<project_id>/<regex('.+\\..+'):token>", 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"]) @main.route("/authenticate", methods=["GET", "POST"])
def authenticate(project_id=None): def authenticate(project_id=None):
"""Authentication form""" """Authentication form"""
@ -203,19 +230,8 @@ def authenticate(project_id=None):
if not form.id.data and request.args.get("project_id"): if not form.id.data and request.args.get("project_id"):
form.id.data = request.args["project_id"] form.id.data = request.args["project_id"]
project_id = form.id.data project_id = form.id.data
# Try to get project_id from token first
token = request.args.get("token") if project_id is None or request.args.get("bad_token") is not None:
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:
# User doesn't provide project identifier or a valid token # User doesn't provide project identifier or a valid token
# return to authenticate form # return to authenticate form
msg = _("You either provided a bad token or no project identifier.") msg = _("You either provided a bad token or no project identifier.")
@ -235,13 +251,9 @@ def authenticate(project_id=None):
setattr(g, "project", project) setattr(g, "project", project)
return redirect(url_for(".list_bills")) 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() is_post_auth = request.method == "POST" and form.validate()
if ( if is_post_auth and check_password_hash(project.password, form.password.data):
is_post_auth
and check_password_hash(project.password, form.password.data)
or token_auth
):
# maintain a list of visited projects # maintain a list of visited projects
if "projects" not in session: if "projects" not in session:
session["projects"] = [] session["projects"] = []