Kanbante 0.46.0 — HUB-16 : correspondance draco par utilisateur (JIT + liaison admin) #84
+6
-3
@@ -9,9 +9,12 @@ est présenté). Vérification hmac/hashlib — mêmes sémantiques que
|
||||
les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0).
|
||||
|
||||
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
|
||||
en session Kanbante native. Kanbante est mono-utilisateur
|
||||
(identifiants configurés par l'environnement) : un jeton valide
|
||||
désigne l'opérateur configuré — pas de table de liens. Sans secret
|
||||
en session Kanbante native. Depuis la 0.46.0 (HUB-16), la
|
||||
correspondance est PAR UTILISATEUR : le ``sub`` des claims désigne
|
||||
le compte Kanbante lié dans ``users.draco_sub`` (résolution et
|
||||
création JIT : app.accounts.resolve_draco_user) — un jeton valide
|
||||
ne désigne plus « l'opérateur » par hypothèse. Ce module ne fait
|
||||
que valider le jeton ; il ne connaît aucun compte. Sans secret
|
||||
configuré, module inerte.
|
||||
"""
|
||||
|
||||
|
||||
+97
-49
@@ -391,15 +391,24 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
and _bearer_user(request) is not None:
|
||||
return await call_next(request)
|
||||
# Recherche partout (#68) : /api/search accepte aussi le jeton
|
||||
# SSO draco_session (mono-utilisateur : un jeton valide désigne
|
||||
# l'opérateur), comme les API de recherche des sœurs. Les
|
||||
# preflights OPTIONS ne portent jamais de cookies : ils sont
|
||||
# répondus par RechercheCorsMiddleware avant d'arriver ici.
|
||||
# SSO draco_session — depuis la 0.46.0 (HUB-16), par
|
||||
# utilisateur : le sub doit désigner un compte Kanbante
|
||||
# (correspondance existante ou compte d'amorçage lié au
|
||||
# passage, voir app.accounts.resolve_draco_user) ; un jeton
|
||||
# valide sans correspondance ne désigne plus l'opérateur et
|
||||
# ne suffit plus. Aucun JIT ici : la création du compte a
|
||||
# lieu à la première connexion par le pont /auth/draco.
|
||||
# Les preflights OPTIONS ne portent jamais de cookies : ils
|
||||
# sont répondus par RechercheCorsMiddleware avant d'arriver ici.
|
||||
if path == "/api/search":
|
||||
try:
|
||||
from app import accounts as accounts_service
|
||||
from app import draco
|
||||
|
||||
if draco.claims_for(request) is not None:
|
||||
claims = draco.claims_for(request)
|
||||
if claims is not None and (
|
||||
accounts_service.resolve_draco_user(claims) is not None
|
||||
):
|
||||
return await call_next(request)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -571,67 +580,50 @@ def draco_bridge(request: Request, next: str = "/"):
|
||||
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
|
||||
|
||||
Point de retour de ``GET /sso/authorize`` de l'émettrice
|
||||
(ObsiGate). Depuis 0.24.0 la liaison est PAR UTILISATEUR :
|
||||
si le ``sub`` draco est lié à un compte, sa session est
|
||||
créée ; si une session native est déjà ouverte, le ``sub``
|
||||
est lié à ce compte (première visite doublement connectée) ;
|
||||
sans compte lié, retour au login — la connexion native qui
|
||||
suit crée le lien (voir login_submit). Sans utilisateurs en
|
||||
base, comportement historique : session de l'opérateur.
|
||||
(ObsiGate). Depuis la 0.46.0 (HUB-16), la correspondance est
|
||||
PAR UTILISATEUR et l'hypothèse mono-utilisateur a disparu —
|
||||
résolution centralisée dans app.accounts.resolve_draco_user :
|
||||
|
||||
- session native déjà ouverte : le ``sub`` libre est lié à
|
||||
ce compte (première visite doublement connectée, inchangé) ;
|
||||
- ``sub`` lié à un compte : sa session est ouverte ;
|
||||
- compte d'amorçage (unique compte, admin, sans liaison) :
|
||||
liaison au premier passage, puis sa session — le compte
|
||||
existant de l'opérateur garde son accès ;
|
||||
- sinon : création JIT d'un compte propre à cette identité
|
||||
(rôle membre, mot de passe local aléatoire non divulgué)
|
||||
et ouverture de sa session.
|
||||
|
||||
Un ``sub`` inconnu n'ouvre plus jamais la session de
|
||||
l'opérateur d'environnement ni celle d'un compte non lié.
|
||||
Inerte sans ``DRACO_SESSION_SECRET``.
|
||||
"""
|
||||
from app import accounts as accounts_service
|
||||
from app import draco
|
||||
|
||||
settings = get_settings()
|
||||
claims = draco.claims_for(request)
|
||||
if not settings.auth_enabled or claims is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
sub = claims.get("sub")
|
||||
sub = str(claims.get("sub") or "")
|
||||
if not sub:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
# Déjà connecté nativement : lier le sub à ce compte.
|
||||
current = _current_user(request)
|
||||
if current is not None and current.get("id") is not None:
|
||||
if sub and not current.get("draco_sub"):
|
||||
if not current.get("draco_sub"):
|
||||
if models.get_user_by_draco(sub) is None:
|
||||
models.update_user(current["id"], draco_sub=sub)
|
||||
models.log_admin(current["id"], "draco.link", str(sub))
|
||||
models.log_admin(current["id"], "draco.link", sub)
|
||||
return RedirectResponse(_safe_next(next), status_code=303)
|
||||
# Compte lié à ce sub : ouvrir sa session.
|
||||
linked = models.get_user_by_draco(sub) if sub else None
|
||||
if linked is not None and linked["active"]:
|
||||
# Correspondance, amorçage ou création JIT : ouvrir la
|
||||
# session du compte que ce sub désigne — et seulement lui.
|
||||
user = accounts_service.resolve_draco_user(claims, provisionner=True)
|
||||
if user is not None:
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_make_session(linked["username"]),
|
||||
max_age=SESSION_DAYS * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
if models.count_users() == 1:
|
||||
# Mono-utilisateur (cas historique) : le premier sub
|
||||
# valide se lie au compte unique et ouvre sa session —
|
||||
# c'est la sémantique livrée en 0.23.0, conservée tant
|
||||
# qu'il n'y a qu'un seul compte.
|
||||
sole = models.list_users()[0]
|
||||
if sole["active"]:
|
||||
if sub and not sole.get("draco_sub"):
|
||||
if models.get_user_by_draco(sub) is None:
|
||||
models.update_user(sole["id"], draco_sub=sub)
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_make_session(sole["username"]),
|
||||
max_age=SESSION_DAYS * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
if models.count_users() == 0:
|
||||
# Mode env pur (aucun compte) : session de l'opérateur.
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_make_session(settings.auth_user),
|
||||
_make_session(user["username"]),
|
||||
max_age=SESSION_DAYS * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
@@ -1164,6 +1156,62 @@ async def admin_reset_user(
|
||||
return RedirectResponse("/admin?msg=Mot+de+passe+r%C3%A9initialis%C3%A9", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/draco")
|
||||
async def admin_draco_user(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
draco_sub: str = Form(""),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Liaison draco par l'administrateur (HUB-16, 0.46.0).
|
||||
|
||||
C'est le mécanisme de liaison des comptes Kanbante
|
||||
préexistants : l'administrateur saisit le ``sub`` draco du
|
||||
compte (colonne Draco de /admin) ; un champ vide délie.
|
||||
Un ``sub`` déjà lié à un autre compte est refusé (la
|
||||
correspondance est biunivoque : users.draco_sub est UNIQUE).
|
||||
Contrairement au rôle et à l'activation, le geste est permis
|
||||
sur son propre compte : c'est la voie manuelle qui établit la
|
||||
correspondance du compte d'amorçage si le premier passage ne
|
||||
l'a pas fait. Le registre matriciel côté Hub (HUB-19) lira
|
||||
cette même correspondance ; ici, seul le côté Kanbante est
|
||||
requis. Procédure : docs/correspondance-draco.md.
|
||||
"""
|
||||
from urllib.parse import quote as _q
|
||||
|
||||
admin = _require_admin(request)
|
||||
if admin is None:
|
||||
return _admin_denied(request)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
target = models.get_user(user_id)
|
||||
if target is None:
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
sub = draco_sub.strip()
|
||||
if sub:
|
||||
autre = models.get_user_by_draco(sub)
|
||||
if autre is not None and autre["id"] != user_id:
|
||||
return RedirectResponse(
|
||||
"/admin?err="
|
||||
+ _q("Identité draco déjà liée à un autre compte"),
|
||||
status_code=303,
|
||||
)
|
||||
models.update_user(user_id, draco_sub=sub)
|
||||
models.log_admin(
|
||||
admin.get("id"), "draco.link.admin",
|
||||
f"{target['username']} : {sub}",
|
||||
)
|
||||
return RedirectResponse(
|
||||
"/admin?msg=" + _q("Liaison draco enregistrée"),
|
||||
status_code=303,
|
||||
)
|
||||
models.update_user(user_id, draco_sub=None)
|
||||
models.log_admin(admin.get("id"), "draco.unlink.admin", target["username"])
|
||||
return RedirectResponse(
|
||||
"/admin?msg=" + _q("Liaison draco retirée"), status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin/connections")
|
||||
async def admin_connections(request: Request, msg: str = "", err: str = ""):
|
||||
admin = _require_admin(request)
|
||||
|
||||
Reference in New Issue
Block a user