Instance locale sans config : mail et lien email optionnels

L'app tourne désormais pour soi sans rien configurer. Sans relais
SMTP, le lien de création de compte s'affiche directement à l'écran,
construit depuis l'URL en cours — aucune config d'URL nécessaire,
même en naviguant par IP sur le LAN (le contrôle anti-open-redirect
du Host ne concerne que le chemin par email). allowed_email_domain
vide accepte tous les emails : l'adresse ne sert que d'identifiant.

Le fichier d'exemple de config était périmé : il datait d'avant les
plages, force_secure_cookies et login_rate_limit_per_min, et
laissait croire le SMTP obligatoire. Il reflète maintenant les clés
réelles, toutes optionnelles et vides par défaut.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-11 16:05:32 +02:00
parent 1b4bf0d488
commit 8e0f2c453f
6 changed files with 199 additions and 65 deletions

View File

@ -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/<identifiant>/`.
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).

View File

@ -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)

View File

@ -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);

View File

@ -50,6 +50,14 @@
Un email a été envoyé à <b>{{ email }}</b> avec un lien pour définir ton mot de passe.
Le lien est valable 24h.
</p>
{% elif step == "link" %}
<p class="login-hint">
Aucun serveur SMTP n'est configuré (optionnel) : le lien pour définir le mot de passe
de <b>{{ email }}</b> s'affiche ici au lieu d'être envoyé par email.
Il est valable 24h.
</p>
<p class="login-link"><a href="{{ link }}">{{ link }}</a></p>
{% endif %}
</div>
</div>

View File

@ -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)

View File

@ -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
}
]
}