From b8202abba5264203cf5f8b04828ea23bc58abe66 Mon Sep 17 00:00:00 2001 From: cosmo Date: Sat, 25 Apr 2026 17:29:53 +0200 Subject: [PATCH] decouple OTP secrets from instances with a dedicated database table and CRUD API endpoints --- data/instances.db | Bin 0 -> 24576 bytes main.py | 171 +++++++++++++++++++++++++++++++++++++--------- static/app.js | 144 ++++++++++++++++++++++++++++++++++---- static/index.html | 42 +++++++++++- static/style.css | 12 ++-- 5 files changed, 317 insertions(+), 52 deletions(-) create mode 100644 data/instances.db diff --git a/data/instances.db b/data/instances.db new file mode 100644 index 0000000000000000000000000000000000000000..303fe2dc371f8008f6c6f63e3c6b6260038b06a6 GIT binary patch literal 24576 zcmeI(%}(1e00(df+QOJhcgqn=AkhlMU}$I@Can}x6+#Lnkzj{aA<5K0o0KG7$8Ky$ zdx7m)_6ob~HTDQ>$2sn25bb4}G*y2qY25fD@y~W8r^?B(;)FJR>-h~Mq?_akktFhp zQbI_A?ye`bEne@VMMiU0u!KmY;|fB*y_ z0D%bbYKLEH1KadXlMs)p5Y-2S}mkNg2#ZYm|elqKT0mi@kq zVYEU|e6NbuRX4;TTFcKyKe%bWOPOzkZwL^800bZa0SG_<0uX=z1Rwwb2>f3HMJYw( zo8%+$NMPyN`O?O-rNa7t`QXTCUzD%w!H4#?R?BZbZ8deaZ@w;E8K(_jdH!O1%k+J3 z_u`$iwo~3}7w34pq|7hEHv|Yk00Izz00bZa0SG_<0uX=z1SU&hc3SQ{JLvrXe@8NR zlhr`91_BU(00bZa0SG_<0uX=z1Rwx`S&6Uz#ft#Z`k%k_j{pG(KmY;|fB*y_009U< n00Izzzyu3m{XfB-i>5&U0uX=z1Rwwb2tWV=5P$##x&nUyxo>M` literal 0 HcmV?d00001 diff --git a/main.py b/main.py index 9101096..9f5d2c6 100644 --- a/main.py +++ b/main.py @@ -58,29 +58,58 @@ def init_db(): ) ''') - # Check if instances table has user_id, if not, add it (simple migration) - c.execute("PRAGMA table_info(instances)") - columns = [col[1] for col in c.fetchall()] - if "user_id" not in columns: - print("Migrating instances table to include user_id...") - # SQLite doesn't support adding a column with a foreign key constraint easily, - # so we'll just add the column for now. - c.execute('ALTER TABLE instances ADD COLUMN user_id INTEGER') - + c.execute(''' + CREATE TABLE IF NOT EXISTS otp_secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + encrypted_secret TEXT NOT NULL, + user_id INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) + ) + ''') + + # Create instances table first if it doesn't exist c.execute(''' CREATE TABLE IF NOT EXISTS instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, ip TEXT NOT NULL, port INTEGER NOT NULL, - encrypted_secret TEXT NOT NULL, + encrypted_secret TEXT, + otp_secret_id INTEGER, last_status TEXT DEFAULT 'Unknown', last_checked REAL DEFAULT 0, user_id INTEGER, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (otp_secret_id) REFERENCES otp_secrets (id) ) ''') + # Now check for column migrations + c.execute("PRAGMA table_info(instances)") + columns = [col[1] for col in c.fetchall()] + + if "user_id" not in columns: + print("Migrating instances table to include user_id...") + c.execute('ALTER TABLE instances ADD COLUMN user_id INTEGER REFERENCES users(id)') + + if "otp_secret_id" not in columns: + print("Migrating instances table to include otp_secret_id...") + c.execute('ALTER TABLE instances ADD COLUMN otp_secret_id INTEGER REFERENCES otp_secrets(id)') + + # Migration: Move existing encrypted_secret to otp_secrets table + c.execute('SELECT id, name, encrypted_secret, user_id FROM instances WHERE encrypted_secret IS NOT NULL') + instances_to_migrate = c.fetchall() + for inst_id, inst_name, secret, user_id in instances_to_migrate: + if secret and user_id: + # Create a new OTP secret entry + secret_name = f"Migrated ({inst_name})" + c.execute('INSERT INTO otp_secrets (name, encrypted_secret, user_id) VALUES (?, ?, ?)', + (secret_name, secret, user_id)) + otp_secret_id = c.lastrowid + # Update instance to point to the new OTP secret + c.execute('UPDATE instances SET otp_secret_id = ? WHERE id = ?', (otp_secret_id, inst_id)) + # Create or update default admin if no users exist or only one user exists c.execute('SELECT COUNT(*) FROM users') count = c.fetchone()[0] @@ -243,17 +272,25 @@ async def change_own_username(request: Request): raise HTTPException(status_code=400, detail="Username already exists") return {"status": "ok"} +class OTPSecretCreate(BaseModel): + name: str + secret: str + +class OTPSecretUpdate(BaseModel): + name: str + secret: Optional[str] = None + class InstanceCreate(BaseModel): name: str ip: str port: int = 4646 - secret: str + otp_secret_id: int class InstanceUpdate(BaseModel): name: str ip: str port: int = 4646 - secret: Optional[str] = None + otp_secret_id: int def clean_secret(secret_input): secret_clean = secret_input.strip() @@ -267,13 +304,90 @@ def clean_secret(secret_input): pass return secret_clean.replace(" ", "").upper() +# OTP Secret Management +@app.get("/api/otp-secrets", dependencies=[Depends(is_authenticated)]) +def get_otp_secrets(request: Request): + user_id = request.session.get("user_id") + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute('SELECT id, name FROM otp_secrets WHERE user_id = ?', (user_id,)) + secrets = [dict(row) for row in c.fetchall()] + conn.close() + return secrets + +@app.post("/api/otp-secrets", dependencies=[Depends(is_authenticated)]) +def create_otp_secret(secret_data: OTPSecretCreate, request: Request): + user_id = request.session.get("user_id") + cleaned = clean_secret(secret_data.secret) + encrypted = fernet.encrypt(cleaned.encode()).decode() + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(''' + INSERT INTO otp_secrets (name, encrypted_secret, user_id) + VALUES (?, ?, ?) + ''', (secret_data.name, encrypted, user_id)) + conn.commit() + conn.close() + return {"status": "ok"} + +@app.put("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)]) +def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request): + user_id = request.session.get("user_id") + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # Verify ownership + c.execute('SELECT id FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) + if not c.fetchone(): + conn.close() + raise HTTPException(status_code=404, detail="OTP Secret not found") + + if secret_data.secret: + cleaned = clean_secret(secret_data.secret) + encrypted = fernet.encrypt(cleaned.encode()).decode() + c.execute(''' + UPDATE otp_secrets SET name=?, encrypted_secret=? + WHERE id=? AND user_id=? + ''', (secret_data.name, encrypted, id, user_id)) + else: + c.execute(''' + UPDATE otp_secrets SET name=? + WHERE id=? AND user_id=? + ''', (secret_data.name, id, user_id)) + + conn.commit() + conn.close() + return {"status": "ok"} + +@app.delete("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)]) +def delete_otp_secret(id: int, request: Request): + user_id = request.session.get("user_id") + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # Check if used by any instances + c.execute('SELECT COUNT(*) FROM instances WHERE otp_secret_id=?', (id,)) + if c.fetchone()[0] > 0: + conn.close() + raise HTTPException(status_code=400, detail="Cannot delete secret because it is in use by one or more instances") + + c.execute('DELETE FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) + conn.commit() + conn.close() + return {"status": "ok"} + async def poll_instances(): while True: try: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() - c.execute('SELECT * FROM instances') + c.execute(''' + SELECT i.*, s.encrypted_secret + FROM instances i + JOIN otp_secrets s ON i.otp_secret_id = s.id + ''') instances = c.fetchall() conn.close() @@ -332,7 +446,12 @@ def get_instances(request: Request): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() - c.execute('SELECT id, name, ip, port, last_status, last_checked FROM instances WHERE user_id = ?', (user_id,)) + c.execute(''' + SELECT i.id, i.name, i.ip, i.port, i.last_status, i.last_checked, i.otp_secret_id, s.name as otp_secret_name + FROM instances i + LEFT JOIN otp_secrets s ON i.otp_secret_id = s.id + WHERE i.user_id = ? + ''', (user_id,)) instances = [dict(row) for row in c.fetchall()] conn.close() return instances @@ -340,14 +459,12 @@ def get_instances(request: Request): @app.post("/api/instances", dependencies=[Depends(is_authenticated)]) def create_instance(inst: InstanceCreate, request: Request): user_id = request.session.get("user_id") - cleaned = clean_secret(inst.secret) - encrypted = fernet.encrypt(cleaned.encode()).decode() conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(''' - INSERT INTO instances (name, ip, port, encrypted_secret, user_id) + INSERT INTO instances (name, ip, port, otp_secret_id, user_id) VALUES (?, ?, ?, ?, ?) - ''', (inst.name, inst.ip, inst.port, encrypted, user_id)) + ''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, user_id)) conn.commit() conn.close() return {"status": "ok"} @@ -364,18 +481,10 @@ def update_instance(id: int, inst: InstanceUpdate, request: Request): conn.close() raise HTTPException(status_code=404, detail="Instance not found") - if inst.secret: - cleaned = clean_secret(inst.secret) - encrypted = fernet.encrypt(cleaned.encode()).decode() - c.execute(''' - UPDATE instances SET name=?, ip=?, port=?, encrypted_secret=? - WHERE id=? AND user_id=? - ''', (inst.name, inst.ip, inst.port, encrypted, id, user_id)) - else: - c.execute(''' - UPDATE instances SET name=?, ip=?, port=? - WHERE id=? AND user_id=? - ''', (inst.name, inst.ip, inst.port, id, user_id)) + c.execute(''' + UPDATE instances SET name=?, ip=?, port=?, otp_secret_id=? + WHERE id=? AND user_id=? + ''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, id, user_id)) conn.commit() conn.close() diff --git a/static/app.js b/static/app.js index bd8043e..9a637df 100644 --- a/static/app.js +++ b/static/app.js @@ -24,21 +24,36 @@ document.addEventListener('DOMContentLoaded', () => { const showAddUserBtn = document.getElementById('show-add-user-btn'); const addUserModal = document.getElementById('add-user-modal'); const modalTitle = document.querySelector('#add-instance-modal h2'); - const secretInput = document.getElementById('secret'); + const otpModalTitle = document.getElementById('otp-modal-title'); + const otpSaveBtn = document.getElementById('otp-save-btn'); + const otpSecretForm = document.getElementById('otp-secret-form'); + const showAddOtpBtn = document.getElementById('show-add-otp-btn'); + const addOtpModal = document.getElementById('add-otp-secret-modal'); + const otpSecretIdSelect = document.getElementById('otp-secret-id'); + const otpSecretsList = document.getElementById('otp-secrets-list'); let config = { firewall_host_ip: null }; let currentUser = null; let editId = null; + let editOtpId = null; let instancesData = []; + let otpSecretsData = []; const openModal = (modal) => modal.classList.add('active'); const closeModal = (modal) => { modal.classList.remove('active'); editId = null; - addForm.reset(); - modalTitle.textContent = 'Add Instance'; - secretInput.placeholder = 'Base32 Secret or otpauth:// URL'; - secretInput.required = true; + editOtpId = null; + if (modal.id === 'add-instance-modal') { + addForm.reset(); + modalTitle.textContent = 'Add Instance'; + } else if (modal.id === 'add-otp-secret-modal') { + otpSecretForm.reset(); + otpModalTitle.textContent = 'Add OTP Secret'; + otpSaveBtn.textContent = 'Add Secret'; + document.getElementById('otp-secret').required = true; + document.getElementById('otp-secret').placeholder = 'Base32 Secret or otpauth:// URL'; + } }; document.querySelectorAll('.btn-close').forEach(btn => { @@ -55,10 +70,17 @@ document.addEventListener('DOMContentLoaded', () => { showAddBtn.addEventListener('click', () => { editId = null; modalTitle.textContent = 'Add Instance'; - secretInput.placeholder = 'Base32 Secret or otpauth:// URL'; - secretInput.required = true; + populateOTPSelect(); openModal(addInstanceModal); }); + showAddOtpBtn.addEventListener('click', () => { + editOtpId = null; + otpModalTitle.textContent = 'Add OTP Secret'; + otpSaveBtn.textContent = 'Add Secret'; + document.getElementById('otp-secret').required = true; + document.getElementById('otp-secret').placeholder = 'Base32 Secret or otpauth:// URL'; + openModal(addOtpModal); + }); showAddUserBtn.addEventListener('click', () => openModal(addUserModal)); const showLogin = () => { @@ -83,6 +105,7 @@ document.addEventListener('DOMContentLoaded', () => { if (item.dataset.tab === 'admin') fetchUsers(); if (item.dataset.tab === 'instances') fetchInstances(); + if (item.dataset.tab === 'otp-secrets') fetchOTPSecrets(); }); }); @@ -119,6 +142,17 @@ document.addEventListener('DOMContentLoaded', () => { } }; + const fetchOTPSecrets = async () => { + try { + const res = await fetch('/api/otp-secrets'); + if (res.status === 401) return showLogin(); + otpSecretsData = await res.json(); + renderOTPSecrets(otpSecretsData); + } catch (e) { + console.error('Failed to fetch OTP secrets:', e); + } + }; + const fetchUsers = async () => { try { const res = await fetch('/api/users'); @@ -152,6 +186,7 @@ document.addEventListener('DOMContentLoaded', () => {

${escapeHtml(inst.name)}

${escapeHtml(inst.ip)}:${inst.port} + OTP: ${escapeHtml(inst.otp_secret_name || 'None')} ${escapeHtml(inst.last_status)} Last checked: ${timeAgo}
@@ -172,6 +207,29 @@ document.addEventListener('DOMContentLoaded', () => { }).join(''); }; + const renderOTPSecrets = (secrets) => { + if (secrets.length === 0) { + otpSecretsList.innerHTML = '
No OTP secrets configured yet.
'; + return; + } + + otpSecretsList.innerHTML = secrets.map(sec => ` +
+
+

${escapeHtml(sec.name)}

+
+
+ + +
+
+ `).join(''); + }; + const renderUsers = (users) => { usersList.innerHTML = users.map(user => `
@@ -195,12 +253,11 @@ document.addEventListener('DOMContentLoaded', () => { const name = document.getElementById('name').value; const ip = document.getElementById('ip').value; const port = parseInt(document.getElementById('port').value, 10); - const secret = document.getElementById('secret').value; + const otp_secret_id = parseInt(otpSecretIdSelect.value, 10); const url = editId ? `/api/instances/${editId}` : '/api/instances'; const method = editId ? 'PUT' : 'POST'; - const body = { name, ip, port }; - if (secret) body.secret = secret; + const body = { name, ip, port, otp_secret_id }; try { const res = await fetch(url, { @@ -228,13 +285,72 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('name').value = inst.name; document.getElementById('ip').value = inst.ip; document.getElementById('port').value = inst.port; - secretInput.value = ''; - secretInput.required = false; - secretInput.placeholder = 'Leave blank to keep current secret'; + populateOTPSelect(inst.otp_secret_id); modalTitle.textContent = 'Edit Instance'; openModal(addInstanceModal); }; + const populateOTPSelect = (selectedId = null) => { + otpSecretIdSelect.innerHTML = '' + + otpSecretsData.map(sec => ``).join(''); + }; + + otpSecretForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = document.getElementById('otp-name').value; + const secret = document.getElementById('otp-secret').value; + + const url = editOtpId ? `/api/otp-secrets/${editOtpId}` : '/api/otp-secrets'; + const method = editOtpId ? 'PUT' : 'POST'; + const body = { name }; + if (secret) body.secret = secret; + + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (res.ok) { + closeModal(addOtpModal); + fetchOTPSecrets(); + } else { + const data = await res.json(); + alert(data.detail || `Failed to ${editOtpId ? 'update' : 'add'} OTP secret`); + } + } catch (e) { + console.error('Error saving OTP secret', e); + } + }); + + window.editOTPSecret = (id) => { + const sec = otpSecretsData.find(s => s.id === id); + if (!sec) return; + editOtpId = id; + document.getElementById('otp-name').value = sec.name; + document.getElementById('otp-secret').value = ''; + document.getElementById('otp-secret').required = false; + document.getElementById('otp-secret').placeholder = 'Leave blank to keep current secret'; + otpModalTitle.textContent = 'Edit OTP Secret'; + otpSaveBtn.textContent = 'Save Changes'; + openModal(addOtpModal); + }; + + window.deleteOTPSecret = async (id) => { + if (!confirm('Are you sure you want to delete this OTP secret?')) return; + try { + const res = await fetch(`/api/otp-secrets/${id}`, { method: 'DELETE' }); + if (res.status === 401) return showLogin(); + if (res.ok) fetchOTPSecrets(); + else { + const data = await res.json(); + alert(data.detail || 'Failed to delete OTP secret'); + } + } catch (e) { + console.error('Error deleting OTP secret', e); + } + }; + loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('login-username').value; @@ -254,6 +370,7 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('login-password').value = ''; fetchConfig(); fetchInstances(); + fetchOTPSecrets(); } else { loginError.style.display = 'block'; } @@ -419,6 +536,7 @@ document.addEventListener('DOMContentLoaded', () => { hideLogin(); fetchConfig(); fetchInstances(); + fetchOTPSecrets(); } else { showLogin(); } diff --git a/static/index.html b/static/index.html index af87ca1..c65365e 100644 --- a/static/index.html +++ b/static/index.html @@ -38,6 +38,7 @@ @@ -66,6 +67,21 @@
+
+
+
+

OTP Secrets

+ +
+
+ +
+
+
+
@@ -132,14 +148,36 @@
- - + +
+ +