Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authentication: plugin hooks plus default --root auth mechanism #783

Merged
merged 8 commits into from
Jun 1, 2020
18 changes: 18 additions & 0 deletions datasette/actor_auth_cookie.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from datasette import hookimpl
from itsdangerous import BadSignature
from http.cookies import SimpleCookie


@hookimpl
def actor_from_request(datasette, request):
cookies = SimpleCookie()
cookies.load(
dict(request.scope.get("headers") or []).get(b"cookie", b"").decode("utf-8")
)
if "ds_actor" not in cookies:
return None
ds_actor = cookies["ds_actor"].value
try:
return datasette.unsign(ds_actor, "actor")
except BadSignature:
return None
54 changes: 52 additions & 2 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import click
from markupsafe import Markup
from itsdangerous import URLSafeSerializer
import jinja2
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, escape
from jinja2.environment import Template
Expand All @@ -23,7 +24,7 @@
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
Expand Down Expand Up @@ -163,12 +164,14 @@ def __init__(
static_mounts=None,
memory=False,
config=None,
secret=None,
version_note=None,
config_dir=None,
):
assert config_dir is None or isinstance(
config_dir, Path
), "config_dir= should be a pathlib.Path"
self._secret = secret or os.urandom(32).hex()
self.files = tuple(files) + tuple(immutables or [])
if config_dir:
self.files += tuple([str(p) for p in config_dir.glob("*.db")])
Expand Down Expand Up @@ -280,6 +283,13 @@ def __init__(
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)

def unsign(self, signed, namespace="default"):
return URLSafeSerializer(self._secret, namespace).loads(signed)

def get_database(self, name=None):
if name is None:
Expand Down Expand Up @@ -406,6 +416,25 @@ def _prepare_connection(self, conn, database):
# pylint: disable=no-member
pm.hook.prepare_connection(conn=conn, database=database, datasette=self)

async def permission_allowed(
self, actor, action, resource_type=None, resource_identifier=None, default=False
):
"Check permissions using the permissions_allowed plugin hook"
for check in pm.hook.permission_allowed(
datasette=self,
actor=actor,
action=action,
resource_type=resource_type,
resource_identifier=resource_identifier,
):
if callable(check):
check = check()
if asyncio.iscoroutine(check):
check = await check
if check is not None:
return check
return default

async def execute(
self,
db_name,
Expand Down Expand Up @@ -567,6 +596,9 @@ def _threads(self):
)
return d

def _actor(self, request):
return {"actor": request.scope.get("actor", None)}

def table_metadata(self, database, table):
"Fetch table-specific metadata."
return (
Expand Down Expand Up @@ -743,6 +775,13 @@ def add_route(view, regex):
JsonDataView.as_asgi(self, "databases.json", self._connected_databases),
r"/-/databases(?P<as_format>(\.json)?)$",
)
add_route(
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$",
)
Expand Down Expand Up @@ -798,7 +837,18 @@ async def route_path(self, scope, receive, send, path):
and scope.get("scheme") != "https"
):
scope = dict(scope, scheme="https")
return await super().route_path(scope, receive, send, path)
# Handle authentication
actor = None
for actor in pm.hook.actor_from_request(
datasette=self.ds, request=Request(scope, receive)
):
if callable(actor):
actor = actor()
if asyncio.iscoroutine(actor):
actor = await actor
if actor:
break
return await super().route_path(dict(scope, actor=actor), receive, send, path)

async def handle_404(self, scope, receive, send, exception=None):
# If URL has a trailing slash, redirect to URL without it
Expand Down
15 changes: 15 additions & 0 deletions datasette/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,16 @@ def package(
help="Set config option using configname:value datasette.readthedocs.io/en/latest/config.html",
multiple=True,
)
@click.option(
"--secret",
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(
Expand All @@ -317,6 +327,8 @@ def serve(
static,
memory,
config,
secret,
root,
version_note,
help_config,
return_instance=False,
Expand Down Expand Up @@ -362,6 +374,7 @@ def serve(
static_mounts=static,
config=dict(config),
memory=memory,
secret=secret,
version_note=version_note,
)

Expand All @@ -380,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")


Expand Down
10 changes: 10 additions & 0 deletions datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,13 @@ def register_output_renderer(datasette):
@hookspec
def register_facet_classes():
"Register Facet subclasses"


@hookspec
def actor_from_request(datasette, request):
"Return an actor dictionary based on the incoming request"


@hookspec
def permission_allowed(datasette, actor, action, resource_type, resource_identifier):
"Check if actor is allowed to perfom this action - return True, False or None"
1 change: 1 addition & 0 deletions datasette/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"datasette.publish.cloudrun",
"datasette.facets",
"datasette.sql_functions",
"datasette.actor_auth_cookie",
)

pm = pluggy.PluginManager("datasette")
Expand Down
40 changes: 37 additions & 3 deletions datasette/views/special.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import json
from datasette.utils.asgi import Response
from .base import BaseView
from http.cookies import SimpleCookie
import secrets


class JsonDataView(BaseView):
name = "json_data"

def __init__(self, datasette, filename, data_callback):
def __init__(self, datasette, filename, data_callback, needs_request=False):
self.ds = datasette
self.filename = filename
self.data_callback = data_callback
self.needs_request = needs_request

async def get(self, request, as_format):
data = self.data_callback()
if self.needs_request:
data = self.data_callback(request)
else:
data = self.data_callback()
if as_format:
headers = {}
if self.ds.cors:
Expand Down Expand Up @@ -41,4 +47,32 @@ def __init__(self, datasette):
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)
27 changes: 27 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,30 @@ For example, if you are sending traffic from ``https://www.example.com/tools/dat
You can do that like so::

datasette mydatabase.db --config base_url:/tools/datasette/

.. _config_secret:

Configuring the secret
----------------------

Datasette uses a secret string to sign secure values such as cookies.

If you do not provide a secret, Datasette will create one when it starts up. This secret will reset every time the Datasette server restarts though, so things like authentication cookies will not stay valid between restarts.

You can pass a secret to Datasette in two ways: with the ``--secret`` command-line option or by setting a ``DATASETTE_SECRET`` environment variable.

::

$ datasette mydb.db --secret=SECRET_VALUE_HERE

Or::

$ export DATASETTE_SECRET=SECRET_VALUE_HERE
$ datasette mydb.db

One way to generate a secure random secret is to use Python like this::

$ python3 -c 'import os; print(os.urandom(32).hex())'
cdb19e94283a20f9d42cca50c5a4871c0aa07392db308755d60a1a5b9bb0fa52

Plugin authors make use of this signing mechanism in their plugins using :ref:`datasette_sign` and :ref:`datasette_unsign`.
4 changes: 4 additions & 0 deletions docs/datasette-serve-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ Options:
--config CONFIG Set config option using configname:value
datasette.readthedocs.io/en/latest/config.html

--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.
47 changes: 47 additions & 0 deletions docs/internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,25 @@ This method lets you read plugin configuration values that were set in ``metadat

Renders a `Jinja template <https://jinja.palletsprojects.com/en/2.11.x/>`__ using Datasette's preconfigured instance of Jinja and returns the resulting string. The template will have access to Datasette's default template functions and any functions that have been made available by other plugins.

await .permission_allowed(actor, action, resource_type=None, resource_identifier=None, default=False)
-----------------------------------------------------------------------------------------------------

``actor`` - dictionary
The authenticated actor. This is usually ``request.scope.get("actor")``.

``action`` - string
The name of the action that is being permission checked.

``resource_type`` - string, optional
The type of resource being checked, e.g. ``"table"``.

``resource_identifier`` - string, optional
The resource identifier, e.g. the name of the table.

Check if the given actor has permission to perform the given action on the given resource. This uses plugins that implement the :ref:`plugin_permission_allowed` plugin hook to decide if the action is allowed or not.

If none of the plugins express an opinion, the return value will be the ``default`` argument. This is deny, but you can pass ``default=True`` to default allow instead.

.. _datasette_get_database:

.get_database(name)
Expand Down Expand Up @@ -164,6 +183,34 @@ Use ``is_memory`` if the connection is to an in-memory SQLite database.

This removes a database that has been previously added. ``name=`` is the unique name of that database, also used in the URL for it.

.. _datasette_sign:

.sign(value, namespace="default")
---------------------------------

``value`` - any serializable type
The value to be signed.

``namespace`` - string, optional
An alternative namespace, see the `itsdangerous salt documentation <https://itsdangerous.palletsprojects.com/en/1.1.x/serializer/#the-salt>`__.

Utility method for signing values, such that you can safely pass data to and from an untrusted environment. This is a wrapper around the `itsdangerous <https://itsdangerous.palletsprojects.com/>`__ library.

This method returns a signed string, which can be decoded and verified using :ref:`datasette_unsign`.

.. _datasette_unsign:

.unsign(value, namespace="default")
-----------------------------------

``signed`` - any serializable type
The signed string that was created using :ref:`datasette_sign`.

``namespace`` - string, optional
The alternative namespace, if one was used.

Returns the original, decoded object that was passed to :ref:`datasette_sign`. If the signature is not valid this raises a ``itsdangerous.BadSignature`` exception.

.. _internals_database:

Database class
Expand Down
Loading