kopia lustrzana https://github.com/simonw/datasette
ds_author cookie can now expire, closes #829
Refs https://github.com/simonw/datasette-auth-github/issues/62#issuecomment-642152076pull/809/head
rodzic
d828abadde
commit
57e812d5de
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 <https://pypi.org/project/python-baseconv/>`__ 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
|
||||
|
|
|
@ -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() <datasette_sign>` to set signed cookies. Here's how you would set the ``ds_actor`` cookie for use with Datasette :ref:`authentication <authentication>`:
|
||||
You can use this with :ref:`datasette.sign() <datasette_sign>` to set signed cookies. Here's how you would set the :ref:`ds_actor cookie <authentication_ds_actor>` for use with Datasette :ref:`authentication <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:
|
||||
|
|
1
setup.py
1
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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,7 +21,7 @@ def test_view_instance(allow, expected_anon, expected_auth):
|
|||
# Should be no padlock
|
||||
assert "<h1>Datasette 🔒</h1>" 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 🔒</h1>" 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 '<a href="/data">data</a></h2>' in anon_response.text
|
||||
assert '<a href="/fixtures">fixtures</a>' 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 '<a href="/data">data</a></h2>' in auth_response.text
|
||||
assert '<a href="/fixtures">fixtures</a> 🔒</h2>' 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 🔒</h1>" 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 🔒</h1>" 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")
|
||||
|
|
Ładowanie…
Reference in New Issue