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
role: admin
smtp_password: 17acdfd671d8ab
totp_secret: null
totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY
test:
email: julien@balet-vs.ch
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
def login_page() -> rx.Component:
def _logo() -> rx.Component:
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.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(
"Se connecter",
"Activer 2FA",
type="submit",
width="100%",
color_scheme="indigo",
),
spacing="3",
rx.button(
"Annuler",
on_click=AuthState.cancel_totp,
type="button",
variant="soft",
color_scheme="gray",
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",
padding="2rem",
background_color="white",

View file

@ -1,10 +1,39 @@
import base64
import io
import os
import bcrypt
import pyotp
import qrcode
import yaml
import reflex as rx
from pathlib import Path
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):
@ -20,6 +49,15 @@ class AuthState(rx.State):
login_pass: 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
mobile_menu_open: bool = False
admin_expanded: bool = True
@ -55,6 +93,11 @@ class AuthState(rx.State):
def set_login_pass(self, value: str):
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):
if self.authenticated:
return rx.redirect("/accueil")
@ -77,20 +120,104 @@ class AuthState(rx.State):
self.login_error = ""
users = self._load_users()
user = users.get(self.login_user)
if user:
try:
ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode())
except Exception:
ok = False
if ok:
self.username = self.login_user
self.name = user.get("name", self.login_user)
self.role = user.get("role", "user")
self.photo_url = user.get("avatar_url", "")
self.login_pass = ""
return rx.redirect("/accueil")
self.login_error = "Identifiant ou mot de passe incorrect"
if not user:
self.login_error = "Identifiant ou mot de passe incorrect"
self.login_pass = ""
return
try:
ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode())
except Exception:
ok = False
if not ok:
self.login_error = "Identifiant ou mot de passe incorrect"
self.login_pass = ""
return
# Mot de passe OK — étape 2FA
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):
self._clear_session()
@ -104,12 +231,8 @@ class AuthState(rx.State):
self.login_user = ""
self.login_pass = ""
self.login_error = ""
self._reset_totp_flow()
@staticmethod
def _load_users() -> dict:
auth_file = DATA_DIR / "auth.yaml"
if auth_file.exists():
with open(auth_file) as f:
data = yaml.safe_load(f) or {}
return data.get("credentials", {}).get("usernames", {})
return {}
return _load_auth_full().get("credentials", {}).get("usernames", {})