"""Independent local operator authentication adapted from the reference session approach.""" from __future__ import annotations import hashlib import hmac import re import secrets import sqlite3 import time from fastapi import HTTPException, Request, Response from .db import Store, StoreConflict, identifier, now COOKIE = "assistant_maintainer" TTL = 12 * 60 * 60 def password_hash(password: str) -> str: salt = secrets.token_bytes(16) result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=64) return f"scrypt${salt.hex()}${result.hex()}" def password_matches(password: str, stored: str) -> bool: try: _, salt, expected = stored.split("$") result = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1, dklen=64) return hmac.compare_digest(result.hex(), expected) except (ValueError, TypeError): return False def token_hash(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() class Auth: def __init__(self, store: Store): self.store = store def bootstrap_admin(self) -> bool: """Create the requested local account only when no maintainer exists.""" with self.store.connect(immediate=True) as conn: if conn.execute("SELECT 1 FROM operators LIMIT 1").fetchone(): return False conn.execute("INSERT INTO operators(id,username,password_hash,created_at) VALUES($1,$2,$3,$4)", (identifier("operator"), "admin", password_hash("123456"), now())) return True def session(self, request: Request) -> dict | None: raw = request.cookies.get(COOKIE, "") if not raw: return None return self.store.one("SELECT s.*,o.username FROM auth_sessions s LEFT JOIN operators o ON o.id=s.operator_id WHERE token_hash=$1 AND expires_at>$2", (token_hash(raw), int(time.time()))) def issue(self, request: Request, response: Response, operator_id: str | None = None) -> dict: previous = request.cookies.get(COOKIE) raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32) with self.store.connect(immediate=True) as conn: if previous: conn.execute("DELETE FROM auth_sessions WHERE token_hash=$1", (token_hash(previous),)) conn.execute("DELETE FROM auth_sessions WHERE expires_at<$1", (int(time.time()),)) conn.execute("INSERT INTO auth_sessions(token_hash,operator_id,csrf,expires_at,created_at) VALUES($1,$2,$3,$4,$5)", (token_hash(raw), operator_id, csrf, int(time.time()) + TTL, now())) response.set_cookie(COOKIE, raw, max_age=TTL, httponly=True, samesite="strict", secure=request.url.scheme == "https", path="/") return {"operator_id": operator_id, "csrf": csrf} def payload(self, session: dict) -> dict: initialized = bool(self.store.one("SELECT id FROM operators LIMIT 1")) authenticated = bool(session.get("operator_id")) user = None if authenticated: user = self.store.one("SELECT id,username,created_at FROM operators WHERE id=$1", (session["operator_id"],)) return {"initialized": initialized, "authenticated": authenticated, "user": user, "csrf_token": session["csrf"], "mode": "local_maintainer"} def status(self, request: Request, response: Response) -> dict: return self.payload(self.session(request) or self.issue(request, response)) def csrf(self, request: Request) -> dict: session = self.session(request) header = request.headers.get("X-CSRF-Token", "") if not session or not header or not hmac.compare_digest(header, session["csrf"]): raise HTTPException(403, "会话校验已失效,请刷新页面后重试") return session def operator(self, request: Request, mutate: bool = False) -> str: session = self.csrf(request) if mutate else self.session(request) if not session or not session.get("operator_id"): raise HTTPException(401, "请先登录维护后台") return session["operator_id"] @staticmethod def validate_credentials(username: str, password: str) -> tuple[str, str]: username = username.strip() if not re.fullmatch(r"[\w.-]{3,64}", username): raise HTTPException(422, "维护者用户名需为 3 至 64 位字母、数字、中文、点或下划线") if len(password) < 10 or len(password) > 256: raise HTTPException(422, "维护者密码需为 10 至 256 个字符") return username, password def setup(self, request: Request, response: Response, username: str, password: str) -> dict: self.csrf(request) username, password = self.validate_credentials(username, password) encoded = password_hash(password) operator_id = identifier("operator") try: with self.store.connect(immediate=True) as conn: if conn.execute("SELECT 1 FROM operators LIMIT 1").fetchone(): raise HTTPException(409, "维护者已初始化,请使用登录入口") conn.execute("INSERT INTO operators(id,username,password_hash,created_at) VALUES($1,$2,$3,$4)", (operator_id, username, encoded, now())) except (sqlite3.IntegrityError, StoreConflict): raise HTTPException(409, "维护者已初始化,请使用登录入口") from None return self.payload(self.issue(request, response, operator_id)) def login(self, request: Request, response: Response, username: str, password: str) -> dict: self.csrf(request) key = token_hash(username.strip().casefold()) current_time = int(time.time()) with self.store.connect(immediate=True) as conn: attempt = conn.execute("SELECT * FROM auth_attempts WHERE key=$1", (key,)).fetchone() if attempt and current_time - attempt["window_start"] < 300 and attempt["failures"] >= 5: raise HTTPException(429, "尝试次数较多,请稍后再试") user = conn.execute("SELECT * FROM operators WHERE username=$1", (username.strip(),)).fetchone() # The fixed dummy hash keeps a nonexistent username on the scrypt path. dummy = "scrypt$" + "00" * 16 + "$" + "00" * 64 valid = password_matches(password, user["password_hash"] if user else dummy) if not user or not valid: if not attempt or current_time - attempt["window_start"] >= 300: conn.execute("INSERT INTO auth_attempts(key,failures,window_start) VALUES($1,$2,$3) ON CONFLICT(key) DO UPDATE SET failures=excluded.failures,window_start=excluded.window_start", (key, 1, current_time)) else: conn.execute("UPDATE auth_attempts SET failures=failures+1 WHERE key=$1", (key,)) error = True else: conn.execute("DELETE FROM auth_attempts WHERE key=$1", (key,)) operator_id = user["id"] error = False if error: raise HTTPException(401, "用户名或密码不正确") return self.payload(self.issue(request, response, operator_id)) def logout(self, request: Request, response: Response) -> dict: self.csrf(request) return self.payload(self.issue(request, response))