diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py index a2aa6889..15ecd331 100644 --- a/datasette/actor_auth_cookie.py +++ b/datasette/actor_auth_cookie.py @@ -1,5 +1,7 @@ from datasette import hookimpl from itsdangerous import BadSignature +import baseconv +import time @hookimpl @@ -7,6 +9,15 @@ def actor_from_request(datasette, request): if "ds_actor" not in request.cookies: return None try: - return datasette.unsign(request.cookies["ds_actor"], "actor") + decoded = datasette.unsign(request.cookies["ds_actor"], "actor") + # If it has "e" and "a" keys process the "e" expiry + if not isinstance(decoded, dict) or "a" not in decoded: + return None + expires_at = decoded.get("e") + if expires_at: + timestamp = int(baseconv.base62.decode(expires_at)) + if time.time() > timestamp: + return None + return decoded["a"] except BadSignature: return None diff --git a/datasette/views/special.py b/datasette/views/special.py index 7f4284a1..dc6a25dc 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -62,7 +62,9 @@ class AuthTokenView(BaseView): if secrets.compare_digest(token, self.ds._root_token): self.ds._root_token = None response = Response.redirect("/") - response.set_cookie("ds_actor", self.ds.sign({"id": "root"}, "actor")) + response.set_cookie( + "ds_actor", self.ds.sign({"a": {"id": "root"}}, "actor") + ) return response else: return Response("Invalid token", status=403) diff --git a/docs/authentication.rst b/docs/authentication.rst index f511e373..9b66132a 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -336,11 +336,55 @@ Authentication plugins can set signed ``ds_actor`` cookies themselves like so: .. code-block:: python response = Response.redirect("/") - response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor")) - return response + response.set_cookie("ds_actor", datasette.sign({ + "a": { + "id": "cleopaws" + } + }, "actor")) Note that you need to pass ``"actor"`` as the namespace to :ref:`datasette_sign`. +The shape of data encoded in the cookie is as follows:: + + { + "a": {... actor ...} + } + +.. _authentication_ds_actor_expiry: + +Including an expiry time +------------------------ + +``ds_actor`` cookies can optionally include a signed expiry timestamp, after which the cookies will no longer be valid. Authentication plugins may chose to use this mechanism to limit the lifetime of the cookie. For example, if a plugin implements single-sign-on against another source it may decide to set short-lived cookies so that if the user is removed from the SSO system their existing Datasette cookies will stop working shortly afterwards. + +To include an expiry, add a ``"e"`` key to the cookie value containing a `base62-encoded integer `__ representing the timestamp when the cookie should expire. For example, here's how to set a cookie that expires after 24 hours: + +.. code-block:: python + + import time + import baseconv + + expires_at = int(time.time()) + (24 * 60 * 60) + + response = Response.redirect("/") + response.set_cookie("ds_actor", datasette.sign({ + "a": { + "id": "cleopaws" + }, + "e": baseconv.base62.encode(expires_at), + }, "actor")) + +The resulting cookie will encode data that looks something like this: + +.. code-block:: json + + { + "a": { + "id": "cleopaws" + }, + "e": "1jjSji" + } + .. _permissions: Built-in permissions diff --git a/docs/internals.rst b/docs/internals.rst index 7978e3d7..d75544e1 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -153,12 +153,12 @@ To set cookies on the response, use the ``response.set_cookie(...)`` method. The samesite="lax", ): -You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the ``ds_actor`` cookie for use with Datasette :ref:`authentication `: +You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the :ref:`ds_actor cookie ` for use with Datasette :ref:`authentication `: .. code-block:: python response = Response.redirect("/") - response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor")) + response.set_cookie("ds_actor", datasette.sign({"a": {"id": "cleopaws"}}, "actor")) return response .. _internals_datasette: diff --git a/setup.py b/setup.py index 678a022f..45af0253 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ setup( "PyYAML~=5.3", "mergedeep>=1.1.1,<1.4.0", "itsdangerous~=1.1", + "python-baseconv==1.2.2", ], entry_points=""" [console_scripts] diff --git a/tests/fixtures.py b/tests/fixtures.py index 1eb1bb6e..a846999b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -109,6 +109,9 @@ class TestClient: def __init__(self, asgi_app): self.asgi_app = asgi_app + def actor_cookie(self, actor): + return self.ds.sign({"a": actor}, "actor") + @async_to_sync async def get( self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None diff --git a/tests/test_auth.py b/tests/test_auth.py index 0e5563a3..5e847445 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,7 @@ from .fixtures import app_client +import baseconv +import pytest +import time def test_auth_token(app_client): @@ -8,7 +11,9 @@ def test_auth_token(app_client): response = app_client.get(path, allow_redirects=False,) assert 302 == response.status assert "/" == response.headers["Location"] - assert {"id": "root"} == app_client.ds.unsign(response.cookies["ds_actor"], "actor") + assert {"a": {"id": "root"}} == app_client.ds.unsign( + response.cookies["ds_actor"], "actor" + ) # Check that a second with same token fails assert app_client.ds._root_token is None assert 403 == app_client.get(path, allow_redirects=False,).status @@ -16,6 +21,18 @@ def test_auth_token(app_client): def test_actor_cookie(app_client): "A valid actor cookie sets request.scope['actor']" - cookie = app_client.ds.sign({"id": "test"}, "actor") + cookie = app_client.actor_cookie({"id": "test"}) response = app_client.get("/", cookies={"ds_actor": cookie}) assert {"id": "test"} == app_client.ds._last_request.scope["actor"] + + +@pytest.mark.parametrize( + "offset,expected", [((24 * 60 * 60), {"id": "test"}), (-(24 * 60 * 60), None),] +) +def test_actor_cookie_that_expires(app_client, offset, expected): + expires_at = int(time.time()) + offset + cookie = app_client.ds.sign( + {"a": {"id": "test"}, "e": baseconv.base62.encode(expires_at)}, "actor" + ) + response = app_client.get("/", cookies={"ds_actor": cookie}) + assert expected == app_client.ds._last_request.scope["actor"] diff --git a/tests/test_canned_write.py b/tests/test_canned_write.py index dc3fba3f..4257806e 100644 --- a/tests/test_canned_write.py +++ b/tests/test_canned_write.py @@ -55,7 +55,7 @@ def test_custom_success_message(canned_write_client): response = canned_write_client.post( "/data/delete_name", {"rowid": 1}, - cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")}, + cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})}, allow_redirects=False, csrftoken_from=True, ) @@ -116,7 +116,7 @@ def test_canned_query_permissions_on_database_page(canned_write_client): # With auth shows four response = canned_write_client.get( "/data.json", - cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")}, + cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})}, ) assert 200 == response.status assert [ @@ -132,6 +132,6 @@ def test_canned_query_permissions_on_database_page(canned_write_client): def test_canned_query_permissions(canned_write_client): assert 403 == canned_write_client.get("/data/delete_name").status assert 200 == canned_write_client.get("/data/update_name").status - cookies = {"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")} + cookies = {"ds_actor": canned_write_client.actor_cookie({"id": "root"})} assert 200 == canned_write_client.get("/data/delete_name", cookies=cookies).status assert 200 == canned_write_client.get("/data/update_name", cookies=cookies).status diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 477b8160..1be9529a 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -21,7 +21,7 @@ def test_view_instance(allow, expected_anon, expected_auth): # Should be no padlock assert "

Datasette 🔒

" not in anon_response.text auth_response = client.get( - path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}, + path, cookies={"ds_actor": client.actor_cookie({"id": "root"})}, ) assert expected_auth == auth_response.status # Check for the padlock @@ -48,7 +48,7 @@ def test_view_database(allow, expected_anon, expected_auth): # Should be no padlock assert ">fixtures 🔒" not in anon_response.text auth_response = client.get( - path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}, + path, cookies={"ds_actor": client.actor_cookie({"id": "root"})}, ) assert expected_auth == auth_response.status if ( @@ -69,7 +69,7 @@ def test_database_list_respects_view_database(): assert 'data' in anon_response.text assert 'fixtures' not in anon_response.text auth_response = client.get( - "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}, + "/", cookies={"ds_actor": client.actor_cookie({"id": "root"})}, ) assert 'data' in auth_response.text assert 'fixtures 🔒' in auth_response.text @@ -100,7 +100,7 @@ def test_database_list_respects_view_table(): for html_fragment in html_fragments: assert html_fragment not in anon_response_text auth_response_text = client.get( - "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}, + "/", cookies={"ds_actor": client.actor_cookie({"id": "root"})}, ).text for html_fragment in html_fragments: assert html_fragment in auth_response_text @@ -127,7 +127,7 @@ def test_view_table(allow, expected_anon, expected_auth): assert ">compound_three_primary_keys 🔒" not in anon_response.text auth_response = client.get( "/fixtures/compound_three_primary_keys", - cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}, + cookies={"ds_actor": client.actor_cookie({"id": "root"})}, ) assert expected_auth == auth_response.status if allow and expected_anon == 403 and expected_auth == 200: @@ -156,7 +156,7 @@ def test_table_list_respects_view_table(): for html_fragment in html_fragments: assert html_fragment not in anon_response.text auth_response = client.get( - "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} + "/fixtures", cookies={"ds_actor": client.actor_cookie({"id": "root"})} ) for html_fragment in html_fragments: assert html_fragment in auth_response.text @@ -180,7 +180,7 @@ def test_view_query(allow, expected_anon, expected_auth): # Should be no padlock assert ">fixtures 🔒" not in anon_response.text auth_response = client.get( - "/fixtures/q", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} + "/fixtures/q", cookies={"ds_actor": client.actor_cookie({"id": "root"})} ) assert expected_auth == auth_response.status if allow and expected_anon == 403 and expected_auth == 200: @@ -206,7 +206,7 @@ def test_execute_sql(metadata): assert 403 == client.get("/fixtures/facet_cities?_where=id=3").status # But for logged in user all of these should work: - cookies = {"ds_actor": client.ds.sign({"id": "root"}, "actor")} + cookies = {"ds_actor": client.actor_cookie({"id": "root"})} response_text = client.get("/fixtures", cookies=cookies).text assert form_fragment in response_text assert 200 == client.get("/fixtures?sql=select+1", cookies=cookies).status @@ -231,7 +231,7 @@ def test_query_list_respects_view_query(): assert html_fragment not in anon_response.text assert '"/fixtures/q"' not in anon_response.text auth_response = client.get( - "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")} + "/fixtures", cookies={"ds_actor": client.actor_cookie({"id": "root"})} ) assert html_fragment in auth_response.text @@ -290,7 +290,7 @@ def test_permissions_debug(app_client): app_client.ds._permission_checks.clear() assert 403 == app_client.get("/-/permissions").status # With the cookie it should work - cookie = app_client.ds.sign({"id": "root"}, "actor") + cookie = app_client.actor_cookie({"id": "root"}) response = app_client.get("/-/permissions", cookies={"ds_actor": cookie}) # Should show one failure and one success soup = Soup(response.body, "html.parser")