feat(auth): écran de connexion + session signée (le Basic reste pour les scripts)

- /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.
This commit is contained in:
Spark
2026-10-14 08:02:49 -04:00
parent 0bdde28b79
commit 319619d200
5 changed files with 290 additions and 36 deletions
+3 -1
View File
@@ -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 |
+13 -3
View File
@@ -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),
)
+167 -28
View File
@@ -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}
)
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Connexion — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="login-body">
<main class="login-wrap">
<section class="login-card">
<div class="login-brand">
<span class="login-mark" aria-hidden="true">📋</span>
<h1>Kanbante</h1>
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
</div>
{% if error %}
<p class="login-error" role="alert">⚠️ {{ error }}</p>
{% endif %}
<form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label>Nom d'utilisateur
<input type="text" name="username" autocomplete="username" required autofocus>
</label>
<label>Mot de passe
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
</section>
</main>
</body>
</html>
+71 -4
View File
@@ -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; }