kopia lustrzana https://github.com/simonw/datasette
--root option and /-/auth-token view, refs #784
rodzic
7690d5ba40
commit
9f3d4aba31
|
@ -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$",
|
||||||
)
|
)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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"})
|
||||||
|
|
|
@ -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"]
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
Ładowanie…
Reference in New Issue