Release Bludit Secure 0.1.5
This commit is contained in:
1 parent
49b4308028
commit
0b983bcfbb
10 files changed
+320
-154
No files matched your search
+122
-77
@@ -17,22 +17,21 @@ final class App
|
||||
if (($parts['scheme'] ?? '') !== 'https') { throw new UserError('HTTPS is required'); }
|
||||
$this->origin = 'https://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
|
||||
}
|
||||
private function scriptUrl(): string {
|
||||
$version = substr(hash_file('sha256', dirname(__DIR__).'/assets/secure.js'), 0, 16);
|
||||
return $this->plugin->htmlPath().'assets/secure.js?v='.$version;
|
||||
}
|
||||
private function cfg(string $key): string { return trim($this->plugin->getValue($key, false)); }
|
||||
private function ssoEnabled(): bool { return $this->cfg('issuer') !== '' && $this->cfg('clientId') !== '' && $this->cfg('clientSecret') !== ''; }
|
||||
private function e(string $v): string { return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); }
|
||||
private function go(string $action = ''): never { header('Location: '.$this->base.($action ? '?bs='.$action : ''), true, 303); exit; }
|
||||
public function accountUrl(): string { return DOMAIN_ADMIN.'plugin/'.strtolower(get_class($this->plugin)); }
|
||||
private function go(string $action = ''): never {
|
||||
$url = $action === 'account' ? $this->accountUrl() : $this->base.($action ? '?bs='.$action : '');
|
||||
header('Location: '.$url, true, 303); exit;
|
||||
}
|
||||
private function form(string $action, string $body): string {
|
||||
return '<form method="post" action="'.$this->e($this->base.'?bs='.$action).'"><input type="hidden" name="secureCsrf" value="'.$this->e($_SESSION['bs']['csrf']).'">'.$body.'</form>';
|
||||
}
|
||||
private function page(string $title, string $body): never {
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store'); header('Referrer-Policy: no-referrer'); header('X-Frame-Options: DENY');
|
||||
header("Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
|
||||
$asset = $this->plugin->htmlPath().'assets/';
|
||||
echo '<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>'.$this->e($title).'</title><link rel="stylesheet" href="'.$asset.'secure.css"></head><body><main><h1>'.$this->e($title).'</h1>';
|
||||
if (isset($_SESSION['bs']['notice'])) { echo '<p role="alert">'.$this->e($_SESSION['bs']['notice']).'</p>'; unset($_SESSION['bs']['notice']); }
|
||||
echo $body.'<p id="status" role="status"></p></main><script src="'.$asset.'secure.js" defer></script></body></html>'; exit;
|
||||
}
|
||||
private function json(mixed $data, int $status = 200): never {
|
||||
http_response_code($status); header('Content-Type: application/json'); header('Cache-Control: no-store'); echo json_encode($data, JSON_THROW_ON_ERROR); exit;
|
||||
}
|
||||
@@ -65,7 +64,7 @@ final class App
|
||||
$security->generateTokenCSRF();
|
||||
$_SESSION['bs']['auth'] = ['name'=>$name, 'at'=>time(), 'revision'=>$a['revision']];
|
||||
unset($_SESSION['bs']['pending'], $_SESSION['bs']['challenge']);
|
||||
$this->go('account');
|
||||
header('Location: '.DOMAIN_ADMIN, true, 303); exit;
|
||||
}
|
||||
private function authenticated(): ?string {
|
||||
global $login;
|
||||
@@ -76,22 +75,22 @@ final class App
|
||||
}
|
||||
private function recent(): string {
|
||||
$name = $this->authenticated();
|
||||
if (!$name || $_SESSION['bs']['auth']['at'] < time()-300) { throw new UserError('Reconnectez-vous avant de modifier la sécurité de votre compte.'); }
|
||||
if (!$name || $_SESSION['bs']['auth']['at'] < time()-300) { throw new UserError('Sign in again before changing your account security.'); }
|
||||
return $name;
|
||||
}
|
||||
private function pendingUser(): array {
|
||||
$p = $_SESSION['bs']['pending'] ?? null;
|
||||
if (!$p || $p['expires'] < time()) { unset($_SESSION['bs']['pending']); throw new UserError('Connexion expirée. Recommencez.'); }
|
||||
if (!$p || $p['expires'] < time()) { unset($_SESSION['bs']['pending']); throw new UserError('Sign-in attempt expired. Please start again.'); }
|
||||
$this->user($p['name']);
|
||||
if ($this->store->get($p['name'])['revision'] !== $p['revision']) { unset($_SESSION['bs']['pending']); throw new UserError('Les facteurs ont changé. Recommencez la connexion.'); }
|
||||
if ($this->store->get($p['name'])['revision'] !== $p['revision']) { unset($_SESSION['bs']['pending']); throw new UserError('Authentication methods have changed. Please sign in again.'); }
|
||||
return $p;
|
||||
}
|
||||
private function budget(string $name, string $scope = 'auth'): void {
|
||||
global $security;
|
||||
if ($security->isBlocked() || !$this->store->attempt($scope.':'.$name, $scope.':'.($_SERVER['REMOTE_ADDR'] ?? 'unknown'))) { throw new UserError('Trop de tentatives. Réessayez dans cinq minutes.'); }
|
||||
if ($security->isBlocked() || !$this->store->attempt($scope.':'.$name, $scope.':'.($_SERVER['REMOTE_ADDR'] ?? 'unknown'))) { throw new UserError('Too many attempts. Try again in five minutes.'); }
|
||||
}
|
||||
public function run(): void {
|
||||
global $login;
|
||||
global $login, $security;
|
||||
$_SESSION['bs'] ??= [];
|
||||
$_SESSION['bs']['csrf'] ??= bin2hex(random_bytes(32));
|
||||
$action = (string)($_GET['bs'] ?? '');
|
||||
@@ -100,10 +99,15 @@ final class App
|
||||
if (!$name) {
|
||||
foreach (['username','role','fingerPrint','sessionTime'] as $k) { \Session::set($k, null); }
|
||||
}
|
||||
if (!$name && ($GLOBALS['layout']['slug'] ?? '') === 'ajax') { $this->json(['error'=>'Complete authentication is required.'], 401); }
|
||||
if ($action === '' && $name) { return; }
|
||||
try {
|
||||
if ($action === 'oidc') { $this->oidc(); }
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($action === '' && !$name) {
|
||||
if (!$security->validateTokenCSRF((string)($_POST['tokenCSRF'] ?? ''))) { throw new UserError('Invalid CSRF'); }
|
||||
$this->password();
|
||||
}
|
||||
$this->csrf();
|
||||
if ($action === 'password') { $this->password(); }
|
||||
if ($action === 'logout') { unset($_SESSION['bs']); $login->logout(); $this->go(); }
|
||||
@@ -112,14 +116,15 @@ final class App
|
||||
if (str_starts_with($action, 'key-')) { $this->webauthn($action); }
|
||||
if ($action === 'manage') { $this->manage(); }
|
||||
}
|
||||
if ($action === 'mfa' || (!$name && isset($_SESSION['bs']['pending']))) { $this->mfaPage(); }
|
||||
if ($name) { $this->accountPage($name); }
|
||||
$this->loginPage();
|
||||
if (!$name && isset($_SESSION['bs']['pending'])) { $this->pendingUser(); }
|
||||
if ($name) { $this->go('account'); }
|
||||
// Bludit renders its existing login theme and view.
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
error_log('bludit-secure: '.get_class($e).' '.$e->getMessage());
|
||||
$msg = $e instanceof UserError ? $e->getMessage() : 'Vérification impossible. Recommencez ou consultez les journaux.';
|
||||
$msg = $e instanceof UserError ? $e->getMessage() : 'Verification failed. Try again or check the server logs.';
|
||||
// Never expose protocol payloads, tokens or secrets to the browser.
|
||||
if (str_starts_with($action, 'key-')) { $this->json(['error'=>'Vérification impossible. Reconnectez-vous ou réessayez.'], 400); }
|
||||
if (str_starts_with($action, 'key-')) { $this->json(['error'=>'Verification failed. Sign in again or retry.'], 400); }
|
||||
$_SESSION['bs']['notice'] = mb_substr($msg, 0, 200);
|
||||
$this->go($this->authenticated() ? 'account' : (isset($_SESSION['bs']['pending']) ? 'mfa' : ''));
|
||||
}
|
||||
@@ -129,22 +134,40 @@ final class App
|
||||
$name = trim((string)($_POST['username'] ?? '')); $pw = (string)($_POST['password'] ?? '');
|
||||
$this->budget($name);
|
||||
$u = $users->getUserDB($name);
|
||||
if (!$u || $u['password'] === '!' || !hash_equals($u['password'], $users->generatePasswordHash($pw, $u['salt']))) { throw new UserError('Identifiants incorrects.'); }
|
||||
if (!$u || $u['password'] === '!' || !hash_equals($u['password'], $users->generatePasswordHash($pw, $u['salt']))) { throw new UserError('Incorrect username or password.'); }
|
||||
$this->pending($name, 'password');
|
||||
}
|
||||
private function loginPage(): never {
|
||||
$body = $this->form('password', '<label>Identifiant<input name="username" autocomplete="username" required></label><label>Mot de passe<input type="password" name="password" autocomplete="current-password" required></label><button>Se connecter</button>');
|
||||
if ($this->ssoEnabled()) { $body .= $this->form('sso', '<button>Se connecter avec '.$this->e($this->cfg('ssoLabel')).'</button>'); }
|
||||
$this->page('Connexion à Bludit', $body);
|
||||
/** Enhance the native login form in place; keep the theme, branding and layout. */
|
||||
public function renderLogin(string $html): string {
|
||||
$pending = isset($_SESSION['bs']['pending']);
|
||||
$extra = '';
|
||||
if (isset($_SESSION['bs']['notice'])) {
|
||||
$extra .= '<div class="alert alert-info" role="alert">'.$this->e($_SESSION['bs']['notice']).'</div>';
|
||||
unset($_SESSION['bs']['notice']);
|
||||
}
|
||||
if ($pending) {
|
||||
$extra .= $this->bootstrapMarkup($this->mfaContent());
|
||||
$extra .= '<p id="status" role="status" class="text-danger mt-3"></p><script src="'.$this->e($this->scriptUrl()).'" defer></script>';
|
||||
}
|
||||
$sso = !$pending && $this->ssoEnabled() ? $this->bootstrapMarkup($this->form('sso', '<button type="submit" class="btn btn-secondary btn-block mt-3 py-3"><span>Sign in with '.$this->e($this->cfg('ssoLabel')).'</span></button>')) : '';
|
||||
$pattern = '~<form\b[^>]*>(?:(?!</form>).)*?name=["\']password["\'](?:(?!</form>).)*?</form>~is';
|
||||
$result = preg_replace_callback($pattern, function ($match) use ($pending, $extra, $sso) {
|
||||
if ($pending) { return $extra; }
|
||||
// Non-visual token for plugin endpoints; native password submission still validates tokenCSRF.
|
||||
$form = preg_replace('~</form>~i', '<input type="hidden" name="secureCsrf" value="'.$this->e($_SESSION['bs']['csrf']).'"></form>', $match[0], 1);
|
||||
return $extra.$form.$sso;
|
||||
}, $html, 1, $count);
|
||||
// Other admin themes can still expose the second factor through their login hooks.
|
||||
return $count ? $result : $html.$extra.$sso;
|
||||
}
|
||||
private function mfaPage(): never {
|
||||
private function mfaContent(): string {
|
||||
$p = $this->pendingUser(); $a = $this->store->get($p['name']);
|
||||
$body = '<p>Validez le second facteur de votre compte.</p>';
|
||||
if ($a['totp'] && $this->allowed($a, 'totp')) { $body .= $this->form('mfa', '<label>Code TOTP<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" required></label><button>Vérifier le code</button>'); }
|
||||
if ($a['keys'] && $this->allowed($a, 'passkey')) { $body .= $this->keyButton(false); }
|
||||
if ($a['recovery']) { $body .= $this->form('mfa', '<label>Code de récupération<input name="recovery" autocomplete="off" required></label><button>Utiliser le code de récupération</button>'); }
|
||||
$body .= $this->form('logout', '<button>Annuler la connexion</button>');
|
||||
$this->page('Vérification en deux étapes', $body);
|
||||
$body = '<p>Verify your identity using a second factor.</p>';
|
||||
if ($a['totp'] && $this->allowed($a, 'totp')) { $body .= $this->form('mfa', '<label>TOTP code<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" required></label><button>Verify code</button>'); }
|
||||
if ($a['keys'] && $this->allowed($a, 'passkey')) { $body .= '<p>Complete the passkey prompt in your browser.</p>'.$this->keyButton(false, true); }
|
||||
if ($a['recovery']) { $body .= $this->form('mfa', '<label>Recovery code<input name="recovery" autocomplete="off" required></label><button>Use recovery code</button>'); }
|
||||
$body .= $this->form('logout', '<button>Cancel sign-in</button>');
|
||||
return '<h2 class="h5 mb-3">Two-step verification</h2>'.$body;
|
||||
}
|
||||
private function verifyCode(): never {
|
||||
$p = $this->pendingUser(); $this->budget($p['name']);
|
||||
@@ -158,42 +181,64 @@ final class App
|
||||
$step = Totp::verify($a['totp'], trim((string)($_POST['code'] ?? '')), $a['lastStep']);
|
||||
if ($step === null) { return false; } $a['lastStep'] = $step; return true;
|
||||
});
|
||||
if (!$ok) { throw new UserError('Code invalide, expiré ou déjà utilisé.'); }
|
||||
if (!$ok) { throw new UserError('This code is invalid, expired or has already been used.'); }
|
||||
$this->finish($p['name']);
|
||||
}
|
||||
private function accountPage(string $name): never {
|
||||
$a = $this->store->get($name);
|
||||
$body = '<p>Compte : <strong>'.$this->e($name).'</strong>. <a href="'.$this->e(DOMAIN_ADMIN).'">Retour à l’administration</a></p>';
|
||||
if ($_SESSION['bs']['auth']['at'] < time()-300) {
|
||||
$this->page('Sécurité de mon compte', $body.'<p>Une nouvelle connexion est nécessaire pour modifier vos facteurs.</p>'.$this->form('logout', '<button>Se déconnecter pour vérifier mon identité</button>'));
|
||||
public function accountView(): string {
|
||||
$name = $this->authenticated();
|
||||
if ($name === null) { throw new UserError('Sign-in is required.'); }
|
||||
$html = \Bootstrap::pageTitle(['title'=>'Security', 'icon'=>'lock']);
|
||||
if (isset($_SESSION['bs']['notice'])) {
|
||||
$html .= '<div class="alert alert-info" role="alert">'.$this->e($_SESSION['bs']['notice']).'</div>';
|
||||
unset($_SESSION['bs']['notice']);
|
||||
}
|
||||
$body .= '<h2>Authentification à deux facteurs</h2><p>Choisissez le facteur demandé après votre mot de passe. « Au choix » permet de présenter un TOTP ou une passkey.</p>';
|
||||
$select = '<label>Second facteur<select name="mode">';
|
||||
foreach (['off'=>'Désactivé','totp'=>'TOTP','passkey'=>'Passkey','either'=>'TOTP ou passkey au choix'] as $v=>$label) { $select .= '<option value="'.$v.'" '.($a['mode']===$v?'selected':'').'>'.$label.'</option>'; }
|
||||
$select .= '</select></label><label><input type="checkbox" name="ssoMfa" value="1" '.($a['ssoMfa']?'checked':'').'> Demander aussi ce second facteur après le SSO</label><button name="op" value="preferences">Enregistrer mes choix</button>';
|
||||
$html .= $this->accountContent($name);
|
||||
return $this->bootstrapMarkup($html).'<p id="status" role="status" class="text-danger mt-3"></p><script src="'.$this->e($this->scriptUrl()).'" defer></script>';
|
||||
}
|
||||
private function bootstrapMarkup(string $html): string {
|
||||
// Use the active admin theme and its Bootstrap components; no standalone CSS.
|
||||
$html = str_replace('<h2>', '<h2 class="h5 mt-4 mb-3 pb-2 border-bottom">', $html);
|
||||
$html = str_replace('<label>', '<label class="d-block mb-3">', $html);
|
||||
$html = str_replace('<form ', '<form class="mb-3" ', $html);
|
||||
$html = preg_replace('/<input(?![^>]*class=)(?![^>]*type="(?:hidden|checkbox)")/', '<input class="form-control"', $html);
|
||||
$html = str_replace('<select ', '<select class="custom-select" ', $html);
|
||||
$html = preg_replace('/<button(?![^>]*class=)/', '<button class="btn btn-outline-primary btn-sm my-1"', $html);
|
||||
$html = str_replace('<code class="secret">', '<code class="d-block border rounded p-3 mb-3 text-break">', $html);
|
||||
$html = str_replace('<pre>', '<pre class="border rounded p-3">', $html);
|
||||
return $html;
|
||||
}
|
||||
private function accountContent(string $name): string {
|
||||
$a = $this->store->get($name);
|
||||
$body = '<p>Account: <strong>'.$this->e($name).'</strong>. <a href="'.$this->e(DOMAIN_ADMIN).'">Back to dashboard</a></p>';
|
||||
if ($_SESSION['bs']['auth']['at'] < time()-300) {
|
||||
return $body.'<div class="alert alert-info">Use Log out in the sidebar, then sign in again to change your authentication methods.</div>';
|
||||
}
|
||||
$body .= '<h2>Two-factor authentication</h2><p>Choose the second factor required after your password. With either method enabled, you can use a TOTP code or a passkey.</p>';
|
||||
$select = '<label>Second factor<select name="mode">';
|
||||
foreach (['off'=>'Disabled','totp'=>'TOTP','passkey'=>'Passkey','either'=>'TOTP or passkey'] as $v=>$label) { $select .= '<option value="'.$v.'" '.($a['mode']===$v?'selected':'').'>'.$label.'</option>'; }
|
||||
$select .= '</select></label><label><input type="checkbox" name="ssoMfa" value="1" '.($a['ssoMfa']?'checked':'').'> Also require this second factor after SSO</label><button name="op" value="preferences">Save preferences</button>';
|
||||
$body .= $this->form('manage', $select);
|
||||
$body .= '<h2>TOTP</h2><p>'.($a['totp'] ? 'TOTP activé.' : 'Aucun TOTP enregistré.').'</p>';
|
||||
$body .= '<h2>TOTP</h2><p>'.($a['totp'] ? 'TOTP is enabled.' : 'No TOTP configured.').'</p>';
|
||||
if (!$a['totp']) {
|
||||
$body .= $this->form('manage', '<button name="op" value="totp-start">Préparer un TOTP</button>');
|
||||
$body .= $this->form('manage', '<button name="op" value="totp-start">Set up TOTP</button>');
|
||||
$en = $_SESSION['bs']['enroll'] ?? null;
|
||||
if ($en && $en['expires'] >= time() && $en['name'] === $name) {
|
||||
$uri = 'otpauth://totp/'.rawurlencode('Bludit:'.$name).'?secret='.$en['secret'].'&issuer=Bludit&algorithm=SHA1&digits=6&period=30';
|
||||
$body .= '<p>Dans Bitwarden, collez cette URI dans le champ « Clé d’authentification (TOTP) » :</p><code class="secret">'.$this->e($uri).'</code>';
|
||||
$body .= $this->form('manage', '<label>Code de confirmation<input name="code" inputmode="numeric" pattern="[0-9]{6}" required></label><button name="op" value="totp-confirm">Confirmer le TOTP</button>');
|
||||
$body .= '<p>Add this setup key to your authenticator app:</p><code class="secret">'.$this->e($uri).'</code>';
|
||||
$body .= $this->form('manage', '<label>Confirmation code<input name="code" inputmode="numeric" pattern="[0-9]{6}" required></label><button name="op" value="totp-confirm">Confirm TOTP</button>');
|
||||
}
|
||||
} else { $body .= $this->form('manage', '<button name="op" value="totp-delete">Supprimer mon TOTP</button>'); }
|
||||
$body .= '<h2>Passkeys</h2><p>Enregistrez une passkey pour ce site dans Bitwarden ou votre authentificateur.</p>'.$this->keyButton(true);
|
||||
foreach ($a['keys'] as $id=>$k) { $body .= $this->form('manage', '<input type="hidden" name="key" value="'.$this->e($id).'"><span>'.$this->e($k['label']).'</span> <button name="op" value="key-delete">Supprimer cette passkey</button>'); }
|
||||
} else { $body .= $this->form('manage', '<button name="op" value="totp-delete">Remove TOTP</button>'); }
|
||||
$body .= '<h2>Passkeys</h2><p>Register a passkey for this site using your password manager or authenticator.</p>'.$this->keyButton(true);
|
||||
foreach ($a['keys'] as $id=>$k) { $body .= $this->form('manage', '<input type="hidden" name="key" value="'.$this->e($id).'"><span>'.$this->e($k['label']).'</span> <button name="op" value="key-delete">Remove passkey</button>'); }
|
||||
$body .= '<h2>SSO</h2>';
|
||||
if ($a['sso']) { $body .= '<p>Identité SSO liée.</p>'.$this->form('manage', '<button name="op" value="unlink">Délier mon SSO</button>'); }
|
||||
elseif ($this->ssoEnabled()) { $body .= $this->form('link', '<button>Lier mon compte à '.$this->e($this->cfg('ssoLabel')).'</button>'); }
|
||||
else { $body .= '<p>Le SSO doit être configuré par un administrateur.</p>'; }
|
||||
$body .= '<h2>Récupération</h2><p>'.count($a['recovery']).' code(s) restant(s). En générer de nouveaux invalide les précédents. Conservez-les hors de votre compte.</p>'.$this->form('manage', '<button name="op" value="recovery">Générer dix codes de récupération</button>');
|
||||
if ($a['sso']) { $body .= '<p>SSO identity linked.</p>'.$this->form('manage', '<button name="op" value="unlink">Unlink SSO</button>'); }
|
||||
elseif ($this->ssoEnabled()) { $body .= $this->form('link', '<button>Link account to '.$this->e($this->cfg('ssoLabel')).'</button>'); }
|
||||
else { $body .= '<p>An administrator must configure SSO first.</p>'; }
|
||||
$body .= '<h2>Recovery</h2><p>'.count($a['recovery']).' recovery code(s) remaining. Generating new codes invalidates the previous ones. Store them outside your account.</p>'.$this->form('manage', '<button name="op" value="recovery">Generate ten recovery codes</button>');
|
||||
if (isset($_SESSION['bs']['codes'])) {
|
||||
$body .= '<p>Ces codes ne seront affichés qu’une fois :</p><pre>'.$this->e(implode("\n", $_SESSION['bs']['codes'])).'</pre>'; unset($_SESSION['bs']['codes']);
|
||||
$body .= '<p>These codes are shown only once:</p><pre>'.$this->e(implode("\n", $_SESSION['bs']['codes'])).'</pre>'; unset($_SESSION['bs']['codes']);
|
||||
}
|
||||
$body .= $this->form('logout', '<button>Se déconnecter</button>');
|
||||
$this->page('Sécurité de mon compte', $body);
|
||||
return $body;
|
||||
}
|
||||
private function manage(): never {
|
||||
$name = $this->recent(); $this->budget($name, 'manage'); $op = (string)($_POST['op'] ?? '');
|
||||
@@ -206,9 +251,9 @@ final class App
|
||||
switch ($op) {
|
||||
case 'totp-confirm':
|
||||
$en = $_SESSION['bs']['enroll'] ?? null;
|
||||
if (!$en || $en['name'] !== $name || $en['expires'] < time() || $a['totp']) { throw new UserError('Enregistrement expiré ou TOTP déjà actif.'); }
|
||||
if (!$en || $en['name'] !== $name || $en['expires'] < time() || $a['totp']) { throw new UserError('Setup expired or TOTP is already enabled.'); }
|
||||
$step = Totp::verify($en['secret'], trim($_POST['code'] ?? ''), -1);
|
||||
if ($step === null) { throw new UserError('Code de confirmation incorrect.'); }
|
||||
if ($step === null) { throw new UserError('Incorrect confirmation code.'); }
|
||||
$a['totp'] = $en['secret']; $a['lastStep'] = $step;
|
||||
if ($a['mode'] === 'off') { $a['mode'] = 'totp'; }
|
||||
unset($_SESSION['bs']['enroll']); break;
|
||||
@@ -218,21 +263,21 @@ final class App
|
||||
case 'recovery': $a['recovery'] = array_map(fn($c)=>hash('sha256',$c), $codes); break;
|
||||
case 'preferences':
|
||||
$mode = $_POST['mode'] ?? '';
|
||||
if (!in_array($mode, ['off','totp','passkey','either'], true)) { throw new UserError('Choix invalide.'); }
|
||||
if (($mode === 'totp' && !$a['totp']) || ($mode === 'passkey' && !$a['keys']) || ($mode === 'either' && (!$a['totp'] || !$a['keys']))) { throw new UserError('Enregistrez d’abord les facteurs choisis.'); }
|
||||
if (!in_array($mode, ['off','totp','passkey','either'], true)) { throw new UserError('Invalid selection.'); }
|
||||
if (($mode === 'totp' && !$a['totp']) || ($mode === 'passkey' && !$a['keys']) || ($mode === 'either' && (!$a['totp'] || !$a['keys']))) { throw new UserError('Register the selected authentication methods first.'); }
|
||||
$a['mode'] = $mode; $a['ssoMfa'] = isset($_POST['ssoMfa']); break;
|
||||
default: throw new UserError('Action inconnue.');
|
||||
default: throw new UserError('Unknown action.');
|
||||
}
|
||||
// Deleting a required factor must not silently disable MFA.
|
||||
if (($a['mode'] === 'totp' && !$a['totp']) || ($a['mode'] === 'passkey' && !$a['keys']) || ($a['mode'] === 'either' && (!$a['totp'] || !$a['keys']))) { throw new UserError('Changez le mode de vérification avant de supprimer ce facteur.'); }
|
||||
if (($a['mode'] === 'totp' && !$a['totp']) || ($a['mode'] === 'passkey' && !$a['keys']) || ($a['mode'] === 'either' && (!$a['totp'] || !$a['keys']))) { throw new UserError('Change the verification mode before removing this authentication method.'); }
|
||||
return ++$a['revision'];
|
||||
});
|
||||
$_SESSION['bs']['auth']['revision'] = $revision;
|
||||
if ($codes) { $_SESSION['bs']['codes'] = $codes; }
|
||||
$_SESSION['bs']['notice'] = 'Modification enregistrée.'; $this->go('account');
|
||||
$_SESSION['bs']['notice'] = 'Changes saved.'; $this->go('account');
|
||||
}
|
||||
private function oidcClient(): \Jumbojett\OpenIDConnectClient {
|
||||
if (!$this->ssoEnabled() || parse_url($this->cfg('issuer'), PHP_URL_SCHEME) !== 'https') { throw new UserError('SSO non configuré.'); }
|
||||
if (!$this->ssoEnabled() || parse_url($this->cfg('issuer'), PHP_URL_SCHEME) !== 'https') { throw new UserError('SSO is not configured.'); }
|
||||
$oidc = new \Jumbojett\OpenIDConnectClient($this->cfg('issuer'), $this->cfg('clientId'), $this->cfg('clientSecret'));
|
||||
$oidc->setRedirectURL($this->base.'?bs=oidc');
|
||||
$oidc->setCodeChallengeMethod('S256');
|
||||
@@ -245,38 +290,38 @@ final class App
|
||||
$_SESSION['bs']['oidc'] = ['link'=>$name, 'expires'=>time()+300];
|
||||
unset($_SESSION['bs']['pending']);
|
||||
$this->oidcClient()->authenticate();
|
||||
throw new UserError('Redirection SSO impossible.');
|
||||
throw new UserError('Unable to redirect to SSO.');
|
||||
}
|
||||
private function oidc(): never {
|
||||
$flow = $_SESSION['bs']['oidc'] ?? null;
|
||||
if (!$flow || $flow['expires'] < time() || !isset($_GET['code'], $_GET['state'])) { throw new UserError('Connexion SSO expirée ou annulée.'); }
|
||||
if (!$flow || $flow['expires'] < time() || !isset($_GET['code'], $_GET['state'])) { throw new UserError('SSO sign-in expired or was cancelled.'); }
|
||||
// The library verifies state, nonce, JWT signature, issuer, audience and expiry.
|
||||
$oidc = $this->oidcClient();
|
||||
$oidc->authenticate();
|
||||
unset($_SESSION['bs']['oidc']);
|
||||
$sub = $oidc->getVerifiedClaims('sub');
|
||||
$iss = $oidc->getVerifiedClaims('iss');
|
||||
if (!is_string($sub) || $sub === '' || !is_string($iss) || $iss !== $this->cfg('issuer')) { throw new UserError('Identité SSO invalide.'); }
|
||||
if (!is_string($sub) || $sub === '' || !is_string($iss) || $iss !== $this->cfg('issuer')) { throw new UserError('Invalid SSO identity.'); }
|
||||
$identity = ['issuer'=>$iss, 'subject'=>$sub];
|
||||
if ($flow['link'] !== null) {
|
||||
$name = $this->recent();
|
||||
if ($name !== $flow['link']) { throw new UserError('Session de liaison invalide.'); }
|
||||
if ($name !== $flow['link']) { throw new UserError('Invalid account-linking session.'); }
|
||||
$rev = $this->store->transaction(function (&$d) use ($name, $identity) {
|
||||
foreach ($d['accounts'] as $other=>$a) { if ($other !== $name && $a['sso'] === $identity) { throw new UserError('Identité déjà liée à un autre compte.'); } }
|
||||
foreach ($d['accounts'] as $other=>$a) { if ($other !== $name && $a['sso'] === $identity) { throw new UserError('This identity is already linked to another account.'); } }
|
||||
$a = Store::account($d, $name); $a['sso'] = $identity; ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision'];
|
||||
});
|
||||
$_SESSION['bs']['auth']['revision'] = $rev;
|
||||
$_SESSION['bs']['notice'] = 'Identité SSO liée.'; $this->go('account');
|
||||
$_SESSION['bs']['notice'] = 'SSO identity linked.'; $this->go('account');
|
||||
}
|
||||
$name = $this->store->transaction(function (&$d) use ($identity) {
|
||||
foreach ($d['accounts'] as $name=>$a) { if ($a['sso'] === $identity) { return $name; } } return null;
|
||||
});
|
||||
if ($name === null) { throw new UserError('Ce SSO n’est lié à aucun compte Bludit. Connectez-vous avec votre mot de passe pour le lier.'); }
|
||||
if ($name === null) { throw new UserError('This SSO identity is not linked to a Bludit account. Sign in with your password to link it.'); }
|
||||
$this->budget($name); $this->pending($name, 'sso');
|
||||
exit;
|
||||
}
|
||||
private function keyButton(bool $create): string {
|
||||
return '<div class="webauthn" data-base="'.$this->e($this->base).'" data-csrf="'.$this->e($_SESSION['bs']['csrf']).'">'.($create?'<label>Nom de la passkey<input class="key-label" maxlength="80" value="Ma passkey"></label>':'').'<button type="button" data-key="'.($create?'create':'get').'">'.($create?'Ajouter une passkey':'Vérifier ma passkey').'</button></div>';
|
||||
private function keyButton(bool $create, bool $automatic = false): string {
|
||||
return '<div class="webauthn"'.($automatic ? ' data-auto-passkey="true"' : '').' data-base="'.$this->e($this->base).'" data-csrf="'.$this->e($_SESSION['bs']['csrf']).'">'.($create?'<label>Passkey name<input class="key-label" maxlength="80" value="My passkey"></label>':'').'<button type="button" data-key="'.($create?'create':'get').'">'.($create?'Add passkey':'Try passkey again').'</button></div>';
|
||||
}
|
||||
private static function b64(string $raw): string { return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); }
|
||||
private static function unb64(string $raw): string {
|
||||
@@ -290,7 +335,7 @@ final class App
|
||||
}
|
||||
private function webauthn(string $action): never {
|
||||
$create = in_array($action, ['key-create-options','key-create-finish'], true);
|
||||
if (!in_array($action, ['key-create-options','key-create-finish','key-get-options','key-get-finish'], true)) { throw new UserError('Action inconnue'); }
|
||||
if (!in_array($action, ['key-create-options','key-create-finish','key-get-options','key-get-finish'], true)) { throw new UserError('Unknown action'); }
|
||||
$name = $create ? $this->recent() : $this->pendingUser()['name'];
|
||||
$this->budget($name, $create ? 'enroll' : 'auth'); $a = $this->store->get($name);
|
||||
if (!$create && (!$this->allowed($a, 'passkey') || !$a['keys'])) { throw new UserError('Passkey unavailable'); }
|
||||
@@ -320,7 +365,7 @@ final class App
|
||||
++$a['revision']; $d['accounts'][$name] = $a; return $a['revision'];
|
||||
});
|
||||
$_SESSION['bs']['auth']['revision'] = $revision;
|
||||
$this->json(['redirect'=>$this->base.'?bs=account']);
|
||||
$this->json(['redirect'=>$this->accountUrl()]);
|
||||
}
|
||||
$id = (string)($_POST['id'] ?? '');
|
||||
$this->store->update($name, function (&$a) use ($id, $ch, $wa, $client, $challenge) {
|
||||
@@ -338,6 +383,6 @@ final class App
|
||||
$login->setLogin($name, $u['role']); $security->generateTokenCSRF();
|
||||
$_SESSION['bs']['auth'] = ['name'=>$name, 'at'=>time(), 'revision'=>$a['revision']];
|
||||
unset($_SESSION['bs']['pending'], $_SESSION['bs']['challenge']);
|
||||
$this->json(['redirect'=>$this->base.'?bs=account']);
|
||||
$this->json(['redirect'=>DOMAIN_ADMIN]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user