Files
folio/public/login/index.php
T
cedricAbonnel c17cad9c66 nettoyage & typo : dead code, helpers factorisés, guillemets courbes (v1.6.13)
- #19 : suppression AuthService / UserRepository / Domain\User — dead code incompatible session
- #22 : env() et db() centralisés dans src/helpers.php, chargé par config/config.php
- #15 : typographieHtml() appliquée après Parsedown dans post_view.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:36:09 +02:00

191 lines
7.9 KiB
PHP

<?php
// projet : mug.a5l.fr
// fichier : pages/login/index.php
// version : 20251011
declare(strict_types=1);
use App\Http\Csrf;
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/src/SiteSettings.php';
require_once dirname(__DIR__, 2) . '/src/mailer.php';
// Paramètres (env)
$ttlMin = (int) env('MAGIC_LINK_TTL_MINUTES', '30');
$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5');
$winHours = (int) env('MAGIC_WINDOW_HOURS', '12');
$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
// --- return_to ---
$defaultReturn = '/';
$sanitize = static function (string $url) use ($defaultReturn): string {
$url = trim($url);
if ($url === '' || !str_starts_with($url, '/')) {
return $defaultReturn;
}
return $url;
};
$returnTo = $sanitize((string)($_GET['return_to'] ?? ($_SERVER['HTTP_REFERER'] ?? $defaultReturn)));
// --- OIDC ---
$oidcEnabled = (bool) (env('OIDC_ISSUER') && env('OIDC_CLIENT_ID'));
$oidcLoginUrl = '/login/oidc' . ($returnTo ? ('?return_to=' . urlencode($returnTo)) : '');
$oidcAuto = (isset($_GET['sso']) && $_GET['sso'] === '1') || (env('OIDC_AUTO', '0') === '1');
if ($oidcEnabled && $oidcAuto) {
header('Location: ' . $oidcLoginUrl, true, 302);
exit;
}
// --- form: demande de lien magique ---
$errors = [];
$okMsg = '';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!Csrf::validate($_POST['_csrf'] ?? null)) {
http_response_code(400);
$errors[] = 'Jeton CSRF invalide.';
} else {
$email = strtolower(trim((string)($_POST['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Adresse email invalide.';
} else {
// rate limit simple par email et IP
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
if (strpos($ip, ',') !== false) {
$ip = trim(explode(',', $ip, 2)[0]);
}
$pdo = db();
$pdo->beginTransaction();
try {
// purge expirés / consommés
$pdo->prepare('DELETE FROM auth_magic_links WHERE email = :e AND (expires_at < NOW() OR consumed_at IS NOT NULL)')
->execute([':e' => $email]);
// 1) cooldown: refuser si un envoi récent < coolMin
$sql = sprintf(
"SELECT 1 FROM auth_magic_links
WHERE email = :e AND created_at >= NOW() - INTERVAL '%d minutes'
LIMIT 1",
max(0, $coolMin)
);
$stmt = $pdo->prepare($sql);
$stmt->execute([':e' => $email]);
if ($stmt->fetchColumn()) {
throw new RuntimeException(sprintf('Un lien vient d’être envoyé. Réessayez dans %d min.
Si vous ne recevez toujours rien, envisagez d\'utiliser un fournisseur de messagerie respectueux de la vie privée,
comme Proton Mail, Tuta, Posteo, Mailfence ou Infomaniak, qui garantissent un hébergement européen
et ne revendent pas vos données. -- Cédrix, le 11/10/2025', $coolMin));
}
// 2) plafond: maxPerWin liens sur winHours
$sql = sprintf(
"SELECT COUNT(*) FROM auth_magic_links
WHERE email = :e AND created_at >= NOW() - INTERVAL '%d hours'",
max(0, $winHours)
);
$stmt = $pdo->prepare($sql);
$stmt->execute([':e' => $email]);
if ((int)$stmt->fetchColumn() >= $maxPerWin) {
throw new RuntimeException('Quota atteint. Réessayez plus tard.');
}
// Génère et enregistre le lien avec TTL ttlMin
$raw = random_bytes(32);
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
$sql = sprintf(
"INSERT INTO auth_magic_links (id,email,token,created_at,expires_at,ip,user_agent,return_to)
VALUES (gen_random_uuid(), :email, :token, NOW(), NOW() + INTERVAL '%d minutes', :ip, :ua, :rt)
RETURNING token",
max(1, $ttlMin)
);
$stmt = $pdo->prepare($sql);
$stmt->execute([
':email' => $email,
':token' => $token,
':ip' => $ip,
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512),
':rt' => ($returnTo !== '/' ? $returnTo : null),
]);
$pdo->commit();
// construit l'URL et envoie le mail
$magicUrl = url('/login/magic.php') . '?token=' . urlencode($token);
$siteName = htmlspecialchars(env('SMTP_FROM_NAME', 'varlog'), ENT_QUOTES);
$html = <<<HTML
<p>Bonjour,</p>
<p>Cliquez sur le lien ci-dessous pour vous connecter à <strong>{$siteName}</strong> :</p>
<p><a href="{$magicUrl}">{$magicUrl}</a></p>
<p>Ce lien est valable {$ttlMin} minutes et ne peut être utilisé qu'une seule fois.</p>
<p>Si vous n'avez pas demandé ce lien, ignorez cet email.</p>
HTML;
envoyer_mail_smtp(
$email,
"Votre lien de connexion — {$siteName}",
$html,
null,
['bypass_rate_limit' => true]
);
// message utilisateur
$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()) {
$pdo->rollBack();
}
$errors[] = $ex->getMessage();
}
}
}
}
$csrf = Csrf::token();
ob_start();
?>
<style>.or-sep{display:flex;align-items:center;gap:.75rem;margin:1.25rem 0}.or-sep::before,.or-sep::after{content:"";flex:1;height:1px;background:#ddd}</style>
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-7 col-lg-5">
<h1 class="mb-1">Connexion</h1>
<p class="text-muted mb-4">Vous n'êtes pas connecté.</p>
<?php foreach ($errors as $e): ?>
<div class="alert alert-danger"><?= htmlspecialchars($e, ENT_QUOTES) ?></div>
<?php endforeach; ?>
<?php if ($okMsg): ?>
<div class="alert alert-success"><?= htmlspecialchars($okMsg, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($oidcEnabled): ?>
<div class="mb-3">
<a class="btn btn-primary w-100" href="<?= htmlspecialchars($oidcLoginUrl, ENT_QUOTES) ?>">Se connecter avec A5L</a>
</div>
<div class="or-sep"><span>ou</span></div>
<?php else: ?>
<div class="alert alert-warning">A5L indisponible : configurez <code>OIDC_ISSUER</code> et <code>OIDC_CLIENT_ID</code> dans <code>.env</code>.</div>
<div class="or-sep"><span>ou</span></div>
<?php endif; ?>
<form method="post" action="/login<?= $returnTo ? ('?return_to=' . urlencode($returnTo)) : '' ?>" novalidate>
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($csrf, ENT_QUOTES) ?>">
<div class="mb-3">
<label class="form-label" for="email">Adresse email</label>
<input class="form-control" id="email" type="email" name="email" required autocomplete="email" inputmode="email" placeholder="vous@domaine.tld">
</div>
<button class="btn btn-primary" type="submit">Recevoir un lien magique</button>
</form>
</div>
</div>
<?php
$content = ob_get_clean();
$title = 'Connexion';
include BASE_PATH . '/templates/layout.php';