Envoie les emails via un relais SMTP configurable plutôt qu'en direct-to-MX

Ajoute le bloc "smtp" (host, port, identifiants, TLS) dans config.json,
plus fiable et plus simple à opérer qu'une livraison directe au MX du
destinataire. data/config.json n'est plus versionné (secrets), remplacé
par data/config.example.json.
This commit is contained in:
Antoine
2026-07-17 21:46:52 +02:00
parent 1d9380736e
commit e6a523f76f
10 changed files with 297 additions and 64 deletions

View File

@ -1,18 +1,22 @@
import asyncio
import json
import re
import secrets
from datetime import datetime, timedelta
from itertools import groupby
import bcrypt
from fastapi import FastAPI, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from mail import send_mail
from models import (
load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user,
toggle_conge, list_all_weeks,
load_notif_config, save_notif_config, find_user_by_token, save_presence,
load_auth, save_auth, find_user_by_reset_token,
)
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats
@ -30,19 +34,30 @@ templates.env.filters["zip"] = zip
app.mount("/static", StaticFiles(directory="static"), name="static")
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,32}$")
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$")
_EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$")
# ── Auth helpers ──────────────────────────────────────────────────────────────
def _get_user(request: Request) -> str | None:
"""Extract current user from cookie.
TODO: replace with SSO token validation when integrating corporate SSO.
"""
"""Extract current user from cookie."""
uid = request.cookies.get("user_id", "").strip()
return uid if _USERNAME_RE.match(uid) else None
def allowed_email_domain() -> str:
return load_config().get("allowed_email_domain", "")
def mail_from_address() -> str:
return load_config().get("mail_from") or "no-reply@pointeuse.local"
def smtp_config() -> dict:
return load_config().get("smtp", {})
def _require_user(request: Request):
"""Return user_id or redirect to login."""
uid = _get_user(request)
@ -217,27 +232,116 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
# ── Login / logout ────────────────────────────────────────────────────────────
RESET_TOKEN_VALIDITY_HOURS = 24
def _login_ctx(request, step, email="", error=None):
return {"request": request, "step": step, "email": email, "error": error}
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
if _get_user(request):
return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": None})
return templates.TemplateResponse("login.html", _login_ctx(request, "email"))
@app.post("/login", response_class=HTMLResponse)
def login_submit(request: Request, response: Response, username: str = Form("")):
username = username.strip().lower()
if not _USERNAME_RE.match(username):
return templates.TemplateResponse("login.html", {
"request": request,
"error": "Nom d'utilisateur invalide (lettres, chiffres, - et _ uniquement, 132 caractères).",
})
# First-ever login: migrate legacy flat data to this user's directory
if needs_migration():
migrate_legacy_to_user(username)
def login_submit(request: Request, email: str = Form("")):
email = email.strip().lower()
m = _EMAIL_RE.match(email)
domain = allowed_email_domain()
if not m or not domain or m.group(2) != domain:
return templates.TemplateResponse("login.html", _login_ctx(
request, "email", email,
f"Seules les adresses @{domain or '?'} sont autorisées.",
))
user_id = m.group(1)
auth = load_auth(user_id)
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
token = secrets.token_urlsafe(32)
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
save_auth(user_id, email=email, token=token, token_expires=expires)
base_url = str(request.base_url).rstrip("/")
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:
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))
@app.post("/login/password", response_class=HTMLResponse)
def login_password(request: Request, email: str = Form(""), password: str = Form("")):
email = email.strip().lower()
m = _EMAIL_RE.match(email)
domain = allowed_email_domain()
if not m or not domain or m.group(2) != domain:
return RedirectResponse("/login", status_code=302)
user_id = m.group(1)
ph = load_auth(user_id).get("password_hash")
if not ph or not bcrypt.checkpw(password.encode(), ph.encode()):
return templates.TemplateResponse("login.html", _login_ctx(
request, "password", email, "Mot de passe incorrect.",
))
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("user_id", username, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
return resp
def _valid_reset_token(token: str) -> str | None:
user_id = find_user_by_reset_token(token)
if not user_id:
return None
expires = load_auth(user_id).get("token_expires")
if not expires or datetime.fromisoformat(expires) < datetime.now():
return None
return user_id
@app.get("/set-password/{token}", response_class=HTMLResponse)
def set_password_page(request: Request, token: str):
valid = _valid_reset_token(token) is not None
return templates.TemplateResponse("set_password.html", {
"request": request, "token": token, "valid": valid, "error": None,
})
@app.post("/set-password/{token}", response_class=HTMLResponse)
def set_password_submit(request: Request, token: str, password: str = Form(""), password2: str = Form("")):
user_id = _valid_reset_token(token)
if not user_id:
return templates.TemplateResponse("set_password.html", {
"request": request, "token": token, "valid": False, "error": None,
})
if len(password) < 8 or password != password2:
return templates.TemplateResponse("set_password.html", {
"request": request, "token": token, "valid": True,
"error": "Les mots de passe ne correspondent pas ou font moins de 8 caractères.",
})
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
save_auth(user_id, password_hash=password_hash, token=None, token_expires=None)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
return resp