-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial prototype of new create tokens page, refs #7
- Loading branch information
Showing
4 changed files
with
377 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 %} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
Oops, something went wrong.