0b8077e43c
- ArticleManager::getSearchIndex() : rebuild automatique si un UUID
référencé dans search_index.json n'existe plus sur le disque (article
supprimé hors CMS via rsync ou suppression manuelle). Même logique que
getBySlug() qui nettoie déjà le slug_index à la volée.
- bootstrap.php : lire SESSION_NAME depuis $_ENV avant session_start(),
permettant de personnaliser le nom du cookie de session via le .env.
- oidc/{start,callback,me}.php : inverser l'ordre des require pour charger
config.php (dotenv) avant bootstrap.php, condition nécessaire pour que
SESSION_NAME soit disponible au démarrage de la session.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
201 lines
6.3 KiB
PHP
201 lines
6.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
|
require_once dirname(__DIR__, 2) . '/config/config.php';
|
|
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
$debug = (env('APP_DEBUG', '0') === '1');
|
|
|
|
$OIDC_ISSUER = rtrim((string)(env('OIDC_ISSUER') ?? ''), '/');
|
|
$OIDC_CLIENT_ID = (string)(env('OIDC_CLIENT_ID') ?? '');
|
|
$OIDC_CLIENT_SECRET = (string)(env('OIDC_CLIENT_SECRET') ?? '');
|
|
$OIDC_REDIRECT_URI = (string)(env('OIDC_REDIRECT_URI') ?: url('oidc/callback'));
|
|
|
|
if (!$OIDC_ISSUER || !$OIDC_CLIENT_ID || !$OIDC_REDIRECT_URI) {
|
|
http_response_code(500);
|
|
echo $debug ? 'OIDC config manquante.' : 'Erreur.';
|
|
exit;
|
|
}
|
|
|
|
$tokenEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/token';
|
|
$userInfoEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/userinfo';
|
|
|
|
if (!isset($_GET['state'], $_SESSION['oidc_state']) || !hash_equals((string)$_SESSION['oidc_state'], (string)$_GET['state'])) {
|
|
http_response_code(400);
|
|
echo $debug ? 'State invalide.' : 'Requête invalide.';
|
|
exit;
|
|
}
|
|
unset($_SESSION['oidc_state']);
|
|
|
|
if (empty($_GET['code'])) {
|
|
http_response_code(400);
|
|
echo $debug ? 'Code manquant.' : 'Requête invalide.';
|
|
exit;
|
|
}
|
|
$code = (string)$_GET['code'];
|
|
|
|
$codeVerifier = $_SESSION['oidc_code_verifier'] ?? null;
|
|
unset($_SESSION['oidc_code_verifier'], $_SESSION['oidc_nonce']);
|
|
|
|
if (!$codeVerifier) {
|
|
http_response_code(400);
|
|
echo $debug ? 'PKCE code_verifier manquant.' : 'Requête invalide.';
|
|
exit;
|
|
}
|
|
|
|
// Échange code → tokens
|
|
$post = [
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $code,
|
|
'redirect_uri' => $OIDC_REDIRECT_URI,
|
|
'client_id' => $OIDC_CLIENT_ID,
|
|
'code_verifier' => $codeVerifier,
|
|
];
|
|
if ($OIDC_CLIENT_SECRET !== '') {
|
|
$post['client_secret'] = $OIDC_CLIENT_SECRET;
|
|
}
|
|
|
|
$ch = curl_init($tokenEndpoint);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986),
|
|
CURLOPT_TIMEOUT => 15,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
]);
|
|
$tokenResponse = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlErr = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($tokenResponse === false || $httpCode !== 200) {
|
|
http_response_code(500);
|
|
echo $debug ? 'Échec échange token : ' . htmlspecialchars($curlErr ?: (string)$tokenResponse) : 'Erreur d\'authentification.';
|
|
exit;
|
|
}
|
|
|
|
$tokens = json_decode((string)$tokenResponse, true) ?: [];
|
|
$accessToken = $tokens['access_token'] ?? null;
|
|
$idToken = $tokens['id_token'] ?? null;
|
|
|
|
if (!$accessToken) {
|
|
http_response_code(500);
|
|
echo $debug ? 'Access token manquant.' : 'Erreur d\'authentification.';
|
|
exit;
|
|
}
|
|
|
|
// UserInfo
|
|
$ch = curl_init($userInfoEndpoint);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
]);
|
|
$userInfoResponse = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($userInfoResponse === false || $httpCode !== 200) {
|
|
http_response_code(500);
|
|
echo $debug ? 'Échec UserInfo.' : 'Erreur d\'authentification.';
|
|
exit;
|
|
}
|
|
|
|
$claims = json_decode((string)$userInfoResponse, true) ?: [];
|
|
$email = $claims['email'] ?? null;
|
|
|
|
// Fallback : lire l'email depuis le payload du id_token
|
|
if (!$email && $idToken && substr_count($idToken, '.') === 2) {
|
|
[, $p, ] = explode('.', $idToken, 3);
|
|
$payload = json_decode((string)base64_decode(strtr($p, '-_', '+/'), true), true);
|
|
if (is_array($payload) && !empty($payload['email'])) {
|
|
$email = $payload['email'];
|
|
}
|
|
}
|
|
|
|
if (!$email) {
|
|
http_response_code(400);
|
|
echo $debug ? 'Email non fourni par l\'IdP.' : 'Impossible de récupérer votre email.';
|
|
exit;
|
|
}
|
|
|
|
// Nom d'affichage depuis les claims SSO
|
|
$ssoName = '';
|
|
if (!empty($claims['given_name']) || !empty($claims['family_name'])) {
|
|
$ssoName = trim(($claims['given_name'] ?? '') . ' ' . ($claims['family_name'] ?? ''));
|
|
} elseif (!empty($claims['name'])) {
|
|
$ssoName = trim($claims['name']);
|
|
} elseif (!empty($claims['preferred_username'])) {
|
|
$ssoName = trim($claims['preferred_username']);
|
|
}
|
|
|
|
// Charge le nom personnalisé depuis la base (prioritaire sur le SSO)
|
|
require_once dirname(__DIR__, 2) . '/src/auth.php';
|
|
$pdo = dbPdo();
|
|
$dbName = '';
|
|
if ($pdo) {
|
|
try {
|
|
$st = $pdo->prepare('SELECT display_name FROM user_profiles WHERE email = :e');
|
|
$st->execute([':e' => strtolower(trim($email))]);
|
|
$dbName = (string)($st->fetchColumn() ?: '');
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
if ($dbName !== '') {
|
|
// Nom personnalisé existant → on le conserve, le SSO ne l'écrase pas
|
|
$sessionName = $dbName;
|
|
} else {
|
|
// Première connexion → on persiste le nom SSO
|
|
$sessionName = $ssoName;
|
|
if ($ssoName !== '' && $pdo) {
|
|
try {
|
|
$pdo->prepare(
|
|
'INSERT INTO user_profiles (email, display_name, updated_at)
|
|
VALUES (:e, :n, now())
|
|
ON CONFLICT (email) DO NOTHING'
|
|
)->execute([':e' => strtolower(trim($email)), ':n' => $ssoName]);
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ouvre la session authentifiée
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_email'] = strtolower(trim($email));
|
|
$_SESSION['user_display_name'] = $sessionName;
|
|
$_SESSION['oidc'] = [
|
|
'issuer' => $OIDC_ISSUER,
|
|
'sub' => $claims['sub'] ?? null,
|
|
'access_token' => $accessToken,
|
|
'id_token' => $idToken,
|
|
'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600),
|
|
];
|
|
|
|
$target = $_SESSION['oidc_return_to'] ?? '/';
|
|
unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']);
|
|
if (!is_string($target) || $target === '' || $target[0] !== '/') {
|
|
$target = '/';
|
|
}
|
|
header('Location: ' . $target, true, 303);
|
|
exit;
|