--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.base import DatasetteError, ureg, AsgiRouter
from .views.database import DatabaseDownload, DatabaseView from .views.database import DatabaseDownload, DatabaseView
from .views.index import IndexView 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 .views.table import RowView, TableView
from .renderer import json_renderer from .renderer import json_renderer
from .database import Database, QueryInterrupted from .database import Database, QueryInterrupted
@ -283,6 +283,7 @@ class Datasette:
pm.hook.prepare_jinja2_environment(env=self.jinja_env) pm.hook.prepare_jinja2_environment(env=self.jinja_env)
self._register_renderers() self._register_renderers()
self._root_token = os.urandom(32).hex()
def sign(self, value, namespace="default"): def sign(self, value, namespace="default"):
return URLSafeSerializer(self._secret, namespace).dumps(value) return URLSafeSerializer(self._secret, namespace).dumps(value)
@ -778,6 +779,9 @@ class Datasette:
JsonDataView.as_asgi(self, "actor.json", self._actor, needs_request=True), JsonDataView.as_asgi(self, "actor.json", self._actor, needs_request=True),
r"/-/actor(?P<as_format>(\.json)?)$", r"/-/actor(?P<as_format>(\.json)?)$",
) )
add_route(
AuthTokenView.as_asgi(self), r"/-/auth-token$",
)
add_route( add_route(
PatternPortfolioView.as_asgi(self), r"/-/patterns$", 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", help="Secret used for signing secure values, such as signed cookies",
envvar="DATASETTE_SECRET", 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("--version-note", help="Additional note to show on /-/versions")
@click.option("--help-config", is_flag=True, help="Show available config options") @click.option("--help-config", is_flag=True, help="Show available config options")
def serve( def serve(
@ -323,6 +328,7 @@ def serve(
memory, memory,
config, config,
secret, secret,
root,
version_note, version_note,
help_config, help_config,
return_instance=False, return_instance=False,
@ -387,6 +393,8 @@ def serve(
asyncio.get_event_loop().run_until_complete(check_databases(ds)) asyncio.get_event_loop().run_until_complete(check_databases(ds))
# Start the server # 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") uvicorn.run(ds.app(), host=host, port=port, log_level="info")

Wyświetl plik

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

Wyświetl plik

@ -1,6 +1,8 @@
import json import json
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
from .base import BaseView from .base import BaseView
from http.cookies import SimpleCookie
import secrets
class JsonDataView(BaseView): class JsonDataView(BaseView):
@ -45,4 +47,32 @@ class PatternPortfolioView(BaseView):
self.ds = datasette self.ds = datasette
async def get(self, request): 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 --secret TEXT Secret used for signing secure values, such as signed
cookies cookies
--root Output URL that sets a cookie authenticating the root user
--version-note TEXT Additional note to show on /-/versions --version-note TEXT Additional note to show on /-/versions
--help-config Show available config options --help-config Show available config options
--help Show this message and exit. --help Show this message and exit.

Wyświetl plik

@ -2,6 +2,7 @@ from datasette.app import Datasette
from datasette.utils import sqlite3 from datasette.utils import sqlite3
from asgiref.testing import ApplicationCommunicator from asgiref.testing import ApplicationCommunicator
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from http.cookies import SimpleCookie
import itertools import itertools
import json import json
import os import os
@ -44,10 +45,14 @@ class TestClient:
self.asgi_app = asgi_app self.asgi_app = asgi_app
@async_to_sync @async_to_sync
async def get(self, path, allow_redirects=True, redirect_count=0, method="GET"): async def get(
return await self._get(path, allow_redirects, redirect_count, method) 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"" query_string = b""
if "?" in path: if "?" in path:
path, _, query_string = path.partition("?") path, _, query_string = path.partition("?")
@ -56,6 +61,12 @@ class TestClient:
raw_path = path.encode("latin-1") raw_path = path.encode("latin-1")
else: else:
raw_path = quote(path, safe="/:,").encode("latin-1") 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 = { scope = {
"type": "http", "type": "http",
"http_version": "1.0", "http_version": "1.0",
@ -63,7 +74,7 @@ class TestClient:
"path": unquote(path), "path": unquote(path),
"raw_path": raw_path, "raw_path": raw_path,
"query_string": query_string, "query_string": query_string,
"headers": [[b"host", b"localhost"]], "headers": headers,
} }
instance = ApplicationCommunicator(self.asgi_app, scope) instance = ApplicationCommunicator(self.asgi_app, scope)
await instance.send_input({"type": "http.request"}) 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, memory=False,
config=[], config=[],
secret=None, secret=None,
root=False,
version_note=None, version_note=None,
help_config=False, help_config=False,
return_instance=True, return_instance=True,

Wyświetl plik

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