Simon Willison 2020-06-10 12:39:54 -07:00
rodzic d828abadde
commit 57e812d5de
9 zmienionych plików z 99 dodań i 21 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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:

Wyświetl plik

@ -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]

Wyświetl plik

@ -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

Wyświetl plik

@ -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"]

Wyświetl plik

@ -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

Wyświetl plik

@ -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")