from __future__ import annotations import re import secrets import sqlite3 from datetime import UTC, datetime from ..ddns_security import hash_lookup_token, hash_secret, verify_secret from .types import AccountRow class AccountsMixin: def create_account(self, hostname: str, username: str | None = None) -> AccountRow: now = datetime.now(UTC).isoformat() token = secrets.token_urlsafe(24) update_slug = secrets.token_urlsafe(32) management_slug = secrets.token_urlsafe(32) username = username or _default_username(hostname) token_hash = hash_secret(token) management_hash = hash_lookup_token(management_slug) with self.connect() as conn: conn.execute( """ INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled) VALUES (?, ?, ?, ?, ?, ?, 0) """, (hostname, username, token_hash, update_slug, management_hash, now), ) return { "hostname": hostname, "username": username, "password": token, "update_slug": update_slug, "management_slug": management_slug, } def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]: with self.connect() as conn: conn.row_factory = sqlite3.Row rows = conn.execute( """ SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at FROM accounts LEFT JOIN records ON records.hostname = accounts.hostname ORDER BY accounts.hostname LIMIT ? OFFSET ? """, (limit, offset), ).fetchall() return [dict(row) for row in rows] def count_accounts(self) -> int: with self.connect() as conn: row = conn.execute("SELECT COUNT(*) FROM accounts").fetchone() return int(row[0]) def get_account_by_hostname(self, hostname: str) -> AccountRow | None: with self.connect() as conn: conn.row_factory = sqlite3.Row account = conn.execute( """ SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash, accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at FROM accounts LEFT JOIN records ON records.hostname = accounts.hostname WHERE accounts.hostname = ? """, (hostname,), ).fetchone() return dict(account) if account else None def get_account_by_slug(self, update_slug: str) -> AccountRow | None: with self.connect() as conn: conn.row_factory = sqlite3.Row account = conn.execute( """ SELECT hostname, username, token_hash, update_slug, disabled FROM accounts WHERE update_slug = ? """, (update_slug,), ).fetchone() return dict(account) if account else None def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None: with self.connect() as conn: conn.row_factory = sqlite3.Row account = conn.execute( """ SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash, accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at FROM accounts LEFT JOIN records ON records.hostname = accounts.hostname WHERE accounts.management_hash = ? """, (hash_lookup_token(management_slug),), ).fetchone() return dict(account) if account else None def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool: if not username or not token: return False with self.connect() as conn: conn.row_factory = sqlite3.Row account = conn.execute( """ SELECT token_hash, disabled FROM accounts WHERE hostname = ? AND username = ? """, (hostname, username), ).fetchone() if not account or account["disabled"]: return False return verify_secret(token, account["token_hash"]) def delete_account(self, hostname: str) -> None: with self.connect() as conn: conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,)) conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,)) def rotate_update_slug(self, hostname: str) -> AccountRow | None: update_slug = secrets.token_urlsafe(32) with self.connect() as conn: conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname)) return self.get_account_by_hostname(hostname) def rotate_management_slug(self, hostname: str) -> AccountRow | None: management_slug = secrets.token_urlsafe(32) management_hash = hash_lookup_token(management_slug) with self.connect() as conn: conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname)) account = self.get_account_by_hostname(hostname) if account: account["management_slug"] = management_slug return account def rotate_password(self, hostname: str) -> AccountRow | None: token = secrets.token_urlsafe(24) token_hash = hash_secret(token) with self.connect() as conn: conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname)) account = self.get_account_by_hostname(hostname) if account: account["password"] = token return account def set_dashboard_password(self, hostname: str, password: str) -> AccountRow | None: password_hash = hash_secret(password) with self.connect() as conn: conn.execute( "UPDATE accounts SET dashboard_password_hash = ? WHERE hostname = ?", (password_hash, hostname), ) return self.get_account_by_hostname(hostname) def clear_dashboard_password(self, hostname: str) -> AccountRow | None: with self.connect() as conn: conn.execute("UPDATE accounts SET dashboard_password_hash = NULL WHERE hostname = ?", (hostname,)) return self.get_account_by_hostname(hostname) def verify_dashboard_password(self, hostname: str, password: str | None) -> bool: if not password: return False with self.connect() as conn: conn.row_factory = sqlite3.Row account = conn.execute( """ SELECT dashboard_password_hash, disabled FROM accounts WHERE hostname = ? """, (hostname,), ).fetchone() if not account or account["disabled"] or not account["dashboard_password_hash"]: return False return verify_secret(password, account["dashboard_password_hash"]) def _default_username(hostname: str) -> str: label = hostname.split(".", 1)[0] return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"