--root option and /-/auth-token view, refs #784

pull/703/head
Simon Willison 2020-05-31 18:03:17 -07:00
rodzic 7690d5ba40
commit 9f3d4aba31
9 zmienionych plików z 89 dodań i 8 usunięć

Wyświetl plik

@ -24,7 +24,7 @@ import uvicorn
from .views.base import DatasetteError, ureg, AsgiRouter
from .views.database import DatabaseDownload, DatabaseView
from .views.index import IndexView
from .views.special import JsonDataView, PatternPortfolioView
from .views.special import JsonDataView, PatternPortfolioView, AuthTokenView
from .views.table import RowView, TableView
from .renderer import json_renderer
from .database import Database, QueryInterrupted
@ -283,6 +283,7 @@ class Datasette:
pm.hook.prepare_jinja2_environment(env=self.jinja_env)
self._register_renderers()
self._root_token = os.urandom(32).hex()
def sign(self, value, namespace="default"):
return URLSafeSerializer(self._secret, namespace).dumps(value)
@ -778,6 +779,9 @@ class Datasette:
JsonDataView.as_asgi(self, "actor.json", self._actor, needs_request=True),
r"/-/actor(?P<as_format>(\.json)?)$",
)
add_route(
AuthTokenView.as_asgi(self), r"/-/auth-token$",
)
add_route(
PatternPortfolioView.as_asgi(self), r"/-/patterns$",
)

Wyświetl plik

@ -304,6 +304,11 @@ def package(
help="Secret used for signing secure values, such as signed cookies",
envvar="DATASETTE_SECRET",
)
@click.option(
"--root",
help="Output URL that sets a cookie authenticating the root user",
is_flag=True,
)
@click.option("--version-note", help="Additional note to show on /-/versions")
@click.option("--help-config", is_flag=True, help="Show available config options")
def serve(
@ -323,6 +328,7 @@ def serve(
memory,
config,
secret,
root,
version_note,
help_config,
return_instance=False,
@ -387,6 +393,8 @@ def serve(
asyncio.get_event_loop().run_until_complete(check_databases(ds))
# Start the server
if root:
print("http://{}:{}/-/auth-token?token={}".format(host, port, ds._root_token))
uvicorn.run(ds.app(), host=host, port=port, log_level="info")

Wyświetl plik

@ -9,6 +9,7 @@ DEFAULT_PLUGINS = (
"datasette.publish.cloudrun",
"datasette.facets",
"datasette.sql_functions",
"datasette.actor_auth_cookie",
)
pm = pluggy.PluginManager("datasette")

Wyświetl plik

@ -1,6 +1,8 @@
import json
from datasette.utils.asgi import Response
from .base import BaseView
from http.cookies import SimpleCookie
import secrets
class JsonDataView(BaseView):
@ -45,4 +47,32 @@ class PatternPortfolioView(BaseView):
self.ds = datasette
async def get(self, request):
return await self.render(["patterns.html"], request=request,)
return await self.render(["patterns.html"], request=request)
class AuthTokenView(BaseView):
name = "auth_token"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
token = request.args.get("token") or ""
if not self.ds._root_token:
return Response("Root token has already been used", status=403)
if secrets.compare_digest(token, self.ds._root_token):
self.ds._root_token = None
cookie = SimpleCookie()
cookie["ds_actor"] = self.ds.sign({"id": "root"}, "actor")
cookie["ds_actor"]["path"] = "/"
response = Response(
body="",
status=302,
headers={
"Location": "/",
"set-cookie": cookie.output(header="").lstrip(),
},
)
return response
else:
return Response("Invalid token", status=403)

Wyświetl plik

@ -32,6 +32,7 @@ Options:
--secret TEXT Secret used for signing secure values, such as signed
cookies
--root Output URL that sets a cookie authenticating the root user
--version-note TEXT Additional note to show on /-/versions
--help-config Show available config options
--help Show this message and exit.

Wyświetl plik

@ -2,6 +2,7 @@ from datasette.app import Datasette
from datasette.utils import sqlite3
from asgiref.testing import ApplicationCommunicator
from asgiref.sync import async_to_sync
from http.cookies import SimpleCookie
import itertools
import json
import os
@ -44,10 +45,14 @@ class TestClient:
self.asgi_app = asgi_app
@async_to_sync
async def get(self, path, allow_redirects=True, redirect_count=0, method="GET"):
return await self._get(path, allow_redirects, redirect_count, method)
async def get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
):
return await self._get(path, allow_redirects, redirect_count, method, cookies)
async def _get(self, path, allow_redirects=True, redirect_count=0, method="GET"):
async def _get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
):
query_string = b""
if "?" in path:
path, _, query_string = path.partition("?")
@ -56,6 +61,12 @@ class TestClient:
raw_path = path.encode("latin-1")
else:
raw_path = quote(path, safe="/:,").encode("latin-1")
headers = [[b"host", b"localhost"]]
if cookies:
sc = SimpleCookie()
for key, value in cookies.items():
sc[key] = value
headers.append([b"cookie", sc.output(header="").encode("utf-8")])
scope = {
"type": "http",
"http_version": "1.0",
@ -63,7 +74,7 @@ class TestClient:
"path": unquote(path),
"raw_path": raw_path,
"query_string": query_string,
"headers": [[b"host", b"localhost"]],
"headers": headers,
}
instance = ApplicationCommunicator(self.asgi_app, scope)
await instance.send_input({"type": "http.request"})

25
tests/test_auth.py 100644
Wyświetl plik

@ -0,0 +1,25 @@
from .fixtures import app_client
def test_auth_token(app_client):
"The /-/auth-token endpoint sets the correct cookie"
assert app_client.ds._root_token is not None
path = "/-/auth-token?token={}".format(app_client.ds._root_token)
response = app_client.get(path, allow_redirects=False,)
assert 302 == response.status
assert "/" == response.headers["Location"]
set_cookie = response.headers["set-cookie"]
assert set_cookie.endswith("; Path=/")
assert set_cookie.startswith("ds_actor=")
cookie_value = set_cookie.split("ds_actor=")[1].split("; Path=/")[0]
assert {"id": "root"} == app_client.ds.unsign(cookie_value, "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
def test_actor_cookie(app_client):
"A valid actor cookie sets request.scope['actor']"
cookie = app_client.ds.sign({"id": "test"}, "actor")
response = app_client.get("/", cookies={"ds_actor": cookie})
assert {"id": "test"} == app_client.ds._last_request.scope["actor"]

Wyświetl plik

@ -76,6 +76,7 @@ def test_metadata_yaml():
memory=False,
config=[],
secret=None,
root=False,
version_note=None,
help_config=False,
return_instance=True,

Wyświetl plik

@ -65,8 +65,8 @@ def documented_views():
first_word = label.split("_")[0]
if first_word.endswith("View"):
view_labels.add(first_word)
# We deliberately don't document this one:
view_labels.add("PatternPortfolioView")
# We deliberately don't document these:
view_labels.update(("PatternPortfolioView", "AuthTokenView"))
return view_labels