Skip to content

Commit 8d553c2

Browse files
author
Dennis Braun
committed
feat: API token authentication for programmatic access
Add Bearer token support for machine-to-machine API access (Home Assistant, scripts, other DOCSight instances). Tokens are managed via Settings > General when admin_password is set. API routes now return 401 JSON instead of 302 redirects. Token creation/revocation requires session auth to prevent privilege escalation from compromised tokens.
1 parent 67036e9 commit 8d553c2

8 files changed

Lines changed: 493 additions & 11 deletions

File tree

app/i18n/de.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,5 +613,19 @@
613613
"timezone": "Zeitzone",
614614
"timezone_hint": "Verwende eine IANA-Zeitzone (z.B. Europe/Berlin), damit die Sommerzeit korrekt berücksichtigt wird.",
615615
"timezone_auto": "Automatisch",
616-
"timezone_posix_warning": "Dein Container verwendet eine POSIX-Zeitzone ohne Sommerzeitunterstützung. Wähle eine IANA-Zeitzone (z.B. Europe/Berlin), um das zu beheben."
616+
"timezone_posix_warning": "Dein Container verwendet eine POSIX-Zeitzone ohne Sommerzeitunterstützung. Wähle eine IANA-Zeitzone (z.B. Europe/Berlin), um das zu beheben.",
617+
"api_tokens_title": "API-Tokens",
618+
"api_tokens_desc": "Tokens für programmatischen API-Zugriff erstellen (z.B. Home Assistant, Skripte)",
619+
"api_token_name": "Token-Name",
620+
"api_token_name_placeholder": "z.B. Home Assistant",
621+
"api_token_create": "Token erstellen",
622+
"api_token_created": "Token erstellt. Jetzt kopieren — er wird nicht erneut angezeigt.",
623+
"api_token_copy_warning": "Dieser Token wird nur einmal angezeigt. Sicher aufbewahren.",
624+
"api_token_copied": "Token kopiert!",
625+
"api_token_prefix": "Token",
626+
"api_token_last_used": "Zuletzt verwendet",
627+
"api_token_revoke": "Widerrufen",
628+
"api_token_revoke_confirm": "Token \"{name}\" widerrufen? Systeme die ihn verwenden verlieren den Zugriff.",
629+
"api_token_revoked": "Token widerrufen",
630+
"api_tokens_none": "Noch keine API-Tokens erstellt."
617631
}

app/i18n/en.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,5 +613,19 @@
613613
"timezone": "Timezone",
614614
"timezone_hint": "Use an IANA timezone (e.g. Europe/Berlin) to ensure correct daylight saving time handling.",
615615
"timezone_auto": "Auto",
616-
"timezone_posix_warning": "Your container uses a POSIX timezone that does not support daylight saving time. Select an IANA timezone (e.g. Europe/Berlin) to fix this."
616+
"timezone_posix_warning": "Your container uses a POSIX timezone that does not support daylight saving time. Select an IANA timezone (e.g. Europe/Berlin) to fix this.",
617+
"api_tokens_title": "API Tokens",
618+
"api_tokens_desc": "Create tokens for programmatic API access (e.g. Home Assistant, scripts)",
619+
"api_token_name": "Token Name",
620+
"api_token_name_placeholder": "e.g. Home Assistant",
621+
"api_token_create": "Create Token",
622+
"api_token_created": "Token created. Copy it now — it won't be shown again.",
623+
"api_token_copy_warning": "This token will only be shown once. Store it securely.",
624+
"api_token_copied": "Token copied!",
625+
"api_token_prefix": "Token",
626+
"api_token_last_used": "Last Used",
627+
"api_token_revoke": "Revoke",
628+
"api_token_revoke_confirm": "Revoke token \"{name}\"? Any system using it will lose access.",
629+
"api_token_revoked": "Token revoked",
630+
"api_tokens_none": "No API tokens created yet."
617631
}

app/i18n/es.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,5 +608,19 @@
608608
"timezone": "Zona horaria",
609609
"timezone_hint": "Usa una zona horaria IANA (ej. Europe/Madrid) para el cambio de horario de verano automatico.",
610610
"timezone_auto": "Automatico",
611-
"timezone_posix_warning": "Tu contenedor usa una zona horaria POSIX sin horario de verano. Selecciona una zona IANA (ej. Europe/Madrid) para corregirlo."
611+
"timezone_posix_warning": "Tu contenedor usa una zona horaria POSIX sin horario de verano. Selecciona una zona IANA (ej. Europe/Madrid) para corregirlo.",
612+
"api_tokens_title": "Tokens API",
613+
"api_tokens_desc": "Crear tokens para acceso API programatico (ej. Home Assistant, scripts)",
614+
"api_token_name": "Nombre del token",
615+
"api_token_name_placeholder": "ej. Home Assistant",
616+
"api_token_create": "Crear token",
617+
"api_token_created": "Token creado. Copialo ahora — no se mostrara de nuevo.",
618+
"api_token_copy_warning": "Este token solo se muestra una vez. Guardalo de forma segura.",
619+
"api_token_copied": "Token copiado!",
620+
"api_token_prefix": "Token",
621+
"api_token_last_used": "Ultimo uso",
622+
"api_token_revoke": "Revocar",
623+
"api_token_revoke_confirm": "Revocar token \"{name}\"? Los sistemas que lo usen perderan el acceso.",
624+
"api_token_revoked": "Token revocado",
625+
"api_tokens_none": "No se han creado tokens API."
612626
}

app/i18n/fr.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,5 +605,19 @@
605605
"timezone": "Fuseau horaire",
606606
"timezone_hint": "Utilisez un fuseau horaire IANA (ex. Europe/Paris) pour le changement d'heure automatique.",
607607
"timezone_auto": "Automatique",
608-
"timezone_posix_warning": "Votre conteneur utilise un fuseau horaire POSIX sans changement d'heure automatique. Selectionnez un fuseau IANA (ex. Europe/Paris) pour corriger."
608+
"timezone_posix_warning": "Votre conteneur utilise un fuseau horaire POSIX sans changement d'heure automatique. Selectionnez un fuseau IANA (ex. Europe/Paris) pour corriger.",
609+
"api_tokens_title": "Tokens API",
610+
"api_tokens_desc": "Creer des tokens pour l'acces API programmatique (ex. Home Assistant, scripts)",
611+
"api_token_name": "Nom du token",
612+
"api_token_name_placeholder": "ex. Home Assistant",
613+
"api_token_create": "Creer un token",
614+
"api_token_created": "Token cree. Copiez-le maintenant — il ne sera plus affiche.",
615+
"api_token_copy_warning": "Ce token ne sera affiche qu'une seule fois. Conservez-le en securite.",
616+
"api_token_copied": "Token copie !",
617+
"api_token_prefix": "Token",
618+
"api_token_last_used": "Derniere utilisation",
619+
"api_token_revoke": "Revoquer",
620+
"api_token_revoke_confirm": "Revoquer le token \"{name}\" ? Les systemes qui l'utilisent perdront l'acces.",
621+
"api_token_revoked": "Token revoque",
622+
"api_tokens_none": "Aucun token API cree."
609623
}

app/storage.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import json
44
import logging
55
import os
6+
import secrets
67
import sqlite3
78
from datetime import datetime, timedelta
89

10+
from werkzeug.security import generate_password_hash, check_password_hash
11+
912
ALLOWED_MIME_TYPES = {
1013
"image/png", "image/jpeg", "image/gif", "image/webp",
1114
"application/pdf", "text/plain",
@@ -275,6 +278,72 @@ def _init_db(self):
275278
except Exception as e:
276279
log.warning("Failed to add is_demo to %s: %s", tbl, e)
277280

281+
# ── API tokens table ──
282+
conn.execute("""
283+
CREATE TABLE IF NOT EXISTS api_tokens (
284+
id INTEGER PRIMARY KEY AUTOINCREMENT,
285+
name TEXT NOT NULL,
286+
token_hash TEXT NOT NULL,
287+
token_prefix TEXT NOT NULL,
288+
created_at TEXT NOT NULL,
289+
last_used_at TEXT,
290+
revoked INTEGER NOT NULL DEFAULT 0
291+
)
292+
""")
293+
294+
# ── API Token Management ──
295+
296+
def create_api_token(self, name):
297+
"""Create a new API token. Returns (token_id, plaintext_token)."""
298+
raw = secrets.token_urlsafe(48)
299+
plaintext = "dsk_" + raw
300+
prefix = plaintext[:8]
301+
token_hash = generate_password_hash(plaintext)
302+
created_at = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
303+
with sqlite3.connect(self.db_path) as conn:
304+
cur = conn.execute(
305+
"INSERT INTO api_tokens (name, token_hash, token_prefix, created_at) VALUES (?, ?, ?, ?)",
306+
(name, token_hash, prefix, created_at),
307+
)
308+
return cur.lastrowid, plaintext
309+
310+
def validate_api_token(self, token):
311+
"""Validate a Bearer token. Returns token info dict or None."""
312+
with sqlite3.connect(self.db_path) as conn:
313+
conn.row_factory = sqlite3.Row
314+
rows = conn.execute(
315+
"SELECT id, name, token_hash, token_prefix, created_at, last_used_at FROM api_tokens WHERE revoked = 0"
316+
).fetchall()
317+
for row in rows:
318+
if check_password_hash(row["token_hash"], token):
319+
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
320+
conn.execute("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", (now, row["id"]))
321+
return {
322+
"id": row["id"],
323+
"name": row["name"],
324+
"token_prefix": row["token_prefix"],
325+
"created_at": row["created_at"],
326+
}
327+
return None
328+
329+
def get_api_tokens(self):
330+
"""Return list of all tokens (without hashes) for UI display."""
331+
with sqlite3.connect(self.db_path) as conn:
332+
conn.row_factory = sqlite3.Row
333+
rows = conn.execute(
334+
"SELECT id, name, token_prefix, created_at, last_used_at, revoked FROM api_tokens ORDER BY created_at DESC"
335+
).fetchall()
336+
return [dict(r) for r in rows]
337+
338+
def revoke_api_token(self, token_id):
339+
"""Soft-revoke a token. Returns True if a token was revoked."""
340+
with sqlite3.connect(self.db_path) as conn:
341+
cur = conn.execute(
342+
"UPDATE api_tokens SET revoked = 1 WHERE id = ? AND revoked = 0",
343+
(token_id,),
344+
)
345+
return cur.rowcount > 0
346+
278347
def save_snapshot(self, analysis):
279348
"""Save current analysis as a snapshot. Runs cleanup afterwards."""
280349
ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")

app/templates/settings.html

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,50 @@
726726
</div>
727727
</div>
728728
</div>
729+
730+
<!-- API Tokens (only when password is set) -->
731+
{% if config.admin_password %}
732+
<div class="settings-card" id="api-tokens-card">
733+
<div class="settings-card-header">
734+
<div class="settings-card-icon"><i data-lucide="key-round"></i></div>
735+
<div>
736+
<div class="settings-card-title">{{ t.api_tokens_title|default('API Tokens') }}</div>
737+
<div class="settings-card-desc">{{ t.api_tokens_desc|default('Create tokens for programmatic API access') }}</div>
738+
</div>
739+
</div>
740+
<div style="padding: 0 16px 16px;">
741+
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
742+
<input type="text" id="api-token-name" placeholder="{{ t.api_token_name_placeholder|default('e.g. Home Assistant') }}" style="flex: 1;">
743+
<button type="button" class="btn btn-primary" onclick="createApiToken()">
744+
<i data-lucide="plus" style="width:16px;height:16px;"></i>
745+
{{ t.api_token_create|default('Create Token') }}
746+
</button>
747+
</div>
748+
<div id="api-token-created-banner" style="display: none; background: var(--card-bg, #1a1d23); border: 1px solid var(--accent, #4f8cff); border-radius: 8px; padding: 12px; margin-bottom: 12px;">
749+
<div style="font-weight: 600; margin-bottom: 4px;">{{ t.api_token_created|default('Token created. Copy it now.') }}</div>
750+
<div style="display: flex; gap: 8px; align-items: center;">
751+
<code id="api-token-plaintext" style="flex: 1; word-break: break-all; font-size: 0.85em; padding: 6px 8px; background: var(--bg, #12141a); border-radius: 4px;"></code>
752+
<button type="button" class="btn" onclick="copyToken()" style="white-space: nowrap;">
753+
<i data-lucide="copy" style="width:14px;height:14px;"></i>
754+
</button>
755+
</div>
756+
<div style="font-size: 0.8em; opacity: 0.7; margin-top: 6px;">{{ t.api_token_copy_warning|default('This token will only be shown once.') }}</div>
757+
</div>
758+
<table id="api-tokens-table" style="width: 100%; font-size: 0.9em; display: none;">
759+
<thead>
760+
<tr style="text-align: left; opacity: 0.7;">
761+
<th style="padding: 4px 8px;">{{ t.api_token_name|default('Name') }}</th>
762+
<th style="padding: 4px 8px;">{{ t.api_token_prefix|default('Token') }}</th>
763+
<th style="padding: 4px 8px;">{{ t.api_token_last_used|default('Last Used') }}</th>
764+
<th style="padding: 4px 8px;"></th>
765+
</tr>
766+
</thead>
767+
<tbody id="api-tokens-body"></tbody>
768+
</table>
769+
<div id="api-tokens-empty" style="opacity: 0.6; font-size: 0.9em;">{{ t.api_tokens_none|default('No API tokens created yet.') }}</div>
770+
</div>
771+
</div>
772+
{% endif %}
729773
</div>
730774

731775
<!-- ═══ Panel: Backup ═══ -->
@@ -1173,10 +1217,106 @@
11731217
saveFooter.style.display = (id === 'support') ? 'none' : '';
11741218
}
11751219

1220+
if (id === 'general') loadApiTokens();
11761221
history.replaceState(null, '', '#' + id);
11771222
closeMobileSidebar();
11781223
}
11791224

1225+
/* ── API Token Management ── */
1226+
function _tokenCell(text, style) {
1227+
var td = document.createElement('td');
1228+
td.style.cssText = style || 'padding:4px 8px;';
1229+
td.textContent = text;
1230+
return td;
1231+
}
1232+
1233+
function loadApiTokens() {
1234+
var table = document.getElementById('api-tokens-table');
1235+
var body = document.getElementById('api-tokens-body');
1236+
var empty = document.getElementById('api-tokens-empty');
1237+
if (!table || !body) return;
1238+
fetch('/api/tokens').then(function(r) { return r.json(); }).then(function(data) {
1239+
var tokens = (data.tokens || []).filter(function(t) { return !t.revoked; });
1240+
while (body.firstChild) body.removeChild(body.firstChild);
1241+
if (tokens.length === 0) {
1242+
table.style.display = 'none';
1243+
if (empty) empty.style.display = 'block';
1244+
return;
1245+
}
1246+
table.style.display = 'table';
1247+
if (empty) empty.style.display = 'none';
1248+
tokens.forEach(function(tk) {
1249+
var tr = document.createElement('tr');
1250+
tr.appendChild(_tokenCell(tk.name, 'padding:4px 8px;'));
1251+
var prefixTd = document.createElement('td');
1252+
prefixTd.style.cssText = 'padding:4px 8px;';
1253+
var code = document.createElement('code');
1254+
code.textContent = tk.token_prefix + '...';
1255+
prefixTd.appendChild(code);
1256+
tr.appendChild(prefixTd);
1257+
tr.appendChild(_tokenCell(tk.last_used_at || '\u2014', 'padding:4px 8px;'));
1258+
var actionTd = document.createElement('td');
1259+
actionTd.style.cssText = 'padding:4px 8px;';
1260+
var btn = document.createElement('button');
1261+
btn.type = 'button';
1262+
btn.className = 'btn btn-sm';
1263+
btn.style.cssText = 'font-size:0.8em;padding:2px 8px;';
1264+
btn.textContent = T.api_token_revoke || 'Revoke';
1265+
btn.setAttribute('data-token-id', tk.id);
1266+
btn.setAttribute('data-token-name', tk.name);
1267+
btn.addEventListener('click', function() {
1268+
revokeToken(parseInt(this.getAttribute('data-token-id')), this.getAttribute('data-token-name'));
1269+
});
1270+
actionTd.appendChild(btn);
1271+
tr.appendChild(actionTd);
1272+
body.appendChild(tr);
1273+
});
1274+
}).catch(function() {});
1275+
}
1276+
1277+
function createApiToken() {
1278+
var inp = document.getElementById('api-token-name');
1279+
var name = (inp.value || '').trim();
1280+
if (!name) { inp.focus(); return; }
1281+
fetch('/api/tokens', {
1282+
method: 'POST',
1283+
headers: {'Content-Type': 'application/json'},
1284+
body: JSON.stringify({name: name})
1285+
}).then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); })
1286+
.then(function(res) {
1287+
if (!res.ok) { showToast(res.data.error || 'Error', false); return; }
1288+
inp.value = '';
1289+
var banner = document.getElementById('api-token-created-banner');
1290+
document.getElementById('api-token-plaintext').textContent = res.data.token;
1291+
banner.style.display = 'block';
1292+
loadApiTokens();
1293+
if (typeof lucide !== 'undefined') lucide.createIcons();
1294+
}).catch(function() { showToast('Error', false); });
1295+
}
1296+
1297+
function copyToken() {
1298+
var text = document.getElementById('api-token-plaintext').textContent;
1299+
navigator.clipboard.writeText(text).then(function() {
1300+
showToast(T.api_token_copied || 'Token copied!', true);
1301+
});
1302+
}
1303+
1304+
function revokeToken(id, name) {
1305+
var msg = (T.api_token_revoke_confirm || 'Revoke token "{name}"?').replace('{name}', name);
1306+
if (!confirm(msg)) return;
1307+
fetch('/api/tokens/' + id, {method: 'DELETE'})
1308+
.then(function(r) { return r.json(); })
1309+
.then(function(data) {
1310+
if (data.success) {
1311+
showToast(T.api_token_revoked || 'Token revoked', true);
1312+
document.getElementById('api-token-created-banner').style.display = 'none';
1313+
loadApiTokens();
1314+
} else {
1315+
showToast(data.error || 'Error', false);
1316+
}
1317+
}).catch(function() { showToast('Error', false); });
1318+
}
1319+
11801320
/* Restore section from URL hash */
11811321
(function() {
11821322
var hash = location.hash.replace('#', '');

0 commit comments

Comments
 (0)