0ed275835d
Refresh/logout and token robustness - drop the post-rotation refresh_tokens row delete in auth_refresh so logout blacklists the current (rotated) refresh token; remove the dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token - reject non-object JWT payloads in _extract_unverified_sub so crafted Authorization headers return 401 instead of crashing with 500 SQLite user store - make builtin-admin seeding idempotent: on a concurrent first start the losing seeder re-checks, finds the winner, and returns instead of raising IntegrityError - per-thread sqlite connections + busy_timeout so Flask worker threads don't hit cross-thread ProgrammingError / SQLITE_BUSY - add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both systemd units so the fallback admin password actually lands on disk Frontend - skip apiFetch 401-recovery for public auth endpoints so a failed login no longer logs out a valid session - add /passkeys to the nav (passkey registration was unreachable); remove the dead checkWebAuthnCapable export - drop the CSP-blocked inline WS-URL script and the __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location Daemon / WS - parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx fallback only — docstring and security docs corrected Install / system - bootstrap_auth.py is now idempotent: preserves existing auth config and syncs the admin password on re-runs (new reset_password helper) - WebUI server block renders auth_basic off (the UI is JWT-protected) - install.sh chown/chmod skips .git to avoid git dubious-ownership breakage - tolerate unreadable /etc/wireguard during system import Contracts / docs - create_user returns 409 on duplicate username per docs/api.md - correct docs/api.md response shapes, docs/security.md blacklist cleanup wording + one-refresh-per-user caveat, stale WS-URL references, and the .htpasswd description Tests: +7 regression tests (rotation/logout revocation, crafted-token 401, concurrent seeding); placeholder-substitution tests replaced with serve-as-is SPA root tests.
186 lines
6.8 KiB
Python
186 lines
6.8 KiB
Python
"""SQLite backend for Vacuum Wall database.
|
|
|
|
Concrete implementation of the Database abstract class using SQLite3.
|
|
Uses Python 3.13+ sqlite3.Statement for prepared statements.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import sqlite3
|
|
from typing import Any, ClassVar
|
|
|
|
from lib.db import (
|
|
Q_DELETE_EXPIRED_BLACKLIST,
|
|
Q_DELETE_PERMISSION_SUBSYSTEM,
|
|
Q_DELETE_PERMISSIONS,
|
|
Q_DELETE_REFRESH_TOKEN,
|
|
Q_DELETE_USER,
|
|
Q_DELETE_WEBAUTHN,
|
|
Q_INSERT_BLACKLIST,
|
|
Q_INSERT_USER,
|
|
Q_INSERT_WEBAUTHN,
|
|
Q_SELECT_ALL_USERS,
|
|
Q_SELECT_BLACKLIST,
|
|
Q_SELECT_PERMISSIONS,
|
|
Q_SELECT_REFRESH_TOKEN,
|
|
Q_SELECT_USER_BY_ID,
|
|
Q_SELECT_USER_BY_NAME,
|
|
Q_SELECT_USER_JWT_SECRET,
|
|
Q_SELECT_USERS_WITH_PERMS,
|
|
Q_SELECT_WEBAUTHN_COUNTS,
|
|
Q_SELECT_WEBAUTHN_ID,
|
|
Q_SELECT_WEBAUTHN_USER,
|
|
Q_UPDATE_JWT_SECRET,
|
|
Q_UPDATE_PASSWORD,
|
|
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
|
|
Q_UPSERT_PERMISSION,
|
|
Q_UPSERT_REFRESH_TOKEN,
|
|
Database,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SQLiteBackend(Database):
|
|
"""SQLite implementation of the Database interface.
|
|
|
|
Uses ``sqlite3.Connection.execute()`` for statement execution and
|
|
``sqlite3.Row`` for row-factory dict access.
|
|
"""
|
|
|
|
def __init__(self, connection_string: str) -> None:
|
|
super().__init__(connection_string)
|
|
self._last_rowid: int = 0
|
|
|
|
QUERY_MAP: ClassVar[dict[str, str]] = {
|
|
# Schema init is handled by direct execution, not prepared statements
|
|
# init_tables is called as _execute_direct(INIT_SQL)
|
|
# Users
|
|
Q_INSERT_USER: (
|
|
"INSERT INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)"
|
|
),
|
|
Q_SELECT_USER_BY_NAME: (
|
|
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE username = ?"
|
|
),
|
|
Q_SELECT_USER_BY_ID: (
|
|
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE id = ?"
|
|
),
|
|
Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?",
|
|
Q_DELETE_USER: "DELETE FROM users WHERE username = ?",
|
|
Q_SELECT_ALL_USERS: (
|
|
"SELECT id, username, created_at FROM users ORDER BY username"
|
|
),
|
|
Q_SELECT_USERS_WITH_PERMS: (
|
|
"SELECT u.id, u.username, u.created_at, p.subsystem, p.level "
|
|
"FROM users u LEFT JOIN permissions p ON u.username = p.username "
|
|
"ORDER BY u.username"
|
|
),
|
|
Q_SELECT_USER_JWT_SECRET: ("SELECT jwt_secret FROM users WHERE username = ?"),
|
|
Q_UPDATE_JWT_SECRET: ("UPDATE users SET jwt_secret = ? WHERE username = ?"),
|
|
# Permissions
|
|
Q_UPSERT_PERMISSION: (
|
|
"INSERT INTO permissions (username, subsystem, level) "
|
|
"VALUES (?, ?, ?) "
|
|
"ON CONFLICT(username, subsystem) DO UPDATE SET level = excluded.level"
|
|
),
|
|
Q_SELECT_PERMISSIONS: (
|
|
"SELECT subsystem, level FROM permissions WHERE username = ?"
|
|
),
|
|
Q_DELETE_PERMISSIONS: ("DELETE FROM permissions WHERE username = ?"),
|
|
Q_DELETE_PERMISSION_SUBSYSTEM: (
|
|
"DELETE FROM permissions WHERE username = ? AND subsystem = ?"
|
|
),
|
|
# Token blacklist
|
|
Q_INSERT_BLACKLIST: (
|
|
"INSERT OR IGNORE INTO token_blacklist (jti, token_type, expires) VALUES (?, ?, ?)"
|
|
),
|
|
Q_SELECT_BLACKLIST: "SELECT jti FROM token_blacklist WHERE jti = ?",
|
|
Q_DELETE_EXPIRED_BLACKLIST: "DELETE FROM token_blacklist WHERE expires < ?",
|
|
# Refresh tokens
|
|
Q_UPSERT_REFRESH_TOKEN: (
|
|
"INSERT INTO refresh_tokens (username, jti, issued_at) "
|
|
"VALUES (?, ?, ?) "
|
|
"ON CONFLICT(username) DO UPDATE SET jti = excluded.jti, issued_at = excluded.issued_at"
|
|
),
|
|
Q_SELECT_REFRESH_TOKEN: "SELECT username, jti, issued_at FROM refresh_tokens WHERE username = ?",
|
|
Q_DELETE_REFRESH_TOKEN: "DELETE FROM refresh_tokens WHERE username = ?",
|
|
# WebAuthn
|
|
Q_INSERT_WEBAUTHN: (
|
|
"INSERT INTO webauthn_creds "
|
|
"(username, credential_id, public_key, sign_count, name, transports) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)"
|
|
),
|
|
Q_SELECT_WEBAUTHN_USER: (
|
|
"SELECT id, credential_id, public_key, sign_count, name, transports "
|
|
"FROM webauthn_creds WHERE username = ?"
|
|
),
|
|
Q_SELECT_WEBAUTHN_ID: (
|
|
"SELECT id, username, credential_id, public_key, sign_count, name, transports "
|
|
"FROM webauthn_creds WHERE credential_id = ?"
|
|
),
|
|
Q_SELECT_WEBAUTHN_COUNTS: (
|
|
"SELECT username, COUNT(*) as cred_count FROM webauthn_creds "
|
|
"GROUP BY username"
|
|
),
|
|
Q_UPDATE_WEBAUTHN_SIGN_COUNT: (
|
|
"UPDATE webauthn_creds SET sign_count = ? WHERE credential_id = ?"
|
|
),
|
|
Q_DELETE_WEBAUTHN: "DELETE FROM webauthn_creds WHERE credential_id = ?",
|
|
}
|
|
|
|
def _connect(self, cs: str) -> Any:
|
|
"""Create a SQLite connection with WAL mode and row factory."""
|
|
conn = sqlite3.connect(cs, isolation_level=None)
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
# Multiple threads/processes hold distinct connections (see
|
|
# Database.conn); wait up to 5s for writers instead of failing
|
|
# immediately with SQLITE_BUSY.
|
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _prepare(self, sql: str) -> Any:
|
|
"""Store the SQL string for later execution.
|
|
|
|
SQLite in-memory DB doesn't support the Python 3.13 conn.prepare()
|
|
API, so we store the raw SQL and execute via conn.execute().
|
|
"""
|
|
return sql
|
|
|
|
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
|
|
"""Execute a prepared statement, returning (rows, rowcount)."""
|
|
cursor = self.conn.execute(stmt, params)
|
|
self._last_rowid = cursor.lastrowid
|
|
result = cursor.fetchall()
|
|
rows: list[dict] = []
|
|
for row in result:
|
|
rows.append(dict(row))
|
|
return rows, cursor.rowcount
|
|
|
|
def _last_insert_id(self, stmt: Any) -> int | dict:
|
|
"""Return the last insert row ID from the most recent execute."""
|
|
return self._last_rowid
|
|
|
|
def _begin(self) -> None:
|
|
self.conn.execute("BEGIN")
|
|
|
|
def _commit(self) -> None:
|
|
self.conn.execute("COMMIT")
|
|
|
|
def _rollback(self) -> None:
|
|
with contextlib.suppress(sqlite3.Error):
|
|
self.conn.execute("ROLLBACK")
|
|
|
|
def _suppress_auto_commit(self) -> None:
|
|
self._in_transaction = True
|
|
|
|
def _restore_auto_commit(self) -> None:
|
|
self._in_transaction = False
|
|
|
|
def _execute_direct(self, sql: str) -> None:
|
|
"""Execute raw SQL without prepared statements (for DDL)."""
|
|
self.conn.executescript(sql)
|