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
|
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
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue