Fixed #27775 -- Support custom expiry in signed cookies.#16207
Fixed #27775 -- Support custom expiry in signed cookies.#16207buugaj wants to merge 3 commits intodjango:mainfrom
Conversation
There was a problem hiding this comment.
Hello! Thank you for your contribution 💪
As it's your first contribution be sure to check out the patch review checklist.
If you're fixing a ticket from Trac make sure to set the "Has patch" flag and include a link to this PR in the ticket!
If you have any design or process questions then you can ask in the Django forum.
Welcome aboard ⛵️!
|
@pelme Does it work for you? |
|
From a quick look, this looks really good! It is very nice that you managed to avoid the double loads()! Will try to find some time to verify that this works in my use case. Have been using the double loads() middleware in my own project for years to workaround this. |
|
I have no easy way to test this in my current project (a couple of Django versions behind). But it looks good from what I can see! I have some test cases in my own code base for my own implementation this -- not sure they are necessary or useful but here they go, feel free to adapt use any or none of them. They use pytest style and time_machine so they need a little bit of adaption to be useful in the Django test suite: from datetime import timedelta
import pytest
import time_machine
from x.utils.datetime import datetime_utc
from x.utils.signed_cookies_session import SessionStore
start = datetime_utc(2000, 1, 1)
@pytest.fixture
@time_machine.travel(start)
def session_key_default_expiry(settings):
settings.SESSION_COOKIE_AGE = 2 * 60 * 60
session = SessionStore()
session["foo"] = "bar"
session.save()
return session.session_key
@pytest.fixture
@time_machine.travel(start)
def session_key_100_hours(settings):
settings.SESSION_COOKIE_AGE = 1 # 1 second, should not be used
session = SessionStore()
session["foo"] = "bar"
session.set_expiry(100 * 60 * 60)
session.save()
return session.session_key
@pytest.fixture
@time_machine.travel(start)
def session_key_browser_expiry(settings):
settings.SESSION_COOKIE_AGE = 2 * 60 * 60
session = SessionStore()
session["foo"] = "bar"
session.set_expiry(0) # 0 means browser expiry
session.save()
return session.session_key
@time_machine.travel(start + timedelta(hours=1))
def test_default_expiry_not_expired(session_key_default_expiry):
session = SessionStore(session_key_default_expiry)
assert session["foo"] == "bar"
@time_machine.travel(start + timedelta(hours=3))
def test_default_expiry_expired(session_key_default_expiry):
session = SessionStore(session_key_default_expiry)
assert "foo" not in session
@time_machine.travel(start + timedelta(hours=3))
def test_custom_expiry_not_expired(session_key_100_hours):
second = SessionStore(session_key_100_hours)
assert second["foo"] == "bar"
@time_machine.travel(start + timedelta(hours=101))
def test_custom_expiry_expired(session_key_100_hours):
session = SessionStore(session_key_100_hours)
assert "foo" not in session
@time_machine.travel(start + timedelta(hours=1))
def test_browser_expiry_not_expired(session_key_browser_expiry):
session = SessionStore(session_key_browser_expiry)
assert session["foo"] == "bar"
@time_machine.travel(start + timedelta(hours=3))
def test_browser_expiry_expired(session_key_browser_expiry):
session = SessionStore(session_key_browser_expiry)
assert "foo" not in session |
| def _verify_max_age(self, max_age=None): | ||
| if max_age is None: | ||
| return | ||
| if isinstance(max_age, datetime.timedelta): | ||
| max_age = max_age.total_seconds() | ||
| # Check timestamp is not older than max_age | ||
| age = time.time() - self.payload_timestamp | ||
| if age > max_age: | ||
| raise SignatureExpired("Signature age %s > %s seconds" % (age, max_age)) |
There was a problem hiding this comment.
We could add this hook in a separate commit (1st) and move the main change to the 2nd commit.
There was a problem hiding this comment.
How important is this?
Should i redo my commits? so that the refactoring of _verify_max_age is in its own commit?
| serializer=JSONSerializer, | ||
| max_age=None, | ||
| fallback_keys=None, | ||
| expiration_key=None, |
There was a problem hiding this comment.
loads() signature should be updated in docs in a versionchanged annotation.
There was a problem hiding this comment.
I'd add tests for loads() calls with custom expiration_key.
| class TimestampSigner(Signer): | ||
| def __init__(self, *args, **kwargs): | ||
| self.expiration_key = kwargs.pop("expiration_key", None) | ||
| self.payload_timestamp = None |
There was a problem hiding this comment.
Changes to the TimestampSigner should be expressed in docs.
| return signing.loads( | ||
| self.session_key, | ||
| serializer=self.serializer, | ||
| # This doesn't handle non-default expiry dates, see #19201 |
There was a problem hiding this comment.
Can we remove this comment? Does it actually solves ticket-19201 as well? 🤔 I think we need more tests to actually confirm this.
There was a problem hiding this comment.
This doesn't solve #19201, however the comment is no longer valid as signing now handles non-default expiry dates.
Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
apollo13
left a comment
There was a problem hiding this comment.
First and foremost this needs more tests especially for the changes in core/signing.py. Secondly I wonder if it were possible instead to simply expose the timestamp via a kwarg to unsign/unsign_object. This way more complex extra validation could be done by the caller.
Something along the lines of:
def unsign(…, return_timestamp=False):
if return_timestamp:
return unsigned_data, timestamp
return unsigned_data
| return super().unsign_object(signed_obj, max_age=max_age, **kwargs) | ||
|
|
||
| # If using expiration key use it's value when present | ||
| value = super().unsign_object(signed_obj, **kwargs, max_age=None) |
There was a problem hiding this comment.
I do not think that we should skip max_age. I have the feeling that max_age should be verified no matter what and the expiration_key should be an additional validation -- if one wouldn't like to have max_age validation then the caller wouldn't have to pass it in.
|
|
||
| class TimestampSigner(Signer): | ||
| def __init__(self, *args, **kwargs): | ||
| self.expiration_key = kwargs.pop("expiration_key", None) |
There was a problem hiding this comment.
Thinking about this in terms of max_age, why is this part of the ctor and not solely passed to usign_object?
|
@buugaj Do you have time to keep working on this? |
|
@felixxm Yup, I should find some time soon. |
|
Closing due to inactivity. Feel-free to send a new patch. |
|
I am attempting to bring this PR back to life |
https://code.djangoproject.com/ticket/27775
Until now signed cookies didn't validate custom expiry times stored in
_session_expiry. The proposed solution introducesexpiration_keytoTimestampSingerand in it's presence defers the age validation to happen after payload serialization.There was old proposal on tackling this problem - #7885 - and while it was less lines of code I've decided to go with the described approach to avoid calling
signing.loadstwice.