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 datasette import hookimpl
|
||||||
from itsdangerous import BadSignature
|
from itsdangerous import BadSignature
|
||||||
|
import baseconv
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
@ -7,6 +9,15 @@ def actor_from_request(datasette, request):
|
||||||
if "ds_actor" not in request.cookies:
|
if "ds_actor" not in request.cookies:
|
||||||
return None
|
return None
|
||||||
try:
|
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:
|
except BadSignature:
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -62,7 +62,9 @@ class AuthTokenView(BaseView):
|
||||||
if secrets.compare_digest(token, self.ds._root_token):
|
if secrets.compare_digest(token, self.ds._root_token):
|
||||||
self.ds._root_token = None
|
self.ds._root_token = None
|
||||||
response = Response.redirect("/")
|
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
|
return response
|
||||||
else:
|
else:
|
||||||
return Response("Invalid token", status=403)
|
return Response("Invalid token", status=403)
|
||||||
|
|
|
@ -336,11 +336,55 @@ Authentication plugins can set signed ``ds_actor`` cookies themselves like so:
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
response = Response.redirect("/")
|
response = Response.redirect("/")
|
||||||
response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor"))
|
response.set_cookie("ds_actor", datasette.sign({
|
||||||
return response
|
"a": {
|
||||||
|
"id": "cleopaws"
|
||||||
|
}
|
||||||
|
}, "actor"))
|
||||||
|
|
||||||
Note that you need to pass ``"actor"`` as the namespace to :ref:`datasette_sign`.
|
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:
|
.. _permissions:
|
||||||
|
|
||||||
Built-in permissions
|
Built-in permissions
|
||||||
|
|
|
@ -153,12 +153,12 @@ To set cookies on the response, use the ``response.set_cookie(...)`` method. The
|
||||||
samesite="lax",
|
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
|
.. code-block:: python
|
||||||
|
|
||||||
response = Response.redirect("/")
|
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
|
return response
|
||||||
|
|
||||||
.. _internals_datasette:
|
.. _internals_datasette:
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -57,6 +57,7 @@ setup(
|
||||||
"PyYAML~=5.3",
|
"PyYAML~=5.3",
|
||||||
"mergedeep>=1.1.1,<1.4.0",
|
"mergedeep>=1.1.1,<1.4.0",
|
||||||
"itsdangerous~=1.1",
|
"itsdangerous~=1.1",
|
||||||
|
"python-baseconv==1.2.2",
|
||||||
],
|
],
|
||||||
entry_points="""
|
entry_points="""
|
||||||
[console_scripts]
|
[console_scripts]
|
||||||
|
|
|
@ -109,6 +109,9 @@ class TestClient:
|
||||||
def __init__(self, asgi_app):
|
def __init__(self, asgi_app):
|
||||||
self.asgi_app = asgi_app
|
self.asgi_app = asgi_app
|
||||||
|
|
||||||
|
def actor_cookie(self, actor):
|
||||||
|
return self.ds.sign({"a": actor}, "actor")
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def get(
|
async def get(
|
||||||
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
|
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
from .fixtures import app_client
|
from .fixtures import app_client
|
||||||
|
import baseconv
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
def test_auth_token(app_client):
|
def test_auth_token(app_client):
|
||||||
|
@ -8,7 +11,9 @@ def test_auth_token(app_client):
|
||||||
response = app_client.get(path, allow_redirects=False,)
|
response = app_client.get(path, allow_redirects=False,)
|
||||||
assert 302 == response.status
|
assert 302 == response.status
|
||||||
assert "/" == response.headers["Location"]
|
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
|
# Check that a second with same token fails
|
||||||
assert app_client.ds._root_token is None
|
assert app_client.ds._root_token is None
|
||||||
assert 403 == app_client.get(path, allow_redirects=False,).status
|
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):
|
def test_actor_cookie(app_client):
|
||||||
"A valid actor cookie sets request.scope['actor']"
|
"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})
|
response = app_client.get("/", cookies={"ds_actor": cookie})
|
||||||
assert {"id": "test"} == app_client.ds._last_request.scope["actor"]
|
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(
|
response = canned_write_client.post(
|
||||||
"/data/delete_name",
|
"/data/delete_name",
|
||||||
{"rowid": 1},
|
{"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,
|
allow_redirects=False,
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
)
|
)
|
||||||
|
@ -116,7 +116,7 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
|
||||||
# With auth shows four
|
# With auth shows four
|
||||||
response = canned_write_client.get(
|
response = canned_write_client.get(
|
||||||
"/data.json",
|
"/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 200 == response.status
|
||||||
assert [
|
assert [
|
||||||
|
@ -132,6 +132,6 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
|
||||||
def test_canned_query_permissions(canned_write_client):
|
def test_canned_query_permissions(canned_write_client):
|
||||||
assert 403 == canned_write_client.get("/data/delete_name").status
|
assert 403 == canned_write_client.get("/data/delete_name").status
|
||||||
assert 200 == canned_write_client.get("/data/update_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/delete_name", cookies=cookies).status
|
||||||
assert 200 == canned_write_client.get("/data/update_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
|
# Should be no padlock
|
||||||
assert "<h1>Datasette 🔒</h1>" not in anon_response.text
|
assert "<h1>Datasette 🔒</h1>" not in anon_response.text
|
||||||
auth_response = client.get(
|
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
|
assert expected_auth == auth_response.status
|
||||||
# Check for the padlock
|
# Check for the padlock
|
||||||
|
@ -48,7 +48,7 @@ def test_view_database(allow, expected_anon, expected_auth):
|
||||||
# Should be no padlock
|
# Should be no padlock
|
||||||
assert ">fixtures 🔒</h1>" not in anon_response.text
|
assert ">fixtures 🔒</h1>" not in anon_response.text
|
||||||
auth_response = client.get(
|
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
|
assert expected_auth == auth_response.status
|
||||||
if (
|
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="/data">data</a></h2>' in anon_response.text
|
||||||
assert '<a href="/fixtures">fixtures</a>' not in anon_response.text
|
assert '<a href="/fixtures">fixtures</a>' not in anon_response.text
|
||||||
auth_response = client.get(
|
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="/data">data</a></h2>' in auth_response.text
|
||||||
assert '<a href="/fixtures">fixtures</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:
|
for html_fragment in html_fragments:
|
||||||
assert html_fragment not in anon_response_text
|
assert html_fragment not in anon_response_text
|
||||||
auth_response_text = client.get(
|
auth_response_text = client.get(
|
||||||
"/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
|
"/", cookies={"ds_actor": client.actor_cookie({"id": "root"})},
|
||||||
).text
|
).text
|
||||||
for html_fragment in html_fragments:
|
for html_fragment in html_fragments:
|
||||||
assert html_fragment in auth_response_text
|
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
|
assert ">compound_three_primary_keys 🔒</h1>" not in anon_response.text
|
||||||
auth_response = client.get(
|
auth_response = client.get(
|
||||||
"/fixtures/compound_three_primary_keys",
|
"/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
|
assert expected_auth == auth_response.status
|
||||||
if allow and expected_anon == 403 and expected_auth == 200:
|
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:
|
for html_fragment in html_fragments:
|
||||||
assert html_fragment not in anon_response.text
|
assert html_fragment not in anon_response.text
|
||||||
auth_response = client.get(
|
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:
|
for html_fragment in html_fragments:
|
||||||
assert html_fragment in auth_response.text
|
assert html_fragment in auth_response.text
|
||||||
|
@ -180,7 +180,7 @@ def test_view_query(allow, expected_anon, expected_auth):
|
||||||
# Should be no padlock
|
# Should be no padlock
|
||||||
assert ">fixtures 🔒</h1>" not in anon_response.text
|
assert ">fixtures 🔒</h1>" not in anon_response.text
|
||||||
auth_response = client.get(
|
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
|
assert expected_auth == auth_response.status
|
||||||
if allow and expected_anon == 403 and expected_auth == 200:
|
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
|
assert 403 == client.get("/fixtures/facet_cities?_where=id=3").status
|
||||||
|
|
||||||
# But for logged in user all of these should work:
|
# 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
|
response_text = client.get("/fixtures", cookies=cookies).text
|
||||||
assert form_fragment in response_text
|
assert form_fragment in response_text
|
||||||
assert 200 == client.get("/fixtures?sql=select+1", cookies=cookies).status
|
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 html_fragment not in anon_response.text
|
||||||
assert '"/fixtures/q"' not in anon_response.text
|
assert '"/fixtures/q"' not in anon_response.text
|
||||||
auth_response = client.get(
|
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
|
assert html_fragment in auth_response.text
|
||||||
|
|
||||||
|
@ -290,7 +290,7 @@ def test_permissions_debug(app_client):
|
||||||
app_client.ds._permission_checks.clear()
|
app_client.ds._permission_checks.clear()
|
||||||
assert 403 == app_client.get("/-/permissions").status
|
assert 403 == app_client.get("/-/permissions").status
|
||||||
# With the cookie it should work
|
# 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})
|
response = app_client.get("/-/permissions", cookies={"ds_actor": cookie})
|
||||||
# Should show one failure and one success
|
# Should show one failure and one success
|
||||||
soup = Soup(response.body, "html.parser")
|
soup = Soup(response.body, "html.parser")
|
||||||
|
|
Ładowanie…
Reference in New Issue