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
@@ -1,93 +1,133 @@
|
||||
# bludit-secure
|
||||
|
||||
Plugin d’authentification pour **Bludit 3.22.0**. Version **0.1.0 expérimentale**, à valider sur une copie du site avant activation en production.
|
||||
OIDC SSO, TOTP and passkey second-factor authentication for **Bludit 3.22.0**.
|
||||
|
||||
## Fonctionnalités
|
||||
Current version: **0.1.5 — experimental**. Test on a copy of your site before deployment.
|
||||
|
||||
- Connexion locale avec le mot de passe Bludit.
|
||||
- Bouton **Se connecter avec XYZ**, dont le nom est configurable, vers un fournisseur OIDC tel que Pocket ID.
|
||||
- Liaison volontaire de l’identité SSO par chaque utilisateur connecté. Association sur **issuer + subject**, jamais automatiquement sur l’adresse e-mail. Aucun utilisateur ni rôle créé à partir des claims OIDC.
|
||||
- TOTP par compte (SHA-1, six chiffres, 30 secondes), compatible avec le champ TOTP de Bitwarden. Activation après confirmation d’un code. Refus des codes déjà utilisés.
|
||||
- Plusieurs passkeys par compte, avec nom et suppression individuelle. Compatible avec les passkeys synchronisées, dont celles conservées dans Bitwarden. Vérification utilisateur obligatoire.
|
||||
- Choix personnel du second facteur : désactivé, TOTP, passkey, ou TOTP/passkey au choix. Possibilité de demander aussi ce facteur local après le SSO.
|
||||
- Dix codes de récupération à usage unique, affichés une fois, renouvelables.
|
||||
## Features
|
||||
|
||||
Les passkeys locales sont enregistrées pour le domaine **Bludit**. La passkey de Pocket ID reste attachée au domaine **Pocket ID**. Ce sont deux inscriptions distinctes. Les passkeys locales servent ici de **second facteur**, après le mot de passe ou le SSO, et non de connexion locale sans mot de passe.
|
||||
- Keeps the existing Bludit login page, theme and password form.
|
||||
- Adds a full-width, slate-grey **Sign in with XYZ** button with a configurable provider name.
|
||||
- Automatically opens the browser passkey prompt after password verification when a passkey is enabled. Cancellation does not trigger a prompt loop; an explicit retry remains available.
|
||||
- Integrates **Security** into the native admin dashboard, including its sidebar, shield icon and Bootstrap controls. Sign out through the existing **Log out** sidebar entry.
|
||||
- Lets each user register TOTP, multiple named passkeys and an OIDC identity, then choose their own second-factor policy: disabled, TOTP, passkey, or either method.
|
||||
- Optionally requires the local second factor after SSO as well.
|
||||
- Provides ten single-use recovery codes, shown once and replaceable at any time.
|
||||
- Uses English for all plugin interface text. No specific password manager is required.
|
||||
|
||||
## Prérequis
|
||||
Local passkeys belong to the **Bludit domain**. Your identity provider's passkeys belong to the **provider domain**. These are separate registrations. Local passkeys act as a second factor after a password or SSO; passwordless local login is not implemented.
|
||||
|
||||
- PHP **8.2 ou supérieur**, extensions sodium, curl, mbstring et openssl, plus les prérequis habituels de Bludit.
|
||||
- Site configuré avec son URL publique **HTTPS** exacte dans Bludit ; certificat valide. Aucun mode dégradé HTTP.
|
||||
- Le serveur PHP doit pouvoir joindre l’issuer Pocket ID et ses endpoints de découverte, clés et jetons.
|
||||
- Accès au stockage `bl-content/databases/plugins/bludit-secure/` en écriture pour PHP.
|
||||
- Un reverse proxy doit transmettre une adresse client fiable à PHP via `REMOTE_ADDR`. Ne jamais faire confiance directement aux en-têtes fournis par le client.
|
||||
## Requirements
|
||||
|
||||
- Bludit **3.22.0** and PHP **8.2+**.
|
||||
- PHP extensions: sodium, curl, mbstring and openssl, plus the usual Bludit requirements.
|
||||
- A valid HTTPS public URL configured in Bludit's advanced settings. HTTPS termination at a reverse proxy alone does not update this setting.
|
||||
- Write access for PHP to `bl-content/databases/plugins/bludit-secure/`.
|
||||
- Network access from PHP to the OIDC provider's discovery, token and signing-key endpoints.
|
||||
- A trusted reverse proxy configuration that supplies the actual client address as `REMOTE_ADDR` for rate limiting. Do not trust arbitrary client-supplied forwarding headers.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Sauvegarder les fichiers et `bl-content` et préparer un accès SSH de récupération.
|
||||
2. Décompresser le ZIP dans `bl-plugins/` pour obtenir `bl-plugins/bludit-secure/plugin.php`. Le ZIP fourni inclut `vendor/`.
|
||||
3. Activer **Bludit Secure** dans les plugins. Les sessions Bludit précédentes devront se reconnecter.
|
||||
4. Ouvrir **Sécurité de mon compte** dans la barre d’administration.
|
||||
1. Back up your site and `bl-content`; retain server access for recovery.
|
||||
2. Extract the packaged ZIP so the entry point is `bl-plugins/bludit-secure/plugin.php`.
|
||||
3. Verify that `bl-plugins/bludit-secure/vendor/autoload.php` exists.
|
||||
4. Configure Bludit's public URL with its exact HTTPS address, then activate **Bludit Secure**.
|
||||
5. Sign in again and open **Security** in the admin sidebar.
|
||||
|
||||
Pour construire depuis les sources :
|
||||
Existing native Bludit sessions and remember-me cookies cannot bypass the plugin. Activation requires a fresh plugin-authenticated session.
|
||||
|
||||
Packaged ZIPs include dependencies. Git checkouts do **not** include `vendor/`; install the locked dependencies before activation:
|
||||
|
||||
```sh
|
||||
composer install --no-dev --prefer-dist --optimize-autoloader
|
||||
```
|
||||
|
||||
Ne pas activer une copie source sans ses dépendances : les accès admin seront refusés tant que le plugin ne peut pas fonctionner.
|
||||
Activating without dependencies or with an HTTP site URL will block access to the admin area until the problem is corrected.
|
||||
|
||||
## Configuration Pocket ID
|
||||
### Docker updates
|
||||
|
||||
Dans Pocket ID, créer un client OIDC confidentiel avec son secret et enregistrer exactement l’URI de retour affichée dans les paramètres du plugin :
|
||||
For `bludit/docker`, copy the extracted plugin into `/usr/share/nginx/html/bl-plugins/bludit-secure/`. Example on the Docker host, with the ZIP already in `/tmp`:
|
||||
|
||||
```sh
|
||||
docker cp bludit:/usr/share/nginx/html/bl-plugins/bludit-secure \
|
||||
"/tmp/bludit-secure-backup-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
unzip -o /tmp/bludit-secure-0.1.5.zip -d /tmp/bludit-secure-0.1.5
|
||||
|
||||
docker cp /tmp/bludit-secure-0.1.5/bludit-secure/. \
|
||||
bludit:/usr/share/nginx/html/bl-plugins/bludit-secure/
|
||||
|
||||
docker restart bludit
|
||||
```
|
||||
|
||||
This updates plugin files. Account settings and enrolled factors remain in `bl-content`. Persist both plugin files and `bl-content` with appropriate mounts so container recreation does not discard them.
|
||||
|
||||
## OIDC configuration
|
||||
|
||||
Create a confidential OIDC client in your provider, such as Pocket ID. Register the exact redirect URI displayed in the plugin settings, for example:
|
||||
|
||||
```text
|
||||
https://blog.example/admin/login?bs=oidc
|
||||
```
|
||||
|
||||
Le chemin respecte le sous-répertoire et le chemin admin configurés dans Bludit. Reporter l’issuer exact (y compris sa convention de slash final), le Client ID et le Client secret dans le plugin. Choisir le nom du bouton, par exemple **Mon SSO**. Laisser le champ secret vide lors d’une modification conserve le secret existant.
|
||||
The URI respects Bludit's configured admin path and installation subdirectory. Enter the exact issuer URL, client ID and client secret. Choose the provider name displayed in **Sign in with XYZ**. Leaving the client secret field blank preserves its current value.
|
||||
|
||||
Chaque utilisateur se connecte d’abord avec son compte Bludit, ouvre **Sécurité de mon compte**, puis clique sur **Lier mon compte à …**. Après validation chez Pocket ID, le compte est lié. Un même couple issuer/subject ne peut pas être associé à deux comptes Bludit.
|
||||
Each user first signs in to their existing Bludit account, opens **Security**, then selects **Link account to …**. After provider authentication, the identity is associated with that account using **issuer + subject**. Identities are never linked automatically by email. A provider identity cannot be linked to two accounts.
|
||||
|
||||
Le plugin utilise le flux Authorization Code avec PKCE S256 et la bibliothèque jumbojett pour vérifier state, nonce, signature et claims. Les rôles restent ceux du compte Bludit. La déconnexion ferme la session Bludit, sans fermer la session Pocket ID ni celles des autres applications. Le logout OIDC global/back-channel n’est pas implémenté.
|
||||
Authorization Code flow uses PKCE S256. The jumbojett library checks state, nonce, token signature and claims. Bludit account roles remain authoritative; the plugin does not create accounts or assign roles from provider claims.
|
||||
|
||||
## Facteurs personnels
|
||||
Logging out ends the Bludit session only. Provider-wide logout and OIDC back-channel logout are not implemented.
|
||||
|
||||
Dans **Sécurité de mon compte** :
|
||||
## Per-user security settings
|
||||
|
||||
- **TOTP** : préparer le secret, copier l’URI `otpauth://` dans Bitwarden, puis confirmer avec un code. Pas de service QR externe.
|
||||
- **Passkey** : donner un nom, ajouter une passkey et choisir Bitwarden dans le navigateur, ou un autre authentificateur compatible.
|
||||
- **Second facteur** : sélectionner le mode souhaité et décider s’il s’applique aussi après le SSO. Le mode « au choix » nécessite l’inscription des deux facteurs.
|
||||
- **Récupération** : générer les codes et les conserver à part.
|
||||
In **Security**:
|
||||
|
||||
L’inscription du premier facteur active automatiquement son mode. Pour retirer un facteur exigé, sélectionner d’abord un mode compatible, ou désactiver le second facteur. Un facteur n’est jamais supprimé en désactivant implicitement la protection.
|
||||
- **TOTP:** select **Set up TOTP**, copy the `otpauth://` setup URI into your authenticator app and confirm a code. TOTP uses SHA-1, six digits and a 30-second period. Previously accepted codes cannot be reused.
|
||||
- **Passkeys:** select **Add passkey**, enter a name and choose your password manager or authenticator in the browser. Multiple passkeys are supported, with user verification required. The browser selects the authenticator; the plugin does not force a particular manager.
|
||||
- **Second factor:** choose the required method and whether it also applies after SSO. **TOTP or passkey** requires both methods to be registered.
|
||||
- **Recovery:** generate codes and store them separately. Generating new codes invalidates all previous codes.
|
||||
|
||||
La gestion des facteurs nécessite une connexion complète datant de moins de cinq minutes. Au-delà, se déconnecter puis se reconnecter avec les facteurs existants. Les modifications invalident les autres sessions du plugin. Les utilisateurs choisissent leurs facteurs ; aucune obligation globale de MFA n’est imposée par cette version.
|
||||
Registering your first factor automatically enables its corresponding verification mode. Before removing a required factor, select a compatible mode or explicitly disable second-factor authentication. Deleting a factor never silently disables protection.
|
||||
|
||||
## Stockage et récupération
|
||||
Changes require a completed sign-in within the last five minutes. After that, use **Log out** in the sidebar and sign in again with your existing factors. Changes invalidate other plugin sessions for that account. There is no site-wide mandatory MFA policy in this version.
|
||||
|
||||
Les secrets TOTP, clés publiques, identités liées, empreintes des codes de récupération et limites de tentatives sont conservés dans un fichier chiffré et authentifié avec sodium. La clé de chiffrement est séparée, dans le même répertoire privé du plugin. Les fichiers sont protégés par un préambule PHP et des permissions 0600. **Ce chiffrement ne protège pas d’un attaquant ayant accès aux fichiers et à la clé du serveur.**
|
||||
## Storage and recovery
|
||||
|
||||
Sauvegarder ensemble `secure.key.php`, `secure.data.php` et les utilisateurs Bludit. Perdre la clé rend les facteurs irrécupérables. Ne pas réattribuer un ancien nom de compte à une autre personne sans retirer ses inscriptions de sécurité. Le client secret OIDC est stocké dans la configuration PHP habituelle du plugin : protéger également cette configuration et ses sauvegardes.
|
||||
TOTP secrets, passkey public keys, linked identities, recovery-code hashes and rate-limit records are stored in an authenticated, sodium-encrypted file. The encryption key is stored separately in the same private plugin data directory. Files have a PHP exit preamble and permissions of 0600. Encryption does not protect against an attacker who can read both the server files and the key.
|
||||
|
||||
Les connexions incomplètes expirent après cinq minutes, les challenges WebAuthn après deux minutes. Les vérifications disposent d’un budget persistant par compte et par IP : dix tentatives sur cinq minutes, avec budgets séparés pour la gestion et l’inscription. Les sessions natives et cookies « se souvenir de moi » ne suffisent pas à passer la protection du plugin. L’accès AJAX est contrôlé avant le dispatch Bludit.
|
||||
Back up `secure.key.php`, `secure.data.php` and the Bludit user database together. Losing the key makes stored factors unrecoverable. Do not reuse an old account name for another person without removing its security registrations. The OIDC client secret is stored in the regular plugin configuration; protect that file and its backups too.
|
||||
|
||||
En cas de perte de tous les facteurs : utiliser un code de récupération. Sans code, un administrateur disposant de SSH peut désactiver temporairement le plugin en renommant son répertoire `bl-plugins/bludit-secure/` **hors de `bl-plugins`**. Cela rétablit l’authentification native pour tout le site : effectuer l’opération en maintenance avec l’accès `/admin` restreint, puis restaurer le plugin et réparer les inscriptions. Désinstaller le plugin depuis Bludit supprime ses données ; ne pas utiliser la désinstallation comme une simple désactivation.
|
||||
Incomplete sign-ins expire after five minutes; WebAuthn challenges expire after two minutes. Persistent verification budgets allow ten attempts per account and IP over five minutes, with separate budgets for management and enrollment. The plugin checks authentication before Bludit dispatches admin AJAX requests.
|
||||
|
||||
Le plugin protège les **sessions web `/admin`**. Il ne modifie pas l’authentification de l’API Bludit, des autres plugins ou des services externes.
|
||||
If all factors are lost, use a recovery code. Without one, a server administrator can temporarily move `bl-plugins/bludit-secure/` **outside `bl-plugins`** to restore native login. Restrict admin access during this maintenance operation, then restore the plugin and repair registrations. Uninstalling the plugin through Bludit deletes its data; it is not a temporary disable operation.
|
||||
|
||||
## Vérification
|
||||
The plugin protects **web sessions under `/admin`**. It does not change authentication for the Bludit API, other plugins or external services.
|
||||
|
||||
JavaScript URLs include a content hash so updated passkey behavior is not hidden by stale asset caches.
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
php tests/core.php
|
||||
composer validate --no-check-publish
|
||||
composer audit --no-dev
|
||||
# With Node.js installed:
|
||||
node tests/auto-passkey.cjs
|
||||
```
|
||||
|
||||
Les tests HTTP dans `tests/http_totp.py` et `tests/http_webauthn.py` nécessitent une **instance jetable** de Bludit 3.22.0, installée avec `admin / TestPassword123`, le plugin activé, une URL publique de fixture `https://127.0.0.1:18765/`, et un serveur PHP local sur `http://127.0.0.1:18765/`. Le transport HTTP est réservé à la fixture ; les assertions WebAuthn simulent l’origine HTTPS. Ils ne remplacent pas les essais navigateur HTTPS.
|
||||
HTTP tests in `tests/http_totp.py` and `tests/http_webauthn.py` require a **disposable** Bludit 3.22.0 instance with the plugin activated and test credentials `admin / TestPassword123`. The fixture uses a configured public URL of `https://127.0.0.1:18765/` and a PHP test server at `http://127.0.0.1:18765/`. HTTP transport and simulated HTTPS origins are fixture-only and do not replace real browser testing.
|
||||
|
||||
Variables : `TEST_PHP` (binaire PHP), `TEST_PHP_INI` (php.ini), `TEST_BLUDIT` (racine de la fixture). `TEST_BASE` est facultatif. Exécuter d’abord `http_totp.py` sur une fixture neuve, puis `http_webauthn.py`. **Le test WebAuthn efface les données de sécurité du plugin dans cette fixture**, jamais lui fournir un site réel.
|
||||
Set `TEST_PHP` (PHP executable), `TEST_PHP_INI` (php.ini) and `TEST_BLUDIT` (fixture root). `TEST_BASE` is optional. Run `http_totp.py` on a fresh fixture, then `http_webauthn.py`. **The WebAuthn test deletes plugin security records in that fixture. Never point it at a real site.**
|
||||
|
||||
Vérifié : syntaxe PHP 8.4, vecteurs RFC 6238, stockage chiffré et altération, limites persistantes, transactions, CSRF, connexion locale, blocage dashboard/AJAX avant MFA, refus du rejeu TOTP/récupération, inscription WebAuthn et signatures d’assertion, rejet d’une origine ou signature invalide et d’un challenge consommé. `composer audit` ne signale aucun avis au moment de la construction.
|
||||
Local checks cover PHP 8.4 syntax, RFC 6238 vectors, encrypted storage and tampering, transactions, persistent limits, native login CSRF, pending-session dashboard/AJAX protection, TOTP and recovery-code replay rejection, signed WebAuthn registration and assertions, invalid origins/signatures, consumed challenges, native admin integration and automatic prompting without cancellation loops.
|
||||
|
||||
**À valider avant production** : connexion complète au Pocket ID réel, passkeys Bitwarden dans le navigateur avec HTTPS réel, reverse proxy, restauration de sauvegarde et compatibilité avec les autres plugins d’authentification. Aucune validation sur le site de production n’a été réalisée.
|
||||
The password-to-passkey browser flow over HTTPS was also reported working by the user. A complete live Pocket ID flow, backup restoration and compatibility with other authentication plugins still require validation. The latest local dependency audit reported no known advisories; rerun it before release.
|
||||
|
||||
## Changes in 0.1.5
|
||||
|
||||
- Retains the native login page and integrates Security into the dashboard.
|
||||
- Automatically requests a passkey after password verification.
|
||||
- Versions JavaScript URLs by content hash.
|
||||
- Uses English interface text and native dropdown styling.
|
||||
- Adds a sidebar shield icon and removes duplicate sign-out buttons.
|
||||
- Styles the SSO button as a full-width secondary action with spacing below Login.
|
||||
@@ -1 +0,0 @@
|
||||
:root{color-scheme:light dark;font:16px/1.5 system-ui,sans-serif;background:#111827;color:#f3f4f6}body{margin:0;padding:2rem 1rem}main{max-width:640px;margin:auto;background:#1f2937;border:1px solid #475569;border-radius:16px;padding:2rem}h1{font-size:1.7rem}h2{font-size:1.2rem;margin-top:2rem;border-top:1px solid #475569;padding-top:1rem}label{display:block;margin:.8rem 0}input:not([type=checkbox]),select{box-sizing:border-box;display:block;width:100%;padding:.65rem;background:#111827;color:#fff;border:1px solid #64748b;border-radius:6px}button{cursor:pointer;background:#93c5fd;color:#111827;border:0;border-radius:6px;padding:.65rem 1rem;margin:.4rem 0}button:disabled{opacity:.5}a{color:#93c5fd}code,pre{overflow-wrap:anywhere;white-space:pre-wrap}.secret{display:block;padding:1rem;border:1px dashed #94a3b8}*:focus-visible{outline:3px solid #fbbf24;outline-offset:3px}#status,[role=alert]{color:#fcd34d}
|
||||
+10
-5
@@ -4,7 +4,7 @@
|
||||
const encode = value => btoa(String.fromCharCode(...new Uint8Array(value))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const status = document.getElementById('status');
|
||||
for (const button of document.querySelectorAll('[data-key]')) {
|
||||
button.addEventListener('click', async () => {
|
||||
const verify = async () => {
|
||||
const box = button.closest('.webauthn');
|
||||
const create = button.dataset.key === 'create';
|
||||
const kind = create ? 'create' : 'get';
|
||||
@@ -13,12 +13,12 @@
|
||||
url.searchParams.set('bs', `key-${kind}-${phase}`);
|
||||
const response = await fetch(url, {method: 'POST', credentials: 'same-origin', body: new URLSearchParams({secureCsrf: box.dataset.csrf, ...fields})});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.error) throw new Error(result.error || 'Vérification impossible.');
|
||||
if (!response.ok || result.error) throw new Error(result.error || 'Verification failed.');
|
||||
return result;
|
||||
};
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) throw new Error('Ce navigateur ne prend pas en charge les passkeys.');
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) throw new Error('This browser does not support passkeys.');
|
||||
const args = await request('options');
|
||||
args.publicKey.challenge = decode(args.publicKey.challenge);
|
||||
if (args.publicKey.user) args.publicKey.user.id = decode(args.publicKey.user.id);
|
||||
@@ -36,8 +36,13 @@
|
||||
}
|
||||
const result = await request('finish', fields);
|
||||
window.location.assign(result.redirect);
|
||||
} catch (error) { status.textContent = error.name === 'NotAllowedError' ? 'Vérification annulée ou délai dépassé.' : error.message; }
|
||||
} catch (error) { status.textContent = error.name === 'NotAllowedError' ? 'Verification cancelled or timed out.' : error.message; }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
};
|
||||
button.addEventListener('click', verify);
|
||||
if (button.closest('[data-auto-passkey="true"]')) {
|
||||
// One prompt per completed password/SSO step. Cancellation never loops.
|
||||
verify();
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -1 +1 @@
|
||||
{"plugin-data":{"name":"Bludit Secure","description":"SSO OIDC, passkeys et TOTP configurables par compte."}}
|
||||
{"plugin-data":{"name":"Bludit Secure","description":"OIDC SSO, passkeys and TOTP with per-account preferences."}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"author":"Interlope","version":"0.1.0","releaseDate":"2026-11-02","license":"MIT","compatible":"3.22.0","notes":"PHP 8.2+, HTTPS, sodium, curl, mbstring. Preview: validate on staging before production."}
|
||||
{"author":"Interlope","version":"0.1.5","releaseDate":"2026-11-02","license":"MIT","compatible":"3.22.0","notes":"PHP 8.2+, HTTPS, sodium, curl, mbstring. Preview: validate on staging before production."}
|
||||
+36
-5
@@ -2,15 +2,17 @@
|
||||
|
||||
class pluginBluditSecure extends Plugin
|
||||
{
|
||||
private ?int $loginBufferLevel = null;
|
||||
|
||||
public function init() {
|
||||
$this->dbFields = ['ssoLabel'=>'Pocket ID', 'issuer'=>'', 'clientId'=>'', 'clientSecret'=>''];
|
||||
}
|
||||
public function form() {
|
||||
$html = '<p>Chaque utilisateur gère ses facteurs dans <a href="'.HTML_PATH_ADMIN_ROOT.'login?bs=account">Sécurité de mon compte</a>.</p>';
|
||||
foreach (['ssoLabel'=>'Nom du SSO (Se connecter avec …)', 'issuer'=>'URL HTTPS de Pocket ID / issuer OIDC', 'clientId'=>'Client ID', 'clientSecret'=>'Client secret'] as $key=>$label) {
|
||||
$html = '<p>Each user can manage their authentication methods in <a href="'.HTML_PATH_ADMIN_ROOT.'plugin/pluginbluditsecure">Security</a>.</p>';
|
||||
foreach (['ssoLabel'=>'SSO name (Sign in with …)', 'issuer'=>'OIDC issuer URL (HTTPS)', 'clientId'=>'Client ID', 'clientSecret'=>'Client secret'] as $key=>$label) {
|
||||
$html .= '<div><label>'.htmlspecialchars($label).'</label><input name="'.$key.'" type="'.($key==='clientSecret'?'password':'text').'" value="'.($key==='clientSecret'?'':htmlspecialchars($this->getValue($key, false), ENT_QUOTES, 'UTF-8')).'" autocomplete="off"></div>';
|
||||
}
|
||||
return $html.'<p>Secret vide : conserver le secret existant. URI de retour OIDC : <code>'.htmlspecialchars(DOMAIN_ADMIN.'login?bs=oidc').'</code>. Aucun compte n’est associé automatiquement par e-mail : chaque utilisateur lie son identité depuis son espace personnel.</p>';
|
||||
return $html.'<p>Leave the secret blank to keep the existing value. OIDC redirect URI: <code>'.htmlspecialchars(DOMAIN_ADMIN.'login?bs=oidc').'</code>. Accounts are never linked automatically by email. Each user must link their SSO identity from Security.</p>';
|
||||
}
|
||||
public function post() {
|
||||
if (empty($_POST['clientSecret'])) { unset($_POST['clientSecret']); }
|
||||
@@ -32,10 +34,39 @@ class pluginBluditSecure extends Plugin
|
||||
error_log('bludit-secure: '.get_class($e).' '.$e->getMessage());
|
||||
http_response_code(503);
|
||||
header('Cache-Control: no-store');
|
||||
exit('Bludit Secure indisponible. Consulter les journaux du serveur.');
|
||||
exit('Bludit Secure is unavailable. Check the server logs.');
|
||||
}
|
||||
}
|
||||
public function beforeAdminLoad() {
|
||||
global $login, $layout;
|
||||
if (!$login->isLogged()) {
|
||||
// Password POSTs are checked in prepare(); never execute native remember-me login.
|
||||
$layout['controller'] = 'bludit-secure-login';
|
||||
$layout['plugin'] = false;
|
||||
}
|
||||
}
|
||||
public function loginBodyBegin() {
|
||||
$this->loginBufferLevel = ob_get_level();
|
||||
ob_start();
|
||||
}
|
||||
public function loginBodyEnd() {
|
||||
if ($this->loginBufferLevel === null || ob_get_level() !== $this->loginBufferLevel + 1) {
|
||||
return '';
|
||||
}
|
||||
$html = ob_get_clean();
|
||||
$this->loginBufferLevel = null;
|
||||
return (new \BluditSecure\App($this))->renderLogin($html);
|
||||
}
|
||||
public function adminController() {
|
||||
global $layout;
|
||||
$layout['title'] = 'Security';
|
||||
header('Cache-Control: no-store');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
}
|
||||
public function adminView() {
|
||||
return (new \BluditSecure\App($this))->accountView();
|
||||
}
|
||||
public function adminSidebar() {
|
||||
return '<a class="nav-link" href="'.HTML_PATH_ADMIN_ROOT.'login?bs=account">Sécurité de mon compte</a>';
|
||||
return '<a class="nav-link" href="'.HTML_PATH_ADMIN_ROOT.'plugin/pluginbluditsecure"><span class="fa fa-shield" aria-hidden="true"></span>Security</a>';
|
||||
}
|
||||
}
|
||||
+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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
(async () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../assets/secure.js'), 'utf8');
|
||||
let prompts = 0, requests = 0, retry;
|
||||
const status = {textContent: ''};
|
||||
const box = {dataset: {base: 'https://blog.test/admin/login', csrf: 'test'}};
|
||||
const button = {
|
||||
dataset: {key: 'get'}, disabled: false,
|
||||
closest: () => box,
|
||||
addEventListener: (event, handler) => { retry = handler; }
|
||||
};
|
||||
vm.runInNewContext(source, {
|
||||
URL, URLSearchParams, Uint8Array, atob, btoa,
|
||||
document: {getElementById: () => status, querySelectorAll: () => [button]},
|
||||
window: {PublicKeyCredential: function () {}},
|
||||
navigator: {credentials: {get: async () => {
|
||||
prompts++;
|
||||
const error = new Error('cancel'); error.name = 'NotAllowedError'; throw error;
|
||||
}}},
|
||||
fetch: async () => {
|
||||
requests++;
|
||||
return {ok: true, json: async () => ({publicKey: {challenge: 'AQID', allowCredentials: []}})};
|
||||
}
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.equal(prompts, 1); assert.equal(requests, 1); assert.equal(button.disabled, false);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.equal(prompts, 1, 'cancellation must not trigger a prompt loop');
|
||||
await retry();
|
||||
assert.equal(prompts, 2, 'explicit retry must remain available');
|
||||
console.log('OK automatic passkey prompt, cancellation without loop, explicit retry');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
+22
-14
@@ -1,26 +1,34 @@
|
||||
from http_helpers import *
|
||||
s,b,h=req();check('Connexion à Bludit' in b,'plugin intercepts native login');token=csrf(b)
|
||||
s,b,h=req();check('id="jsusername"' in b and 'login-card' in b,'native Bludit login preserved');token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=password',{'secureCsrf':'bad','username':'admin','password':'TestPassword123'});check(s==303,'invalid CSRF rejected')
|
||||
s,b,h=req();token=csrf(b)
|
||||
native_token=re.search(r'name="tokenCSRF"[^>]*value="([^"]+)"', b)[1]
|
||||
s,b,h=req('/admin/login',{'tokenCSRF':'invalid','username':'admin','password':'TestPassword123'});check(s==303,'native form rejects invalid CSRF')
|
||||
s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'wrong'});check(s==303,'bad password rejected')
|
||||
s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});check(s==303 and 'bs=account' in h['Location'],'local login completes when MFA disabled')
|
||||
s,b,h=req('/admin/login?bs=account');check('Sécurité de mon compte' in b,'personal security page');token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'totp-start'});s,b,h=req('/admin/login?bs=account');secret=re.search(r'secret=([A-Z2-7]+)',b)[1]
|
||||
s,b,h=req('/admin/login',{'tokenCSRF':native_token,'username':'admin','password':'TestPassword123','remember':'true'});check(s==303 and h['Location'].endswith('/admin/'),'native login form completes on dashboard when MFA disabled')
|
||||
s,b,h=req('/admin/plugin/pluginbluditsecure');check('Security' in b,'personal security page');token=csrf(b)
|
||||
check('sidebar col-lg-2' in b and 'bludit.bootstrap.css' in b,'account uses native Bludit admin layout')
|
||||
check('assets/secure.css' not in b and b.lower().count('<!doctype html>') == 1,'no standalone account theme')
|
||||
check(re.search(r'assets/secure\.js\?v=[0-9a-f]{16}', b), 'dashboard script URL has a content hash')
|
||||
check('class="custom-select"' in b and 'btn btn-outline-primary' in b,'account uses Bootstrap controls')
|
||||
s,legacy,headers=req('/admin/login?bs=account');check(s==303 and '/plugin/pluginbluditsecure' in headers['Location'],'legacy account URL redirects to dashboard page')
|
||||
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'totp-start'});s,b,h=req('/admin/plugin/pluginbluditsecure');secret=re.search(r'secret=([A-Z2-7]+)',b)[1]
|
||||
def totp(secret):
|
||||
return subprocess.check_output([PHP,'-c',INI,'-r',f'require "{PLUGIN}/src/Totp.php"; echo \\BluditSecure\\Totp::code("{secret}", intdiv(time(),30));']).decode()
|
||||
code=totp(secret)
|
||||
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'totp-confirm','code':code});s,b,h=req('/admin/login?bs=account');check('TOTP activé' in b,'TOTP enrollment confirmed')
|
||||
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'recovery'});s,b,h=req('/admin/login?bs=account');codes=re.search(r'<pre>(.*?)</pre>',b,re.S)[1].splitlines();check(len(codes)==10,'ten recovery codes')
|
||||
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'totp-confirm','code':code});s,b,h=req('/admin/plugin/pluginbluditsecure');check('TOTP is enabled' in b,'TOTP enrollment confirmed')
|
||||
s,b,h=req('/admin/login?bs=manage',{'secureCsrf':token,'op':'recovery'});s,b,h=req('/admin/plugin/pluginbluditsecure');codes=re.search(r'<pre[^>]*>(.*?)</pre>',b,re.S)[1].splitlines();check(len(codes)==10,'ten recovery codes')
|
||||
s,b,h=req('/admin/login?bs=logout',{'secureCsrf':token});s,b,h=req();token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});check('bs=mfa' in h['Location'],'password only creates pending state')
|
||||
s,b,h=req('/admin/ajax/dashboard');check('Vérification en deux étapes' in b,'pending cannot access AJAX')
|
||||
s,b,h=req('/admin/');check('Vérification en deux étapes' in b,'pending cannot access dashboard');token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'code':code});s,b,h=req('/admin/');check('Vérification en deux étapes' in b,'reject already used TOTP')
|
||||
s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'recovery':codes[0]});check('bs=account' in h['Location'],'recovery completes MFA')
|
||||
s,b,h=req('/admin/login?bs=account');check('9 code(s)' in b,'recovery atomically consumed');token=csrf(b)
|
||||
native_token=re.search(r'name="tokenCSRF"[^>]*value="([^"]+)"', b)[1]
|
||||
s,b,h=req('/admin/login',{'tokenCSRF':native_token,'username':'admin','password':'TestPassword123','remember':'true'});check('bs=mfa' in h['Location'],'native password form only creates pending state')
|
||||
s,b,h=req('/admin/ajax/dashboard');check(s==401,'pending cannot access AJAX')
|
||||
s,b,h=req('/admin/');check('Two-step verification' in b,'pending cannot access dashboard');token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'code':code});s,b,h=req('/admin/');check('Two-step verification' in b,'reject already used TOTP')
|
||||
s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'recovery':codes[0]});check(h['Location'].endswith('/admin/'),'recovery completes MFA on dashboard')
|
||||
s,b,h=req('/admin/plugin/pluginbluditsecure');check('9 recovery code(s)' in b,'recovery atomically consumed');token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=logout',{'secureCsrf':token});s,b,h=req();token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});s,b,h=req('/admin/');token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'recovery':codes[0]});s,b,h=req('/admin/');check('Vérification en deux étapes' in b,'recovery replay rejected')
|
||||
s,b,h=req('/admin/login?bs=mfa',{'secureCsrf':token,'recovery':codes[0]});s,b,h=req('/admin/');check('Two-step verification' in b,'recovery replay rejected')
|
||||
# Direct AJAX without a session must also remain unauthenticated.
|
||||
cookies.clear();s,b,h=req('/admin/ajax/dashboard');check('Connexion à Bludit' in b,'anonymous AJAX intercepted before native dispatch')
|
||||
cookies.clear();s,b,h=req('/admin/ajax/dashboard');check(s==401,'anonymous AJAX intercepted before native dispatch')
|
||||
print('Integration checks complete')
|
||||
@@ -15,15 +15,18 @@ keyfile=str(pathlib.Path(FIXTURE)/'test-key.pem')
|
||||
raw=subprocess.check_output(PHP_CMD+['-r',f'$k=openssl_pkey_new(["private_key_type"=>OPENSSL_KEYTYPE_EC,"curve_name"=>"prime256v1"]);openssl_pkey_export($k,$pem);file_put_contents("{keyfile}",$pem);$d=openssl_pkey_get_details($k);echo json_encode([base64_encode($d["ec"]["x"]),base64_encode($d["ec"]["y"])]);'])
|
||||
x,y=map(base64.b64decode,json.loads(raw))
|
||||
s,b,h=req();token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});s,b,h=req('/admin/login?bs=account');token=csrf(b)
|
||||
s,b,h=req('/admin/login?bs=password',{'secureCsrf':token,'username':'admin','password':'TestPassword123'});s,b,h=req('/admin/plugin/pluginbluditsecure');token=csrf(b)
|
||||
def post(action,**fields):return req('/admin/login?bs='+action,{'secureCsrf':token,**fields})
|
||||
s,b,h=post('key-create-options');options=json.loads(b);check(s==200,'registration options')
|
||||
cred=b'bludit-test-key-1';rp=hashlib.sha256(b'127.0.0.1').digest()
|
||||
auth=rp+b'\x45'+struct.pack('>I',0)+b'\0'*16+struct.pack('>H',len(cred))+cred+cbor({1:2,3:-7,-1:1,-2:x,-3:y})
|
||||
client=json.dumps({'type':'webauthn.create','challenge':options['publicKey']['challenge'],'origin':'https://127.0.0.1:18765'}).encode()
|
||||
s,b,h=post('key-create-finish',clientDataJSON=b64(client),attestationObject=b64(cbor({'fmt':'none','attStmt':{},'authData':auth})),label='Test passkey');check(s==200,'signed registration accepted')
|
||||
s,b,h=req('/admin/login?bs=account');check('Test passkey' in b,'registered passkey listed');token=csrf(b)
|
||||
post('logout');s,b,h=req();token=csrf(b);post('password',username='admin',password='TestPassword123');s,b,h=req('/admin/');token=csrf(b);check('Vérification en deux étapes' in b,'passkey enabled as second factor')
|
||||
s,b,h=req('/admin/plugin/pluginbluditsecure');check('Test passkey' in b,'registered passkey listed');token=csrf(b)
|
||||
post('logout');s,b,h=req();token=csrf(b);post('password',username='admin',password='TestPassword123');s,b,h=req('/admin/');token=csrf(b);check('Two-step verification' in b and 'login-card' in b,'MFA uses native login layout')
|
||||
check('data-auto-passkey="true"' in b,'passkey prompt starts automatically')
|
||||
check(re.search(r'assets/secure\.js\?v=[0-9a-f]{16}', b), 'login script URL has a content hash to invalidate stale caches')
|
||||
check('name="password"' not in b and 'assets/secure.css' not in b,'native password form replaced only during second phase')
|
||||
def assertion(origin,valid=True):
|
||||
s,b,h=post('key-get-options');check(s==200,'assertion options');opts=json.loads(b)
|
||||
client=json.dumps({'type':'webauthn.get','challenge':opts['publicKey']['challenge'],'origin':origin}).encode()
|
||||
@@ -34,7 +37,7 @@ def assertion(origin,valid=True):
|
||||
s,b,h=assertion('https://evil.example');check(s==400,'wrong origin rejected')
|
||||
s,b,h=assertion('https://127.0.0.1:18765',False);check(s==400,'bad signature rejected')
|
||||
s,b,h=assertion('https://127.0.0.1:18765');check(s==200 and 'redirect' in json.loads(b),'valid signed assertion completes MFA')
|
||||
s,b,h=req('/admin/login?bs=account');check('Sécurité de mon compte' in b,'WebAuthn authenticated session')
|
||||
s,b,h=req('/admin/plugin/pluginbluditsecure');check('Security' in b,'WebAuthn authenticated session')
|
||||
# A finish request cannot reuse a consumed challenge.
|
||||
s,b,h=post('key-get-finish',id=b64(cred));check(s==400,'consumed challenge rejected')
|
||||
print('WebAuthn integration checks complete')
|
||||
Reference in new issue
Block a user