Update Live-streaming code (auto-daily features)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SESSION_COOKIE_NAME = "live_admin_session"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
def _is_local_ip(ip: str) -> bool:
|
||||
ip = (ip or "").strip()
|
||||
return ip in {"127.0.0.1", "::1", "::ffff:127.0.0.1", "localhost"}
|
||||
|
||||
|
||||
class AdminAuthManager:
|
||||
def __init__(self, data_dir: str | Path, logger, *, session_ttl_sec: int = 12 * 60 * 60):
|
||||
self.data_dir = Path(data_dir)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.path = self.data_dir / "admin_auth.json"
|
||||
self.audit_path = self.data_dir / "admin_audit.log"
|
||||
self.logger = logger
|
||||
self.session_ttl_sec = int(session_ttl_sec)
|
||||
self._sessions: dict[str, dict[str, Any]] = {}
|
||||
self._data = self._default_data()
|
||||
self._load()
|
||||
|
||||
def _default_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"created_at": "",
|
||||
"password_hash": "",
|
||||
"password_salt": "",
|
||||
"password_updated_at": "",
|
||||
}
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
self._data.update(data)
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"[后台认证] 读取认证文件失败: {exc}")
|
||||
|
||||
def _save(self) -> None:
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
def _hash_password(self, password: str, salt: bytes) -> str:
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=2 ** 14,
|
||||
r=8,
|
||||
p=1,
|
||||
dklen=64,
|
||||
)
|
||||
return base64.b64encode(digest).decode("ascii")
|
||||
|
||||
def is_bootstrapped(self) -> bool:
|
||||
return bool(self._data.get("password_hash") and self._data.get("password_salt"))
|
||||
|
||||
def can_bootstrap_ip(self, ip: str) -> bool:
|
||||
return _is_local_ip(ip)
|
||||
|
||||
def bootstrap(self, password: str) -> None:
|
||||
password = str(password or "")
|
||||
if len(password) < 8:
|
||||
raise ValueError("后台密码至少 8 位")
|
||||
salt = secrets.token_bytes(16)
|
||||
now = _utc_now_iso()
|
||||
self._data = {
|
||||
"version": 1,
|
||||
"created_at": self._data.get("created_at") or now,
|
||||
"password_hash": self._hash_password(password, salt),
|
||||
"password_salt": base64.b64encode(salt).decode("ascii"),
|
||||
"password_updated_at": now,
|
||||
}
|
||||
self._sessions.clear()
|
||||
self._save()
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
if not self.is_bootstrapped():
|
||||
return False
|
||||
try:
|
||||
salt = base64.b64decode(self._data["password_salt"])
|
||||
except Exception:
|
||||
return False
|
||||
current = self._hash_password(str(password or ""), salt)
|
||||
return hmac.compare_digest(current, self._data.get("password_hash", ""))
|
||||
|
||||
def create_session(self, client_ip: str, user_agent: str = "") -> tuple[str, int]:
|
||||
self.cleanup_sessions()
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = int(time.time())
|
||||
expires_at = now + self.session_ttl_sec
|
||||
self._sessions[token] = {
|
||||
"created_at": now,
|
||||
"expires_at": expires_at,
|
||||
"client_ip": client_ip or "",
|
||||
"user_agent": (user_agent or "")[:240],
|
||||
}
|
||||
return token, expires_at
|
||||
|
||||
def cleanup_sessions(self) -> None:
|
||||
now = int(time.time())
|
||||
expired = [token for token, session in self._sessions.items() if int(session.get("expires_at", 0)) <= now]
|
||||
for token in expired:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def get_session(self, token: str) -> dict[str, Any] | None:
|
||||
self.cleanup_sessions()
|
||||
if not token:
|
||||
return None
|
||||
session = self._sessions.get(token)
|
||||
if not session:
|
||||
return None
|
||||
session["expires_at"] = int(time.time()) + self.session_ttl_sec
|
||||
return session
|
||||
|
||||
def destroy_session(self, token: str) -> None:
|
||||
if token:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def write_audit(self, *, action: str, target: str = "", client_ip: str = "", session_id: str = "", detail: str = "") -> None:
|
||||
entry = {
|
||||
"at": _utc_now_iso(),
|
||||
"action": action,
|
||||
"target": target,
|
||||
"client_ip": client_ip,
|
||||
"session_id": session_id[:12],
|
||||
"detail": detail,
|
||||
}
|
||||
line = json.dumps(entry, ensure_ascii=False)
|
||||
try:
|
||||
with open(self.audit_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"[后台认证] 写审计日志失败: {exc}")
|
||||
self.logger.info(f"[审计] action={action} target={target or '-'} ip={client_ip or '-'} detail={detail or '-'}")
|
||||
|
||||
Reference in New Issue
Block a user