mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
max_signed_tokens_ttl setting, closes #1858
Also redesigned token format to include creation time and optional duration.
This commit is contained in:
parent
af5d5d0243
commit
382a871583
6 changed files with 99 additions and 25 deletions
|
|
@ -129,6 +129,11 @@ SETTINGS = (
|
||||||
True,
|
True,
|
||||||
"Allow users to create and use signed API tokens",
|
"Allow users to create and use signed API tokens",
|
||||||
),
|
),
|
||||||
|
Setting(
|
||||||
|
"max_signed_tokens_ttl",
|
||||||
|
0,
|
||||||
|
"Maximum allowed expiry time for signed API tokens",
|
||||||
|
),
|
||||||
Setting("suggest_facets", True, "Calculate and display suggested facets"),
|
Setting("suggest_facets", True, "Calculate and display suggested facets"),
|
||||||
Setting(
|
Setting(
|
||||||
"default_cache_ttl",
|
"default_cache_ttl",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ def actor_from_request(datasette, request):
|
||||||
prefix = "dstok_"
|
prefix = "dstok_"
|
||||||
if not datasette.setting("allow_signed_tokens"):
|
if not datasette.setting("allow_signed_tokens"):
|
||||||
return None
|
return None
|
||||||
|
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||||
authorization = request.headers.get("authorization")
|
authorization = request.headers.get("authorization")
|
||||||
if not authorization:
|
if not authorization:
|
||||||
return None
|
return None
|
||||||
|
|
@ -69,11 +70,31 @@ def actor_from_request(datasette, request):
|
||||||
decoded = datasette.unsign(token, namespace="token")
|
decoded = datasette.unsign(token, namespace="token")
|
||||||
except itsdangerous.BadSignature:
|
except itsdangerous.BadSignature:
|
||||||
return None
|
return None
|
||||||
expires_at = decoded.get("e")
|
if "t" not in decoded:
|
||||||
if expires_at is not None:
|
# Missing timestamp
|
||||||
if expires_at < time.time():
|
return None
|
||||||
|
created = decoded["t"]
|
||||||
|
if not isinstance(created, int):
|
||||||
|
# Invalid timestamp
|
||||||
|
return None
|
||||||
|
duration = decoded.get("d")
|
||||||
|
if duration is not None and not isinstance(duration, int):
|
||||||
|
# Invalid duration
|
||||||
|
return None
|
||||||
|
if (duration is None and max_signed_tokens_ttl) or (
|
||||||
|
duration is not None
|
||||||
|
and max_signed_tokens_ttl
|
||||||
|
and duration > max_signed_tokens_ttl
|
||||||
|
):
|
||||||
|
duration = max_signed_tokens_ttl
|
||||||
|
if duration:
|
||||||
|
if time.time() - created > duration:
|
||||||
|
# Expired
|
||||||
return None
|
return None
|
||||||
return {"id": decoded["a"], "token": "dstok"}
|
actor = {"id": decoded["a"], "token": "dstok"}
|
||||||
|
if duration:
|
||||||
|
actor["token_expires"] = created + duration
|
||||||
|
return actor
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -102,9 +123,9 @@ def register_commands(cli):
|
||||||
def create_token(id, secret, expires_after, debug):
|
def create_token(id, secret, expires_after, debug):
|
||||||
"Create a signed API token for the specified actor ID"
|
"Create a signed API token for the specified actor ID"
|
||||||
ds = Datasette(secret=secret)
|
ds = Datasette(secret=secret)
|
||||||
bits = {"a": id, "token": "dstok"}
|
bits = {"a": id, "token": "dstok", "t": int(time.time())}
|
||||||
if expires_after:
|
if expires_after:
|
||||||
bits["e"] = int(time.time()) + expires_after
|
bits["d"] = expires_after
|
||||||
token = ds.sign(bits, namespace="token")
|
token = ds.sign(bits, namespace="token")
|
||||||
click.echo("dstok_{}".format(token))
|
click.echo("dstok_{}".format(token))
|
||||||
if debug:
|
if debug:
|
||||||
|
|
|
||||||
|
|
@ -195,20 +195,24 @@ class CreateTokenView(BaseView):
|
||||||
async def post(self, request):
|
async def post(self, request):
|
||||||
self.check_permission(request)
|
self.check_permission(request)
|
||||||
post = await request.post_vars()
|
post = await request.post_vars()
|
||||||
expires = None
|
|
||||||
errors = []
|
errors = []
|
||||||
|
duration = None
|
||||||
if post.get("expire_type"):
|
if post.get("expire_type"):
|
||||||
duration = post.get("expire_duration")
|
duration_string = post.get("expire_duration")
|
||||||
if not duration or not duration.isdigit() or not int(duration) > 0:
|
if (
|
||||||
|
not duration_string
|
||||||
|
or not duration_string.isdigit()
|
||||||
|
or not int(duration_string) > 0
|
||||||
|
):
|
||||||
errors.append("Invalid expire duration")
|
errors.append("Invalid expire duration")
|
||||||
else:
|
else:
|
||||||
unit = post["expire_type"]
|
unit = post["expire_type"]
|
||||||
if unit == "minutes":
|
if unit == "minutes":
|
||||||
expires = int(duration) * 60
|
duration = int(duration_string) * 60
|
||||||
elif unit == "hours":
|
elif unit == "hours":
|
||||||
expires = int(duration) * 60 * 60
|
duration = int(duration_string) * 60 * 60
|
||||||
elif unit == "days":
|
elif unit == "days":
|
||||||
expires = int(duration) * 60 * 60 * 24
|
duration = int(duration_string) * 60 * 60 * 24
|
||||||
else:
|
else:
|
||||||
errors.append("Invalid expire duration unit")
|
errors.append("Invalid expire duration unit")
|
||||||
token_bits = None
|
token_bits = None
|
||||||
|
|
@ -216,8 +220,10 @@ class CreateTokenView(BaseView):
|
||||||
if not errors:
|
if not errors:
|
||||||
token_bits = {
|
token_bits = {
|
||||||
"a": request.actor["id"],
|
"a": request.actor["id"],
|
||||||
"e": (int(time.time()) + expires) if expires else None,
|
"t": int(time.time()),
|
||||||
}
|
}
|
||||||
|
if duration:
|
||||||
|
token_bits["d"] = duration
|
||||||
token = "dstok_{}".format(self.ds.sign(token_bits, "token"))
|
token = "dstok_{}".format(self.ds.sign(token_bits, "token"))
|
||||||
return await self.render(
|
return await self.render(
|
||||||
["create_token.html"],
|
["create_token.html"],
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,21 @@ This is turned on by default. Use the following to turn it off::
|
||||||
|
|
||||||
Turning this setting off will disable the ``/-/create-token`` page, :ref:`described here <CreateTokenView>`. It will also cause any incoming ``Authorization: Bearer dstok_...`` API tokens to be ignored.
|
Turning this setting off will disable the ``/-/create-token`` page, :ref:`described here <CreateTokenView>`. It will also cause any incoming ``Authorization: Bearer dstok_...`` API tokens to be ignored.
|
||||||
|
|
||||||
|
.. _setting_max_signed_tokens_ttl:
|
||||||
|
|
||||||
|
max_signed_tokens_ttl
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Maximum allowed expiry time for signed API tokens created by users.
|
||||||
|
|
||||||
|
Defaults to ``0`` which means no limit - tokens can be created that will never expire.
|
||||||
|
|
||||||
|
Set this to a value in seconds to limit the maximum expiry time. For example, to set that limit to 24 hours you would use::
|
||||||
|
|
||||||
|
datasette mydatabase.db --setting max_signed_tokens_ttl 86400
|
||||||
|
|
||||||
|
This setting is enforced when incoming tokens are processed.
|
||||||
|
|
||||||
.. _setting_default_cache_ttl:
|
.. _setting_default_cache_ttl:
|
||||||
|
|
||||||
default_cache_ttl
|
default_cache_ttl
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,7 @@ def test_settings_json(app_client):
|
||||||
"sql_time_limit_ms": 200,
|
"sql_time_limit_ms": 200,
|
||||||
"allow_download": True,
|
"allow_download": True,
|
||||||
"allow_signed_tokens": True,
|
"allow_signed_tokens": True,
|
||||||
|
"max_signed_tokens_ttl": 0,
|
||||||
"allow_facet": True,
|
"allow_facet": True,
|
||||||
"suggest_facets": True,
|
"suggest_facets": True,
|
||||||
"default_cache_ttl": 5,
|
"default_cache_ttl": 5,
|
||||||
|
|
|
||||||
|
|
@ -173,13 +173,19 @@ def test_auth_create_token(app_client, post_data, errors, expected_duration):
|
||||||
# Extract token from page
|
# Extract token from page
|
||||||
token = response2.text.split('value="dstok_')[1].split('"')[0]
|
token = response2.text.split('value="dstok_')[1].split('"')[0]
|
||||||
details = app_client.ds.unsign(token, "token")
|
details = app_client.ds.unsign(token, "token")
|
||||||
assert details.keys() == {"a", "e"}
|
assert details.keys() == {"a", "t", "d"} or details.keys() == {"a", "t"}
|
||||||
assert details["a"] == "test"
|
assert details["a"] == "test"
|
||||||
if expected_duration is None:
|
if expected_duration is None:
|
||||||
assert details["e"] is None
|
assert "d" not in details
|
||||||
else:
|
else:
|
||||||
about_right = int(time.time()) + expected_duration
|
assert details["d"] == expected_duration
|
||||||
assert about_right - 2 < details["e"] < about_right + 2
|
# And test that token
|
||||||
|
response3 = app_client.get(
|
||||||
|
"/-/actor.json",
|
||||||
|
headers={"Authorization": "Bearer {}".format("dstok_{}".format(token))},
|
||||||
|
)
|
||||||
|
assert response3.status == 200
|
||||||
|
assert response3.json["actor"]["id"] == "test"
|
||||||
|
|
||||||
|
|
||||||
def test_auth_create_token_not_allowed_for_tokens(app_client):
|
def test_auth_create_token_not_allowed_for_tokens(app_client):
|
||||||
|
|
@ -206,6 +212,7 @@ def test_auth_create_token_not_allowed_if_allow_signed_tokens_off(app_client):
|
||||||
(
|
(
|
||||||
("allow_signed_tokens_off", False),
|
("allow_signed_tokens_off", False),
|
||||||
("no_token", False),
|
("no_token", False),
|
||||||
|
("no_timestamp", False),
|
||||||
("invalid_token", False),
|
("invalid_token", False),
|
||||||
("expired_token", False),
|
("expired_token", False),
|
||||||
("valid_unlimited_token", True),
|
("valid_unlimited_token", True),
|
||||||
|
|
@ -214,12 +221,15 @@ def test_auth_create_token_not_allowed_if_allow_signed_tokens_off(app_client):
|
||||||
)
|
)
|
||||||
def test_auth_with_dstok_token(app_client, scenario, should_work):
|
def test_auth_with_dstok_token(app_client, scenario, should_work):
|
||||||
token = None
|
token = None
|
||||||
|
_time = int(time.time())
|
||||||
if scenario in ("valid_unlimited_token", "allow_signed_tokens_off"):
|
if scenario in ("valid_unlimited_token", "allow_signed_tokens_off"):
|
||||||
token = app_client.ds.sign({"a": "test"}, "token")
|
token = app_client.ds.sign({"a": "test", "t": _time}, "token")
|
||||||
elif scenario == "valid_expiring_token":
|
elif scenario == "valid_expiring_token":
|
||||||
token = app_client.ds.sign({"a": "test", "e": int(time.time()) + 1000}, "token")
|
token = app_client.ds.sign({"a": "test", "t": _time - 50, "d": 1000}, "token")
|
||||||
elif scenario == "expired_token":
|
elif scenario == "expired_token":
|
||||||
token = app_client.ds.sign({"a": "test", "e": int(time.time()) - 1000}, "token")
|
token = app_client.ds.sign({"a": "test", "t": _time - 2000, "d": 1000}, "token")
|
||||||
|
elif scenario == "no_timestamp":
|
||||||
|
token = app_client.ds.sign({"a": "test"}, "token")
|
||||||
elif scenario == "invalid_token":
|
elif scenario == "invalid_token":
|
||||||
token = "invalid"
|
token = "invalid"
|
||||||
if token:
|
if token:
|
||||||
|
|
@ -232,7 +242,16 @@ def test_auth_with_dstok_token(app_client, scenario, should_work):
|
||||||
response = app_client.get("/-/actor.json", headers=headers)
|
response = app_client.get("/-/actor.json", headers=headers)
|
||||||
try:
|
try:
|
||||||
if should_work:
|
if should_work:
|
||||||
assert response.json == {"actor": {"id": "test", "token": "dstok"}}
|
assert response.json.keys() == {"actor"}
|
||||||
|
actor = response.json["actor"]
|
||||||
|
expected_keys = {"id", "token"}
|
||||||
|
if scenario != "valid_unlimited_token":
|
||||||
|
expected_keys.add("token_expires")
|
||||||
|
assert actor.keys() == expected_keys
|
||||||
|
assert actor["id"] == "test"
|
||||||
|
assert actor["token"] == "dstok"
|
||||||
|
if scenario != "valid_unlimited_token":
|
||||||
|
assert isinstance(actor["token_expires"], int)
|
||||||
else:
|
else:
|
||||||
assert response.json == {"actor": None}
|
assert response.json == {"actor": None}
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -251,15 +270,22 @@ def test_cli_create_token(app_client, expires):
|
||||||
token = result.output.strip()
|
token = result.output.strip()
|
||||||
assert token.startswith("dstok_")
|
assert token.startswith("dstok_")
|
||||||
details = app_client.ds.unsign(token[len("dstok_") :], "token")
|
details = app_client.ds.unsign(token[len("dstok_") :], "token")
|
||||||
expected_keys = {"a", "token"}
|
expected_keys = {"a", "token", "t"}
|
||||||
if expires:
|
if expires:
|
||||||
expected_keys.add("e")
|
expected_keys.add("d")
|
||||||
assert details.keys() == expected_keys
|
assert details.keys() == expected_keys
|
||||||
assert details["a"] == "test"
|
assert details["a"] == "test"
|
||||||
response = app_client.get(
|
response = app_client.get(
|
||||||
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
|
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
|
||||||
)
|
)
|
||||||
if expires is None or expires > 0:
|
if expires is None or expires > 0:
|
||||||
assert response.json == {"actor": {"id": "test", "token": "dstok"}}
|
expected_actor = {
|
||||||
|
"id": "test",
|
||||||
|
"token": "dstok",
|
||||||
|
}
|
||||||
|
if expires and expires > 0:
|
||||||
|
expected_actor["token_expires"] = details["t"] + expires
|
||||||
|
assert response.json == {"actor": expected_actor}
|
||||||
else:
|
else:
|
||||||
assert response.json == {"actor": None}
|
expected_actor = None
|
||||||
|
assert response.json == {"actor": expected_actor}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue