mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-05 20:51:49 +02:00
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:
parent
1842fba115
commit
57525fc2b6
6 changed files with 73 additions and 21 deletions
|
@ -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="non_timed_token")
|
project_id = Project.verify_token(auth_token, token_type="auth")
|
||||||
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:
|
||||||
|
|
|
@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy
|
||||||
from itsdangerous import (
|
from itsdangerous import (
|
||||||
BadSignature,
|
BadSignature,
|
||||||
SignatureExpired,
|
SignatureExpired,
|
||||||
TimedJSONWebSignatureSerializer,
|
|
||||||
URLSafeSerializer,
|
URLSafeSerializer,
|
||||||
|
URLSafeTimedSerializer,
|
||||||
)
|
)
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
@ -339,41 +339,60 @@ class Project(db.Model):
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
def generate_token(self, expiration=0):
|
def generate_token(self, token_type="auth"):
|
||||||
"""Generate a timed and serialized JsonWebToken
|
"""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(
|
if token_type == "reset":
|
||||||
current_app.config["SECRET_KEY"], expiration
|
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})
|
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
|
return token
|
||||||
|
|
||||||
@staticmethod
|
@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,
|
"""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),
|
||||||
|
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":
|
loads_kwargs = {}
|
||||||
serializer = TimedJSONWebSignatureSerializer(
|
if token_type == "reset":
|
||||||
current_app.config["SECRET_KEY"]
|
serializer = URLSafeTimedSerializer(
|
||||||
|
current_app.config["SECRET_KEY"], salt=token_type
|
||||||
)
|
)
|
||||||
|
loads_kwargs["max_age"] = max_age
|
||||||
else:
|
else:
|
||||||
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
|
serializer = URLSafeSerializer(
|
||||||
|
current_app.config["SECRET_KEY"], salt=token_type
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
data = serializer.loads(token)
|
data = serializer.loads(token, **loads_kwargs)
|
||||||
except SignatureExpired:
|
except SignatureExpired:
|
||||||
return None
|
return None
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
return None
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
Hi,
|
Hi,
|
||||||
|
|
||||||
You requested to reset the password of the following project: "{{ project.name }}".
|
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.
|
This link is only valid for one hour.
|
||||||
|
|
||||||
Hope this helps,
|
Hope this helps,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
Salut,
|
Salut,
|
||||||
|
|
||||||
Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}".
|
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.
|
Ce lien est seulement valide pendant 1 heure.
|
||||||
|
|
||||||
Faites-en bon usage !
|
Faites-en bon usage !
|
||||||
|
|
|
@ -92,6 +92,39 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
resp = self.client.get("/authenticate?token=token")
|
resp = self.client.get("/authenticate?token=token")
|
||||||
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_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):
|
def test_password_reminder(self):
|
||||||
# test that it is possible to have an email containing the password of a
|
# test that it is possible to have an email containing the password of a
|
||||||
# project in case people forget it (and it happens!)
|
# project in case people forget it (and it happens!)
|
||||||
|
|
|
@ -202,7 +202,7 @@ def authenticate(project_id=None):
|
||||||
# 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="non_timed_token")
|
project_id = Project.verify_token(token, token_type="auth")
|
||||||
token_auth = True
|
token_auth = True
|
||||||
else:
|
else:
|
||||||
if not form.id.data and request.args.get("project_id"):
|
if not form.id.data and request.args.get("project_id"):
|
||||||
|
@ -381,7 +381,7 @@ def reset_password():
|
||||||
return render_template(
|
return render_template(
|
||||||
"reset_password.html", form=form, error=_("No token provided")
|
"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:
|
if not project_id:
|
||||||
return render_template(
|
return render_template(
|
||||||
"reset_password.html", form=form, error=_("Invalid token")
|
"reset_password.html", form=form, error=_("Invalid token")
|
||||||
|
|
Loading…
Reference in a new issue