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