From 319619d200d5a18ba113ff557980508e7df60c23 Mon Sep 17 00:00:00 2001 From: Spark Date: Wed, 14 Oct 2026 08:02:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=C3=A9cran=20de=20connexion=20+?= =?UTF-8?q?=20session=20sign=C3=A9e=20(le=20Basic=20reste=20pour=20les=20s?= =?UTF-8?q?cripts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /login professionnel, cookie HMAC 7 jours, next local seulement, CSRF double-submit réutilisé, /logout réel + lien Sortir. - Middleware : session OU Basic ; navigateurs redirigés vers /login, API en 401 JSON ; /static et /login publics. --- README.md | 4 +- app/config.py | 16 +++- app/main.py | 195 +++++++++++++++++++++++++++++++++------ app/templates/login.html | 36 ++++++++ static/css/style.css | 75 ++++++++++++++- 5 files changed, 290 insertions(+), 36 deletions(-) create mode 100644 app/templates/login.html diff --git a/README.md b/README.md index b1d036a..be6a0db 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,10 @@ sudo systemctl enable --now docker | `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea | | `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement | | `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) | -| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis | +| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts | | `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée | +| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) | +| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé | | `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » | | `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) | | `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet | diff --git a/app/config.py b/app/config.py index 7a66bbb..bb69739 100644 --- a/app/config.py +++ b/app/config.py @@ -8,9 +8,15 @@ Environment variables: GITEA_URL Base URL of the Gitea instance. GITEA_TOKEN Gitea API token (read/write issues). KANBANTE_DB_PATH Path of the local SQLite database. - KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth). - KANBANTE_AUTH_PASSWORD Basic-auth password. + KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth). + KANBANTE_AUTH_PASSWORD Login password. KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured. + KANBANTE_SESSION_SECRET HMAC key for the login session cookie + (default: derived from the credentials — + rotating the password invalidates sessions). + KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot + refreshes (chantier B, default 120; + 0 disables the refresher). KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many days counts as "active" (default 365). KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30). @@ -89,7 +95,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.21.0" +APP_VERSION = "0.22.0" def _env_bool(name: str, default: bool) -> bool: @@ -158,6 +164,8 @@ class Settings: briefing_include_standup: bool = False rules_raw: str = "" zombie_days: int = 30 + session_secret: str = "" + snapshot_interval: int = 120 @property def auth_enabled(self) -> bool: @@ -203,4 +211,6 @@ def get_settings() -> Settings: briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False), rules_raw=os.getenv("KANBANTE_RULES", ""), zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30), + session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""), + snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120), ) diff --git a/app/main.py b/app/main.py index 9b2c799..fead3e8 100644 --- a/app/main.py +++ b/app/main.py @@ -22,13 +22,21 @@ import json import logging import re import secrets +import time from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from pathlib import Path +from urllib.parse import quote import httpx from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse +from fastapi.responses import ( + HTMLResponse, + JSONResponse, + RedirectResponse, + Response, + StreamingResponse, +) from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape from starlette.middleware.base import BaseHTTPMiddleware @@ -37,6 +45,7 @@ from app.config import APP_VERSION, get_settings from app.gitea_client import GiteaClient from app import code_links as code_links_service from app import digest as digest_service +from app import snapshots as snapshots_service from app import flow as flow_service from app import forecast as forecast_service from app import rules as rules_service @@ -155,6 +164,13 @@ async def lifespan(app: FastAPI): logger.warning( "GITEA_TOKEN is not set: only public Gitea content will be visible." ) + # Chantier B: hydrate the client cache from persisted + # snapshots BEFORE serving, then keep them warm in the + # background — pages never wait on Gitea when data exists. + gitea.hydrate_from_snapshots() + snapshot_task = asyncio.create_task( + snapshots_service.snapshot_loop(gitea, classify_repos) + ) scheduler = asyncio.create_task(_briefing_scheduler()) digest_task = asyncio.create_task(_digest_scheduler()) rules_task = asyncio.create_task(_rules_scheduler()) @@ -179,6 +195,7 @@ async def lifespan(app: FastAPI): settings.capture_ntfy_topic, ) yield + snapshot_task.cancel() scheduler.cancel() digest_task.cancel() rules_task.cancel() @@ -192,20 +209,81 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") gitea = GiteaClient() -# ── Auth middleware ──────────────────────────────────────── +# ── Auth: login form + signed session cookie (0.22.0) ────── +# Before 0.22.0 the browser got HTTP Basic's native prompt. +# Browsers now use a real login page; the session rides in an +# HMAC-signed cookie. An Authorization: Basic header is still +# honoured for scripts and API clients. /api/health stays +# public for Docker's HEALTHCHECK; /webhooks/gitea is +# authenticated by its HMAC signature (Gitea cannot log in) and +# /api/capture by its dedicated token. + +SESSION_COOKIE = "kanbante_session" +SESSION_DAYS = 7 +_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login") + + +def _session_secret() -> bytes: + settings = get_settings() + if settings.session_secret: + return settings.session_secret.encode("utf-8") + # Derived from the credentials: rotating the password + # invalidates every outstanding session (documented trade-off). + material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}" + return hashlib.sha256(material.encode("utf-8")).digest() + + +def _make_session(user: str) -> str: + exp = int(time.time()) + SESSION_DAYS * 86400 + payload = f"{user}.{exp}" + sig = hmac.new( + _session_secret(), payload.encode("utf-8"), hashlib.sha256 + ).hexdigest() + return f"{payload}.{sig}" + + +def _session_user(request: Request) -> str | None: + raw = request.cookies.get(SESSION_COOKIE, "") + user, _, rest = raw.partition(".") + exp, _, sig = rest.partition(".") + if not user or not exp or not sig: + return None + expected = hmac.new( + _session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256 + ).hexdigest() + if not secrets.compare_digest(sig, expected): + return None + try: + if int(exp) < int(time.time()): + return None + except ValueError: + return None + if not secrets.compare_digest(user, get_settings().auth_user): + return None + return user + + +def _basic_ok(request: Request) -> bool: + settings = get_settings() + header = request.headers.get("authorization", "") + if not header.lower().startswith("basic "): + return False + try: + decoded = base64.b64decode(header[6:]).decode("utf-8") + except Exception: + return False + user, _, password = decoded.partition(":") + return secrets.compare_digest( + user, settings.auth_user + ) and secrets.compare_digest(password, settings.auth_password) + class AuthMiddleware(BaseHTTPMiddleware): - """HTTP Basic auth, enabled only when credentials are configured. - - /api/health stays public so Docker's HEALTHCHECK (liveness) works. - When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every - other route answers 503: failing closed beats failing open. - """ + """Session cookie (browser) or Basic header (scripts).""" async def dispatch(self, request: Request, call_next): - # /api/health: liveness for Docker. /webhooks/gitea: authenticated - # by its HMAC signature, not by Basic auth (Gitea cannot log in). - if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"): + path = request.url.path + if path in _PUBLIC_PATHS or path.startswith("/static/"): return await call_next(request) settings = get_settings() if not settings.auth_enabled: @@ -220,30 +298,82 @@ class AuthMiddleware(BaseHTTPMiddleware): status_code=503, ) return await call_next(request) - - ok = False - header = request.headers.get("authorization", "") - if header.lower().startswith("basic "): - try: - decoded = base64.b64decode(header[6:]).decode("utf-8") - except Exception: - decoded = "" - user, _, password = decoded.partition(":") - ok = secrets.compare_digest( - user, settings.auth_user - ) and secrets.compare_digest(password, settings.auth_password) - if not ok: - return Response( - "Authentication required", + if _session_user(request) is not None or _basic_ok(request): + return await call_next(request) + # API consumers get a plain 401 (no browser prompt, no + # redirect they cannot follow); browsers get the login page. + if path.startswith("/api/") or "text/html" not in request.headers.get( + "accept", "" + ): + return JSONResponse( + {"ok": False, "error": "Authentication required"}, status_code=401, - headers={"WWW-Authenticate": 'Basic realm="Kanbante"'}, ) - return await call_next(request) + target = path + if request.url.query: + target = f"{path}?{request.url.query}" + return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303) app.add_middleware(AuthMiddleware) +def _safe_next(raw: str) -> str: + """Only local paths may be a post-login destination.""" + if raw.startswith("/") and not raw.startswith("//") and "://" not in raw: + return raw + return "/" + + +@app.get("/login") +async def login_page(request: Request, next: str = "/"): + if not get_settings().auth_enabled or _session_user(request) is not None: + return RedirectResponse(_safe_next(next), status_code=303) + return render(request, "login.html", next=_safe_next(next), error="") + + +@app.post("/login") +async def login_submit( + request: Request, + username: str = Form(""), + password: str = Form(""), + next: str = Form("/"), + csrf_token: str = Form(""), +): + settings = get_settings() + if not settings.auth_enabled: + return RedirectResponse("/", status_code=303) + if not _csrf_valid(request, csrf_token): + return render( + request, "login.html", status_code=403, + next=_safe_next(next), error="Session expirée — réessaie.", + ) + ok = secrets.compare_digest( + username, settings.auth_user + ) and secrets.compare_digest(password, settings.auth_password) + if not ok: + return render( + request, "login.html", status_code=401, + next=_safe_next(next), error="Identifiants incorrects.", + ) + response = RedirectResponse(_safe_next(next), status_code=303) + response.set_cookie( + SESSION_COOKIE, + _make_session(username), + max_age=SESSION_DAYS * 86400, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/logout") +async def logout(): + response = RedirectResponse("/login", status_code=303) + response.delete_cookie(SESSION_COOKIE) + return response + + # ── Live updates (SSE hub) ───────────────────────────────── class LiveHub: @@ -330,6 +460,8 @@ def render( context.setdefault("csrf_token", token) context.setdefault("version", APP_VERSION) context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, "")) + context.setdefault("auth_enabled", get_settings().auth_enabled) + context.setdefault("data_freshness", snapshots_service.freshness_label()) html = _tpl_env.get_template(name).render(**context) response = HTMLResponse(html, status_code=status_code) if generated: @@ -2303,6 +2435,13 @@ async def gitea_webhook(request: Request): handled = "pr-merged:" + (",".join(results) if results else "none") gitea.invalidate_cache() + # Chantier B: a webhook means this repo just changed — refresh + # its snapshots right away instead of waiting for the interval. + if full_name and "/" in full_name: + _owner, _, _repo = full_name.partition("/") + asyncio.create_task( + snapshots_service.refresh_repo_snapshots(gitea, _owner, _repo) + ) await hub.broadcast( {"repo": full_name, "event": event, "action": action, "issue": number} ) diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..fc7945a --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,36 @@ + + + + + + + Connexion — Kanbante + + + +
+ +
+ + diff --git a/static/css/style.css b/static/css/style.css index 3b78ac2..521be3e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1297,11 +1297,22 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(- display: flex; align-items: center; justify-content: space-between; gap: 8px; } .side-collapse { - display: flex; align-items: center; gap: 8px; cursor: pointer; - background: transparent; border: 1px solid var(--border); border-radius: 9px; - color: var(--text-2); padding: 6px 10px; font-size: 0.85rem; white-space: nowrap; + /* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la + barre, à droite de la recherche (même gabarit qu'elle). */ + flex-shrink: 0; + width: 38px; height: 38px; border-radius: 10px; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + background: var(--surface-2); color: var(--text-2); font-size: 1.02rem; + border: 1px solid var(--border); + transition: border-color 0.15s, color 0.15s, transform 0.15s; } -.side-collapse:hover { color: var(--text); } +.side-collapse:hover { + color: var(--text); border-color: var(--accent, #58a6ff); + transform: translateY(-1px); +} +.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; } +.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; } +.side-logout:hover { color: var(--text); text-decoration: none; } .side-collapse-ico { display: inline-block; transition: transform 0.18s ease; } body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); } .side-version { color: var(--text-3); font-size: 0.8rem; } @@ -1541,3 +1552,59 @@ a.ms-title:hover { text-decoration: underline; } border-radius: 5px; padding: 0 5px; background: var(--surface); } .capture-form input.repo-unknown { border-color: #f0a35e; } + +/* ── Écran de connexion (0.22.0) ────────────── */ +.login-body { + min-height: 100dvh; margin: 0; + display: flex; align-items: center; justify-content: center; + background: + radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%), + radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%), + var(--bg, #0a0f16); + padding: 24px 16px; +} +.login-wrap { width: 100%; max-width: 400px; } +.login-card { + background: var(--surface, #10151f); + border: 1px solid var(--border); + border-radius: 18px; + padding: 34px 32px 26px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); +} +.login-brand { text-align: center; margin-bottom: 22px; } +.login-mark { + display: inline-flex; align-items: center; justify-content: center; + width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem; + background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25)); + border: 1px solid var(--border); +} +.login-brand h1 { + margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700; + background: linear-gradient(120deg, #a78bfa, #7dd3fc); + -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; +} +.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; } +.login-error { + margin: 0 0 14px; padding: 10px 12px; border-radius: 10px; + background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45); + color: #f5a58c; font-size: 0.9rem; +} +.login-form { display: flex; flex-direction: column; gap: 14px; } +.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; } +.login-form input { + padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px; + background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2); + font: inherit; font-weight: 400; +} +.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; } +.login-submit { + margin-top: 4px; padding: 12px 18px; cursor: pointer; + background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7)); + color: #fff; border: none; border-radius: 10px; + font: inherit; font-weight: 600; font-size: 1rem; + transition: filter 0.15s, transform 0.1s; +} +.login-submit:hover { filter: brightness(1.12); } +.login-submit:active { transform: scale(0.98); } +.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }