Files
Enclume/enclume/auth.py
T
hcornet b063d59525
build / Garde-fou (pull_request) Failing after 7s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Failing after 8s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Skipped
build / Images Harbor (web, Dockerfile) (push) Skipped
Partage par lien, comptes Authentik, soumission et moderation de packs, prerequis des sondes ; corrige le lien casse vers le script de l'editeur
2026-09-10 16:28:26 +02:00

156 lines
5.1 KiB
Python

"""Connexion optionnelle par OpenID Connect.
Le site reste entierement utilisable sans compte : se connecter n'ajoute que
l'enregistrement de projets cote serveur et la soumission de packs.
Un seul fournisseur est declare, Authentik, qui federe lui-meme GitHub, Google
ou tout autre fournisseur social. L'application n'a donc jamais a connaitre les
identifiants de ces services, ni a etre redeployee quand on en ajoute un.
"""
from __future__ import annotations
import functools
from typing import Any, Callable
from authlib.integrations.flask_client import OAuth
from flask import Blueprint, current_app, jsonify, redirect, request, session, url_for
from .config import Config
from .db import Utilisateur, db, maintenant
oauth = OAuth()
bp = Blueprint("auth", __name__)
def init_app(app) -> None:
oauth.init_app(app)
if not Config.oidc_actif():
return
oauth.register(
name="fournisseur",
client_id=Config.OIDC_CLIENT_ID,
client_secret=Config.OIDC_CLIENT_SECRET,
server_metadata_url=Config.OIDC_METADATA,
client_kwargs={"scope": Config.OIDC_SCOPES},
)
app.register_blueprint(bp)
# --------------------------------------------------------------------------- #
# Etat de la session
# --------------------------------------------------------------------------- #
def utilisateur_courant() -> Utilisateur | None:
identifiant = session.get("utilisateur_id")
if not identifiant or not Config.base_active():
return None
return db.session.get(Utilisateur, identifiant)
def est_administrateur(utilisateur: Utilisateur | None = None) -> bool:
utilisateur = utilisateur or utilisateur_courant()
return bool(utilisateur and utilisateur.administrateur)
def connexion_requise(vue: Callable) -> Callable:
@functools.wraps(vue)
def enveloppe(*args: Any, **kwargs: Any):
if utilisateur_courant() is None:
return jsonify({"erreur": "connexion requise"}), 401
return vue(*args, **kwargs)
return enveloppe
def administration_requise(vue: Callable) -> Callable:
@functools.wraps(vue)
def enveloppe(*args: Any, **kwargs: Any):
utilisateur = utilisateur_courant()
if utilisateur is None:
return jsonify({"erreur": "connexion requise"}), 401
if not utilisateur.administrateur:
return jsonify({"erreur": "reserve a la moderation"}), 403
return vue(*args, **kwargs)
return enveloppe
# --------------------------------------------------------------------------- #
# Parcours de connexion
# --------------------------------------------------------------------------- #
def _destination_sure(brut: str | None) -> str:
"""N'accepte qu'un chemin interne : pas de redirection ouverte."""
if not brut or not brut.startswith("/") or brut.startswith("//"):
return "/"
return brut
@bp.get("/connexion")
def connexion():
session["retour"] = _destination_sure(request.args.get("retour"))
redirection = url_for("auth.retour", _external=True)
if Config.URL_PUBLIQUE:
redirection = f"{Config.URL_PUBLIQUE}{url_for('auth.retour')}"
return oauth.fournisseur.authorize_redirect(redirection)
@bp.get("/connexion/retour")
def retour():
try:
jeton = oauth.fournisseur.authorize_access_token()
except Exception: # noqa: BLE001 - echec d'echange, on renvoie sans detail
current_app.logger.warning("echec de l'echange du jeton OIDC")
return redirect("/?connexion=echec")
revendications = jeton.get("userinfo") or {}
if not revendications:
try:
revendications = oauth.fournisseur.userinfo(token=jeton)
except Exception: # noqa: BLE001
revendications = {}
sujet = str(revendications.get("sub") or "").strip()
if not sujet:
return redirect("/?connexion=echec")
utilisateur = db.session.query(Utilisateur).filter_by(sujet=sujet).one_or_none()
if utilisateur is None:
utilisateur = Utilisateur(sujet=sujet)
db.session.add(utilisateur)
utilisateur.email = str(revendications.get("email") or "")[:320]
utilisateur.nom = str(
revendications.get("name") or revendications.get("preferred_username") or ""
)[:255]
utilisateur.administrateur = _est_admin(revendications)
utilisateur.vu_le = maintenant()
db.session.commit()
session.clear()
session["utilisateur_id"] = utilisateur.id
session.permanent = True
return redirect(_destination_sure(session.pop("retour", "/")))
def _est_admin(revendications: dict) -> bool:
"""Deux voies : appartenance a un groupe, ou liste explicite de comptes.
La seconde existe pour le premier demarrage, avant que le mapping de portee
"groups" ne soit configure cote Authentik.
"""
groupes = revendications.get("groups") or []
if isinstance(groupes, str):
groupes = [groupes]
if Config.ADMIN_GROUPE and Config.ADMIN_GROUPE in groupes:
return True
email = str(revendications.get("email") or "").lower()
return bool(email and email in Config.ADMIN_COMPTES)
@bp.post("/deconnexion")
def deconnexion():
session.clear()
return jsonify({"statut": "deconnecte"})