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 (
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)

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.
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.

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.
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.

View file

@ -21,8 +21,8 @@
</td>
<td>
{{ _("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()) }}">
{{ 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(".invitation", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
</a>
</td>
</tr>

View file

@ -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)

View file

@ -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):

View file

@ -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"<ul><li>{error_list}</li></ul>"
# I18N: Form error with a list of 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))
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("/<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"])
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"] = []