mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-29 17:52:37 +02:00
Include project code into project authentication token (#802)
Fix #780 This a breaking change, the API for authentication is different, as it now requires `project_id`. Token is generated with only the project_id (so it's shorter than before), and signature is done by mixing password with secret key. Thus, it expires on every project code change.
This commit is contained in:
parent
c13c4c7e3c
commit
bbe00ebb57
11 changed files with 114 additions and 30 deletions
|
@ -35,7 +35,9 @@ 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", 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:
|
||||||
|
|
|
@ -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,61 @@ 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")
|
token = serializer.dumps([self.id])
|
||||||
else:
|
else:
|
||||||
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
|
serializer = URLSafeSerializer(
|
||||||
token = serializer.dumps({"project_id": self.id})
|
current_app.config["SECRET_KEY"] + self.password, salt=token_type
|
||||||
|
)
|
||||||
|
token = serializer.dumps([self.id])
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_token(token, token_type="timed_token"):
|
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),
|
||||||
|
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"
|
||||||
"""
|
"""
|
||||||
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"])
|
project = Project.query.get(project_id) if project_id is not None else None
|
||||||
|
password = project.password if project is not None else ""
|
||||||
|
serializer = URLSafeSerializer(
|
||||||
|
current_app.config["SECRET_KEY"] + password, 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"]
|
|
||||||
|
data_project = data[0] if isinstance(data, list) else None
|
||||||
|
return (
|
||||||
|
data_project if project_id is None or data_project == project_id else None
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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 s’occupe du reste.
|
Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on s’occupe 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.
|
||||||
|
|
|
@ -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 !
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -213,7 +213,9 @@ 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)
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import json
|
||||||
import re
|
import re
|
||||||
from time import sleep
|
from time import sleep
|
||||||
import unittest
|
import unittest
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
from flask import session
|
from flask import session
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -11,6 +12,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from ihatemoney import models
|
from ihatemoney import models
|
||||||
from ihatemoney.currency_convertor import CurrencyConverter
|
from ihatemoney.currency_convertor import CurrencyConverter
|
||||||
|
from ihatemoney.tests.common.help_functions import extract_link
|
||||||
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
|
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
|
||||||
from ihatemoney.versioning import LoggingMode
|
from ihatemoney.versioning import LoggingMode
|
||||||
|
|
||||||
|
@ -87,11 +89,52 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
)
|
)
|
||||||
# Test empty and invalid tokens
|
# Test empty and invalid tokens
|
||||||
self.client.get("/exit")
|
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)))
|
||||||
|
)
|
||||||
|
assert "You either provided a bad token" in resp.data.decode("utf-8")
|
||||||
|
|
||||||
resp = self.client.get("/authenticate")
|
resp = self.client.get("/authenticate")
|
||||||
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"))
|
||||||
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_code_invalidation(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")
|
||||||
|
link = extract_link(response, "share the following link")
|
||||||
|
|
||||||
|
self.client.get("/exit")
|
||||||
|
response = self.client.get(link)
|
||||||
|
# Link is valid
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
# Change password to invalidate token
|
||||||
|
# Other data are required, but useless for the test
|
||||||
|
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!)
|
||||||
|
|
|
@ -1,5 +1,16 @@
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
|
||||||
def em_surround(string, regex_escape=False):
|
def em_surround(string, regex_escape=False):
|
||||||
if regex_escape:
|
if regex_escape:
|
||||||
return r'<em class="font-italic">%s<\/em>' % string
|
return r'<em class="font-italic">%s<\/em>' % string
|
||||||
else:
|
else:
|
||||||
return '<em class="font-italic">%s</em>' % string
|
return '<em class="font-italic">%s</em>' % string
|
||||||
|
|
||||||
|
|
||||||
|
def extract_link(data, start_prefix):
|
||||||
|
base_index = data.find(start_prefix)
|
||||||
|
start = data.find('href="', base_index) + 6
|
||||||
|
end = data.find('">', base_index)
|
||||||
|
link = Markup(data[start:end]).unescape()
|
||||||
|
return link
|
||||||
|
|
|
@ -199,15 +199,21 @@ 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="non_timed_token")
|
verified_project_id = Project.verify_token(
|
||||||
token_auth = True
|
token, token_type="auth", project_id=project_id
|
||||||
|
)
|
||||||
|
if verified_project_id == project_id:
|
||||||
|
token_auth = True
|
||||||
|
else:
|
||||||
|
project_id = None
|
||||||
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
|
||||||
|
@ -381,7 +387,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