Include project code into project authentication token

Fix #780

The token used for shared links (aka authentication) includes the project password (aka code).
This code is checked when reading the token.
If the code is changed, the token is invalid. Test is included for that.

Also, reset token are now using URLSafeTimedSerializer instead of the deprecated JWS provided by itsdangerous.
The main change is that age is checked when verifying. The coolest effect is that warnings disappeared from tests.
This commit is contained in:
Glandos 2021-07-14 18:56:34 +02:00
parent 1842fba115
commit 57525fc2b6
6 changed files with 73 additions and 21 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="non_timed_token")
project_id = Project.verify_token(auth_token, token_type="auth")
if auth_token and project_id:
project = Project.query.get(project_id)
if project:

View file

@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy
from itsdangerous import (
BadSignature,
SignatureExpired,
TimedJSONWebSignatureSerializer,
URLSafeSerializer,
URLSafeTimedSerializer,
)
import sqlalchemy
from sqlalchemy import orm
@ -339,41 +339,60 @@ class Project(db.Model):
db.session.delete(self)
db.session.commit()
def generate_token(self, expiration=0):
def generate_token(self, token_type="auth"):
"""Generate a timed and serialized JsonWebToken
:param expiration: Token expiration time (in seconds)
:param token_type: Either "auth" for authentication (invalidated when project code changed),
or "reset" for password reset (invalidated after expiration)
"""
if expiration:
serializer = TimedJSONWebSignatureSerializer(
current_app.config["SECRET_KEY"], expiration
if token_type == "reset":
serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
)
token = serializer.dumps({"project_id": self.id}).decode("utf-8")
else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
token = serializer.dumps({"project_id": self.id})
else:
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"], salt=token_type
)
token = serializer.dumps({"project_id": self.id, "password": self.password})
return token
@staticmethod
def verify_token(token, token_type="timed_token"):
def verify_token(token, token_type="auth", 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 max_age: Token expiration time (in seconds). Only used with token_type "reset"
"""
if token_type == "timed_token":
serializer = TimedJSONWebSignatureSerializer(
current_app.config["SECRET_KEY"]
loads_kwargs = {}
if token_type == "reset":
serializer = URLSafeTimedSerializer(
current_app.config["SECRET_KEY"], salt=token_type
)
loads_kwargs["max_age"] = max_age
else:
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"], salt=token_type
)
try:
data = serializer.loads(token)
data = serializer.loads(token, **loads_kwargs)
except SignatureExpired:
return None
except BadSignature:
return None
return data["project_id"]
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
def __str__(self):
return self.name

View file

@ -1,7 +1,7 @@
Hi,
You requested to reset the password of the following project: "{{ project.name }}".
You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}.
You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
This link is only valid for one hour.
Hope this helps,

View file

@ -1,7 +1,7 @@
Salut,
Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}".
Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}.
Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
Ce lien est seulement valide pendant 1 heure.
Faites-en bon usage !

View file

@ -92,6 +92,39 @@ class BudgetTestCase(IhatemoneyTestCase):
resp = self.client.get("/authenticate?token=token")
self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
def test_invite_expiration_with_code(self):
"""Test that invitation link expire after code change"""
self.login("raclette")
self.post_project("raclette")
response = self.client.get("/raclette/invite").data.decode("utf-8")
base_index = response.find("share the following link")
start = response.find('href="', base_index) + 6
end = response.find('">', base_index)
link = response[start:end]
self.client.get("/exit")
response = self.client.get(link)
# Link is valid
assert response.status_code == 302
response = self.client.post(
"/raclette/edit",
data={
"name": "raclette",
"contact_email": "zorglub@notmyidea.org",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)
assert response.status_code == 200
assert "alert-danger" not in response.data.decode("utf-8")
self.client.get("/exit")
response = self.client.get(link, follow_redirects=True)
# Link is invalid
self.assertIn("You either provided a bad token", response.data.decode("utf-8"))
def test_password_reminder(self):
# test that it is possible to have an email containing the password of a
# project in case people forget it (and it happens!)

View file

@ -202,7 +202,7 @@ def authenticate(project_id=None):
# Try to get project_id from token first
token = request.args.get("token")
if token:
project_id = Project.verify_token(token, token_type="non_timed_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"):
@ -381,7 +381,7 @@ def reset_password():
return render_template(
"reset_password.html", form=form, error=_("No token provided")
)
project_id = Project.verify_token(token)
project_id = Project.verify_token(token, token_type="reset")
if not project_id:
return render_template(
"reset_password.html", form=form, error=_("Invalid token")