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] auth_token = auth_header.split(" ")[1]
except IndexError: except IndexError:
abort(401) 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: if auth_token and project_id:
project = Project.query.get(project_id) project = Project.query.get(project_id)
if project: if project:

View file

@ -353,20 +353,21 @@ class Project(db.Model):
token = serializer.dumps({"project_id": self.id}) token = serializer.dumps({"project_id": self.id})
else: else:
serializer = URLSafeSerializer( 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 return token
@staticmethod @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, """Return the project id associated to the provided token,
None if the provided token is expired or not valid. None if the provided token is expired or not valid.
:param token: Serialized TimedJsonWebToken :param token: Serialized TimedJsonWebToken
:param token_type: Either "auth" for authentication (invalidated when project code changed), :param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration) 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" :param max_age: Token expiration time (in seconds). Only used with token_type "reset"
""" """
loads_kwargs = {} loads_kwargs = {}
@ -376,8 +377,10 @@ class Project(db.Model):
) )
loads_kwargs["max_age"] = max_age loads_kwargs["max_age"] = max_age
else: else:
project = Project.query.get(project_id)
password = project.password if project is not None else ''
serializer = URLSafeSerializer( serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"], salt=token_type current_app.config["SECRET_KEY"] + password, salt=token_type
) )
try: try:
data = serializer.loads(token, **loads_kwargs) data = serializer.loads(token, **loads_kwargs)
@ -386,13 +389,8 @@ class Project(db.Model):
except BadSignature: except BadSignature:
return None return None
password = data.get("password", None) data_project = data.get("project_id")
project_id = data["project_id"] return data_project if project_id is None or data_project == project_id else None
if password is not None:
project = Project.query.get(project_id)
if project is None or project.password != password:
return None
return project_id
def __str__(self): def __str__(self):
return self.name 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. 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) }} 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, 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) }} 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, 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, token=g.project.generate_token()) }} {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
</a> </a>
</td> </td>
</tr> </tr>

View file

@ -213,7 +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("/authenticate?token={}".format(decoded_resp["token"])) resp = self.client.get(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

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

View file

@ -199,15 +199,16 @@ def admin():
def authenticate(project_id=None): def authenticate(project_id=None):
"""Authentication form""" """Authentication form"""
form = AuthenticationForm() form = AuthenticationForm()
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 # Try to get project_id from token first
token = request.args.get("token") token = request.args.get("token")
if token: if token:
project_id = Project.verify_token(token, token_type="auth") project_id = Project.verify_token(token, token_type="auth", project_id=project_id)
token_auth = True token_auth = True
else: else:
if not form.id.data and request.args.get("project_id"):
form.id.data = request.args["project_id"]
project_id = form.id.data
token_auth = False token_auth = False
if project_id is None: 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