198 lines
8.0 KiB
Python
198 lines
8.0 KiB
Python
"""Persistent, operator-authorized first-install setup.
|
|
|
|
Initialize the marker before the main schema: an existing users table identifies
|
|
an upgraded installation, while a new database must finish the setup wizard.
|
|
The marker and first administrator are protected by SQLite write transactions.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
import hmac
|
|
from math import ceil
|
|
from time import time
|
|
from typing import Literal
|
|
|
|
from .. import db
|
|
from ..config import settings
|
|
from ..security import hash_password, validate_password_policy
|
|
|
|
|
|
SetupStep = Literal["administrator", "apps", "preferences", "review"]
|
|
SETUP_STEPS = ("administrator", "apps", "preferences", "review")
|
|
BOOTSTRAP_WINDOW_SECONDS = 15 * 60
|
|
BOOTSTRAP_IP_ATTEMPTS = 5
|
|
BOOTSTRAP_GLOBAL_ATTEMPTS = 30
|
|
|
|
|
|
class SetupUnavailableError(ValueError):
|
|
"""Setup has finished, or another administrator already exists."""
|
|
|
|
|
|
class InvalidSetupTokenError(ValueError):
|
|
"""The operator's setup token was absent or did not match."""
|
|
|
|
|
|
def initialize_setup_state() -> None:
|
|
"""Run once before init_db; subsequent calls preserve progress."""
|
|
with db._connect() as conn:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
existing_install = conn.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
|
|
).fetchone() is not None
|
|
conn.execute(
|
|
"""CREATE TABLE IF NOT EXISTS installation_setup (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
|
|
step TEXT NOT NULL,
|
|
completed_at TEXT
|
|
)"""
|
|
)
|
|
conn.execute(
|
|
"""CREATE TABLE IF NOT EXISTS installation_setup_attempts (
|
|
scope TEXT NOT NULL,
|
|
key_hash TEXT NOT NULL,
|
|
occurred_at REAL NOT NULL
|
|
)"""
|
|
)
|
|
conn.execute(
|
|
"""INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
|
|
VALUES (1, ?, ?, ?)""",
|
|
(
|
|
int(existing_install),
|
|
"review" if existing_install else "administrator",
|
|
datetime.now(timezone.utc).isoformat() if existing_install else None,
|
|
),
|
|
)
|
|
|
|
|
|
def get_setup_state() -> dict:
|
|
with db._connect() as conn:
|
|
# Old databases and isolated callers without startup initialization are
|
|
# already installed. A missing marker must never open public bootstrap.
|
|
table = conn.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
|
|
).fetchone()
|
|
row = conn.execute(
|
|
"SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
|
|
).fetchone() if table else None
|
|
if row is None:
|
|
return {"completed": True, "step": "review", "completed_at": None}
|
|
return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
|
|
|
|
|
|
def is_setup_required() -> bool:
|
|
return not get_setup_state()["completed"]
|
|
|
|
|
|
def get_public_setup_status() -> dict:
|
|
required = is_setup_required()
|
|
return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
|
|
|
|
|
|
def setup_token_configured() -> bool:
|
|
"""Reject missing values and obvious examples, without claiming to measure entropy."""
|
|
token = str(getattr(settings, "setup_token", "") or "").strip()
|
|
placeholder = token.casefold().replace("_", "-")
|
|
return (
|
|
len(token) >= 32
|
|
and len(set(token)) > 1
|
|
and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
|
|
)
|
|
|
|
|
|
def consume_bootstrap_attempt(client_ip: str) -> int | None:
|
|
"""Atomically reserve one attempt; return Retry-After when limited.
|
|
|
|
The IP is keyed using the existing HMAC helper, never stored in clear text.
|
|
A shared cap limits distributed attempts and expensive password hashing.
|
|
"""
|
|
now = time()
|
|
cutoff = now - BOOTSTRAP_WINDOW_SECONDS
|
|
limits = (
|
|
("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
|
|
("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
|
|
)
|
|
with db._connect() as conn:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.execute(
|
|
"DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
|
|
(cutoff,),
|
|
)
|
|
retry_after = 0
|
|
for scope, key, maximum in limits:
|
|
count, oldest = conn.execute(
|
|
"""SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
|
|
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
|
|
(scope, key, cutoff),
|
|
).fetchone()
|
|
if count >= maximum:
|
|
retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
|
|
if retry_after:
|
|
return retry_after
|
|
conn.executemany(
|
|
"INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
|
|
[(scope, key, now) for scope, key, _ in limits],
|
|
)
|
|
return None
|
|
|
|
|
|
def bootstrap_administrator(setup_token: str, username: str, password: str) -> None:
|
|
"""Claim fresh setup exactly once using the deployment's setup token."""
|
|
expected = str(getattr(settings, "setup_token", "") or "")
|
|
if not setup_token_configured() or not hmac.compare_digest(
|
|
setup_token.encode("utf-8"), expected.encode("utf-8")
|
|
):
|
|
raise InvalidSetupTokenError("Invalid setup token.")
|
|
username = username.strip()
|
|
if not username or len(username) > 100 or any(
|
|
character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
|
|
):
|
|
raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
|
|
if len(password) > 1024:
|
|
raise ValueError("Password must contain no more than 1024 characters.")
|
|
password = validate_password_policy(password)
|
|
if not is_setup_required() or db.has_admin_user():
|
|
raise SetupUnavailableError("Initial administrator setup is no longer available.")
|
|
|
|
password_hash = hash_password(password)
|
|
with db._connect() as conn:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
|
|
admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
|
|
if setup is None or setup[0] or admin:
|
|
raise SetupUnavailableError("Initial administrator setup is no longer available.")
|
|
if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
|
|
raise SetupUnavailableError("That username already exists.")
|
|
conn.execute(
|
|
"""INSERT INTO users (username, password_hash, role, auth_provider, created_at)
|
|
VALUES (?, ?, 'admin', 'local', ?)""",
|
|
(username, password_hash, datetime.now(timezone.utc).isoformat()),
|
|
)
|
|
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
|
|
|
|
|
|
def update_setup_step(step: SetupStep) -> dict:
|
|
if step not in SETUP_STEPS:
|
|
raise ValueError("Invalid setup step.")
|
|
if not is_setup_required():
|
|
return get_setup_state()
|
|
with db._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
|
|
)
|
|
return get_setup_state()
|
|
|
|
|
|
def complete_setup() -> dict:
|
|
if not is_setup_required():
|
|
return get_setup_state()
|
|
with db._connect() as conn:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
|
|
raise SetupUnavailableError("Create an administrator before completing setup.")
|
|
conn.execute(
|
|
"""UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
|
|
WHERE id = 1 AND completed = 0""",
|
|
(datetime.now(timezone.utc).isoformat(),),
|
|
)
|
|
return get_setup_state()
|