c17cad9c66
- #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>
191 lines
7.9 KiB
PHP
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';
|