page apprentis ok

This commit is contained in:
Julien Balet 2026-05-10 10:57:28 +02:00
parent 129ca39e2d
commit 23e0b2bf60
4 changed files with 1028 additions and 168 deletions

View file

@ -11,7 +11,7 @@ credentials:
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
role: admin role: admin
smtp_password: 17acdfd671d8ab smtp_password: 17acdfd671d8ab
totp_secret: null totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY
test: test:
email: julien@balet-vs.ch email: julien@balet-vs.ch
name: test name: test

File diff suppressed because it is too large Load diff

View file

@ -2,51 +2,192 @@ import reflex as rx
from ..state import AuthState from ..state import AuthState
def login_page() -> rx.Component: def _logo() -> rx.Component:
return rx.center( return rx.center(
rx.form( rx.image(src="/logo.png", width="320px", height="auto"),
width="100%",
)
def _error_box(msg) -> rx.Component:
return rx.box(
rx.text(msg, color="red", size="2"),
padding="0.5rem 1rem",
background_color="#fff5f5",
border="1px solid #ffcccc",
border_radius="6px",
width="100%",
)
def _password_form() -> rx.Component:
return rx.form(
rx.vstack(
_logo(),
rx.cond(AuthState.login_error != "", _error_box(AuthState.login_error)),
rx.input(
name="username",
placeholder="Identifiant",
value=AuthState.login_user,
on_change=AuthState.set_login_user,
width="100%",
),
rx.input(
name="password",
placeholder="Mot de passe",
type="password",
value=AuthState.login_pass,
on_change=AuthState.set_login_pass,
width="100%",
),
rx.button(
"Se connecter",
type="submit",
width="100%",
color_scheme="indigo",
),
spacing="3",
width="100%",
align="center",
),
on_submit=AuthState.handle_login,
width="100%",
)
def _setup_form() -> rx.Component:
"""Première connexion : QR code à scanner + code de confirmation."""
return rx.form(
rx.vstack(
_logo(),
rx.heading("Configuration 2FA", size="4", color="#37474f"),
rx.text(
"Scanne ce QR code avec ton application Authenticator "
"(Google Authenticator, Microsoft Authenticator, Authy…) puis "
"saisis le code à 6 chiffres pour confirmer.",
size="2",
color="#555",
text_align="center",
),
rx.center(
rx.image(
src=AuthState.totp_qr_data_url,
width="220px",
height="220px",
),
width="100%",
),
rx.text(
"Compte : ", AuthState.totp_pending_user,
size="1",
color="var(--gray-9)",
),
rx.cond(AuthState.totp_error != "", _error_box(AuthState.totp_error)),
rx.input(
name="totp_code",
placeholder="Code à 6 chiffres",
value=AuthState.totp_code,
on_change=AuthState.set_totp_code,
width="100%",
max_length=6,
auto_focus=True,
text_align="center",
font_size="1.4rem",
letter_spacing="0.4rem",
),
rx.vstack( rx.vstack(
rx.center(
rx.image(src="/logo.png", width="320px", height="auto"),
width="100%",
),
rx.cond(
AuthState.login_error != "",
rx.box(
rx.text(AuthState.login_error, color="red", size="2"),
padding="0.5rem 1rem",
background_color="#fff5f5",
border="1px solid #ffcccc",
border_radius="6px",
width="100%",
),
),
rx.input(
name="username",
placeholder="Identifiant",
value=AuthState.login_user,
on_change=AuthState.set_login_user,
width="100%",
),
rx.input(
name="password",
placeholder="Mot de passe",
type="password",
value=AuthState.login_pass,
on_change=AuthState.set_login_pass,
width="100%",
),
rx.button( rx.button(
"Se connecter", "Activer 2FA",
type="submit", type="submit",
width="100%", width="100%",
color_scheme="indigo", color_scheme="indigo",
), ),
spacing="3", rx.button(
"Annuler",
on_click=AuthState.cancel_totp,
type="button",
variant="soft",
color_scheme="gray",
width="100%",
),
width="100%", width="100%",
align="center", spacing="2",
),
spacing="3",
width="100%",
align="center",
),
on_submit=AuthState.verify_totp,
width="100%",
)
def _verify_form() -> rx.Component:
"""2FA déjà active : juste demander le code."""
return rx.form(
rx.vstack(
_logo(),
rx.heading("Vérification 2FA", size="4", color="#37474f"),
rx.text(
"Entre le code à 6 chiffres affiché par ton application Authenticator.",
size="2",
color="#555",
text_align="center",
),
rx.text(
"Compte : ", AuthState.totp_pending_user,
size="1",
color="var(--gray-9)",
),
rx.cond(AuthState.totp_error != "", _error_box(AuthState.totp_error)),
rx.input(
name="totp_code",
placeholder="Code à 6 chiffres",
value=AuthState.totp_code,
on_change=AuthState.set_totp_code,
width="100%",
max_length=6,
auto_focus=True,
text_align="center",
font_size="1.4rem",
letter_spacing="0.4rem",
),
rx.vstack(
rx.button(
"Valider",
type="submit",
width="100%",
color_scheme="indigo",
),
rx.button(
"Annuler",
on_click=AuthState.cancel_totp,
type="button",
variant="soft",
color_scheme="gray",
width="100%",
),
width="100%",
spacing="2",
),
spacing="3",
width="100%",
align="center",
),
on_submit=AuthState.verify_totp,
width="100%",
)
def login_page() -> rx.Component:
return rx.center(
rx.box(
rx.match(
AuthState.totp_step,
("setup", _setup_form()),
("verify", _verify_form()),
_password_form(),
), ),
on_submit=AuthState.handle_login,
width="420px", width="420px",
padding="2rem", padding="2rem",
background_color="white", background_color="white",

View file

@ -1,10 +1,39 @@
import base64
import io
import os import os
import bcrypt import bcrypt
import pyotp
import qrcode
import yaml import yaml
import reflex as rx import reflex as rx
from pathlib import Path from pathlib import Path
DATA_DIR = Path(os.getenv("DATA_DIR", "data")) DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
TOTP_ISSUER = "EPTM Dashboard"
def _load_auth_full() -> dict:
"""Lit auth.yaml complet (config dict, pas seulement usernames)."""
auth_file = DATA_DIR / "auth.yaml"
if auth_file.exists():
with open(auth_file, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def _save_auth_full(cfg: dict) -> None:
auth_file = DATA_DIR / "auth.yaml"
with open(auth_file, "w", encoding="utf-8") as f:
yaml.dump(cfg, f, allow_unicode=True)
def _make_totp_qr_data_url(secret: str, label: str) -> str:
"""Génère un data URL PNG du QR code TOTP."""
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=TOTP_ISSUER)
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format="PNG")
return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
class AuthState(rx.State): class AuthState(rx.State):
@ -20,6 +49,15 @@ class AuthState(rx.State):
login_pass: str = "" login_pass: str = ""
login_error: str = "" login_error: str = ""
# 2FA flow (in-memory, ephémère)
# totp_step in {"password", "setup", "verify"}
totp_step: str = "password"
totp_pending_user: str = ""
totp_secret_pending: str = "" # secret généré en setup, pas encore sauvé
totp_qr_data_url: str = ""
totp_code: str = ""
totp_error: str = ""
sidebar_collapsed: bool = False sidebar_collapsed: bool = False
mobile_menu_open: bool = False mobile_menu_open: bool = False
admin_expanded: bool = True admin_expanded: bool = True
@ -55,6 +93,11 @@ class AuthState(rx.State):
def set_login_pass(self, value: str): def set_login_pass(self, value: str):
self.login_pass = value self.login_pass = value
def set_totp_code(self, value: str):
# Garder uniquement les chiffres, max 6
self.totp_code = "".join(ch for ch in value if ch.isdigit())[:6]
self.totp_error = ""
def index_redirect(self): def index_redirect(self):
if self.authenticated: if self.authenticated:
return rx.redirect("/accueil") return rx.redirect("/accueil")
@ -77,20 +120,104 @@ class AuthState(rx.State):
self.login_error = "" self.login_error = ""
users = self._load_users() users = self._load_users()
user = users.get(self.login_user) user = users.get(self.login_user)
if user: if not user:
try: self.login_error = "Identifiant ou mot de passe incorrect"
ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode()) self.login_pass = ""
except Exception: return
ok = False try:
if ok: ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode())
self.username = self.login_user except Exception:
self.name = user.get("name", self.login_user) ok = False
self.role = user.get("role", "user") if not ok:
self.photo_url = user.get("avatar_url", "") self.login_error = "Identifiant ou mot de passe incorrect"
self.login_pass = "" self.login_pass = ""
return rx.redirect("/accueil") return
self.login_error = "Identifiant ou mot de passe incorrect"
# Mot de passe OK — étape 2FA
self.login_pass = "" self.login_pass = ""
self.totp_pending_user = self.login_user
self.totp_code = ""
self.totp_error = ""
if user.get("totp_secret"):
# 2FA déjà configurée — demander le code
self.totp_step = "verify"
self.totp_secret_pending = ""
self.totp_qr_data_url = ""
else:
# Première connexion 2FA — générer secret + QR
secret = pyotp.random_base32()
self.totp_secret_pending = secret
label = f"{self.login_user}@{TOTP_ISSUER}"
self.totp_qr_data_url = _make_totp_qr_data_url(secret, label)
self.totp_step = "setup"
def verify_totp(self, form_data: dict | None = None):
"""Vérifie le code TOTP saisi. Si setup, sauve le secret. Puis login."""
self.totp_error = ""
if not self.totp_pending_user:
# Session perdue — retour login
self.cancel_totp()
return rx.redirect("/login")
if len(self.totp_code) != 6:
self.totp_error = "Code à 6 chiffres requis"
return
# Déterminer le secret à valider
if self.totp_step == "setup":
secret = self.totp_secret_pending
else:
cfg = _load_auth_full()
user = cfg.get("credentials", {}).get("usernames", {}).get(self.totp_pending_user)
if not user:
self.cancel_totp()
return rx.redirect("/login")
secret = user.get("totp_secret") or ""
if not secret:
self.totp_error = "Configuration 2FA manquante — contactez un administrateur"
return
# Vérifier code (valid_window=1 → tolère ±30s de dérive d'horloge)
totp = pyotp.TOTP(secret)
if not totp.verify(self.totp_code, valid_window=1):
self.totp_error = "Code invalide"
self.totp_code = ""
return
# Code OK
cfg = _load_auth_full()
users = cfg.get("credentials", {}).get("usernames", {})
user = users.get(self.totp_pending_user)
if not user:
self.cancel_totp()
return rx.redirect("/login")
# Si setup, sauver le secret dans auth.yaml
if self.totp_step == "setup":
users[self.totp_pending_user]["totp_secret"] = secret
_save_auth_full(cfg)
# Finaliser la connexion
self.username = self.totp_pending_user
self.name = user.get("name", self.totp_pending_user)
self.role = user.get("role", "user")
self.photo_url = user.get("avatar_url", "")
self._reset_totp_flow()
return rx.redirect("/accueil")
def cancel_totp(self):
"""Annule le flow 2FA et revient à l'étape password."""
self._reset_totp_flow()
def _reset_totp_flow(self):
self.totp_step = "password"
self.totp_pending_user = ""
self.totp_secret_pending = ""
self.totp_qr_data_url = ""
self.totp_code = ""
self.totp_error = ""
def logout(self): def logout(self):
self._clear_session() self._clear_session()
@ -104,12 +231,8 @@ class AuthState(rx.State):
self.login_user = "" self.login_user = ""
self.login_pass = "" self.login_pass = ""
self.login_error = "" self.login_error = ""
self._reset_totp_flow()
@staticmethod @staticmethod
def _load_users() -> dict: def _load_users() -> dict:
auth_file = DATA_DIR / "auth.yaml" return _load_auth_full().get("credentials", {}).get("usernames", {})
if auth_file.exists():
with open(auth_file) as f:
data = yaml.safe_load(f) or {}
return data.get("credentials", {}).get("usernames", {})
return {}