page apprentis ok
This commit is contained in:
parent
129ca39e2d
commit
23e0b2bf60
4 changed files with 1028 additions and 168 deletions
|
|
@ -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
|
|
@ -2,25 +2,29 @@ import reflex as rx
|
|||
from ..state import AuthState
|
||||
|
||||
|
||||
def login_page() -> rx.Component:
|
||||
def _logo() -> rx.Component:
|
||||
return rx.center(
|
||||
rx.form(
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
|
|
@ -47,6 +51,143 @@ def login_page() -> rx.Component:
|
|||
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.button(
|
||||
"Activer 2FA",
|
||||
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 _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(),
|
||||
),
|
||||
width="420px",
|
||||
padding="2rem",
|
||||
background_color="white",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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 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")
|
||||
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", {})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue