diff --git a/README.md b/README.md index e6faf95..b04a370 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ OIDC SSO, TOTP and passkey second-factor authentication for **Bludit 3.22.0**. -Current version: **0.1.5 — experimental**. Test on a copy of your site before deployment. +Current version: **0.1.6 — experimental**. Test on a copy of your site before deployment. ## Features @@ -10,6 +10,7 @@ Current version: **0.1.5 — experimental**. Test on a copy of your site before - 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. +- Shows SSO link status, provider, SSO username and SSO email in the administrator's native **Users** list and the user's **Security** page. - 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. @@ -54,9 +55,9 @@ For `bludit/docker`, copy the extracted plugin into `/usr/share/nginx/html/bl-pl 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 +unzip -o /tmp/bludit-secure-0.1.6.zip -d /tmp/bludit-secure-0.1.6 -docker cp /tmp/bludit-secure-0.1.5/bludit-secure/. \ +docker cp /tmp/bludit-secure-0.1.6/bludit-secure/. \ bludit:/usr/share/nginx/html/bl-plugins/bludit-secure/ docker restart bludit @@ -76,6 +77,10 @@ The URI respects Bludit's configured admin path and installation subdirectory. E 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. +The plugin requests `openid profile email`. The provider's username (`preferred_username`) and email are display information, stored in the encrypted plugin database. They do not change the Bludit username, email or role and are never used to associate accounts. Emails not marked verified by the provider are labeled accordingly. Administrators can see these details on **Users**; each user can see their own on **Security**. + +Existing SSO links remain valid. Sign in with SSO once after upgrading to populate the username and email; the provider may ask you to approve the additional scopes. Profile details refresh after a successful SSO sign-in, including the local second factor when required. Missing optional claims are shown as **Not provided**. Unlinking SSO removes its stored profile details. + 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. The plugin requires advertised PKCE S256 support, a matching nonce, integer `iat` and `exp` claims, and a matching authorized party (`azp`) for multiple audiences. State is checked before contacting the token endpoint. Discovery, token and signing-key endpoints must use direct HTTPS URLs: HTTP and endpoint redirects are rejected. Responses are limited to 1 MiB. Authorization starts have a separate persistent attempt budget. Changing the global SSO configuration requires an administrator sign-in within the last five minutes. @@ -113,6 +118,13 @@ The plugin protects **web sessions under `/admin`**. It does not change authenti JavaScript URLs include a content hash so updated passkey behavior is not hidden by stale asset caches. +## Changes in 0.1.6 + +- Adds SSO link status, provider, username and email to the native administrator Users table. +- Displays the linked SSO profile on Security and refreshes it after completed SSO sign-in. +- Requests profile and email scopes while keeping account matching strictly on issuer and subject. +- Preserves existing registrations and indicates when an older link needs an SSO sign-in to load profile details. + ## Changes in 0.1.5 - Retains the native login page and integrates Security into the dashboard. diff --git a/assets/users.js b/assets/users.js new file mode 100644 index 0000000..c3272a7 --- /dev/null +++ b/assets/users.js @@ -0,0 +1,55 @@ +'use strict'; +(() => { + const data = document.getElementById('bs-sso-users'); + if (!data) return; + const records = new Map(JSON.parse(data.textContent).map(record => [new URL(record.url, document.baseURI).href, record])); + const tables = new Set(); + for (const row of document.querySelectorAll('table tbody tr')) { + const link = row.querySelector('td a[href]'); + const record = link && records.get(new URL(link.getAttribute('href'), document.baseURI).href); + const table = row.closest('table'); + if (!record || !table || row.dataset.bsSso) continue; + row.dataset.bsSso = 'true'; + tables.add(table); + const status = document.createElement('td'); + const badge = document.createElement('span'); + badge.className = record.linked ? 'badge badge-success' : 'badge badge-secondary'; + badge.textContent = record.linked ? 'Linked' : 'Not linked'; + status.appendChild(badge); + if (record.linked) { + const provider = document.createElement('div'); + provider.className = 'small text-muted'; + provider.textContent = record.provider; + status.appendChild(provider); + } + const username = document.createElement('td'); + username.textContent = record.linked ? (record.username || (record.needsRefresh ? 'Sign in with SSO to refresh' : 'Not provided')) : '—'; + const email = document.createElement('td'); + email.textContent = record.linked ? (record.email || (record.needsRefresh ? 'Sign in with SSO to refresh' : 'Not provided')) : '—'; + if (record.email && !record.emailVerified) { + const note = document.createElement('div'); + note.className = 'small text-muted'; + note.textContent = 'Not verified by provider'; + email.appendChild(note); + } + row.append(status, username, email); + } + for (const table of tables) { + const header = table.querySelector('thead tr'); + if (!header || header.dataset.bsSso) continue; + header.dataset.bsSso = 'true'; + for (const title of ['SSO', 'SSO username', 'SSO email']) { + const cell = document.createElement('th'); + cell.className = 'border-bottom-0'; + cell.scope = 'col'; + cell.textContent = title; + header.appendChild(cell); + } + if (!table.parentElement.classList.contains('table-responsive')) { + const wrapper = document.createElement('div'); + wrapper.className = 'table-responsive'; + table.parentNode.insertBefore(wrapper, table); + wrapper.appendChild(table); + } + } +})(); diff --git a/metadata.json b/metadata.json index b83f7f9..4c0ef32 100644 --- a/metadata.json +++ b/metadata.json @@ -1 +1 @@ -{"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."} +{"author":"Interlope","version":"0.1.6","releaseDate":"2026-11-03","license":"MIT","compatible":"3.22.0","notes":"PHP 8.2+, HTTPS, sodium, curl, mbstring. Preview: validate on staging before production."} diff --git a/plugin.php b/plugin.php index 7dac8c0..c19fce0 100644 --- a/plugin.php +++ b/plugin.php @@ -76,4 +76,7 @@ class pluginBluditSecure extends Plugin public function adminSidebar() { return 'Security'; } + public function adminBodyEnd() { + return (new \BluditSecure\App($this))->usersViewEnhancement(); + } } diff --git a/src/App.php b/src/App.php index ba81efe..3eaa38b 100644 --- a/src/App.php +++ b/src/App.php @@ -52,7 +52,7 @@ final class App return $a['mode'] === 'either' || $a['mode'] === $factor; } private function requiresMfa(array $a, string $via): bool { return $a['mode'] !== 'off' && ($via !== 'sso' || $a['ssoMfa']); } - private function pending(string $name, string $via, ?int $revision = null, ?string $userStamp = null): void { + private function pending(string $name, string $via, ?int $revision = null, ?string $userStamp = null, ?array $ssoProfile = null): void { $user = $this->user($name); $a = $this->store->get($name); if (($revision !== null && $a['revision'] !== $revision) || ($userStamp !== null && $this->userStamp($user) !== $userStamp)) { throw new UserError('Account changed. Please sign in again.'); } @@ -60,6 +60,7 @@ final class App // Do not carry enrollment secrets, recovery codes or linking flows across sign-ins. $_SESSION['bs'] = ['csrf'=>bin2hex(random_bytes(32))]; $_SESSION['bs']['pending'] = ['name'=>$name, 'via'=>$via, 'expires'=>time()+300, 'revision'=>$a['revision'], 'userStamp'=>$this->userStamp($user)]; + if ($via === 'sso' && $ssoProfile !== null) { $_SESSION['bs']['pending']['ssoProfile'] = $ssoProfile; } if (!$this->requiresMfa($a, $via)) { $this->finish($name); } $this->go('mfa'); } @@ -75,6 +76,7 @@ final class App $this->store->transaction(function (&$d) use ($name, $p, $login, $security) { $u = $this->user($name); $a = Store::account($d, $name); if ($a['revision'] !== $p['revision'] || $this->userStamp($u) !== $p['userStamp']) { throw new UserError('Account changed. Please sign in again.'); } + if ($p['via'] === 'sso' && isset($p['ssoProfile'])) { $d['accounts'][$name]['ssoProfile'] = $p['ssoProfile']; } $login->setLogin($name, $u['role']); $security->generateTokenCSRF(); $_SESSION['bs']['auth'] = ['name'=>$name, 'at'=>time(), 'revision'=>$p['revision'], 'userStamp'=>$p['userStamp']]; @@ -256,7 +258,16 @@ final class App $body .= '

Passkeys

Register a passkey for this site using your password manager or authenticator.

'.$this->keyButton(true); foreach ($a['keys'] as $id=>$k) { $body .= $this->form('manage', ''.$this->e($k['label']).' '); } $body .= '

SSO

'; - if ($a['sso']) { $body .= '

SSO identity linked.

'.$this->form('manage', ''); } + if ($a['sso']) { + $profile = $a['ssoProfile'] ?? null; + $body .= '

SSO identity linked to '.$this->e($profile['provider'] ?? $a['sso']['issuer']).'.

'; + if ($profile !== null) { + $body .= '

SSO username: '.$this->e($profile['username'] ?? 'Not provided').'
SSO email: '.$this->e($profile['email'] ?? 'Not provided'); + if (!empty($profile['email']) && empty($profile['emailVerified'])) { $body .= ' (not verified by provider)'; } + $body .= '

'; + } else { $body .= '

Sign in with SSO to load your provider username and email.

'; } + $body .= $this->form('manage', ''); + } elseif ($this->ssoEnabled()) { $body .= $this->form('link', ''); } else { $body .= '

An administrator must configure SSO first.

'; } $body .= '

Recovery

'.count($a['recovery']).' recovery code(s) remaining. Generating new codes invalidates the previous ones. Store them outside your account.

'.$this->form('manage', ''); @@ -286,7 +297,7 @@ final class App unset($_SESSION['bs']['enroll']); break; case 'totp-delete': $a['totp'] = null; $a['lastStep'] = -1; break; case 'key-delete': unset($a['keys'][(string)($_POST['key'] ?? '')]); break; - case 'unlink': $a['sso'] = null; break; + case 'unlink': $a['sso'] = null; $a['ssoProfile'] = null; break; case 'recovery': $a['recovery'] = array_map(fn($c)=>hash('sha256',$c), $codes); break; case 'preferences': $mode = $_POST['mode'] ?? ''; @@ -307,6 +318,7 @@ final class App if (!$this->ssoEnabled() || parse_url($this->cfg('issuer'), PHP_URL_SCHEME) !== 'https') { throw new UserError('SSO is not configured.'); } $oidc = new OidcClient($this->cfg('issuer'), $this->cfg('clientId'), $this->cfg('clientSecret')); $oidc->setRedirectURL($this->base.'?bs=oidc'); + $oidc->addScope(['profile', 'email']); $oidc->setCodeChallengeMethod('S256'); $oidc->setAllowImplicitFlow(false); $oidc->setTimeout(15); @@ -332,14 +344,15 @@ final class App $iss = $oidc->getVerifiedClaims('iss'); if (!is_string($sub) || $sub === '' || !is_string($iss) || $iss !== $this->cfg('issuer')) { throw new UserError('Invalid SSO identity.'); } $identity = ['issuer'=>$iss, 'subject'=>$sub]; + $profile = $this->ssoProfile($oidc); if ($flow['link'] !== null) { $name = $this->recent(); if ($name !== $flow['link']) { throw new UserError('Invalid account-linking session.'); } - $rev = $this->store->transaction(function (&$d) use ($name, $identity, $flow) { + $rev = $this->store->transaction(function (&$d) use ($name, $identity, $flow, $profile) { 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); if ($a['revision'] !== $flow['revision']) { throw new UserError('Account changed. Please start linking again.'); } - $a['sso'] = $identity; ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision']; + $a['sso'] = $identity; $a['ssoProfile'] = $profile; ++$a['revision']; $d['accounts'][$name] = $a; return $a['revision']; }); $_SESSION['bs']['auth']['revision'] = $rev; $_SESSION['bs']['notice'] = 'SSO identity linked.'; $this->go('account'); @@ -348,9 +361,46 @@ final class App foreach ($d['accounts'] as $name=>$a) { if ($a['sso'] === $identity) { return ['name'=>$name, 'revision'=>$a['revision']]; } } return null; }); if ($match === null) { throw new UserError('This SSO identity is not linked to a Bludit account. Sign in with your password to link it.'); } - $this->budget($match['name']); $this->pending($match['name'], 'sso', $match['revision']); + $this->budget($match['name']); $this->pending((string)$match['name'], 'sso', $match['revision'], null, $profile); exit; } + private function ssoProfile(OidcClient $oidc): array { + $claim = static function (mixed $value, int $length): ?string { + if (!is_string($value)) { return null; } + $value = mb_substr(trim(preg_replace('/[\x00-\x1f\x7f]/u', '', $value)), 0, $length); + return $value === '' ? null : $value; + }; + return [ + 'provider'=>mb_substr($this->cfg('ssoLabel'), 0, 80) ?: $this->cfg('issuer'), + 'username'=>$claim($oidc->getVerifiedClaims('preferred_username'), 200), + 'email'=>$claim($oidc->getVerifiedClaims('email'), 320), + 'emailVerified'=>$oidc->getVerifiedClaims('email_verified') === true + ]; + } + public function usersViewEnhancement(): string { + global $layout, $users; + if (($layout['view'] ?? '') !== 'users') { return ''; } + $name = $this->authenticated(); + if ($name === null || $this->user($name)['role'] !== 'admin') { return ''; } + $names = $users->keys(); + $rows = $this->store->transaction(function (&$d) use ($names) { + $rows = []; + foreach ($names as $name) { + $a = Store::account($d, (string)$name); + $identity = $a['sso']; $profile = $a['ssoProfile'] ?? null; + $rows[] = ['url'=>HTML_PATH_ADMIN_ROOT.'edit-user/'.$name, 'linked'=>$identity !== null, + 'provider'=>$identity !== null ? ($profile['provider'] ?? $identity['issuer']) : null, + 'username'=>$identity !== null ? ($profile['username'] ?? null) : null, + 'email'=>$identity !== null ? ($profile['email'] ?? null) : null, + 'emailVerified'=>$identity !== null && ($profile['emailVerified'] ?? false) === true, + 'needsRefresh'=>$identity !== null && $profile === null]; + } + return $rows; + }); + $data = json_encode($rows, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); + $version = substr(hash_file('sha256', dirname(__DIR__).'/assets/users.js'), 0, 16); + return ''; + } private function keyButton(bool $create, bool $automatic = false): string { return '
'.($create?'':'').'
'; } diff --git a/src/Store.php b/src/Store.php index 205fd11..815a9f8 100644 --- a/src/Store.php +++ b/src/Store.php @@ -65,7 +65,7 @@ final class Store } finally { if (file_exists($tmp)) { unlink($tmp); } } } public static function account(array $data, string $name): array { - return $data['accounts'][$name] ?? ['totp'=>null, 'lastStep'=>-1, 'keys'=>[], 'recovery'=>[], 'mode'=>'off', 'sso'=>null, 'ssoMfa'=>true, 'revision'=>0]; + return $data['accounts'][$name] ?? ['totp'=>null, 'lastStep'=>-1, 'keys'=>[], 'recovery'=>[], 'mode'=>'off', 'sso'=>null, 'ssoProfile'=>null, 'ssoMfa'=>true, 'revision'=>0]; } public function get(string $name): array { return $this->transaction(fn(&$d) => self::account($d, $name)); } public function update(string $name, callable $fn): mixed {