Merge branch 'image-cache' into 'main'

Image cache

See merge request yaal/canaille!117
This commit is contained in:
Éloi Rivard 2023-03-18 00:36:32 +00:00
commit 9d5d58fb57
17 changed files with 155 additions and 59 deletions

View file

@ -7,11 +7,13 @@ Added
***** *****
- Organization field. :pr:`116` - Organization field. :pr:`116`
- ETag and Last-Modified headers on user photos. :pr:`116`
Changed Changed
******* *******
- UX rework. Submenu addition. :pr:`114` - UX rework. Submenu addition. :pr:`114`
- Properly handle LDAP date timezones. :pr:`117`
[0.0.22] - 2023-03-13 [0.0.22] - 2023-03-13
===================== =====================

View file

@ -1,8 +1,7 @@
import datetime
import io import io
from dataclasses import astuple from dataclasses import astuple
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from datetime import timedelta
from typing import List from typing import List
import pkg_resources import pkg_resources
@ -205,11 +204,13 @@ class Invitation:
@property @property
def creation_date(self): def creation_date(self):
return datetime.fromisoformat(self.creation_date_isoformat) return datetime.datetime.fromisoformat(self.creation_date_isoformat)
def has_expired(self): def has_expired(self):
DEFAULT_INVITATION_DURATION = 2 * 24 * 60 * 60 DEFAULT_INVITATION_DURATION = 2 * 24 * 60 * 60
return datetime.now() - self.creation_date > timedelta( return datetime.datetime.now(
datetime.timezone.utc
) - self.creation_date > datetime.timedelta(
seconds=current_app.config.get( seconds=current_app.config.get(
"INVITATION_EXPIRATION", DEFAULT_INVITATION_DURATION "INVITATION_EXPIRATION", DEFAULT_INVITATION_DURATION
) )
@ -234,7 +235,7 @@ def user_invitation(user):
if request.form and form.validate(): if request.form and form.validate():
form_validated = True form_validated = True
invitation = Invitation( invitation = Invitation(
datetime.now().isoformat(), datetime.datetime.now(datetime.timezone.utc).isoformat(),
form.uid.data, form.uid.data,
form.uid_editable.data, form.uid_editable.data,
form.mail.data, form.mail.data,
@ -713,9 +714,19 @@ def photo(uid, field):
if not user: if not user:
abort(404) abort(404)
etag = None
if request.if_modified_since and request.if_modified_since >= user.modifyTimestamp:
return "", 304
etag = profile_hash(uid, user.modifyTimestamp.isoformat())
if request.if_none_match and etag in request.if_none_match:
return "", 304
photos = getattr(user, field) photos = getattr(user, field)
if not photos: if not photos:
abort(404) abort(404)
stream = io.BytesIO(photos[0]) stream = io.BytesIO(photos[0])
return send_file(stream, mimetype="image/jpeg") return send_file(
stream, mimetype="image/jpeg", last_modified=user.modifyTimestamp, etag=etag
)

View file

@ -34,8 +34,11 @@ def ldap_to_python(value, syntax):
if value == LDAP_NULL_DATE: if value == LDAP_NULL_DATE:
# python cannot represent datetimes with year 0 # python cannot represent datetimes with year 0
return datetime.datetime.min return datetime.datetime.min
else: if value.endswith("Z"):
return datetime.datetime.strptime(value, "%Y%m%d%H%M%SZ") if value else None return datetime.datetime.strptime(value, "%Y%m%d%H%M%SZ").replace(
tzinfo=datetime.timezone.utc
)
return datetime.datetime.strptime(value, "%Y%m%d%H%M%S%z")
if syntax == Syntax.INTEGER: if syntax == Syntax.INTEGER:
return int(value.decode("utf-8")) return int(value.decode("utf-8"))
@ -57,8 +60,10 @@ def python_to_ldap(value, syntax, encode=True):
if syntax == Syntax.GENERALIZED_TIME and isinstance(value, datetime.datetime): if syntax == Syntax.GENERALIZED_TIME and isinstance(value, datetime.datetime):
if value == datetime.datetime.min: if value == datetime.datetime.min:
value = LDAP_NULL_DATE value = LDAP_NULL_DATE
else: elif value.tzinfo == datetime.timezone.utc:
value = value.strftime("%Y%m%d%H%M%SZ") value = value.strftime("%Y%m%d%H%M%SZ")
else:
value = value.strftime("%Y%m%d%H%M%S%z")
if syntax == Syntax.INTEGER and isinstance(value, int): if syntax == Syntax.INTEGER and isinstance(value, int):
value = str(value) value = str(value)

View file

@ -51,7 +51,7 @@ def add(user):
) )
client_id = gen_salt(24) client_id = gen_salt(24)
client_id_issued_at = datetime.datetime.now() client_id_issued_at = datetime.datetime.now(datetime.timezone.utc)
client = Client( client = Client(
client_id=client_id, client_id=client_id,
client_id_issued_at=client_id_issued_at, client_id_issued_at=client_id_issued_at,

View file

@ -96,7 +96,7 @@ def restore(user, consent_id):
else: else:
consent.restore() consent.restore()
if not consent.issue_date: if not consent.issue_date:
consent.issue_date = datetime.datetime.now() consent.issue_date = datetime.datetime.now(datetime.timezone.utc)
consent.save() consent.save()
flash(_("The access has been restored"), "success") flash(_("The access has been restored"), "success")

View file

@ -149,7 +149,7 @@ def authorize():
client=client, client=client,
subject=user, subject=user,
scope=scopes, scope=scopes,
issue_date=datetime.datetime.now(), issue_date=datetime.datetime.now(datetime.timezone.utc),
) )
consent.save() consent.save()

View file

@ -141,13 +141,17 @@ class AuthorizationCode(LDAPObject, AuthorizationCodeMixin):
return self.nonce return self.nonce
def is_expired(self): def is_expired(self):
return ( return self.issue_date + datetime.timedelta(
self.issue_date + datetime.timedelta(seconds=int(self.lifetime)) seconds=int(self.lifetime)
< datetime.datetime.now() ) < datetime.datetime.now(datetime.timezone.utc)
)
def get_auth_time(self): def get_auth_time(self):
return int((self.issue_date - datetime.datetime(1970, 1, 1)).total_seconds()) return int(
(
self.issue_date
- datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
).total_seconds()
)
class Token(LDAPObject, TokenMixin): class Token(LDAPObject, TokenMixin):
@ -185,11 +189,17 @@ class Token(LDAPObject, TokenMixin):
return int(self.lifetime) return int(self.lifetime)
def get_issued_at(self): def get_issued_at(self):
return int((self.issue_date - datetime.datetime(1970, 1, 1)).total_seconds()) return int(
(
self.issue_date
- datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
).total_seconds()
)
def get_expires_at(self): def get_expires_at(self):
issue_timestamp = ( issue_timestamp = (
self.issue_date - datetime.datetime(1970, 1, 1) self.issue_date
- datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
).total_seconds() ).total_seconds()
return int(issue_timestamp) + int(self.lifetime) return int(issue_timestamp) + int(self.lifetime)
@ -197,13 +207,12 @@ class Token(LDAPObject, TokenMixin):
if self.revokation_date: if self.revokation_date:
return False return False
return self.expire_date >= datetime.datetime.now() return self.expire_date >= datetime.datetime.now(datetime.timezone.utc)
def is_expired(self): def is_expired(self):
return ( return self.issue_date + datetime.timedelta(
self.issue_date + datetime.timedelta(seconds=int(self.lifetime)) seconds=int(self.lifetime)
< datetime.datetime.now() ) < datetime.datetime.now(datetime.timezone.utc)
)
def is_revoked(self): def is_revoked(self):
return bool(self.revokation_date) return bool(self.revokation_date)
@ -231,7 +240,7 @@ class Consent(LDAPObject):
return bool(self.revokation_date) return bool(self.revokation_date)
def revoke(self): def revoke(self):
self.revokation_date = datetime.datetime.now() self.revokation_date = datetime.datetime.now(datetime.timezone.utc)
self.save() self.save()
tokens = Token.query( tokens = Token.query(

View file

@ -124,7 +124,7 @@ def generate_user_claims(user, claims, jwt_mapping_config=None):
def save_authorization_code(code, request): def save_authorization_code(code, request):
nonce = request.data.get("nonce") nonce = request.data.get("nonce")
now = datetime.datetime.now() now = datetime.datetime.now(datetime.timezone.utc)
scope = request.client.get_allowed_scope(request.scope) scope = request.client.get_allowed_scope(request.scope)
code = AuthorizationCode( code = AuthorizationCode(
authorization_code_id=gen_salt(48), authorization_code_id=gen_salt(48),
@ -193,7 +193,7 @@ class RefreshTokenGrant(_RefreshTokenGrant):
return credential.subject return credential.subject
def revoke_old_credential(self, credential): def revoke_old_credential(self, credential):
credential.revokation_date = datetime.datetime.now() credential.revokation_date = datetime.datetime.now(datetime.timezone.utc)
credential.save() credential.save()
@ -235,7 +235,7 @@ def query_client(client_id):
def save_token(token, request): def save_token(token, request):
now = datetime.datetime.now() now = datetime.datetime.now(datetime.timezone.utc)
t = Token( t = Token(
token_id=gen_salt(48), token_id=gen_salt(48),
type=token["token_type"], type=token["token_type"],
@ -278,7 +278,7 @@ class RevocationEndpoint(_RevocationEndpoint):
return query_token(token, token_type_hint) return query_token(token, token_type_hint)
def revoke_token(self, token, request): def revoke_token(self, token, request):
token.revokation_date = datetime.datetime.now() token.revokation_date = datetime.datetime.now(datetime.timezone.utc)
token.save() token.save()
@ -340,7 +340,7 @@ class ClientRegistrationEndpoint(ClientManagementMixin, _ClientRegistrationEndpo
def save_client(self, client_info, client_metadata, request): def save_client(self, client_info, client_metadata, request):
client_info["client_id_issued_at"] = datetime.datetime.fromtimestamp( client_info["client_id_issued_at"] = datetime.datetime.fromtimestamp(
client_info["client_id_issued_at"] client_info["client_id_issued_at"], datetime.timezone.utc
) )
if "scope" in client_metadata and not isinstance( if "scope" in client_metadata and not isinstance(
client_metadata["scope"], list client_metadata["scope"], list

View file

@ -53,7 +53,7 @@ def revoke(user, token_id):
if not token: if not token:
abort(404) abort(404)
token.revokation_date = datetime.datetime.now() token.revokation_date = datetime.datetime.now(datetime.timezone.utc)
token.save() token.save()
flash(_("The token has successfully been revoked."), "success") flash(_("The token has successfully been revoked."), "success")

View file

@ -100,12 +100,30 @@ def test_fuzzy(slapd_connection, user, moderator, admin):
def test_ldap_to_python(): def test_ldap_to_python():
assert ( assert (
python_to_ldap(datetime.datetime.min, Syntax.GENERALIZED_TIME) python_to_ldap(
== b"000001010000Z" datetime.datetime(2000, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc),
Syntax.GENERALIZED_TIME,
)
== b"20000102030405Z"
) )
assert ( assert (
python_to_ldap(datetime.datetime(2000, 1, 2, 3, 4, 5), Syntax.GENERALIZED_TIME) python_to_ldap(
== b"20000102030405Z" datetime.datetime(
2000,
1,
2,
3,
4,
5,
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=79200)),
),
Syntax.GENERALIZED_TIME,
)
== b"20000102030405-0200"
)
assert (
python_to_ldap(datetime.datetime.min, Syntax.GENERALIZED_TIME)
== b"000001010000Z"
) )
assert python_to_ldap(1337, Syntax.INTEGER) == b"1337" assert python_to_ldap(1337, Syntax.INTEGER) == b"1337"
@ -121,7 +139,18 @@ def test_ldap_to_python():
def test_python_to_ldap(): def test_python_to_ldap():
assert ldap_to_python( assert ldap_to_python(
b"20000102030405Z", Syntax.GENERALIZED_TIME b"20000102030405Z", Syntax.GENERALIZED_TIME
) == datetime.datetime(2000, 1, 2, 3, 4, 5) ) == datetime.datetime(2000, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
assert ldap_to_python(
b"20000102030405-0200", Syntax.GENERALIZED_TIME
) == datetime.datetime(
2000,
1,
2,
3,
4,
5,
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=79200)),
)
assert ( assert (
ldap_to_python(b"000001010000Z", Syntax.GENERALIZED_TIME) ldap_to_python(b"000001010000Z", Syntax.GENERALIZED_TIME)
== datetime.datetime.min == datetime.datetime.min

View file

@ -92,7 +92,7 @@ def client(testclient, other_client, slapd_connection):
"https://mydomain.tld/redirect2", "https://mydomain.tld/redirect2",
], ],
logo_uri="https://mydomain.tld/logo.png", logo_uri="https://mydomain.tld/logo.png",
client_id_issued_at=datetime.datetime.now(), client_id_issued_at=datetime.datetime.now(datetime.timezone.utc),
client_secret=gen_salt(48), client_secret=gen_salt(48),
grant_types=[ grant_types=[
"password", "password",
@ -128,7 +128,7 @@ def other_client(testclient, slapd_connection):
"https://myotherdomain.tld/redirect2", "https://myotherdomain.tld/redirect2",
], ],
logo_uri="https://myotherdomain.tld/logo.png", logo_uri="https://myotherdomain.tld/logo.png",
client_id_issued_at=datetime.datetime.now(), client_id_issued_at=datetime.datetime.now(datetime.timezone.utc),
client_secret=gen_salt(48), client_secret=gen_salt(48),
grant_types=[ grant_types=[
"password", "password",
@ -163,7 +163,7 @@ def authorization(testclient, user, client, slapd_connection):
response_type="code", response_type="code",
scope="openid profile", scope="openid profile",
nonce="nonce", nonce="nonce",
issue_date=datetime.datetime(2020, 1, 1), issue_date=datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
lifetime="3600", lifetime="3600",
challenge="challenge", challenge="challenge",
challenge_method="method", challenge_method="method",
@ -185,7 +185,7 @@ def token(testclient, client, user, slapd_connection):
token_type=None, token_type=None,
refresh_token=gen_salt(48), refresh_token=gen_salt(48),
scope="openid profile", scope="openid profile",
issue_date=datetime.datetime.now(), issue_date=datetime.datetime.now(datetime.timezone.utc),
lifetime=str(3600), lifetime=str(3600),
) )
t.save() t.save()
@ -210,7 +210,7 @@ def consent(testclient, client, user, slapd_connection):
client=client, client=client,
subject=user, subject=user,
scope=["openid", "profile"], scope=["openid", "profile"],
issue_date=datetime.datetime.now(), issue_date=datetime.datetime.now(datetime.timezone.utc),
) )
t.save() t.save()
yield t yield t

View file

@ -16,7 +16,7 @@ def test_clean_command(testclient, slapd_connection, client, user):
response_type="code", response_type="code",
scope="openid profile", scope="openid profile",
nonce="nonce", nonce="nonce",
issue_date=datetime.datetime.now().replace(microsecond=0), issue_date=datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0),
lifetime=3600, lifetime=3600,
challenge="challenge", challenge="challenge",
challenge_method="method", challenge_method="method",
@ -33,7 +33,8 @@ def test_clean_command(testclient, slapd_connection, client, user):
scope="openid profile", scope="openid profile",
nonce="nonce", nonce="nonce",
issue_date=( issue_date=(
datetime.datetime.now().replace(microsecond=0) - datetime.timedelta(days=1) datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
- datetime.timedelta(days=1)
), ),
lifetime=3600, lifetime=3600,
challenge="challenge", challenge="challenge",
@ -50,7 +51,9 @@ def test_clean_command(testclient, slapd_connection, client, user):
type=None, type=None,
refresh_token=gen_salt(48), refresh_token=gen_salt(48),
scope="openid profile", scope="openid profile",
issue_date=(datetime.datetime.now().replace(microsecond=0)), issue_date=(
datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
),
lifetime=3600, lifetime=3600,
) )
valid_token.save() valid_token.save()
@ -63,7 +66,8 @@ def test_clean_command(testclient, slapd_connection, client, user):
refresh_token=gen_salt(48), refresh_token=gen_salt(48),
scope="openid profile", scope="openid profile",
issue_date=( issue_date=(
datetime.datetime.now().replace(microsecond=0) - datetime.timedelta(days=1) datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
- datetime.timedelta(days=1)
), ),
lifetime=3600, lifetime=3600,
) )

View file

@ -197,7 +197,9 @@ def test_client_delete(testclient, logged_admin):
client = Client(client_id="client_id") client = Client(client_id="client_id")
client.save() client.save()
token = Token( token = Token(
token_id="id", client=client, issue_datetime=datetime.datetime.utcnow() token_id="id",
client=client,
issue_datetime=datetime.datetime.now(datetime.timezone.utc),
) )
token.save() token.save()
consent = Consent( consent = Consent(

View file

@ -30,7 +30,9 @@ def test_token_list_pagination(testclient, logged_admin, client):
type=None, type=None,
refresh_token=gen_salt(48), refresh_token=gen_salt(48),
scope="openid profile", scope="openid profile",
issue_date=(datetime.datetime.now().replace(microsecond=0)), issue_date=(
datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
),
lifetime=3600, lifetime=3600,
) )
token.save() token.save()
@ -77,7 +79,9 @@ def test_token_list_search(testclient, logged_admin, client):
type=None, type=None,
refresh_token=gen_salt(48), refresh_token=gen_salt(48),
scope="openid profile", scope="openid profile",
issue_date=(datetime.datetime.now().replace(microsecond=0)), issue_date=(
datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
),
lifetime=3600, lifetime=3600,
) )
token1.save() token1.save()
@ -89,7 +93,9 @@ def test_token_list_search(testclient, logged_admin, client):
type=None, type=None,
refresh_token=gen_salt(48), refresh_token=gen_salt(48),
scope="openid profile", scope="openid profile",
issue_date=(datetime.datetime.now().replace(microsecond=0)), issue_date=(
datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
),
lifetime=3600, lifetime=3600,
) )
token2.save() token2.save()

View file

@ -64,7 +64,9 @@ def test_revoke_refresh_token_with_hint(testclient, user, client, token):
def test_cannot_refresh_after_revocation(testclient, user, client, token): def test_cannot_refresh_after_revocation(testclient, user, client, token):
token.revokation_date = datetime.datetime.utcnow() - datetime.timedelta(days=7) token.revokation_date = datetime.datetime.now(
datetime.timezone.utc
) - datetime.timedelta(days=7)
token.save() token.save()
res = testclient.post( res = testclient.post(

View file

@ -1,5 +1,4 @@
from datetime import datetime import datetime
from datetime import timedelta
from canaille.account import Invitation from canaille.account import Invitation
from canaille.models import User from canaille.models import User
@ -161,7 +160,7 @@ def test_invitation_login_already_taken(testclient, logged_admin):
def test_registration(testclient, foo_group): def test_registration(testclient, foo_group):
invitation = Invitation( invitation = Invitation(
datetime.now().isoformat(), datetime.datetime.now(datetime.timezone.utc).isoformat(),
"someoneelse", "someoneelse",
False, False,
"someone@mydomain.tld", "someone@mydomain.tld",
@ -174,7 +173,7 @@ def test_registration(testclient, foo_group):
def test_registration_invalid_hash(testclient, foo_group): def test_registration_invalid_hash(testclient, foo_group):
now = datetime.now().isoformat() now = datetime.datetime.now(datetime.timezone.utc).isoformat()
invitation = Invitation( invitation = Invitation(
now, "anything", False, "someone@mydomain.tld", [foo_group.id] now, "anything", False, "someone@mydomain.tld", [foo_group.id]
) )
@ -185,7 +184,7 @@ def test_registration_invalid_hash(testclient, foo_group):
def test_registration_invalid_data(testclient, foo_group): def test_registration_invalid_data(testclient, foo_group):
invitation = Invitation( invitation = Invitation(
datetime.now().isoformat(), datetime.datetime.now(datetime.timezone.utc).isoformat(),
"someoneelse", "someoneelse",
False, False,
"someone@mydomain.tld", "someone@mydomain.tld",
@ -197,7 +196,9 @@ def test_registration_invalid_data(testclient, foo_group):
def test_registration_more_than_48_hours_after_invitation(testclient, foo_group): def test_registration_more_than_48_hours_after_invitation(testclient, foo_group):
two_days_ago = datetime.now() - timedelta(hours=48) two_days_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
hours=48
)
invitation = Invitation( invitation = Invitation(
two_days_ago.isoformat(), two_days_ago.isoformat(),
"someoneelse", "someoneelse",
@ -213,7 +214,7 @@ def test_registration_more_than_48_hours_after_invitation(testclient, foo_group)
def test_registration_no_password(testclient, foo_group): def test_registration_no_password(testclient, foo_group):
invitation = Invitation( invitation = Invitation(
datetime.now().isoformat(), datetime.datetime.now(datetime.timezone.utc).isoformat(),
"someoneelse", "someoneelse",
False, False,
"someone@mydomain.tld", "someone@mydomain.tld",
@ -238,7 +239,7 @@ def test_registration_no_password(testclient, foo_group):
def test_no_registration_if_logged_in(testclient, logged_user, foo_group): def test_no_registration_if_logged_in(testclient, logged_user, foo_group):
invitation = Invitation( invitation = Invitation(
datetime.now().isoformat(), datetime.datetime.now(datetime.timezone.utc).isoformat(),
"someoneelse", "someoneelse",
False, False,
"someone@mydomain.tld", "someone@mydomain.tld",
@ -275,7 +276,7 @@ def test_groups_are_saved_even_when_user_does_not_have_read_permission(
] # remove groups from default read permissions ] # remove groups from default read permissions
invitation = Invitation( invitation = Invitation(
datetime.now().isoformat(), datetime.datetime.now(datetime.timezone.utc).isoformat(),
"someoneelse", "someoneelse",
False, False,
"someone@mydomain.tld", "someone@mydomain.tld",

View file

@ -1,3 +1,5 @@
import datetime
from canaille.models import User from canaille.models import User
from webtest import Upload from webtest import Upload
@ -5,8 +7,31 @@ from webtest import Upload
def test_photo(testclient, user, jpeg_photo): def test_photo(testclient, user, jpeg_photo):
user.jpegPhoto = [jpeg_photo] user.jpegPhoto = [jpeg_photo]
user.save() user.save()
user = User.get(id=user.id)
res = testclient.get("/profile/user/jpegPhoto") res = testclient.get("/profile/user/jpegPhoto")
assert res.body == jpeg_photo assert res.body == jpeg_photo
assert res.last_modified == user.modifyTimestamp
etag = res.etag
assert etag
res = testclient.get(
"/profile/user/jpegPhoto",
headers={
"If-Modified-Since": (
res.last_modified + datetime.timedelta(days=1)
).strftime("%a, %d %b %Y %H:%M:%S UTC")
},
status=304,
)
assert not res.body
res = testclient.get(
"/profile/user/jpegPhoto",
headers={"If-None-Match": etag},
status=304,
)
assert not res.body
def test_photo_invalid_user(testclient, user): def test_photo_invalid_user(testclient, user):