Skip to content

Commit

Permalink
Initial prototype of new create tokens page, refs #7
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Jul 13, 2023
1 parent cb63877 commit 53b4c41
Show file tree
Hide file tree
Showing 4 changed files with 377 additions and 2 deletions.
42 changes: 41 additions & 1 deletion datasette_auth_tokens/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,47 @@
from datasette import hookimpl
import secrets
from .views import create_api_token

CREATE_TABLES_SQL = """
CREATE TABLE _datasette_auth_tokens (
id INTEGER PRIMARY KEY,
secret TEXT,
description TEXT,
permissions TEXT,
actor_id TEXT,
created_timestamp INTEGER,
last_used_timestamp INTEGER,
expires_after_seconds INTEGER
);
"""


@hookimpl
def startup(datasette):
config = _config(datasette)
if not config.get("manage_tokens"):
return

async def inner():
db = datasette.get_database()
if "_datasette_auth_tokens" not in await db.table_names():
await db.execute_write(CREATE_TABLES_SQL)

return inner


@hookimpl
def register_routes(datasette):
config = _config(datasette)
if not config.get("manage_tokens"):
return
return [(r"^/-/api/tokens/create$", create_api_token)]


@hookimpl
def actor_from_request(datasette, request):
async def inner():
config = datasette.plugin_config("datasette-auth-tokens") or {}
config = _config(datasette)
allowed_tokens = config.get("tokens") or []
query_param = config.get("param")
authorization = request.headers.get("authorization")
Expand Down Expand Up @@ -51,3 +87,7 @@ async def inner():
}

return inner


def _config(datasette):
return datasette.plugin_config("datasette-auth-tokens") or {}
169 changes: 169 additions & 0 deletions datasette_auth_tokens/templates/create_api_token.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
{% extends "base.html" %}

{% block title %}Create an API token{% endblock %}

{% block extra_head %}
<style type="text/css">
#restrict-permissions label {
display: inline;
width: 90%;
}
</style>
{% endblock %}

{% block content %}

<h1>Create an API token</h1>

<p>This token will allow API access with the same abilities as your current user, <strong>{{ request.actor.id }}</strong></p>

{% if token %}
<div>
<h2>Your API token</h2>
<form>
<input type="text" class="copyable" style="width: 40%" value="{{ token }}">
<span class="copy-link-wrapper"></span>
</form>
<!--- show token in a <details> -->
<details style="margin-top: 1em">
<summary>Token details</summary>
<pre>{{ token_bits|tojson(4) }}</pre>
</details>
</div>
<h2>Create another token</h2>
{% endif %}

{% if errors %}
{% for error in errors %}
<p class="message-error">{{ error }}</p>
{% endfor %}
{% endif %}

<form action="{{ urls.path('-/api/tokens/create') }}" method="post">
<div>
<div style="margin-bottom: 0.5em">
<input type="text" name="description" placeholder="Optional token description" style="width: 40%">
</div>
<div class="select-wrapper" style="width: unset">
<select name="expire_type">
<option value="">Token never expires</option>
<option value="minutes">Expires after X minutes</option>
<option value="hours">Expires after X hours</option>
<option value="days">Expires after X days</option>
</select>
</div>
<input type="text" name="expire_duration" style="width: 10%">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Create token">

<p style="margin-top: 1em" id="token-summary"></p>

<h2>All databases and tables</h2>
<ul>
{% for permission in all_permissions %}
<li><label><input type="checkbox" name="all:{{ permission }}"> {{ permission }}</label></li>
{% endfor %}
</ul>

{% for database in database_with_tables %}
<h2>All tables in "{{ database.name }}"</h2>
<ul>
{% for permission in database_permissions %}
<li><label><input type="checkbox" name="database:{{ database.encoded }}:{{ permission }}"> {{ permission }}</label></li>
{% endfor %}
</ul>
{% endfor %}
<h2>Specific tables</h2>
{% for database in database_with_tables %}
{% for table in database.tables %}
<h3>{{ database.name }}: {{ table.name }}</h3>
<ul>
{% for permission in resource_permissions %}
<li><label><input type="checkbox" name="resource:{{ database.encoded }}:{{ table.encoded }}:{{ permission }}"> {{ permission }}</label></li>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}

</form>
</div>

<script>
var expireDuration = document.querySelector('input[name="expire_duration"]');
expireDuration.style.display = 'none';
var expireType = document.querySelector('select[name="expire_type"]');
function showHideExpireDuration() {
if (expireType.value) {
expireDuration.style.display = 'inline';
expireDuration.setAttribute("placeholder", expireType.value.replace("Expires after X ", ""));
} else {
expireDuration.style.display = 'none';
}
}

function updateTokenSummary() {
// Get all selected checkbox values on page
var allChecked = document.querySelectorAll('input[type="checkbox"]:checked');
var checkedValues = [];
for (var i = 0; i < allChecked.length; i++) {
checkedValues.push(humanPermission(allChecked[i].name));
}
var tokenSummary = document.querySelector('#token-summary');
if (checkedValues.length == 0) {
tokenSummary.innerHTML = 'Token will have all of the permissions of your current user';
} else {
// Build a <ul> of checkedValues
let html = ['<ul style="margin-top: 0.5em">'];
checkedValues.forEach(function(value) {
html.push('<li style="list-style-type: disc; margin-left: 1em;">' + value + '</li>');
});
html.push('</ul>');
tokenSummary.innerHTML = 'Token will be restricted to: ' + html.join('');
}
}
updateTokenSummary();

function humanPermission(permission) {
// database:messages:view-database becomes "messages database: view-database"
// resource:messages:t2:view-table becomes "messages/t2 table: view-table"
var parts = permission.split(':');
var type = parts[0];
var db = parts[1];
var action = parts[parts.length - 1];
if (type == 'all') {
return 'all databases and tables: ' + action;
} else if (type == 'database') {
return db + ' database: ' + action;
} else if (type == 'resource') {
var table = parts[2];
return db + '/' + table + ' table: ' + action;
}
}

// On any input change on page, update token summary
document.addEventListener('change', updateTokenSummary);

showHideExpireDuration();
expireType.addEventListener('change', showHideExpireDuration);
var copyInput = document.querySelector(".copyable");
if (copyInput) {
var wrapper = document.querySelector(".copy-link-wrapper");
var button = document.createElement("button");
button.className = "copyable-copy-button";
button.setAttribute("type", "button");
button.innerHTML = "Copy to clipboard";
button.onclick = (ev) => {
ev.preventDefault();
copyInput.select();
document.execCommand("copy");
button.innerHTML = "Copied!";
setTimeout(() => {
button.innerHTML = "Copy to clipboard";
}, 1500);
};
wrapper.appendChild(button);
wrapper.insertAdjacentElement("afterbegin", button);
}
</script>

{% endblock %}
163 changes: 163 additions & 0 deletions datasette_auth_tokens/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
from datasette import Forbidden, Response
from datasette.utils import (
tilde_encode,
tilde_decode,
)
import json
import secrets
import time


async def create_api_token(request, datasette):
_check_permission(datasette, request)
if request.method == "GET":
return Response.html(
await datasette.render_template(
"create_api_token.html",
await _shared(datasette, request),
request=request,
)
)
elif request.method == "POST":
post = await request.post_vars()
errors = []
expires_after = None
if post.get("expire_type"):
duration_string = post.get("expire_duration")
if (
not duration_string
or not duration_string.isdigit()
or not int(duration_string) > 0
):
errors.append("Invalid expire duration")
else:
unit = post["expire_type"]
if unit == "minutes":
expires_after = int(duration_string) * 60
elif unit == "hours":
expires_after = int(duration_string) * 60 * 60
elif unit == "days":
expires_after = int(duration_string) * 60 * 60 * 24
else:
errors.append("Invalid expire duration unit")

# Are there any restrictions?
restrict_all = []
restrict_database = {}
restrict_resource = {}

for key in post:
if key.startswith("all:") and key.count(":") == 1:
restrict_all.append(key.split(":")[1])
elif key.startswith("database:") and key.count(":") == 2:
bits = key.split(":")
database = tilde_decode(bits[1])
action = bits[2]
restrict_database.setdefault(database, []).append(action)
elif key.startswith("resource:") and key.count(":") == 3:
bits = key.split(":")
database = tilde_decode(bits[1])
resource = tilde_decode(bits[2])
action = bits[3]
restrict_resource.setdefault(database, {}).setdefault(
resource, []
).append(action)

# Reuse Datasette signed tokens mechanism to create parts of the token
throwaway_signed_token = datasette.create_token(
request.actor["id"],
expires_after=expires_after,
restrict_all=restrict_all,
restrict_database=restrict_database,
restrict_resource=restrict_resource,
)
token_bits = datasette.unsign(
throwaway_signed_token[len("dstok_") :], namespace="token"
)
permissions = token_bits.get("_r") or None

db = datasette.get_database()
secret = secrets.token_urlsafe(16)
cursor = await db.execute_write(
"""
insert into _datasette_auth_tokens
(secret, description, permissions, actor_id, created_timestamp, expires_after_seconds)
values
(:secret, :description, :permissions, :actor_id, :created_timestamp, :expires_after_seconds)
""",
{
"secret": secret,
"permissions": json.dumps(permissions),
"description": post.get("description") or None,
"actor_id": request.actor["id"],
"created_timestamp": int(time.time()),
"expires_after_seconds": expires_after,
},
)
token = "dsatok_{}_{}".format(cursor.lastrowid, secret)

context = await _shared(datasette, request)
context.update({"errors": errors, "token": token, "token_bits": token_bits})
return Response.html(
await datasette.render_template(
"create_api_token.html", context, request=request
)
)
else:
raise Forbidden("Invalid method")


def _check_permission(datasette, request):
if not request.actor:
raise Forbidden("You must be logged in to create a token")
if not request.actor.get("id"):
raise Forbidden(
"You must be logged in as an actor with an ID to create a token"
)
if request.actor.get("token"):
raise Forbidden(
"Token authentication cannot be used to create additional tokens"
)


async def _shared(datasette, request):
_check_permission(datasette, request)
# Build list of databases and tables the user has permission to view
database_with_tables = []
for database in datasette.databases.values():
if database.name in ("_internal", "_memory"):
continue
if not await datasette.permission_allowed(
request.actor, "view-database", database.name
):
continue
hidden_tables = await database.hidden_table_names()
tables = []
for table in await database.table_names():
if table in hidden_tables:
continue
if not await datasette.permission_allowed(
request.actor,
"view-table",
resource=(database.name, table),
):
continue
tables.append({"name": table, "encoded": tilde_encode(table)})
database_with_tables.append(
{
"name": database.name,
"encoded": tilde_encode(database.name),
"tables": tables,
}
)
return {
"actor": request.actor,
"all_permissions": datasette.permissions.keys(),
"database_permissions": [
key for key, value in datasette.permissions.items() if value.takes_database
],
"resource_permissions": [
key for key, value in datasette.permissions.items() if value.takes_resource
],
"database_with_tables": database_with_tables,
}

0 comments on commit 53b4c41

Please sign in to comment.