API BREAKING CHANGE the authenticate token now need project_id= param in URL

This change is introduced to have the ability to invalidate auth token with password change.
Token payload is still the same, but the key is the concatenation of SECRET_KEY project password.
To have a clean verification, we need to have the project id before loading payload, to build the serializer with the correct key (including the password).
This commit is contained in:
Glandos 2021-07-17 23:05:24 +02:00
parent 57525fc2b6
commit d9a4389d42
8 changed files with 22 additions and 22 deletions

View file

@ -35,7 +35,7 @@ def need_auth(f):
auth_token = auth_header.split(" ")[1]
except IndexError:
abort(401)
project_id = Project.verify_token(auth_token, token_type="auth")
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:

View file

@ -353,20 +353,21 @@ class Project(db.Model):
token = serializer.dumps({"project_id": self.id})
else:
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"], salt=token_type
current_app.config["SECRET_KEY"] + self.password, salt=token_type
)
token = serializer.dumps({"project_id": self.id, "password": self.password})
token = serializer.dumps({"project_id": self.id})
return token
@staticmethod
def verify_token(token, token_type="auth", max_age=3600):
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"
"""
loads_kwargs = {}
@ -376,8 +377,10 @@ class Project(db.Model):
)
loads_kwargs["max_age"] = max_age
else:
project = Project.query.get(project_id)
password = project.password if project is not None else ''
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"], salt=token_type
current_app.config["SECRET_KEY"] + password, salt=token_type
)
try:
data = serializer.loads(token, **loads_kwargs)
@ -386,13 +389,8 @@ class Project(db.Model):
except BadSignature:
return None
password = data.get("password", None)
project_id = data["project_id"]
if password is not None:
project = Project.query.get(project_id)
if project is None or project.password != password:
return None
return project_id
data_project = data.get("project_id")
return data_project if project_id is None or data_project == project_id else None
def __str__(self):
return self.name

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

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

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, token=g.project.generate_token()) }}">
{{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}
<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>
</td>
</tr>

View file

@ -213,7 +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("/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)

View file

@ -6,6 +6,7 @@ from time import sleep
import unittest
from flask import session
from markupsafe import Markup
import pytest
from werkzeug.security import check_password_hash, generate_password_hash
@ -100,7 +101,7 @@ class BudgetTestCase(IhatemoneyTestCase):
base_index = response.find("share the following link")
start = response.find('href="', base_index) + 6
end = response.find('">', base_index)
link = response[start:end]
link = Markup(response[start:end]).unescape()
self.client.get("/exit")
response = self.client.get(link)

View file

@ -199,15 +199,16 @@ def admin():
def authenticate(project_id=None):
"""Authentication form"""
form = AuthenticationForm()
# Try to get project_id from token first
token = request.args.get("token")
if token:
project_id = Project.verify_token(token, token_type="auth")
token_auth = True
else:
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="auth", project_id=project_id)
token_auth = True
else:
token_auth = False
if project_id is None:
# User doesn't provide project identifier or a valid token