diff --git a/README.md b/README.md index f0e65de..ef2e107 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,20 @@ performance — une seule fois par semaine et par onglet, promis. Chaque personne a son propre compte, ses propres pointages, congés, réglages et jetons de présence — tout est stocké séparément dans `data/users//`. -Connexion par email + mot de passe, restreinte au domaine `allowed_email_domain` (voir -[Configuration](#configuration)). Pour un compte qui n'a pas encore de mot de passe, se -connecter avec son adresse envoie un lien (valable 24h) pour en définir un — c'est aussi -le seul moyen d'en obtenir un : il n'y a pas de "mot de passe oublié" en libre-service une -fois un mot de passe défini. La session est un cookie signé (HMAC), sans base de données -de sessions côté serveur. +**Utilisation locale sans aucune config** : `data/config.json` est optionnel (créé +avec les valeurs par défaut au premier lancement) et la config mail aussi — sans +relais SMTP, le lien de création de compte s'affiche directement à l'écran au lieu +d'être envoyé par email. Lance, ouvre [http://localhost:8000](http://localhost:8000), tape ton +email, clique sur le lien affiché, définis ton mot de passe : c'est tout. + +Connexion par email + mot de passe. Si `allowed_email_domain` est renseigné, seules +les adresses de ce domaine peuvent se connecter (vide = tous les domaines acceptés). +Pour un compte qui n'a pas encore de mot de passe, se connecter avec son adresse +génère un lien (valable 24h) pour en définir un — envoyé par email si un serveur +SMTP est configuré, affiché à l'écran sinon. C'est aussi le seul moyen d'en obtenir +un : il n'y a pas de "mot de passe oublié" en libre-service une fois un mot de +passe défini. La session est un cookie signé (HMAC), sans base de données de +sessions côté serveur. --- @@ -146,37 +154,44 @@ Seul `data/config.example.json` est versionné. ## Configuration -`data/config.json` (voir `data/config.example.json`) : +`data/config.json` (voir `data/config.example.json`) — toutes les clés sont +optionnelles (le fichier lui-même est créé avec les défauts au premier lancement) : ```json { "heures_semaine": 39.0, "jours_par_semaine": 5, - "log_level": "DEBUG", - "allowed_email_domain": "exemple.fr", - "base_url": "https://pointeuse.exemple.fr", - "mail_from": "no-reply@exemple.fr", + "log_level": "INFO", + "allowed_email_domain": "", + "base_url": "", + "mail_from": "", "smtp": { - "host": "smtp.exemple.fr", + "host": "", "port": 587, - "user": "no-reply@exemple.fr", - "password": "change-moi", + "user": "", + "password": "", "use_tls": true - } + }, + "plages": { "matin_debut": "09:00", "matin_fin": "12:00", "aprem_debut": "14:00", "aprem_fin": "17:00", "pause_dejeuner_fin": "13:30" }, + "astreinte_facteurs": [] } ``` - `heures_semaine` — heures hebdomadaires visées (ex: `39.0`). - `jours_par_semaine` — nombre de jours travaillés dans la semaine (ex: `5`). Le quota quotidien est calculé : `heures_semaine ÷ jours_par_semaine` (39h / 5 = 7h48/jour). - `log_level` — `DEBUG`, `INFO`, `WARNING` ou `ERROR`. Contrôle la verbosité des logs (`docker logs`). -- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter. -- `base_url` — schéma + domaine (ex: `https://pointeuse.exemple.fr`, sans `/` final) utilisés - pour construire les liens envoyés par email (magic link) et les URLs Tasker/Automation - affichées dans Réglages. Si vide, ces URLs sont dérivées de la requête entrante (Host header) - quand celui-ci correspond à `localhost` ou à `allowed_email_domain` ; sinon l'envoi d'email est - refusé par sécurité (anti open-redirect). **Toujours renseigner `base_url` en production.** -- `mail_from` — adresse expéditrice des emails de connexion. -- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS). +- `allowed_email_domain` — **optionnel** : seules les adresses de ce domaine peuvent se + connecter ; vide = tous les domaines acceptés (l'email ne sert que d'identifiant). +- `base_url` — **optionnel** : schéma + domaine (ex: `https://pointeuse.exemple.fr`, sans `/` final) + utilisés pour construire les liens envoyés par email. Si vide, ces URLs sont dérivées de + la requête entrante (Host header) quand celui-ci correspond à `localhost` ou à + `allowed_email_domain` ; sinon l'envoi d'email est refusé par sécurité (anti + open-redirect). **À renseigner en production si le mail est activé.** +- `mail_from` — **optionnel** : adresse expéditrice des emails de connexion (défaut local + `no-reply@pointeuse.local`). +- `smtp` — **optionnel** : serveur relais utilisé pour l'envoi (host, port, identifiants, + TLS). Sans `smtp.host`, aucun email n'est envoyé : le lien de création de compte + s'affiche directement à l'écran — parfait pour une instance locale perso. - `force_secure_cookies` (optionnel, `false` par défaut) — force le flag `Secure` sur le cookie de session même si la requête entrante n'est pas vue comme HTTPS par l'app (utile derrière un reverse proxy qui termine le TLS). diff --git a/app/main.py b/app/main.py index 3423733..b57dd37 100644 --- a/app/main.py +++ b/app/main.py @@ -659,8 +659,15 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): RESET_TOKEN_VALIDITY_HOURS = 24 -def _login_ctx(request, step, email="", error=None): - return {"request": request, "step": step, "email": email, "error": error} +def _login_ctx(request, step, email="", error=None, link=None): + return {"request": request, "step": step, "email": email, "error": error, "link": link} + + +def _email_autorise(m, domain: str) -> bool: + """True si l'email est acceptable. Domaine configuré → suffixe strict ; + domaine vide (config optionnelle, ex. instance locale perso) → tout domaine + est accepté, l'email ne sert que d'identifiant.""" + return bool(m) and (not domain or m.group(2) == domain) @app.get("/login", response_class=HTMLResponse) @@ -684,11 +691,11 @@ def login_submit(request: Request, email: str = Form("")): m = _EMAIL_RE.match(email) domain = allowed_email_domain() logger.info("POST /login: email=%r domaine_attendu=%r", email, domain) - if not m or not domain or m.group(2) != domain: + if not _email_autorise(m, domain): logger.warning("login refusé (domaine invalide): email=%r", email) return templates.TemplateResponse("login.html", _login_ctx( request, "email", email, - f"Seules les adresses @{domain or '?'} sont autorisées.", + f"Seules les adresses @{domain} sont autorisées." if domain else "Adresse email invalide.", )) user_id = m.group(1) @@ -698,17 +705,25 @@ def login_submit(request: Request, email: str = Form("")): if auth.get("password_hash"): return templates.TemplateResponse("login.html", _login_ctx(request, "password", email)) - # No account yet: (re)send a link to set the password - base_url = _safe_base_url(request) - if not base_url: - logger.warning( - "login refusé (Host non fiable pour générer un lien email): host=%r email=%r", - request.headers.get("host"), email, - ) - return templates.TemplateResponse("login.html", _login_ctx( - request, "email", email, - "Domaine non autorisé pour générer un lien de connexion.", - )) + # Pas encore de compte : générer un lien pour définir le mot de passe. La + # config mail est optionnelle : avec un relais SMTP, le lien part par email + # (host contrôlé par _safe_base_url, anti open-redirect) ; sans relais + # (ex. instance locale mono-utilisateur), il s'affiche directement à + # l'écran — construit depuis l'URL en cours, celle que l'utilisateur regarde. + smtp = smtp_config() + if smtp.get("host"): + base_url = _safe_base_url(request) + if not base_url: + logger.warning( + "login refusé (Host non fiable pour générer un lien email): host=%r email=%r", + request.headers.get("host"), email, + ) + return templates.TemplateResponse("login.html", _login_ctx( + request, "email", email, + "Domaine non autorisé pour générer un lien de connexion.", + )) + else: + base_url = str(request.base_url).rstrip("/") token = secrets.token_urlsafe(32) expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat() @@ -716,21 +731,24 @@ def login_submit(request: Request, email: str = Form("")): logger.info("nouveau token de création de compte pour %r (expire %s)", user_id, expires) link = f"{base_url}/set-password/{token}" - try: - send_mail( - email, - "Définis ton mot de passe — Décompte Horaire", - f"Clique sur ce lien pour définir ton mot de passe (valable {RESET_TOKEN_VALIDITY_HOURS}h) :\n\n{link}\n", - mail_from_address(), - smtp_config(), - ) - except Exception: - logger.exception("échec envoi email d'invitation à %r", email) - return templates.TemplateResponse("login.html", _login_ctx( - request, "email", email, "Impossible d'envoyer l'email pour le moment, réessaie plus tard.", - )) + if smtp.get("host"): + try: + send_mail( + email, + "Définis ton mot de passe — Décompte Horaire", + f"Clique sur ce lien pour définir ton mot de passe (valable {RESET_TOKEN_VALIDITY_HOURS}h) :\n\n{link}\n", + mail_from_address(), + smtp, + ) + except Exception: + logger.exception("échec envoi email d'invitation à %r", email) + return templates.TemplateResponse("login.html", _login_ctx( + request, "email", email, "Impossible d'envoyer l'email pour le moment, réessaie plus tard.", + )) + return templates.TemplateResponse("login.html", _login_ctx(request, "sent", email)) - return templates.TemplateResponse("login.html", _login_ctx(request, "sent", email)) + logger.warning("SMTP non configuré : lien de définition de mot de passe affiché à l'écran pour %r", email) + return templates.TemplateResponse("login.html", _login_ctx(request, "link", email, link=link)) @app.post("/login/password", response_class=HTMLResponse) @@ -747,7 +765,7 @@ def login_password(request: Request, email: str = Form(""), password: str = Form m = _EMAIL_RE.match(email) domain = allowed_email_domain() logger.info("POST /login/password: email=%r", email) - if not m or not domain or m.group(2) != domain: + if not _email_autorise(m, domain): logger.warning("login/password refusé (domaine invalide): email=%r", email) return RedirectResponse("/login", status_code=302) diff --git a/app/templates/base.html b/app/templates/base.html index aeb326b..6e3d7d1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1460,6 +1460,8 @@ } .login-input:focus { border-color: var(--accent); } .login-hint { font-size: 11px; color: var(--muted); } + .login-link { margin: .6rem 0 0; font-size: 13px; word-break: break-all; } + .login-link a { color: var(--accent); } .login-btn { margin-top: .5rem; font-family: var(--mono); diff --git a/app/templates/login.html b/app/templates/login.html index 1f93887..56c9fe3 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -50,6 +50,14 @@ Un email a été envoyé à {{ email }} avec un lien pour définir ton mot de passe. Le lien est valable 24h.

+ + {% elif step == "link" %} +

+ Aucun serveur SMTP n'est configuré (optionnel) : le lien pour définir le mot de passe + de {{ email }} s'affiche ici au lieu d'être envoyé par email. + Il est valable 24h. +

+ {% endif %} diff --git a/app/tests/test_security.py b/app/tests/test_security.py index 2cb1298..bd083c5 100644 --- a/app/tests/test_security.py +++ b/app/tests/test_security.py @@ -597,3 +597,80 @@ def test_rate_limit_can_be_disabled_via_config(): cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN models.save_config(cfg) main._LOGIN_ATTEMPTS.clear() + + +# ── Config mail optionnelle : usage local sans relais SMTP ──────────────────── + +def test_sans_smtp_le_lien_de_compte_s_affiche_a_l_ecran(monkeypatch): + """Sans smtp.host, la création de compte doit fonctionner sans email : le + lien de définition de mot de passe s'affiche directement dans la page + (construit depuis l'URL en cours, pas de vecteur de phishing email).""" + cfg = models.load_config() + ancien_smtp = cfg.get("smtp") + ancien_domaine = cfg.get("allowed_email_domain") + cfg["smtp"] = {} + cfg["allowed_email_domain"] = "" + models.save_config(cfg) + try: + r = client.post("/login", data={"email": "solo.perso@nimporte.fr"}, + headers={"Host": "localhost:8000"}, follow_redirects=False) + assert r.status_code == 200 + assert "/set-password/" in r.text # le lien est affiché + assert "SMTP" in r.text # avec l'explication + # Le lien affiché fonctionne : définir le mot de passe puis se connecter + import re as _re + token = _re.search(r"/set-password/([A-Za-z0-9_\-]+)", r.text).group(1) + r = client.post(f"/set-password/{token}", + data={"password": "s" * 12, "password2": "s" * 12}, + follow_redirects=False) + assert r.status_code == 302 + r = client.post("/login/password", + data={"email": "solo.perso@nimporte.fr", "password": "s" * 12}, + follow_redirects=False) + assert r.status_code == 302 # connexion réussie + finally: + cfg["smtp"] = ancien_smtp + cfg["allowed_email_domain"] = ancien_domaine or "x.fr" + models.save_config(cfg) + + +def test_domaine_vide_accepte_tous_les_domaines(monkeypatch): + """allowed_email_domain vide (défaut, instance locale) : tout domaine accepté + à la connexion par mot de passe. Renseigné, il reste strictement appliqué.""" + cfg = models.load_config() + ancien = cfg.get("allowed_email_domain") + cfg["allowed_email_domain"] = "" + models.save_config(cfg) + try: + _seed_user("libre.nimportequi") + r = client.post("/login/password", + data={"email": "libre.nimportequi@ailleurs.org", "password": "x" * 12}, + follow_redirects=False) + assert r.status_code == 302 # accepté malgré @ailleurs.org + # Un email sans domaine valide reste refusé + r = client.post("/login", data={"email": "pas-un-email"}, follow_redirects=False) + assert "invalide" in r.text.lower() + finally: + cfg["allowed_email_domain"] = ancien or "x.fr" + models.save_config(cfg) + +def test_sans_smtp_le_lien_utilise_l_url_courante_sans_base_url(): + """Sans SMTP, aucune config d'URL n'est nécessaire : le lien affiché est + construit depuis l'URL en cours (ex. http://192.168.1.10:8000 sur le LAN), + même avec base_url vide et allowed_email_domain vide. Le contrôle Host + strict (_safe_base_url) ne concerne que le chemin par email.""" + cfg = models.load_config() + ancien_smtp = cfg.get("smtp") + ancien_domaine = cfg.get("allowed_email_domain") + ancienne_base = cfg.get("base_url") + cfg.update({"smtp": {}, "allowed_email_domain": "", "base_url": ""}) + models.save_config(cfg) + try: + r = client.post("/login", data={"email": "lan.user@chez.moi"}, + headers={"Host": "192.168.1.10:8000"}, follow_redirects=False) + assert r.status_code == 200 + assert "http://192.168.1.10:8000/set-password/" in r.text + finally: + cfg.update({"smtp": ancien_smtp, "allowed_email_domain": ancien_domaine or "x.fr", + "base_url": ancienne_base or ""}) + models.save_config(cfg) diff --git a/data/config.example.json b/data/config.example.json index 9747c2f..ebfc0d6 100644 --- a/data/config.example.json +++ b/data/config.example.json @@ -1,21 +1,35 @@ { "heures_semaine": 39.0, "jours_par_semaine": 5, + "log_level": "INFO", + "allowed_email_domain": "", + "base_url": "", + "mail_from": "", + "smtp": { + "host": "", + "port": 587, + "user": "", + "password": "", + "use_tls": true + }, + "force_secure_cookies": false, + "login_rate_limit_per_min": 10, + "plages": { + "matin_debut": "09:00", + "matin_fin": "12:00", + "aprem_debut": "14:00", + "aprem_fin": "17:00", + "pause_dejeuner_fin": "13:30", + "arrivee_visee": "", + "depart_vise": "", + "astreinte_forfait": "", + "astreinte_active": true, + "repos_hebdo_active": false + }, "astreinte_facteurs": [ {"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5}, {"nom": "Journée", "jours": ["semaine"], "debut": "08:00", "fin": "20:00", "facteur": 1.0}, {"nom": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5}, {"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0} - ], - "log_level": "DEBUG", - "allowed_email_domain": "exemple.fr", - "base_url": "", - "mail_from": "no-reply@exemple.fr", - "smtp": { - "host": "smtp.exemple.fr", - "port": 587, - "user": "no-reply@exemple.fr", - "password": "change-moi", - "use_tls": true - } + ] }