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:
138
app/main.py
138
app/main.py
@ -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, 1–32 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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user