1060 lines
38 KiB
Python
1060 lines
38 KiB
Python
"""SQLite database models, initialization, and user-scoped helpers.
|
|
|
|
Multi-user design:
|
|
- The `users` table stores every account. Root (ID 0) is the administrator and
|
|
is created automatically on first boot (fresh DB) or when migrating an
|
|
existing single-user database (all pre-existing data becomes Root's data).
|
|
- Every per-user table carries a `user_id INTEGER NOT NULL DEFAULT 0` column,
|
|
so legacy single-user rows are automatically attributed to Root on migration
|
|
and any NEW column added in the future gets a DEFAULT (the migration fills
|
|
missing columns with their SCHEMA defaults).
|
|
- `auth_config` is keyed by `user_id` (legacy `id=1` row maps to user 0).
|
|
"""
|
|
import sqlite3
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
|
|
SCHEMA = """
|
|
-- Global schema version (single row). All new columns must carry a DEFAULT.
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
version INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
|
|
-- Auth tokens (dual-token auth): access (short-lived, per-request) and
|
|
-- refresh (long-lived, stored in an httpOnly cookie). Only hashes are stored.
|
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
token_hash TEXT NOT NULL,
|
|
kind TEXT NOT NULL DEFAULT 'access' CHECK(kind IN ('access','refresh')),
|
|
view TEXT NOT NULL DEFAULT 'display' CHECK(view IN ('control','display')),
|
|
temp INTEGER NOT NULL DEFAULT 0,
|
|
session_version INTEGER NOT NULL DEFAULT 0,
|
|
reauth_at INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
remark TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
|
|
is_temp INTEGER NOT NULL DEFAULT 0,
|
|
temp_totp_secret TEXT DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending')),
|
|
session_version INTEGER NOT NULL DEFAULT 0,
|
|
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('dark','light','auto')),
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_config (
|
|
user_id INTEGER PRIMARY KEY DEFAULT 0,
|
|
passkey_credential_id TEXT DEFAULT '',
|
|
passkey_public_key TEXT DEFAULT '',
|
|
totp_secret TEXT DEFAULT '',
|
|
totp_enabled INTEGER DEFAULT 0,
|
|
recovery_codes TEXT DEFAULT '',
|
|
temp_password TEXT DEFAULT '',
|
|
must_configure_mfa INTEGER DEFAULT 1
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS calendar_groups (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
name TEXT NOT NULL,
|
|
color TEXT DEFAULT '#4A90D9',
|
|
enabled INTEGER DEFAULT 1,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS calendar_sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
group_id INTEGER NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('upload', 'subscription', 'manual')),
|
|
name TEXT NOT NULL,
|
|
url TEXT,
|
|
ics_data TEXT,
|
|
last_sync TEXT,
|
|
FOREIGN KEY (group_id) REFERENCES calendar_groups(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
source_id INTEGER NOT NULL,
|
|
uid TEXT,
|
|
summary TEXT,
|
|
description TEXT,
|
|
location TEXT,
|
|
dtstart TEXT,
|
|
dtend TEXT,
|
|
is_all_day INTEGER DEFAULT 0,
|
|
FOREIGN KEY (source_id) REFERENCES calendar_sources(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS reminders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
calendar_group_id INTEGER NOT NULL,
|
|
minutes_before INTEGER DEFAULT 15,
|
|
enabled INTEGER DEFAULT 1,
|
|
FOREIGN KEY (calendar_group_id) REFERENCES calendar_groups(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS weather_config (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
api_key TEXT DEFAULT '',
|
|
city TEXT DEFAULT 'Beijing',
|
|
lat REAL,
|
|
lon REAL,
|
|
units TEXT DEFAULT 'metric',
|
|
update_interval INTEGER DEFAULT 1800,
|
|
jwt_private_key TEXT DEFAULT '',
|
|
jwt_public_key TEXT DEFAULT '',
|
|
credential_id TEXT DEFAULT '',
|
|
project_id TEXT DEFAULT '',
|
|
qweather_host TEXT DEFAULT ''
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS weather_cache (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
data TEXT,
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS weather_hourly_cache (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
data TEXT,
|
|
fetched_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS weather_minutely_cache (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
data TEXT,
|
|
fetched_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS weather_warning_cache (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
data TEXT,
|
|
fetched_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS clock_config (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
city TEXT DEFAULT 'Beijing',
|
|
timezone TEXT DEFAULT 'Asia/Shanghai',
|
|
is_primary INTEGER DEFAULT 1
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS alarms (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
label TEXT DEFAULT '',
|
|
time TEXT NOT NULL,
|
|
days TEXT DEFAULT '1,2,3,4,5,6,7',
|
|
enabled INTEGER DEFAULT 1,
|
|
sound TEXT DEFAULT 'default',
|
|
once INTEGER DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS countdowns (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
label TEXT DEFAULT '',
|
|
duration_seconds INTEGER DEFAULT 300,
|
|
end_time TEXT DEFAULT '',
|
|
enabled INTEGER DEFAULT 1,
|
|
sound TEXT DEFAULT 'default'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS display_config (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
mode TEXT NOT NULL DEFAULT 'default' CHECK(mode IN ('default', 'custom')),
|
|
custom_html TEXT DEFAULT '',
|
|
custom_html_1 TEXT DEFAULT '',
|
|
custom_html_2 TEXT DEFAULT '',
|
|
custom_html_3 TEXT DEFAULT ''
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pomodoro_state (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
running INTEGER DEFAULT 0,
|
|
phase TEXT DEFAULT 'work' CHECK(phase IN ('work', 'break')),
|
|
work_minutes INTEGER DEFAULT 25,
|
|
break_minutes INTEGER DEFAULT 5,
|
|
long_break_minutes INTEGER DEFAULT 15,
|
|
cycles_before_long_break INTEGER DEFAULT 4,
|
|
end_time TEXT,
|
|
remaining_seconds INTEGER DEFAULT 1500,
|
|
cycles INTEGER DEFAULT 0,
|
|
enabled INTEGER DEFAULT 0,
|
|
display_controls_enabled INTEGER DEFAULT 1,
|
|
cycles_date TEXT DEFAULT '',
|
|
work_label TEXT DEFAULT '工作中',
|
|
break_label TEXT DEFAULT '休息中'
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user_id);
|
|
"""
|
|
|
|
|
|
# Tables whose rows are completely per-user (used for cascade delete).
|
|
USER_DATA_TABLES = [
|
|
"auth_config", "calendar_groups", "calendar_sources", "calendar_events",
|
|
"reminders", "weather_config", "weather_cache", "weather_hourly_cache",
|
|
"weather_minutely_cache", "weather_warning_cache", "clock_config",
|
|
"alarms", "countdowns", "display_config", "pomodoro_state",
|
|
"auth_tokens",
|
|
]
|
|
|
|
|
|
def get_db(db_path: str = "data.db") -> sqlite3.Connection:
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
|
|
def _db_path(preferred: str = None) -> str:
|
|
"""Get the actual database path, preferring current_app config if available."""
|
|
if preferred:
|
|
return preferred
|
|
try:
|
|
from flask import current_app
|
|
return current_app.config.get("DB_PATH", "data.db")
|
|
except Exception:
|
|
return "data.db"
|
|
|
|
|
|
# ─── Users ───────────────────────────────────
|
|
|
|
def get_users(db_path: str = None) -> list:
|
|
"""Return all users ordered by id."""
|
|
conn = get_db(_db_path(db_path))
|
|
rows = [dict(r) for r in conn.execute("SELECT * FROM users ORDER BY id").fetchall()]
|
|
conn.close()
|
|
return rows
|
|
|
|
|
|
def get_active_user_ids(db_path: str = None) -> list:
|
|
"""Return ids of all active (non-pending) users. Used by the scheduler."""
|
|
conn = get_db(_db_path(db_path))
|
|
rows = conn.execute("SELECT id FROM users WHERE status='active' ORDER BY id").fetchall()
|
|
conn.close()
|
|
return [r["id"] for r in rows]
|
|
|
|
|
|
def get_user(user_id: int, db_path: str = None) -> dict | None:
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def user_exists(user_id: int, db_path: str = None) -> bool:
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute("SELECT 1 FROM users WHERE id=?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return row is not None
|
|
|
|
|
|
def is_admin_user(user_id: int, db_path: str = None) -> bool:
|
|
"""Root is always admin; otherwise check the role column."""
|
|
if user_id == 0:
|
|
return True
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute("SELECT role FROM users WHERE id=?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return bool(row and row["role"] == "admin")
|
|
|
|
|
|
def create_user(remark: str = "", role: str = "user", is_temp: int = 0,
|
|
temp_totp_secret: str = "", status: str = "active",
|
|
db_path: str = None) -> int:
|
|
"""Create a user. Returns the new user id.
|
|
The remark defaults to the user's own id (the only place an id is shown).
|
|
"""
|
|
conn = get_db(_db_path(db_path))
|
|
cur = conn.execute(
|
|
"INSERT INTO users (remark, role, is_temp, temp_totp_secret, status) VALUES (?, ?, ?, ?, ?)",
|
|
(remark, role, 1 if is_temp else 0, temp_totp_secret, status)
|
|
)
|
|
uid = cur.lastrowid
|
|
if not remark:
|
|
conn.execute("UPDATE users SET remark=? WHERE id=?", (str(uid), uid))
|
|
conn.commit()
|
|
conn.close()
|
|
return uid
|
|
|
|
|
|
def update_user(user_id: int, remark: str = None, role: str = None,
|
|
is_temp: int = None, temp_totp_secret: str = None,
|
|
status: str = None, theme: str = None, db_path: str = None):
|
|
conn = get_db(_db_path(db_path))
|
|
fields = []
|
|
vals = []
|
|
for key, val in [("remark", remark), ("role", role), ("is_temp", is_temp),
|
|
("temp_totp_secret", temp_totp_secret), ("status", status),
|
|
("theme", theme)]:
|
|
if val is not None:
|
|
fields.append(f"{key}=?")
|
|
vals.append(val)
|
|
if fields:
|
|
vals.append(user_id)
|
|
conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE id=?", vals)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def delete_user(user_id: int, db_path: str = None):
|
|
"""Delete a user and ALL of their data. Root (id 0) cannot be deleted.
|
|
|
|
Deletion order respects foreign keys (children before parents).
|
|
"""
|
|
if user_id == 0:
|
|
return
|
|
conn = get_db(_db_path(db_path))
|
|
# Children first so FK constraints never reject a delete.
|
|
ordered = [
|
|
"calendar_events", "calendar_sources", "reminders", "calendar_groups",
|
|
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
|
|
"weather_warning_cache", "weather_config", "clock_config",
|
|
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
|
|
"auth_tokens",
|
|
]
|
|
try:
|
|
for table in ordered:
|
|
conn.execute(f"DELETE FROM {table} WHERE user_id=?", (user_id,))
|
|
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_user_session_version(user_id: int, db_path: str = None) -> int:
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return row["session_version"] if row else -1
|
|
|
|
|
|
def bump_user_session_version(user_id: int, db_path: str = None) -> int:
|
|
"""Invalidate all sessions of one user by incrementing their version."""
|
|
conn = get_db(_db_path(db_path))
|
|
conn.execute("UPDATE users SET session_version = session_version + 1 WHERE id=?", (user_id,))
|
|
conn.commit()
|
|
row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return row["session_version"] if row else -1
|
|
|
|
|
|
def bump_all_session_versions(db_path: str = None) -> int:
|
|
"""Invalidate every session (admin 'kick all' action)."""
|
|
conn = get_db(_db_path(db_path))
|
|
conn.execute("UPDATE users SET session_version = session_version + 1")
|
|
conn.commit()
|
|
conn.close()
|
|
return 0
|
|
|
|
|
|
def is_temp_account(user_id: int, db_path: str = None) -> bool:
|
|
"""True if the account is a temp account (awaiting first login-method setup)."""
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute("SELECT is_temp FROM users WHERE id=?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return bool(row and row["is_temp"])
|
|
|
|
|
|
def get_temp_totp_secret(user_id: int, db_path: str = None) -> str:
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute("SELECT temp_totp_secret FROM users WHERE id=?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return (row["temp_totp_secret"] or "") if row else ""
|
|
|
|
|
|
def finalize_temp_account(user_id: int, db_path: str = None):
|
|
"""Convert a temp account into a formal account:
|
|
invalidates the temp TOTP, clears the temp flag, and revokes every
|
|
temp=1 token (a temp refresh must never mint formal tokens afterwards).
|
|
"""
|
|
conn = get_db(_db_path(db_path))
|
|
conn.execute(
|
|
"UPDATE users SET is_temp=0, temp_totp_secret='' WHERE id=?",
|
|
(user_id,)
|
|
)
|
|
conn.execute(
|
|
"UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?",
|
|
(user_id,)
|
|
)
|
|
conn.execute(
|
|
"DELETE FROM auth_tokens WHERE user_id=? AND temp=1",
|
|
(user_id,)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
# ─── Display config (per user) ──────────────
|
|
|
|
def _ensure_display_row(conn, user_id: int):
|
|
row = conn.execute("SELECT id FROM display_config WHERE user_id=?", (user_id,)).fetchone()
|
|
if row is None:
|
|
conn.execute(
|
|
"INSERT INTO display_config (user_id, mode) VALUES (?, 'default')",
|
|
(user_id,)
|
|
)
|
|
|
|
|
|
def get_display_config(db_path: str = None, user_id: int = 0) -> dict:
|
|
db = get_db(_db_path(db_path))
|
|
row = db.execute(
|
|
"SELECT mode, custom_html, custom_html_1, custom_html_2, custom_html_3 "
|
|
"FROM display_config WHERE user_id=?",
|
|
(user_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
db.execute(
|
|
"INSERT INTO display_config (user_id, mode, custom_html, custom_html_1, custom_html_2, custom_html_3) "
|
|
"VALUES (?, 'default', '', '', '', '')",
|
|
(user_id,)
|
|
)
|
|
db.commit()
|
|
cfg = {"mode": "default", "custom_html": "", "custom_html_1": "", "custom_html_2": "", "custom_html_3": ""}
|
|
else:
|
|
cfg = {
|
|
"mode": row["mode"],
|
|
"custom_html": row["custom_html"] or "",
|
|
"custom_html_1": row["custom_html_1"] or "",
|
|
"custom_html_2": row["custom_html_2"] or "",
|
|
"custom_html_3": row["custom_html_3"] or "",
|
|
}
|
|
db.close()
|
|
return cfg
|
|
|
|
|
|
def set_display_mode(mode: str, db_path: str = None, user_id: int = 0) -> bool:
|
|
if mode not in ("default", "custom"):
|
|
return False
|
|
db = get_db(_db_path(db_path))
|
|
_ensure_display_row(db, user_id)
|
|
db.execute("UPDATE display_config SET mode=? WHERE user_id=?", (mode, user_id))
|
|
db.commit()
|
|
db.close()
|
|
return True
|
|
|
|
|
|
def set_custom_html(html: str, db_path: str = None, user_id: int = 0) -> bool:
|
|
db = get_db(_db_path(db_path))
|
|
_ensure_display_row(db, user_id)
|
|
db.execute("UPDATE display_config SET custom_html=? WHERE user_id=?", (html, user_id))
|
|
db.commit()
|
|
db.close()
|
|
return True
|
|
|
|
|
|
def set_custom_html_template(index: int, html: str, db_path: str = None, user_id: int = 0) -> bool:
|
|
if index not in (1, 2, 3):
|
|
return False
|
|
db = get_db(_db_path(db_path))
|
|
_ensure_display_row(db, user_id)
|
|
db.execute(
|
|
f"UPDATE display_config SET custom_html_{index}=? WHERE user_id=?",
|
|
(html, user_id)
|
|
)
|
|
db.commit()
|
|
db.close()
|
|
return True
|
|
|
|
|
|
def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool:
|
|
"""Copy template N (0=main custom_html, 1-3=custom_html_1-3) into custom_html and set mode to custom."""
|
|
db = get_db(_db_path(db_path))
|
|
row = db.execute(
|
|
"SELECT custom_html, custom_html_1, custom_html_2, custom_html_3 "
|
|
"FROM display_config WHERE user_id=?",
|
|
(user_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
db.close()
|
|
return False
|
|
if index == 0:
|
|
html = row["custom_html"] or ""
|
|
elif index == 1:
|
|
html = row["custom_html_1"] or ""
|
|
elif index == 2:
|
|
html = row["custom_html_2"] or ""
|
|
elif index == 3:
|
|
html = row["custom_html_3"] or ""
|
|
else:
|
|
db.close()
|
|
return False
|
|
db.execute("UPDATE display_config SET mode='custom', custom_html=? WHERE user_id=?", (html, user_id))
|
|
db.commit()
|
|
db.close()
|
|
return True
|
|
|
|
|
|
# ─── Auth tokens (dual-token auth) ───────────
|
|
# Access tokens are short-lived and validated on every request; refresh tokens
|
|
# are long-lived and stored in an httpOnly cookie. Only SHA-256 hashes are
|
|
# stored in the DB. Kicking a user/device = delete their tokens + bump the
|
|
# user's session_version (which invalidates every outstanding access token).
|
|
|
|
ACCESS_TOKEN_TTL = 15 * 60 # 15 minutes
|
|
REFRESH_TOKEN_TTL = 30 * 24 * 3600 # 30 days (sliding, renewed on each refresh)
|
|
|
|
|
|
def _hash_token(raw: str) -> str:
|
|
import hashlib
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _prune_expired_tokens(conn):
|
|
conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (int(time.time()),))
|
|
|
|
|
|
def create_auth_token(user_id: int, kind: str, view: str = "display",
|
|
temp: bool = False, ttl: int = None,
|
|
db_path: str = None) -> str:
|
|
"""Create an auth token row and return the raw (unhashed) token.
|
|
Only the SHA-256 hash is persisted."""
|
|
import secrets as _secrets
|
|
if ttl is None:
|
|
ttl = ACCESS_TOKEN_TTL if kind == "access" else REFRESH_TOKEN_TTL
|
|
raw = _secrets.token_urlsafe(48 if kind == "refresh" else 32)
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
_prune_expired_tokens(conn)
|
|
ver_row = conn.execute(
|
|
"SELECT session_version FROM users WHERE id=?", (user_id,)
|
|
).fetchone()
|
|
ver = ver_row["session_version"] if ver_row else 0
|
|
conn.execute(
|
|
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
|
|
"session_version, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(user_id, _hash_token(raw), kind, view, 1 if temp else 0, ver,
|
|
int(time.time()) + ttl)
|
|
)
|
|
conn.commit()
|
|
return raw
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _token_lookup(raw: str, kind: str, db_path: str = None) -> dict | None:
|
|
"""Look up a token by its raw value. For access tokens the row is only
|
|
valid while it matches the user's current session_version (kick support).
|
|
Returns None when invalid/expired/user inactive/version bumped."""
|
|
if not raw:
|
|
return None
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
h = _hash_token(raw)
|
|
row = conn.execute(
|
|
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
|
|
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind=?",
|
|
(h, kind)
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
if row["expires_at"] <= int(time.time()):
|
|
return None
|
|
if row["user_status"] != "active":
|
|
return None
|
|
if kind == "access" and row["session_version"] != _cur_session_version(row["user_id"], conn):
|
|
return None
|
|
return dict(row)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _cur_session_version(user_id: int, conn) -> int:
|
|
r = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
|
|
return r["session_version"] if r else -1
|
|
|
|
|
|
def find_access_token(raw: str, db_path: str = None) -> dict | None:
|
|
"""Validate an access token. Returns the row dict or None."""
|
|
return _token_lookup(raw, "access", db_path)
|
|
|
|
|
|
def rotate_refresh_token(raw: str, db_path: str = None) -> dict | None:
|
|
"""Atomically validate and rotate a refresh token (concurrency-safe).
|
|
Returns a fresh {access_token, refresh_token} pair or None. The old refresh
|
|
token is consumed inside a single transaction, so a replayed/concurrent
|
|
duplicate can never mint two valid pairs."""
|
|
if not raw:
|
|
return None
|
|
import secrets as _secrets
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
row = conn.execute(
|
|
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
|
|
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'",
|
|
(_hash_token(raw),)
|
|
).fetchone()
|
|
if (not row or row["expires_at"] <= int(time.time())
|
|
or row["user_status"] != "active"):
|
|
conn.rollback()
|
|
return None
|
|
cur = conn.execute("DELETE FROM auth_tokens WHERE id=?", (row["id"],))
|
|
if cur.rowcount == 0:
|
|
conn.rollback()
|
|
return None
|
|
now = int(time.time())
|
|
acc_raw = _secrets.token_urlsafe(32)
|
|
ref_raw = _secrets.token_urlsafe(48)
|
|
ver = row["session_version"]
|
|
conn.execute(
|
|
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
|
|
"session_version, expires_at) VALUES (?, ?, 'access', ?, ?, ?, ?)",
|
|
(row["user_id"], _hash_token(acc_raw), row["view"], row["temp"],
|
|
ver, now + ACCESS_TOKEN_TTL)
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO auth_tokens (user_id, token_hash, kind, view, temp, "
|
|
"session_version, expires_at) VALUES (?, ?, 'refresh', ?, ?, ?, ?)",
|
|
(row["user_id"], _hash_token(ref_raw), row["view"], row["temp"],
|
|
ver, now + REFRESH_TOKEN_TTL)
|
|
)
|
|
conn.commit()
|
|
return {"access_token": acc_raw, "refresh_token": ref_raw}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def find_refresh_token(raw: str, db_path: str = None) -> dict | None:
|
|
"""Validate a refresh token (status check only; version is not compared
|
|
here because a refresh token belongs to one device and is deleted on
|
|
kick, not merely invalidated)."""
|
|
if not raw:
|
|
return None
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
h = _hash_token(raw)
|
|
row = conn.execute(
|
|
"SELECT t.*, u.status AS user_status FROM auth_tokens t "
|
|
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=? AND t.kind='refresh'",
|
|
(h,)
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
if row["expires_at"] <= int(time.time()):
|
|
return None
|
|
if row["user_status"] != "active":
|
|
return None
|
|
return dict(row)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def mark_token_reauth(token_row_id: int, db_path: str = None):
|
|
"""Stamp the current access token row as re-authenticated (per-device)."""
|
|
if not token_row_id:
|
|
return
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
conn.execute("UPDATE auth_tokens SET reauth_at=? WHERE id=?",
|
|
(int(time.time()), token_row_id))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def delete_auth_token(raw: str, kind: str = None, db_path: str = None):
|
|
"""Delete a single token row by raw value (logout of one device)."""
|
|
if not raw:
|
|
return
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
h = _hash_token(raw)
|
|
if kind:
|
|
conn.execute("DELETE FROM auth_tokens WHERE token_hash=? AND kind=?", (h, kind))
|
|
else:
|
|
conn.execute("DELETE FROM auth_tokens WHERE token_hash=?", (h,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def revoke_user_tokens(user_id: int, db_path: str = None):
|
|
"""Delete every token of a user (kick all devices of one account).
|
|
Callers should also bump_user_session_version() to invalidate access tokens."""
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
conn.execute("DELETE FROM auth_tokens WHERE user_id=?", (user_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def revoke_all_tokens(db_path: str = None):
|
|
"""Delete every token of every user (Root: kick all users)."""
|
|
conn = get_db(_db_path(db_path))
|
|
try:
|
|
conn.execute("DELETE FROM auth_tokens")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ─── Auth / MFA helpers (per user) ──────────
|
|
|
|
def get_auth_config(db_path: str = None, user_id: int = 0) -> dict:
|
|
"""Return the full auth_config row for a user, with defaults for missing fields."""
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute("SELECT * FROM auth_config WHERE user_id = ?", (user_id,)).fetchone()
|
|
if row is None:
|
|
conn.execute(
|
|
"""INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key,
|
|
totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa)
|
|
VALUES (?, '', '', '', 0, '', '', 1)""",
|
|
(user_id,)
|
|
)
|
|
conn.commit()
|
|
cfg = {
|
|
"user_id": user_id,
|
|
"passkey_credential_id": "",
|
|
"passkey_public_key": "",
|
|
"totp_secret": "",
|
|
"totp_enabled": 0,
|
|
"recovery_codes": "",
|
|
"temp_password": "",
|
|
"must_configure_mfa": 1,
|
|
}
|
|
else:
|
|
cfg = {
|
|
"user_id": row["user_id"],
|
|
"passkey_credential_id": row["passkey_credential_id"] or "",
|
|
"passkey_public_key": row["passkey_public_key"] or "",
|
|
"totp_secret": row["totp_secret"] or "",
|
|
"totp_enabled": bool(row["totp_enabled"]),
|
|
"recovery_codes": row["recovery_codes"] or "",
|
|
"temp_password": row["temp_password"] or "",
|
|
"must_configure_mfa": bool(row["must_configure_mfa"]),
|
|
}
|
|
conn.close()
|
|
return cfg
|
|
|
|
|
|
def update_auth_config(user_id: int = 0, **kwargs):
|
|
"""Update auth_config fields. Keys match DB column names."""
|
|
db_path = _db_path(kwargs.pop("_db_path", None))
|
|
conn = get_db(db_path)
|
|
row = conn.execute("SELECT user_id FROM auth_config WHERE user_id = ?", (user_id,)).fetchone()
|
|
fields = []
|
|
vals = []
|
|
for key, val in kwargs.items():
|
|
if key in ("passkey_credential_id", "passkey_public_key", "totp_secret",
|
|
"recovery_codes", "temp_password", "must_configure_mfa", "totp_enabled"):
|
|
fields.append(f"{key}=?")
|
|
vals.append(val)
|
|
if not fields:
|
|
conn.close()
|
|
return
|
|
if row is None:
|
|
cols = ["user_id"] + [f.split("=")[0] for f in fields]
|
|
col_str = ", ".join(cols)
|
|
ph = ", ".join("?" * len(cols))
|
|
conn.execute(f"INSERT INTO auth_config ({col_str}) VALUES ({ph})", (user_id, *vals))
|
|
else:
|
|
vals.append(user_id)
|
|
conn.execute(f"UPDATE auth_config SET {', '.join(fields)} WHERE user_id=?", vals)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def get_temp_password(db_path: str = None, user_id: int = 0) -> str:
|
|
cfg = get_auth_config(db_path, user_id)
|
|
return cfg["temp_password"]
|
|
|
|
|
|
def set_temp_password(password: str, db_path: str = None, user_id: int = 0):
|
|
update_auth_config(user_id, temp_password=password, _db_path=db_path)
|
|
|
|
|
|
def clear_temp_password(db_path: str = None, user_id: int = 0):
|
|
update_auth_config(user_id, temp_password="", must_configure_mfa=0, _db_path=db_path)
|
|
|
|
|
|
def get_recovery_codes(db_path: str = None, user_id: int = 0) -> list:
|
|
cfg = get_auth_config(db_path, user_id)
|
|
raw = cfg["recovery_codes"]
|
|
if not raw:
|
|
return []
|
|
return raw.split(",")
|
|
|
|
|
|
def set_recovery_codes(codes: list, db_path: str = None, user_id: int = 0):
|
|
update_auth_config(user_id, recovery_codes=",".join(codes), _db_path=db_path)
|
|
|
|
|
|
def has_mfa_configured(db_path: str = None, user_id: int = 0) -> bool:
|
|
cfg = get_auth_config(db_path, user_id)
|
|
return bool(cfg["passkey_credential_id"]) or bool(cfg["totp_enabled"])
|
|
|
|
|
|
def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int | None:
|
|
"""Find the user who owns a given WebAuthn credential id (for login)."""
|
|
conn = get_db(_db_path(db_path))
|
|
row = conn.execute(
|
|
"SELECT a.user_id FROM auth_config a "
|
|
"JOIN users u ON u.id = a.user_id "
|
|
"WHERE a.passkey_credential_id = ? AND u.status = 'active'",
|
|
(credential_id,)
|
|
).fetchone()
|
|
conn.close()
|
|
return row["user_id"] if row else None
|
|
|
|
|
|
# ─── DB init / migration ─────────────────────
|
|
|
|
def _ensure_defaults(conn):
|
|
"""Insert default rows for config tables and the Root user when missing.
|
|
Generates a random 6-digit temp password when Root's auth_config is created
|
|
(or when a migrated DB has no MFA configured yet).
|
|
"""
|
|
import secrets
|
|
import string
|
|
|
|
# Schema-version row (single row, id=1).
|
|
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
|
|
|
# Root user (id 0)
|
|
root = conn.execute("SELECT id FROM users WHERE id=0").fetchone()
|
|
if root is None:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO users (id, remark, role, status) VALUES (0, 'Root', 'admin', 'active')"
|
|
)
|
|
|
|
# weather_config for Root
|
|
count = conn.execute("SELECT COUNT(*) FROM weather_config WHERE user_id=0").fetchone()[0]
|
|
if count == 0:
|
|
conn.execute("INSERT INTO weather_config (user_id, city, lat, lon) "
|
|
"VALUES (0, 'Beijing', 39.9042, 116.4074)")
|
|
|
|
# clock_config for Root
|
|
count = conn.execute("SELECT COUNT(*) FROM clock_config WHERE user_id=0").fetchone()[0]
|
|
if count == 0:
|
|
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) "
|
|
"VALUES (0, 'Beijing', 'Asia/Shanghai', 1)")
|
|
|
|
# auth_config for Root
|
|
row = conn.execute("SELECT * FROM auth_config WHERE user_id=0").fetchone()
|
|
mfa_configured = bool(row and (row["passkey_credential_id"] or row["totp_enabled"]))
|
|
if row is None:
|
|
temp_pw = "".join(secrets.choice(string.digits) for _ in range(6))
|
|
conn.execute(
|
|
"INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key, "
|
|
"totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa) "
|
|
"VALUES (0, '', '', '', 0, '', ?, 1)",
|
|
(temp_pw,)
|
|
)
|
|
_EMITTED_PASSWORDS.append(temp_pw)
|
|
print(f"\n{'='*50}", file=sys.stderr)
|
|
print(f" 🔐 Root 初始临时密码: {temp_pw}", file=sys.stderr)
|
|
print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr)
|
|
print(f"{'='*50}\n", file=sys.stderr)
|
|
else:
|
|
# Migrated DB. If Root already has MFA, no temp password is needed.
|
|
if mfa_configured:
|
|
if row["temp_password"] or row["must_configure_mfa"]:
|
|
conn.execute(
|
|
"UPDATE auth_config SET temp_password='', must_configure_mfa=0 WHERE user_id=0"
|
|
)
|
|
elif (not row["temp_password"]) and row["must_configure_mfa"]:
|
|
# Migrated DB without MFA — generate a fresh temp password.
|
|
temp_pw = "".join(secrets.choice(string.digits) for _ in range(6))
|
|
conn.execute(
|
|
"UPDATE auth_config SET temp_password=? WHERE user_id=0",
|
|
(temp_pw,)
|
|
)
|
|
_EMITTED_PASSWORDS.append(temp_pw)
|
|
print(f"\n{'='*50}", file=sys.stderr)
|
|
print(f" 🔐 迁移已存在数据库 — Root 临时密码: {temp_pw}", file=sys.stderr)
|
|
print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr)
|
|
print(f"{'='*50}\n", file=sys.stderr)
|
|
|
|
conn.commit()
|
|
|
|
|
|
# Stores passwords that were just generated, for the startup script to print.
|
|
_EMITTED_PASSWORDS: list = []
|
|
|
|
# Current schema version supported by this program. Bump it whenever the
|
|
# SCHEMA changes so existing databases get rebuilt+migrated on next start.
|
|
DB_VERSION = 1
|
|
|
|
|
|
def _table_columns(conn, table_name):
|
|
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")]
|
|
|
|
|
|
def _migrate_auth_config(conn_new, conn_old):
|
|
"""Special-case migration for auth_config: the legacy table is keyed by
|
|
`id` (single user, id=1), the new table is keyed by `user_id`.
|
|
The legacy single-user row belongs to Root (user 0).
|
|
"""
|
|
old_cols = _table_columns(conn_old, "auth_config")
|
|
new_cols = _table_columns(conn_new, "auth_config")
|
|
if not old_cols:
|
|
return
|
|
rows = conn_old.execute("SELECT * FROM auth_config").fetchall()
|
|
for row in rows:
|
|
d = dict(row)
|
|
if "user_id" in old_cols:
|
|
uid = d.get("user_id") or 0
|
|
else:
|
|
# Legacy single-user DB: the id=1 row belongs to Root (user 0).
|
|
old_id = d.get("id", 1)
|
|
uid = 0 if old_id == 1 else (old_id or 0)
|
|
common = [c for c in new_cols if c in old_cols and c not in ("id", "user_id")]
|
|
if not common:
|
|
continue
|
|
col_str = ", ".join(common)
|
|
placeholders = ", ".join("?" * len(common))
|
|
conn_new.execute(
|
|
f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})",
|
|
(uid, *[d[c] for c in common])
|
|
)
|
|
|
|
|
|
def init_db(db_path: str = "data.db"):
|
|
"""Initialize the database with schema-version aware migration.
|
|
|
|
- No database file: create it from the latest SCHEMA and create Root.
|
|
- Existing database:
|
|
* no `meta.version` (legacy DB) → rebuild + migrate compatible data
|
|
* DB version < DB_VERSION → rebuild + migrate
|
|
* DB version == DB_VERSION → use as-is (fast path)
|
|
* DB version > DB_VERSION → abort with an error (DB is newer
|
|
than this program)
|
|
|
|
Rebuild copies compatible data (legacy single-user data becomes Root's)
|
|
and fills missing columns with SCHEMA defaults.
|
|
"""
|
|
if not os.path.exists(db_path):
|
|
conn = get_db(db_path)
|
|
conn.executescript(SCHEMA)
|
|
_ensure_defaults(conn)
|
|
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
|
conn.commit()
|
|
conn.close()
|
|
return
|
|
|
|
conn_probe = get_db(db_path)
|
|
old_version = _read_db_version(conn_probe)
|
|
conn_probe.close()
|
|
|
|
if old_version is not None and old_version > DB_VERSION:
|
|
raise SystemExit(
|
|
f"[FATAL] 数据库 schema 版本 (v{old_version}) 高于当前程序支持的版本 "
|
|
f"(v{DB_VERSION})。请先升级程序,或删除/降级数据库后再启动。"
|
|
)
|
|
|
|
if old_version is not None and old_version == DB_VERSION:
|
|
# Same version — no rebuild needed. Just ensure defaults exist.
|
|
conn = get_db(db_path)
|
|
_ensure_defaults(conn)
|
|
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
|
|
conn.commit()
|
|
conn.close()
|
|
return
|
|
|
|
# Migrate by rebuilding: create a fresh DB with the latest schema,
|
|
# copy compatible data from the old DB, then replace the old DB.
|
|
new_db_path = db_path + ".new"
|
|
backup_path = db_path + ".backup"
|
|
|
|
# Clean up any stale temporary database from a previous failed run.
|
|
if os.path.exists(new_db_path):
|
|
os.remove(new_db_path)
|
|
|
|
conn_new = get_db(new_db_path)
|
|
conn_new.executescript(SCHEMA)
|
|
|
|
conn_old = get_db(db_path)
|
|
# Disable FK enforcement while copying; rows are copied table by table.
|
|
conn_old.execute("PRAGMA foreign_keys=OFF")
|
|
conn_new.execute("PRAGMA foreign_keys=OFF")
|
|
|
|
tables = conn_old.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
).fetchall()
|
|
|
|
for (table_name,) in tables:
|
|
# Tables that no longer exist in the new schema are dropped (e.g. camera_config).
|
|
exists_new = conn_new.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
(table_name,)
|
|
).fetchone()
|
|
if not exists_new:
|
|
continue
|
|
|
|
if table_name == "auth_config":
|
|
_migrate_auth_config(conn_new, conn_old)
|
|
continue
|
|
|
|
old_cols = _table_columns(conn_old, table_name)
|
|
new_cols = _table_columns(conn_new, table_name)
|
|
common_cols = [c for c in new_cols if c in old_cols]
|
|
if not common_cols:
|
|
continue
|
|
|
|
col_str = ", ".join(common_cols)
|
|
placeholders = ", ".join("?" * len(common_cols))
|
|
sql = f"INSERT OR IGNORE INTO {table_name} ({col_str}) VALUES ({placeholders})"
|
|
|
|
for row in conn_old.execute(f"SELECT {col_str} FROM {table_name}"):
|
|
try:
|
|
conn_new.execute(sql, tuple(row))
|
|
except Exception:
|
|
# Ignore rows that cannot be migrated (e.g. constraint violations).
|
|
pass
|
|
|
|
conn_new.commit()
|
|
|
|
# Now fill defaults for any tables that are still empty + Root bootstrap.
|
|
_ensure_defaults(conn_new)
|
|
|
|
# Stamp the migrated database with the program's schema version.
|
|
conn_new.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
|
|
conn_new.commit()
|
|
|
|
conn_old.close()
|
|
conn_new.close()
|
|
|
|
# Replace old DB with the new one, keeping a backup.
|
|
if os.path.exists(backup_path):
|
|
os.remove(backup_path)
|
|
os.rename(db_path, backup_path)
|
|
os.rename(new_db_path, db_path)
|
|
|
|
|
|
def _read_db_version(conn) -> int | None:
|
|
"""Read the schema version from the `meta` table.
|
|
Returns None when the DB is legacy (no meta table / no version column).
|
|
"""
|
|
try:
|
|
row = conn.execute("SELECT version FROM meta WHERE id=1").fetchone()
|
|
return int(row["version"]) if row else None
|
|
except Exception:
|
|
return None
|