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:
61
README.md
61
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/<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).
|
||||
|
||||
78
app/main.py
78
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)
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user