diff --git a/docs/auth-magic-link.md b/docs/auth-magic-link.md new file mode 100644 index 0000000..e909cdb --- /dev/null +++ b/docs/auth-magic-link.md @@ -0,0 +1,98 @@ +# Authentification — Lien magique + +Mécanisme d'authentification par email sans mot de passe. L'utilisateur reçoit un lien à usage unique, valide un temps limité, qui ouvre une session PHP. + +## Fichiers concernés + +| Fichier | Rôle | +|---|---| +| `public/login/index.php` | Formulaire de demande + génération du token | +| `public/login/magic.php` | Consommation du token + ouverture de session | +| Table BDD `auth_magic_links` | Persistance des tokens | + +## Configuration (`.env`) + +| Variable | Défaut | Description | +|---|---|---| +| `MAGIC_LINK_TTL_MINUTES` | `30` | Durée de validité du lien | +| `MAGIC_COOLDOWN_MINUTES` | `5` | Délai minimal entre deux demandes pour le même email | +| `MAGIC_WINDOW_HOURS` | `12` | Fenêtre glissante pour le plafond | +| `MAGIC_MAX_PER_WINDOW` | `5` | Nombre maximal de liens émis par fenêtre | + +## Schéma BDD — `auth_magic_links` + +```sql +id UUID PRIMARY KEY (gen_random_uuid()) +email TEXT NOT NULL +token TEXT NOT NULL UNIQUE +created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +expires_at TIMESTAMPTZ NOT NULL +consumed_at TIMESTAMPTZ NULL -- NULL = non consommé +ip TEXT +user_agent TEXT +return_to TEXT NULL -- chemin relatif de redirection post-login +``` + +## Phase 1 — Demande du lien (`index.php`, POST) + +``` +[Formulaire email] → POST /login + │ + ├─ Validation CSRF + ├─ Validation syntaxe email + │ + ├─ Purge BDD : DELETE liens expirés ou consommés pour cet email + │ + ├─ Cooldown : un lien < MAGIC_COOLDOWN_MINUTES → refus + ├─ Plafond : >= MAGIC_MAX_PER_WINDOW liens dans MAGIC_WINDOW_HOURS → refus + │ + ├─ Génération token : random_bytes(32) → base64url (43 chars, URL-safe) + ├─ INSERT auth_magic_links (expires_at = NOW() + TTL) + │ + └─ [stub] Envoi email : /login/magic.php?token= +``` + +Le token est un base64url sans padding (`+/` remplacés par `-_`, `=` supprimés) — il ne contient que des caractères `[A-Za-z0-9\-_]`. + +L'envoi de l'email est actuellement un **stub** : le code construit `$magicUrl` mais l'appel SMTP n'est pas implémenté (`index.php:142`). + +## Phase 2 — Consommation du lien (`magic.php`, GET) + +``` +[Clic sur le lien] → GET /login/magic.php?token= + │ + ├─ Validation format token (regex [A-Za-z0-9\-\_]) + │ + ├─ BEGIN TRANSACTION + │ ├─ SELECT ... FOR UPDATE (verrou anti double-consommation) + │ ├─ Token inconnu → 400 + │ ├─ consumed_at non null → "Lien déjà utilisé" + │ ├─ expires_at dépassé → "Lien expiré" + │ ├─ UPDATE consumed_at = NOW() + │ └─ COMMIT + │ + ├─ session_start() + session_regenerate_id(true) + ├─ $_SESSION['user_email'] = email + │ + └─ Redirection 303 → return_to (validé : doit commencer par /) +``` + +Le `FOR UPDATE` garantit qu'un token ne peut pas être consommé deux fois en cas de double-clic ou de requêtes concurrentes. + +## Sécurités notables + +- **CSRF** sur le formulaire de demande +- **Rate limiting** double (cooldown + plafond glissant) par email +- **Token URL-safe** généré par CSPRNG (`random_bytes`) +- **Verrou transactionnel** (`FOR UPDATE`) à la consommation +- **`session_regenerate_id(true)`** — prévient la fixation de session +- **`return_to` filtré** : seuls les chemins relatifs (commençant par `/`) sont acceptés + +## État actuel — ce qui reste à câbler + +L'envoi SMTP n'est pas implémenté. Le token est correctement généré et persisté en BDD, mais le mail n'est pas envoyé. Voir `public/login/index.php:142` : + +```php +$magicUrl = url('/login/magic.php') . '?token=' . urlencode($token); +/* envoyer_mail_smtp(...) ou mail(...) */ +``` diff --git a/public/login/index.php b/public/login/index.php index 740a96e..00efec7 100644 --- a/public/login/index.php +++ b/public/login/index.php @@ -143,7 +143,7 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { /* envoyer_mail_smtp(...) ou mail(...) */ // message utilisateur - $okMsg = "Un lien vient d’être envoyé. Vérifiez votre boîte de réception et le dossier spam/indésirables."; + $okMsg = 'Un lien vient d’être envoyé. Vérifiez votre boîte de réception et le dossier spam/indésirables.'; } catch (\Throwable $ex) { if ($pdo->inTransaction()) { @@ -198,4 +198,3 @@ ob_start(); $content = ob_get_clean(); $title = 'Connexion'; include BASE_PATH . '/templates/layout.php'; - diff --git a/templates/post_list.php b/templates/post_list.php index 1c5c67a..7cf0926 100644 --- a/templates/post_list.php +++ b/templates/post_list.php @@ -2,24 +2,14 @@ require_once BASE_PATH . '/src/Parsedown.php'; $Parsedown = new Parsedown(); -$coverGradients = [ - 'linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%)', - 'linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)', - 'linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%)', - 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)', - 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)', - 'linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%)', -]; - ob_start(); ?>
- $post): ?> + text($post['content']); $preview = mb_strimwidth(strip_tags($html), 0, 120, '…'); - $gradient = $coverGradients[$i % count($coverGradients)]; $postUrl = '/post/' . rawurlencode($post['slug']); $isDraft = !$post['published']; $isAvantPremiere = $post['published'] && strtotime((string)($post['published_at'] ?? '')) > time(); @@ -32,13 +22,10 @@ ob_start();
Avant-première
- -
+ + +
+