"""Dashboard login — minimal cookie-session auth. Credentials come from `core_config.json` → `auth.{username,password}`. The session is signed by Starlette's SessionMiddleware (stateless cookie). """ from __future__ import annotations from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from pydantic import BaseModel from Project.Sanad.config import BASE_DIR from Project.Sanad.core.config_loader import section as _cfg_section from Project.Sanad.core.logger import get_logger router = APIRouter() log = get_logger("dashboard.auth") _AUTH_CFG = _cfg_section("core", "auth") or {} USERNAME = _AUTH_CFG.get("username", "admin") PASSWORD = _AUTH_CFG.get("password", "admin") LOGIN_PAGE = BASE_DIR / "dashboard" / "static" / "login.html" def is_authed(request: Request) -> bool: return bool(request.session.get("user")) # ── login history ──────────────────────────────────────────────────── # Kept in a small JSON file rather than only in the log, so the dashboard can # show "last login, from which device" without parsing log text. LOGIN_HISTORY = BASE_DIR / "data" / "logins.json" _MAX_HISTORY = 200 def _client_ip(request: Request) -> str: """Real client IP. The site sits behind Cloudflare and then an Apache reverse proxy, so request.client is always 127.0.0.1 — the forwarded headers are the only source of the actual address.""" for header in ("cf-connecting-ip", "x-forwarded-for", "x-real-ip"): value = request.headers.get(header, "") if value: return value.split(",")[0].strip() return getattr(request.client, "host", "") or "?" def _describe_device(request: Request) -> str: """Short human label for the User-Agent — "Chrome on Windows", not 200 characters of tokens.""" ua = request.headers.get("user-agent", "") if not ua: return "unknown device" low = ua.lower() if "android" in low: platform = "Android" elif "iphone" in low: platform = "iPhone" elif "ipad" in low: platform = "iPad" elif "windows" in low: platform = "Windows" elif "mac os" in low or "macintosh" in low: platform = "Mac" elif "linux" in low: platform = "Linux" else: platform = "unknown OS" # order matters: Edge/Opera also contain "chrome", Chrome contains "safari" if "edg/" in low: browser = "Edge" elif "opr/" in low or "opera" in low: browser = "Opera" elif "firefox" in low: browser = "Firefox" elif "chrome" in low or "crios" in low: browser = "Chrome" elif "safari" in low: browser = "Safari" elif "curl" in low: browser = "curl" elif "powershell" in low or "winhttp" in low: browser = "PowerShell" else: browser = "unknown browser" return f"{browser} on {platform}" def _load_logins() -> list: try: import json return json.loads(LOGIN_HISTORY.read_text(encoding="utf-8")) or [] except Exception: return [] def _record_login(request: Request, username: str, ok: bool) -> None: """Append one sign-in attempt. Never raises: a logging problem must not stop someone from signing in.""" try: import json from datetime import datetime entries = _load_logins() entries.append({ "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "user": username, "ok": bool(ok), "device": _describe_device(request), "ip": _client_ip(request), "user_agent": request.headers.get("user-agent", "")[:300], }) del entries[:-_MAX_HISTORY] LOGIN_HISTORY.parent.mkdir(parents=True, exist_ok=True) tmp = LOGIN_HISTORY.with_suffix(".json.tmp") tmp.write_text(json.dumps(entries, ensure_ascii=False, indent=1), encoding="utf-8") tmp.replace(LOGIN_HISTORY) except Exception: log.exception("could not record login history") class LoginPayload(BaseModel): username: str password: str @router.get("/login", include_in_schema=False) async def login_page(request: Request): if is_authed(request): return RedirectResponse("/", status_code=303) if LOGIN_PAGE.exists(): return HTMLResponse(LOGIN_PAGE.read_text(encoding="utf-8")) return HTMLResponse("