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
	
	 Simon Willison
						Simon Willison