207 lines
7.9 KiB
PHP
207 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;
|
||
|
||
// --- Helpers AVANT tout usage ---
|
||
if (!function_exists('env')) {
|
||
function env(string $key, ?string $default = null): ?string
|
||
{
|
||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
||
return (string)$_ENV[$key];
|
||
}
|
||
$v = getenv($key);
|
||
if ($v !== false && $v !== '') {
|
||
return (string)$v;
|
||
}
|
||
return $default;
|
||
}
|
||
}
|
||
if (!function_exists('db')) {
|
||
function db(): \PDO
|
||
{
|
||
return \App\Infrastructure\Database::get();
|
||
}
|
||
}
|
||
if (!function_exists('url')) {
|
||
function url(string $path = '/'): string
|
||
{
|
||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||
return $scheme . '://' . $host . $path;
|
||
}
|
||
}
|
||
|
||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||
require_once dirname(__DIR__, 2) . '/config/config.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 ici...
|
||
$magicUrl = url('/login/magic.php') . '?token=' . urlencode($token);
|
||
/* 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.";
|
||
|
||
} catch (\Throwable $ex) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
$errors[] = $ex->getMessage();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$csrf = Csrf::token();
|
||
?>
|
||
<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Connexion</title>
|
||
<link rel="stylesheet" href="/assets/bootstrap/bootstrap.min.css">
|
||
<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>
|
||
</head>
|
||
<body class="container py-5">
|
||
<h1 class="mb-3">Bienvenue 👋</h1>
|
||
<p class="text-muted">Vous n’êtes pas connecté. Accédez auxfonctionnalités en vous identifiant.</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>
|
||
</body>
|
||
</html>
|
||
|
||
|
||
|
||
|
||
|