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:
@@ -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
@@ -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
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user