"""多用户访问密码认证 — 自托管场景。 设计: - 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。 即使 auth.json 泄露, 也无法逆向出明文密码。 - 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。 - 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。 - 多用户: 支持多个账号, 数据共享不隔离; role=admin 可管理用户。 安全要点: - 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。 - 登录限流: 错5次锁5分钟(见 auth router 内存计数)。 """ from __future__ import annotations import hashlib import json import logging import os import secrets as _secrets import threading import time from pathlib import Path logger = logging.getLogger(__name__) # PBKDF2 参数(NIST 推荐, 单次校验 ~100ms, 兼顾安全与响应) _PBKDF2_ITER = 200_000 _SALT_LEN = 16 _TOKEN_BYTES = 32 # 会话有效期: 30 天(自托管, 长一点减少重登频率) SESSION_TTL = 30 * 24 * 3600 # schema 版本 _CURRENT_SCHEMA_VERSION = 2 _lock = threading.Lock() # 内存中的有效会话: { token: {username, expire} }。进程重启后从磁盘恢复。 _sessions: dict[str, dict] = {} # ================================================================ # 存储 # ================================================================ def _path() -> Path: from app.config import settings p = settings.data_dir / "user_data" / "auth.json" p.parent.mkdir(parents=True, exist_ok=True) return p def _load() -> dict: p = _path() if p.exists(): try: return json.loads(p.read_text(encoding="utf-8")) except Exception as e: # noqa: BLE001 logger.warning("auth.json malformed: %s", e) return {} def _save(data: dict) -> None: p = _path() p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") try: os.chmod(p, 0o600) except OSError: pass # ================================================================ # 密码工具 # ================================================================ def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: """返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。""" if salt is None: salt = os.urandom(_SALT_LEN) dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER) return salt.hex(), dk.hex() def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool: """恒定时间比较, 防时序攻击。""" try: salt = bytes.fromhex(salt_hex) expected = bytes.fromhex(hash_hex) except ValueError: return False actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER) return _secrets.compare_digest(actual, expected) # ================================================================ # 数据迁移 (v1 → v2) # ================================================================ def _migrate_from_v1(data: dict) -> dict | None: """检测旧版单用户格式并迁移。返回迁移后的新数据, 无需迁移则返回 None。""" if data.get("schema_version") == _CURRENT_SCHEMA_VERSION: return None if not data.get("password_hash"): return None logger.info("检测到旧版 auth.json, 正在迁移到多用户格式...") new_data = { "schema_version": _CURRENT_SCHEMA_VERSION, "users": {}, "sessions": {}, "updated_at": int(time.time()), } # 将旧密码迁移为 admin 用户 salt = data.get("password_salt", "") phash = data.get("password_hash", "") new_data["users"]["admin"] = { "password_hash": phash, "password_salt": salt, "role": "admin", "created_at": int(time.time()), } # 迁移旧 sessions old_sessions = data.get("sessions") or {} now = time.time() for token, expire in old_sessions.items(): if isinstance(expire, (int, float)) and expire > now: new_data["sessions"][token] = {"username": "admin", "expire": expire} logger.info("auth.json 迁移完成: admin 用户, %d 个会话恢复", len(new_data["sessions"])) return new_data # ================================================================ # 用户管理 # ================================================================ def _ensure_loaded() -> dict: """加载数据, 必要时执行迁移。""" data = _load() migrated = _migrate_from_v1(data) if migrated is not None: data = migrated _save(data) return data def is_configured() -> bool: """是否已有用户。""" data = _ensure_loaded() return bool(data.get("users")) def create_user(username: str, password: str, role: str = "viewer") -> None: """创建用户。username 已存在则抛出 ValueError。""" if len(password) < 6: raise ValueError("密码至少 6 位") if not username or not username.strip(): raise ValueError("用户名不能为空") username = username.strip() if not username.replace("_", "").replace("-", "").isalnum(): raise ValueError("用户名只能包含字母、数字、下划线和连字符") salt_hex, hash_hex = _hash_password(password) with _lock: data = _ensure_loaded() users = data.setdefault("users", {}) if username in users: raise ValueError(f"用户 '{username}' 已存在") users[username] = { "password_hash": hash_hex, "password_salt": salt_hex, "role": role, "created_at": int(time.time()), } data["updated_at"] = int(time.time()) _save(data) logger.info("user created: %s (role=%s)", username, role) def list_users() -> list[dict]: """返回用户列表(不含密码哈希)。""" data = _ensure_loaded() users = data.get("users") or {} result = [] for name, info in users.items(): result.append({ "username": name, "role": info.get("role", "viewer"), "created_at": info.get("created_at"), }) result.sort(key=lambda u: u["created_at"] or 0) return result def delete_user(username: str) -> bool: """删除用户。admin 用户不可删除。返回是否成功。""" with _lock: data = _ensure_loaded() users = data.get("users") or {} if username not in users: return False if users[username].get("role") == "admin": raise ValueError("不能删除 admin 用户") del users[username] # 清除该用户的所有会话 sessions = data.get("sessions") or {} data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username} _sessions.clear() # 重新加载时同步 data["updated_at"] = int(time.time()) _save(data) logger.info("user deleted: %s", username) return True def change_password(username: str, old_password: str, new_password: str) -> bool: """用户自己改密码。验证旧密码, 返回是否成功。""" if len(new_password) < 6: raise ValueError("密码至少 6 位") with _lock: data = _ensure_loaded() users = data.get("users") or {} user = users.get(username) if not user: return False if not _verify_password(old_password, user.get("password_salt", ""), user.get("password_hash", "")): return False salt_hex, hash_hex = _hash_password(new_password) user["password_hash"] = hash_hex user["password_salt"] = salt_hex data["updated_at"] = int(time.time()) _save(data) # 改密码不清除会话(与单用户模式不同, 多用户下只清自己的) logger.info("password changed: %s", username) return True def admin_reset_password(username: str, new_password: str) -> bool: """管理员重置用户密码。清除该用户所有会话。""" if len(new_password) < 6: raise ValueError("密码至少 6 位") with _lock: data = _ensure_loaded() users = data.get("users") or {} if username not in users: return False salt_hex, hash_hex = _hash_password(new_password) users[username]["password_hash"] = hash_hex users[username]["password_salt"] = salt_hex # 清除被重置用户的所有会话 sessions = data.get("sessions") or {} data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username} data["updated_at"] = int(time.time()) _save(data) logger.info("password reset by admin: %s", username) return True # ================================================================ # 会话管理 # ================================================================ def login(username: str, password: str) -> tuple[str, str] | None: """验证密码, 成功则创建会话并返回 (token, username), 失败返回 None。""" data = _ensure_loaded() users = data.get("users") or {} user = users.get(username) if not user: return None if not _verify_password(password, user.get("password_salt", ""), user.get("password_hash", "")): return None token = _secrets.token_urlsafe(_TOKEN_BYTES) expire = time.time() + SESSION_TTL with _lock: _sessions[token] = {"username": username, "expire": expire} _persist_sessions_locked() return token, username def revoke_session(token: str) -> None: """注销会话(登出)。""" with _lock: _sessions.pop(token, None) _persist_sessions_locked() def get_session_user(token: str) -> str | None: """返回 token 对应的 username, 无效/过期则返回 None。过期自动清理。""" if not token: return None with _lock: session = _sessions.get(token) if session is None: return None if time.time() > session["expire"]: _sessions.pop(token, None) _persist_sessions_locked() return None return session["username"] def get_session_role(token: str) -> str | None: """返回 token 对应用户的 role, 无效返回 None。""" username = get_session_user(token) if not username: return None data = _ensure_loaded() users = data.get("users") or {} user = users.get(username) return user.get("role") if user else None def bootstrap_from_env() -> bool: """首次初始化: 若环境变量 AUTH_PASSWORD 已配置且无用户, 则创建 admin 用户。 公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。 一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。 Returns: True 表示本次用环境变量初始化了密码; False 表示无需初始化。 """ from app.config import settings pwd = (settings.auth_password or "").strip() if not pwd: return False data = _ensure_loaded() if data.get("users"): return False try: create_user("admin", pwd, role="admin") logger.info("admin user bootstrapped from AUTH_PASSWORD env (one-time)") return True except ValueError as e: logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e) return False # ================================================================ # 兼容旧版 # ================================================================ # set_password 保留为 create_user 别名(旧 api/auth.py 中可能使用) def set_password(password: str) -> None: """兼容旧版: 创建/更新 admin 用户密码。""" with _lock: data = _ensure_loaded() users = data.get("users") or {} if "admin" in users: salt_hex, hash_hex = _hash_password(password) users["admin"]["password_hash"] = hash_hex users["admin"]["password_salt"] = salt_hex data["updated_at"] = int(time.time()) _save(data) _sessions.clear() logger.info("admin password updated via set_password") else: create_user("admin", password, role="admin") def is_valid_session(token: str) -> bool: """兼容旧版: 检查会话是否有效。""" return get_session_user(token) is not None def verify_and_create_session(password: str) -> str | None: """兼容旧版: 作为 admin 登录, 返回 token。""" result = login("admin", password) if result: return result[0] return None # ================================================================ # 启动恢复 # ================================================================ def _persist_sessions_locked() -> None: """把当前内存会话写回 auth.json(需持锁调用)。""" d = _load() d.setdefault("users", {}) d["sessions"] = dict(_sessions) if "schema_version" not in d: d["schema_version"] = _CURRENT_SCHEMA_VERSION _save(d) def _restore_sessions() -> None: """启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。""" with _lock: data = _ensure_loaded() now = time.time() saved = data.get("sessions") or {} for token, session in saved.items(): if isinstance(session, dict): expire = session.get("expire", 0) username = session.get("username", "") elif isinstance(session, (int, float)): # 兼容旧版: 旧格式 {token: expire} 映射到 admin expire = session username = "admin" else: continue if expire > now: _sessions[token] = {"username": username, "expire": expire} if len(_sessions) != len(saved): _persist_sessions_locked() # 模块加载时恢复会话 try: _restore_sessions() except Exception as e: # noqa: BLE001 logger.warning("restore sessions failed: %s", e)