eptm_dashboard/ESCADAWEB_AUTH.md
2026-05-11 15:45:44 +02:00

11 KiB

Escadaweb — Login + 2FA + Forçage langue (Playwright)

Documentation des briques réutilisables pour automatiser une session Escadaweb (escadaweb.vs.ch) : login Keycloak, code TOTP, forçage du français côté UI. Issu de scripts/sync_esacada.py.

Pré-requis

pip install playwright pyotp
playwright install chromium

Configuration (data/settings.json)

Les identifiants et secrets ne sont pas hardcodés — ils viennent du fichier data/settings.json (édité depuis /params dans l'app).

{
  "escada_username": "prenom.nom@vs.ch",
  "escada_password": "•••",
  "totp_secret":     "BASE32SECRET"
}

Sans escada_username / escada_password → le script affiche la fenêtre Chromium et attend que tu te connectes à la main (utile pour le 1er run, où le profil persistant Chromium se construit).

Sans totp_secret → le code TOTP doit être saisi à la main. Pour le récupérer la 1ère fois, scanne le QR proposé par Escadaweb avec [Aegis/Authy/etc.] ET copie le secret BASE32 sous-jacent (souvent caché derrière un lien "Saisie manuelle").

URLs Escadaweb

BASE_URL           = "https://escadaweb.vs.ch"
LEHRPERSONEN_URL   = f"{BASE_URL}/Lehrpersonen"
CLASSES_URL        = f"{BASE_URL}/Lehrpersonen/ViewKlassen.aspx"
EINSTELLUNGEN_URL  = f"{BASE_URL}/Lehrpersonen/Dialogs/DlgEinstellungen.aspx"

Le subpath /Lehrpersonen/ est obligatoire — sans lui le SSO ne sert pas la bonne app et le login échoue silencieusement.

Profil Chromium persistant

Indispensable : permet de garder le cookie de session Keycloak entre les runs, sinon tu refais 2FA à chaque fois.

from pathlib import Path
from playwright.sync_api import sync_playwright

PROFILE_DIR = Path("data/browser_profile")   # adapter au projet

def _launch_context():
    PROFILE_DIR.mkdir(parents=True, exist_ok=True)
    pw = sync_playwright().start()
    ctx = pw.chromium.launch_persistent_context(
        str(PROFILE_DIR),
        headless=True,                  # False pour debug visuel
        args=["--start-maximized", "--disable-popup-blocking"],
        accept_downloads=True,
    )
    page = ctx.pages[0] if ctx.pages else ctx.new_page()
    return pw, ctx, page

Note : launch_persistent_context ouvre toujours un seul context ; on récupère le premier page existant pour éviter d'avoir un onglet vide en plus du nôtre.

Code à coller

Helpers settings

import json
from pathlib import Path

_ROOT = Path(__file__).resolve().parent.parent  # adapter au projet

def _load_settings() -> dict:
    p = _ROOT / "data" / "settings.json"
    if not p.exists():
        return {}
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        return {}

def _load_totp_secret() -> str | None:
    return _load_settings().get("totp_secret") or None

def _load_escada_creds() -> tuple[str, str]:
    s = _load_settings()
    return s.get("escada_username", ""), s.get("escada_password", "")

1) Remplir le formulaire Keycloak

Sélecteurs exacts du formulaire SSO (edusso.apps.vs.ch) :

<input id="username" name="username" type="text">
<input id="password" name="password" type="password">
<input id="kc-login" name="login"    type="submit">
from playwright.sync_api import Page

def _try_fill_login(page: Page) -> bool:
    username, password = _load_escada_creds()
    if not username or not password:
        return False
    try:
        page.wait_for_selector("input#username", state="visible", timeout=5_000)
        page.wait_for_selector("input#password", state="visible", timeout=2_000)
        page.locator("input#username").fill(username)
        page.locator("input#password").fill(password)
        try:
            page.locator("input#kc-login").click(timeout=2_000)
        except Exception:
            page.locator("input#password").press("Enter")
        return True
    except Exception:
        return False

2) Remplir le code TOTP

Le champ OTP de Keycloak est rendu caché par CSS avant la vérification — un page.fill() Playwright standard échoue ("locator not visible"). On contourne via page.evaluate() (JS pur) qui injecte la valeur et déclenche les events.

import pyotp

def _try_fill_totp(page: Page, secret: str) -> bool:
    try:
        code = pyotp.TOTP(secret).now()
        result = page.evaluate("""(code) => {
            const inp = document.querySelector('#otp')
                     || document.querySelector('[name="otp"]')
                     || document.querySelector('[autocomplete="one-time-code"]')
                     || document.querySelector('input[type="text"]:not([type="hidden"])');
            if (!inp) return 'not_found';
            inp.value = code;
            inp.dispatchEvent(new Event('input',  {bubbles: true}));
            inp.dispatchEvent(new Event('change', {bubbles: true}));
            return 'filled';
        }""", code)
        if result != "filled":
            return False
        submitted = page.evaluate("""() => {
            const btn = document.querySelector('input[type="submit"]')
                     || document.querySelector('button[type="submit"]');
            if (btn) { btn.click(); return 'clicked'; }
            const form = document.querySelector('form');
            if (form) { form.submit(); return 'submitted'; }
            return 'no_submit';
        }""")
        return submitted in ("clicked", "submitted")
    except Exception:
        return False

3) Forcer le français côté UI

Tout le parsing du HTML (libellés boutons, en-têtes de tableau, etc.) suppose le français. Si l'utilisateur a la session en allemand, on bascule via DlgEinstellungen :

import sys

_lang_ok = False   # mémo process : évite de re-naviguer à chaque appel

def _ensure_french_language(page: Page) -> None:
    global _lang_ok
    if _lang_ok:
        return
    page.goto(EINSTELLUNGEN_URL, wait_until="domcontentloaded", timeout=15_000)
    try:
        page.wait_for_load_state("networkidle", timeout=8_000)
    except Exception:
        pass

    inp_loc = page.locator("#ContentPlaceHolderSite_DropDownList_sprache_I")
    try:
        inp_loc.wait_for(state="visible", timeout=8_000)
    except Exception:
        print("ERR [LANG] Dropdown langue introuvable — arrêt.")
        sys.exit(1)

    cur_val = inp_loc.input_value()
    if cur_val != "français":
        page.evaluate("""() => {
            const inp = document.querySelector('#ContentPlaceHolderSite_DropDownList_sprache_I');
            if (inp) {
                inp.value = 'français';
                ASPx.ETextChanged('ContentPlaceHolderSite_DropDownList_sprache');
            }
        }""")
        page.locator("span.dx-vam:has-text('Speichern')").first.click()
        try:
            page.wait_for_load_state("networkidle", timeout=10_000)
        except Exception:
            pass
        # Attendre que le grid soit prêt avant de rendre la main
        try:
            page.wait_for_selector(
                "a[href*='ViewAbsenzenErweitert']", state="attached", timeout=30_000
            )
        except Exception:
            pass
    else:
        page.goto(CLASSES_URL, wait_until="domcontentloaded", timeout=15_000)

    _lang_ok = True

⚠️ Le bouton "Enregistrer" est resté libellé Speichern (allemand) même après changement de langue côté UI — bug d'Escadaweb. Le sélecteur ci-dessus matche ce label tel quel.

4) Orchestrateur

import time
from playwright.sync_api import TimeoutError as PWTimeout, Error as PWError

def _ensure_logged_in(page: Page) -> None:
    """Si on est déjà sur ViewKlassen, juste vérifier la langue. Sinon,
    boucle pendant 5 min : remplit login + TOTP automatiquement (si
    secrets configurés), ou laisse l'utilisateur taper en manuel."""
    if "ViewKlassen" in page.url:
        _ensure_french_language(page)
        return

    _totp_secret = _load_totp_secret()
    _username, _password = _load_escada_creds()

    cur = page.url.lower()
    if "login" not in cur and "logon" not in cur and "viewklassen" not in cur:
        page.goto(LEHRPERSONEN_URL)

    deadline    = time.time() + 300   # 5 min
    _last_login = 0.0
    _last_totp  = 0.0

    while time.time() < deadline:
        try:
            if "ViewKlassen" in page.url:
                _ensure_french_language(page)
                return

            if _username and _password and (time.time() - _last_login) > 5:
                if _try_fill_login(page):
                    _last_login = time.time()
                    try:
                        page.wait_for_load_state("networkidle", timeout=8_000)
                    except (PWTimeout, PWError):
                        pass

            if _totp_secret and (time.time() - _last_totp) > 5:
                if _try_fill_totp(page, _totp_secret):
                    _last_totp = time.time()
                    try:
                        page.wait_for_url("**ViewKlassen**", timeout=10_000)
                        return
                    except (PWTimeout, PWError):
                        pass

            page.wait_for_timeout(800)
        except PWError:
            if "ViewKlassen" in page.url:
                _ensure_french_language(page)
                return

    print("ERR Délai de connexion dépassé (5 min).")
    sys.exit(1)

Exemple minimal

pw, ctx, page = _launch_context()
try:
    page.goto(CLASSES_URL)
    _ensure_logged_in(page)
    # → à ce point : session active, langue = français, on est sur
    #   /Lehrpersonen/ViewKlassen.aspx. Le reste du script peut tourner.
    print("Connecté :", page.url)
finally:
    ctx.close()
    pw.stop()

Pièges et points d'attention

  • Toujours page.goto(CLASSES_URL) avant _ensure_logged_in(). Si on attaque une autre page (par ex. ViewLernende) sans session, on rebondit sur une URL Keycloak où les sélecteurs de login ne sont pas les mêmes, et _try_fill_login échoue.
  • Throttling : on attend 5 s entre deux tentatives de fill login / TOTP pour ne pas spammer le serveur ni déclencher un anti-bot.
  • Pas de time.sleep() long : utiliser page.wait_for_timeout(ms) (Playwright) ou wait_for_load_state pour laisser le navigateur traiter ses événements.
  • headless=True convient pour la prod ; pour debug, passer headless=False permet de voir ce qui se passe.
  • Le profil Chromium persistant garde la session vivante des jours (cookie SSO long terme). Si tu supprimes data/browser_profile/, tu repars de zéro et le 2FA sera redemandé.
  • Multi-instance : un profil persistant Chromium ne peut être ouvert que par un seul process à la fois. Si tu paralléllises, copier le profil ou utiliser launch() + cookies exportés.

Fichiers de référence