diff --git a/.idea/module-auth.iml b/.idea/module-auth.iml index f9bf76d1..35220ee3 100644 --- a/.idea/module-auth.iml +++ b/.idea/module-auth.iml @@ -3,6 +3,7 @@ + @@ -106,6 +107,8 @@ + + diff --git a/.idea/php.xml b/.idea/php.xml index dbff9f6d..3cb79bf4 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -118,6 +118,8 @@ + + diff --git a/.phpstan/config.neon b/.phpstan/config.neon index 9abefef5..89ab3a36 100644 --- a/.phpstan/config.neon +++ b/.phpstan/config.neon @@ -1,5 +1,5 @@ parameters: - level: 0 + level: 1 paths: - ../helpers - ../src diff --git a/admin/controllers/Accounts.php b/admin/controllers/Accounts.php index 1e01c180..fad5ccca 100755 --- a/admin/controllers/Accounts.php +++ b/admin/controllers/Accounts.php @@ -512,6 +512,7 @@ public function edit(): void // -------------------------------------------------------------------------- + /** @var \Nails\Auth\Resource\User|null $oUser */ $oUser = $oUserModel->getById($oUri->segment(5)); if (empty($oUser)) { diff --git a/admin/controllers/Merge.php b/admin/controllers/Merge.php index 320be808..bbf99e25 100644 --- a/admin/controllers/Merge.php +++ b/admin/controllers/Merge.php @@ -48,7 +48,7 @@ public static function announce(): Nav|array|null ->addAction('Merge Users'); } - return $oNavGroup ?? null; + return null; } // -------------------------------------------------------------------------- diff --git a/admin/controllers/Settings.php b/admin/controllers/Settings.php index 43828e49..4937b6be 100644 --- a/admin/controllers/Settings.php +++ b/admin/controllers/Settings.php @@ -16,6 +16,7 @@ use Nails\Admin\Helper; use Nails\Auth\Constants; use Nails\Auth\Controller\BaseAdmin; +use Nails\Auth\Service\Passkey; use Nails\Auth\Service\SocialSignOn; use Nails\Common\Service\AppSetting; use Nails\Common\Service\Asset; @@ -113,6 +114,7 @@ public function index(): void if (userHasPermission('admin:auth:settings:update:login')) { $aSettings['user_login_captcha_enabled'] = (bool) $oInput->post('user_login_captcha_enabled'); + $aSettings[Passkey::SETTING_ENABLED] = (bool) $oInput->post(Passkey::SETTING_ENABLED); } // -------------------------------------------------------------------------- @@ -219,11 +221,9 @@ public function index(): void $bRollback = false; - if (!empty($aSettings)) { - if (!$oAppSettingService->set($aSettings, 'auth')) { - $error = $oAppSettingService->lastError(); - $bRollback = true; - } + if (!$oAppSettingService->set($aSettings, 'auth')) { + $error = $oAppSettingService->lastError(); + $bRollback = true; } if (!empty($aSettingsEncrypted)) { diff --git a/admin/language/english/admin_accounts_lang.php b/admin/language/english/admin_accounts_lang.php index e9677632..271f14f5 100644 --- a/admin/language/english/admin_accounts_lang.php +++ b/admin/language/english/admin_accounts_lang.php @@ -131,3 +131,17 @@ $lang['accounts_delete_error_selfie'] = 'You can\'t delete yourself.'; $lang['accounts_delete_success'] = 'User %s was deleted successfully.'; $lang['accounts_delete_error'] = 'There was a problem deleting %s.'; + +// -------------------------------------------------------------------------- + +// Passkeys +$lang['accounts_edit_passkeys_none'] = 'This user has not registered any passkeys.'; +$lang['accounts_edit_passkeys_disabled'] = 'Passkeys are not currently enabled for this site; existing passkeys are listed below but cannot be used to sign in.'; +$lang['accounts_edit_passkeys_warning'] = 'Revoking a passkey cannot be undone; the user will have to register the device again.'; +$lang['accounts_edit_passkeys_label'] = 'Name'; +$lang['accounts_edit_passkeys_added'] = 'Added'; +$lang['accounts_edit_passkeys_last_used'] = 'Last used'; +$lang['accounts_edit_passkeys_never_used'] = 'Never'; +$lang['accounts_edit_passkeys_details'] = 'Details'; +$lang['accounts_edit_passkeys_revoke'] = 'Revoke'; +$lang['accounts_edit_passkeys_synced'] = 'Synced'; diff --git a/admin/views/Accounts/edit/inc-passkeys.php b/admin/views/Accounts/edit/inc-passkeys.php new file mode 100644 index 00000000..579333cf --- /dev/null +++ b/admin/views/Accounts/edit/inc-passkeys.php @@ -0,0 +1,107 @@ + +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + getTransports(); + + ?> + + + created); + echo Helper::loadDateTimeCell($oPasskey->last_used ? (string) $oPasskey->last_used : null); + echo Helper::loadBoolCell($oPasskey->is_backed_up); + + /** + * The AAGUID names the model of authenticator; it is only of use + * when supporting a user, so it sits under the transports rather + * than taking a column of its own. + */ + echo Helper::loadCellAuto( + $aTransports + ? htmlspecialchars(implode(', ', $aTransports)) + : null, + 'field field--transports', + $oPasskey->aaguid + ? '
' . htmlspecialchars($oPasskey->aaguid) . '' + : '' + ); + + ?> + + + + +
+ + + + + + + + + + + +
+ +
+ label)?> + + +
+
diff --git a/admin/views/Settings/index.php b/admin/views/Settings/index.php index 9fa88645..f2be345f 100644 --- a/admin/views/Settings/index.php +++ b/admin/views/Settings/index.php @@ -38,12 +38,50 @@ !userHasPermission('admin:auth:settings:update:login') ? null : [ 'label' => 'Login', 'content' => function () { + + /** @var \Nails\Auth\Service\Passkey $oPasskeyService */ + $oPasskeyService = \Nails\Factory::service('Passkey', \Nails\Auth\Constants::MODULE_SLUG); + echo form_field_boolean([ 'key' => 'user_login_captcha_enabled', 'label' => 'Captcha', 'default' => (bool) appSetting('user_login_captcha_enabled', 'auth'), 'info' => anchor('admin/captcha/settings', 'Manage captcha settings here'), ]); + + echo form_field_boolean([ + 'key' => \Nails\Auth\Service\Passkey::SETTING_ENABLED, + 'label' => 'Passkeys', + 'default' => (bool) appSetting(\Nails\Auth\Service\Passkey::SETTING_ENABLED, 'auth'), + 'info' => 'Allows users to sign in with a passkey, and to register one against their account.', + ]); + + /** + * Read only: both are derived from BASE_URL and can only be overridden in + * the app's config, because changing the Relying Party ID invalidates every + * passkey already registered. + */ + + ?> +
+

+ Relying Party ID: + getRpId())?> +

+

+ Permitted origins: + getAllowedOrigins()))?> +

+

+ + Derived from BASE_URL. Override with + and + . + Changing the Relying Party ID invalidates existing passkeys. + +

+
+ form{display:block;flex-basis:100%;width:100%;margin:0}.nails-auth .form__actions>noscript{flex-basis:100%;width:100%;margin:0}.nails-auth.passkeys{max-width:900px}.nails-auth.passkeys .form--inline{gap:.5rem;margin:0}.nails-auth.passkeys thead th{white-space:nowrap}.nails-auth.passkeys .passkeys__date{white-space:nowrap}.nails-auth.passkeys input[name=label]{flex:1 1 6rem;min-width:0}.nails-auth.passkeys td:first-child .form--inline{flex-wrap:nowrap;width:100%}.nails-auth.passkeys .passkeys__table{overflow-x:auto}@media(max-width: 767px){.nails-auth.passkeys table,.nails-auth.passkeys tbody,.nails-auth.passkeys tr,.nails-auth.passkeys td{display:block;width:100%}.nails-auth.passkeys thead{display:none}.nails-auth.passkeys tr{padding:var(--spacing-md, 1rem) 0;border-bottom:1px solid var(--color-border, #d1d5db)}.nails-auth.passkeys td{padding:.25rem 0;text-align:left;border:0}.nails-auth.passkeys td[data-label]::before{content:attr(data-label) ": ";font-weight:var(--font-weight-bold, 600)}.nails-auth.passkeys .passkeys__actions{margin-top:.5rem}}[data-passkey-error][hidden],[data-passkey-block][hidden]{display:none} diff --git a/assets/js/passkey.js b/assets/js/passkey.js new file mode 100644 index 00000000..2bd9b249 --- /dev/null +++ b/assets/js/passkey.js @@ -0,0 +1,971 @@ +'use strict'; + +/** + * WebAuthn passkey support. + * + * Exposes window.NAILS.PASSKEY so that other components - notably the MFA driver, + * which ships no JavaScript of its own - can run a ceremony without repeating any + * of the encoding work. + */ +(function() { + + var API_BASE = 'api/auth/passkey/'; + + var conditionalAbortController = null; + + /** + * Below this, a rejection cannot be somebody deciding not to continue + */ + var MIN_HUMAN_RESPONSE_MS = 250; + + /** + * How long to wait before saying so in the console when no prompt has appeared + */ + var CEREMONY_WARN_MS = 10000; + + /** + * The backstop. A hooked `navigator.credentials` can leave the promise pending + * for ever - no prompt, no rejection, nothing to report - so the ceremony is + * abandoned once the authenticator's own timeout has certainly passed. + */ + var CEREMONY_TIMEOUT_MS = 60000; + + // -------------------------------------------------------------------------- + + /** + * Whether this browser can do WebAuthn at all + * + * @return {boolean} + */ + function isSupported() { + return typeof window.PublicKeyCredential === 'function' && + !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get); + } + + // -------------------------------------------------------------------------- + + /** + * Whether the browser can offer a passkey from a form field's own dropdown + * + * @return {Promise} + */ + function isConditionalMediationAvailable() { + if (!isSupported() || typeof window.PublicKeyCredential.isConditionalMediationAvailable !== 'function') { + return Promise.resolve(false); + } + return window.PublicKeyCredential.isConditionalMediationAvailable().catch(function() { + return false; + }); + } + + // -------------------------------------------------------------------------- + + /** + * Whether this device has a built in authenticator to enrol + * + * @return {Promise} + */ + function isPlatformAuthenticatorAvailable() { + if (!isSupported() || + typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== 'function') { + return Promise.resolve(false); + } + return window.PublicKeyCredential + .isUserVerifyingPlatformAuthenticatorAvailable() + .catch(function() { + return false; + }); + } + + // -------------------------------------------------------------------------- + + /** + * Decodes a base64url string into an ArrayBuffer + * + * @param {string} value The base64url encoded value + * + * @return {ArrayBuffer} + */ + function decode(value) { + var padded = value.replace(/-/g, '+').replace(/_/g, '/'); + while (padded.length % 4) { + padded += '='; + } + var binary = window.atob(padded); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + + // -------------------------------------------------------------------------- + + /** + * Encodes an ArrayBuffer as a base64url string + * + * @param {ArrayBuffer} buffer The buffer to encode + * + * @return {string} + */ + function encode(buffer) { + var bytes = new Uint8Array(buffer); + var binary = ''; + for (var i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + // -------------------------------------------------------------------------- + + /** + * Serialises a credential the way PublicKeyCredential.toJSON() would + * + * Used in place of toJSON() where the browser does not implement it yet, so that + * the server only ever sees one shape. + * + * @param {PublicKeyCredential} credential The credential to serialise + * + * @return {Object} + */ + function toJSON(credential) { + if (typeof credential.toJSON === 'function') { + return credential.toJSON(); + } + + var response = credential.response; + var out = { + 'id': credential.id, + 'rawId': encode(credential.rawId), + 'type': credential.type, + 'authenticatorAttachment': credential.authenticatorAttachment || null, + 'clientExtensionResults': credential.getClientExtensionResults + ? credential.getClientExtensionResults() + : {}, + 'response': { + 'clientDataJSON': encode(response.clientDataJSON) + } + }; + + if (response.attestationObject) { + out.response.attestationObject = encode(response.attestationObject); + out.response.transports = typeof response.getTransports === 'function' + ? response.getTransports() + : []; + } else { + out.response.authenticatorData = encode(response.authenticatorData); + out.response.signature = encode(response.signature); + out.response.userHandle = response.userHandle ? encode(response.userHandle) : null; + } + + return out; + } + + // -------------------------------------------------------------------------- + + /** + * Turns the server's creation options back into the binary the browser wants + * + * @param {Object} options The options as sent by the server + * + * @return {Object} + */ + function decodeCreationOptions(options) { + var decoded = Object.assign({}, options); + + decoded.challenge = decode(options.challenge); + decoded.user = Object.assign({}, options.user, {'id': decode(options.user.id)}); + + if (Array.isArray(options.excludeCredentials)) { + decoded.excludeCredentials = options.excludeCredentials.map(function(item) { + return Object.assign({}, item, {'id': decode(item.id)}); + }); + } + + return decoded; + } + + // -------------------------------------------------------------------------- + + /** + * Turns the server's request options back into the binary the browser wants + * + * @param {Object} options The options as sent by the server + * + * @return {Object} + */ + function decodeRequestOptions(options) { + var decoded = Object.assign({}, options); + + decoded.challenge = decode(options.challenge); + + if (Array.isArray(options.allowCredentials)) { + decoded.allowCredentials = options.allowCredentials.map(function(item) { + return Object.assign({}, item, {'id': decode(item.id)}); + }); + } + + return decoded; + } + + // -------------------------------------------------------------------------- + + /** + * Fails a ceremony which never settles + * + * An extension which intercepts `navigator.credentials` can swallow the call + * entirely, leaving a promise which neither resolves nor rejects. Without this + * the user is left looking at a disabled button and the console stays silent. + * + * @param {Promise} promise The ceremony + * @param {number} timeout How long the options gave the authenticator + * @param {AbortController} controls Lets the pending call be abandoned + * + * @return {Promise} + */ + function withWatchdog(promise, timeout, controls) { + + var settled = false; + var mark = function() { + settled = true; + }; + + promise.then(mark, mark); + + var warn = window.setTimeout(function() { + if (!settled) { + console.warn( + '[passkey] no prompt after ' + (CEREMONY_WARN_MS / 1000) + 's. The browser has ' + + 'not opened a passkey prompt and has not reported an error; a password manager ' + + 'or other extension may be intercepting navigator.credentials.' + ); + } + }, CEREMONY_WARN_MS); + + var expire = new Promise(function(resolve, reject) { + window.setTimeout(function() { + if (settled) { + return; + } + if (controls) { + try { + controls.abort(); + } catch (e) { + // Nothing more can be done; the rejection below is what matters + } + } + var error = new Error('The passkey prompt never opened.'); + error.passkeyRefused = true; + error.passkeyTimedOut = true; + reject(error); + }, Math.max(timeout || 0, CEREMONY_TIMEOUT_MS)); + }); + + return Promise.race([promise, expire]).finally(function() { + window.clearTimeout(warn); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Records why a ceremony failed + * + * The browser reports "the user declined" and "the browser refused to ask" with + * the same NotAllowedError, so the two are told apart here: a page which is not + * focused never got to ask, and nobody declines a prompt in a quarter of a + * second. Without this an environment problem looks exactly like a cancellation, + * which is to say it looks like nothing happening at all. + * + * @param {Error} error The error the browser raised + * @param {number} startedAt When the ceremony was started + * + * @return {Error} + */ + function annotateCeremonyError(error, startedAt) { + + var elapsed = Date.now() - startedAt; + var focused = document.hasFocus(); + + if (error && error.name === 'NotAllowedError' && (!focused || elapsed < MIN_HUMAN_RESPONSE_MS)) { + error.passkeyRefused = true; + error.passkeyUnfocused = !focused; + } + + console.warn('[passkey] ceremony failed', { + 'name': error && error.name, + 'message': error && error.message, + 'elapsedMs': elapsed, + 'documentFocused': focused + }); + + return error; + } + + // -------------------------------------------------------------------------- + + /** + * Runs a registration ceremony + * + * @param {Object} options The decoded creation options + * + * @return {Promise} + */ + function create(options) { + + var startedAt = Date.now(); + var controls = new AbortController(); + + var ceremony = navigator.credentials + .create({'publicKey': decodeCreationOptions(options), 'signal': controls.signal}) + .then(function(credential) { + if (!credential) { + throw new Error('cancelled'); + } + return toJSON(credential); + }); + + return withWatchdog(ceremony, options.timeout, controls) + .catch(function(error) { + throw annotateCeremonyError(error, startedAt); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Runs an authentication ceremony + * + * @param {Object} options The decoded request options + * @param {Object} settings Optional mediation and abort signal + * + * @return {Promise} + */ + function get(options, settings) { + settings = settings || {}; + + var request = {'publicKey': decodeRequestOptions(options)}; + + if (settings.mediation) { + request.mediation = settings.mediation; + } + + /** + * The conditional path supplies its own signal so the button can abandon it; + * the modal path gets one of its own so the watchdog has something to pull. + */ + var controls = settings.signal ? null : new AbortController(); + + request.signal = settings.signal || controls.signal; + + var startedAt = Date.now(); + + var ceremony = navigator.credentials + .get(request) + .then(function(credential) { + if (!credential) { + throw new Error('cancelled'); + } + return toJSON(credential); + }); + + /** + * A conditional request legitimately waits for as long as the user ignores the + * field, so only the modal one is watched. + */ + return (settings.mediation === 'conditional' + ? ceremony + : withWatchdog(ceremony, options.timeout, controls) + ).catch(function(error) { + throw annotateCeremonyError(error, startedAt); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Posts JSON to one of the passkey endpoints + * + * @param {string} endpoint The endpoint name + * @param {Object} body The request body + * + * @return {Promise} + */ + function post(endpoint, body) { + return request('POST', endpoint, body); + } + + // -------------------------------------------------------------------------- + + /** + * Talks to one of the passkey endpoints + * + * @param {string} method The HTTP method + * @param {string} endpoint The endpoint name + * @param {Object} body The request body + * + * @return {Promise} + */ + function request(method, endpoint, body) { + return window + .fetch(siteUrl(API_BASE + endpoint), { + 'method': method, + 'credentials': 'same-origin', + 'headers': { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + 'body': method === 'GET' ? undefined : JSON.stringify(body || {}) + }) + .then(function(response) { + + /** + * An older MFA module may still redirect inside setLoginData(); a + * redirected reply is treated as navigation rather than as an error + * the user has to read. + */ + if (response.redirected) { + window.location.assign(response.url); + return new Promise(function() {}); + } + + return response.text().then(function(text) { + + var payload = null; + + try { + payload = JSON.parse(text); + } catch (e) { + payload = null; + } + + if (response.ok && payload) { + return payload.data || {}; + } + + /** + * A failure here is usually a server error rendered as an HTML page, + * which tells the user nothing. Report what can be said usefully and + * put the body in the console for whoever is debugging it. + */ + var message = payload && payload.error + ? payload.error + : 'Sorry, something went wrong (HTTP ' + response.status + '). Please try again.'; + + var error = new Error(message); + error.status = response.status; + error.body = text; + + console.error('[passkey] ' + method + ' ' + endpoint + ' failed', { + 'status': response.status, + 'body': text.slice(0, 2000) + }); + + throw error; + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Builds an absolute URL for a site path + * + * @param {string} path The path, relative to the site root + * + * @return {string} + */ + function siteUrl(path) { + var form = document.querySelector('[data-passkey-site-url]'); + var base = form ? form.getAttribute('data-passkey-site-url') : '/'; + return (base || '/').replace(/\/+$/, '') + '/' + path; + } + + // -------------------------------------------------------------------------- + + /** + * Registers a new passkey against the logged in account + * + * @param {Object} settings Optionally carries a label for the new passkey + * + * @return {Promise} + */ + function register(settings) { + settings = settings || {}; + + return post('register', {}) + .then(function(data) { + return create(data.options); + }) + .then(function(credential) { + return post('attest', { + 'label': settings.label || null, + 'credential': credential + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Signs in with a passkey + * + * @param {Object} settings Mediation, remember-me, and return URL + * + * @return {Promise} + */ + function login(settings) { + settings = settings || {}; + + var signal = null; + + if (settings.conditional) { + conditionalAbortController = new AbortController(); + signal = conditionalAbortController.signal; + } + + return post('challenge', {}) + .then(function(data) { + return get(data.options, { + // Only the conditional path names a mediation; the modal one takes the default + 'mediation': settings.conditional ? 'conditional' : null, + 'signal': signal + }); + }) + .then(function(credential) { + return post('assert', { + 'credential': credential, + 'remember': !!settings.remember, + 'return_to': settings.returnTo || null + }); + }) + .then(function(data) { + window.location.assign(data.redirect); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Abandons any conditional request which is waiting in the background + * + * Called before starting an explicit ceremony, because a browser will only + * entertain one outstanding request at a time and the newest should win. + * + * @return {void} + */ + function abortConditional() { + if (conditionalAbortController) { + conditionalAbortController.abort(); + conditionalAbortController = null; + } + } + + // -------------------------------------------------------------------------- + + /** + * Whether an error is the user declining, rather than something going wrong + * + * @param {Error} error The error to test + * + * @return {boolean} + */ + function isCancellation(error) { + + if (!error || error.passkeyRefused) { + return false; + } + + return error.name === 'NotAllowedError' || error.name === 'AbortError' || + error.message === 'cancelled'; + } + + // -------------------------------------------------------------------------- + + /** + * Turns an error into something a user can act on + * + * The browser reports ceremony failures as DOMExceptions whose messages are + * written for developers, so the well-known ones are named explicitly. + * + * @param {Error} error The error to describe + * + * @return {string} + */ + function describeError(error) { + + if (error && error.passkeyUnfocused) { + return 'The passkey prompt could not open because this page was not in focus. ' + + 'Click anywhere on the page, then try again.'; + } + + if (error && error.passkeyTimedOut) { + return 'The passkey prompt never opened. A password manager or browser extension ' + + 'may be blocking it; please try again, or sign in with your password.'; + } + + if (error && error.passkeyRefused) { + return 'Your browser could not open the passkey prompt. This is often a browser ' + + 'extension interfering; please try again, or sign in with your password.'; + } + + switch (error && error.name) { + + case 'InvalidStateError': + return 'This device already has a passkey for your account.'; + + case 'SecurityError': + return 'Passkeys need a secure (HTTPS) connection to this site.'; + + case 'NotSupportedError': + case 'ConstraintError': + return 'This device cannot create the kind of passkey this site asks for.'; + + case 'UnknownError': + return 'This device could not complete the request. Please try again.'; + + default: + return (error && error.message) + ? error.message + : 'Sorry, something went wrong. Please try again.'; + } + } + + // -------------------------------------------------------------------------- + + /** + * Marks a control as waiting on the browser + * + * A ceremony can sit for a long time - the user may be reaching for their phone - + * so the control says what it is doing rather than just going dim. + * + * @param {Element} control The control to mark + * @param {boolean} busy Whether the ceremony is running + * + * @return {void} + */ + function setBusy(control, busy) { + + if (busy) { + control.setAttribute('data-passkey-idle-label', control.innerHTML); + control.innerHTML = control.getAttribute('data-passkey-busy-label') || + 'Waiting for your passkey…'; + } else if (control.hasAttribute('data-passkey-idle-label')) { + control.innerHTML = control.getAttribute('data-passkey-idle-label'); + control.removeAttribute('data-passkey-idle-label'); + } + + control.disabled = busy; + control.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + + // -------------------------------------------------------------------------- + + /** + * Shows an error near the control which produced it + * + * @param {Element} origin The control the user interacted with + * @param {string} message The message to show + * + * @return {void} + */ + function showError(origin, message) { + var target = origin && origin.closest ? origin.closest('div, form, td') : null; + var element = (target || document).querySelector('[data-passkey-error]'); + + if (element) { + element.textContent = message; + element.hidden = false; + } + } + + // -------------------------------------------------------------------------- + + /** + * Hides any error previously shown near a control + * + * @param {Element} origin The control the user interacted with + * + * @return {void} + */ + function clearError(origin) { + var target = origin && origin.closest ? origin.closest('div, form, td') : null; + var element = (target || document).querySelector('[data-passkey-error]'); + + if (element) { + element.hidden = true; + } + } + + // -------------------------------------------------------------------------- + + /** + * Wires up the login button + * + * @return {void} + */ + function bindLoginButtons() { + var buttons = document.querySelectorAll('[data-passkey-login]'); + + Array.prototype.forEach.call(buttons, function(button) { + + /** + * The button may be wrapped in a block carrying a separator; reveal the two + * together so a rule never appears without the button it introduces. + */ + var block = button.closest ? button.closest('[data-passkey-block]') : null; + + if (block) { + block.hidden = false; + } + + button.hidden = false; + + button.addEventListener('click', function() { + + clearError(button); + abortConditional(); + + setBusy(button, true); + + var form = document.getElementById('login-form'); + var remember = form ? form.querySelector('[name="remember"]') : null; + + login({ + 'remember': remember ? remember.checked : false, + 'returnTo': button.getAttribute('data-passkey-return-to') || + (form ? form.getAttribute('data-passkey-return-to') : null) + }).catch(function(error) { + setBusy(button, false); + if (!isCancellation(error)) { + showError(button, describeError(error)); + } + }); + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Starts a conditional request so the identifier field can offer a passkey + * + * @return {void} + */ + function bindConditional() { + var field = document.querySelector('[data-passkey-conditional]'); + + if (!field) { + return; + } + + isConditionalMediationAvailable().then(function(available) { + + if (!available) { + return; + } + + var form = document.getElementById('login-form'); + + login({ + 'conditional': true, + 'returnTo': form ? form.getAttribute('data-passkey-return-to') : null + }).catch(function() { + // A cancelled or superseded conditional request is not worth reporting + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Wires up the "add a passkey" buttons + * + * @return {void} + */ + function bindRegisterButtons() { + var buttons = document.querySelectorAll('[data-passkey-register]'); + + if (!buttons.length) { + return; + } + + Array.prototype.forEach.call(buttons, function(button) { + + var isNudge = button.hasAttribute('data-passkey-nudge'); + + /** + * On the nudge, a device with nothing to enrol should not be asked at all, + * so the browser answers on the user's behalf and moves them along. + */ + var ready = isNudge ? isPlatformAuthenticatorAvailable() : Promise.resolve(isSupported()); + + ready.then(function(available) { + + if (!available) { + if (isNudge) { + var skip = document.getElementById('passkey-nudge-skip'); + if (skip) { + skip.submit(); + } + } else { + var notice = document.querySelector('[data-passkey-unsupported]'); + if (notice) { + notice.hidden = false; + } + } + return; + } + + button.hidden = false; + + button.addEventListener('click', function() { + + clearError(button); + setBusy(button, true); + + var labelField = document.querySelector('[data-passkey-label]'); + + register({'label': labelField ? labelField.value : null}) + .then(function() { + window.location.assign( + button.getAttribute('data-passkey-redirect') || window.location.href + ); + }) + .catch(function(error) { + setBusy(button, false); + if (!isCancellation(error)) { + showError(button, describeError(error)); + } + }); + }); + }); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Wires up the declarative controls the MFA driver renders + * + * The driver supplies options and a target; the ceremony runs here, the result is + * written into a hidden input, and the surrounding form is submitted. That keeps + * the driver free of JavaScript of its own. + * + * @return {void} + */ + function bindDriverControls() { + + var controls = document.querySelectorAll('[data-passkey-assert][data-options], [data-passkey-create][data-options]'); + + Array.prototype.forEach.call(controls, function(control) { + + if (!isSupported()) { + showError(control, 'This browser does not support passkeys.'); + return; + } + + control.hidden = false; + + control.addEventListener('click', function() { + + clearError(control); + control.disabled = true; + + var options; + + try { + options = JSON.parse(control.getAttribute('data-options')); + } catch (e) { + control.disabled = false; + showError(control, 'This request is malformed; please reload the page.'); + return; + } + + var ceremony = control.hasAttribute('data-passkey-create') + ? create(options) + : get(options, {}); + + ceremony + .then(function(credential) { + + var target = document.querySelector(control.getAttribute('data-target')); + var form = control.closest('form'); + + if (!target || !form) { + throw new Error('unexpected'); + } + + target.value = JSON.stringify(credential); + + var action = control.getAttribute('data-action'); + if (action) { + var field = form.querySelector('[name="action"]'); + if (field) { + field.value = action; + } + } + + form.submit(); + }) + .catch(function(error) { + control.disabled = false; + if (!isCancellation(error)) { + showError(control, describeError(error)); + } + }); + }); + }); + } + + // -------------------------------------------------------------------------- + + window.NAILS = window.NAILS || {}; + window.NAILS.PASSKEY = { + 'isSupported': isSupported, + 'isConditionalMediationAvailable': isConditionalMediationAvailable, + 'isPlatformAuthenticatorAvailable': isPlatformAuthenticatorAvailable, + 'toJSON': toJSON, + 'decodeCreationOptions': decodeCreationOptions, + 'decodeRequestOptions': decodeRequestOptions, + 'create': create, + 'get': get, + 'register': register, + 'login': login, + 'abortConditional': abortConditional + }; + + // -------------------------------------------------------------------------- + + /** + * Wires up every declarative control on the page + * + * @return {void} + */ + function boot() { + + if (!isSupported()) { + return; + } + + bindLoginButtons(); + bindConditional(); + bindRegisterButtons(); + bindDriverControls(); + } + + // -------------------------------------------------------------------------- + + /** + * DOMContentLoaded may already have fired by the time this runs - the asset is + * deferred, and a page may inject it later still - in which case waiting for the + * event would leave every control hidden. + */ + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } + +})(); diff --git a/assets/js/passkey.min.js b/assets/js/passkey.min.js new file mode 100644 index 00000000..cbdc95f4 --- /dev/null +++ b/assets/js/passkey.min.js @@ -0,0 +1 @@ +(()=>{"use strict";!function(){var e=null;function t(){return"function"==typeof window.PublicKeyCredential&&!!(navigator.credentials&&navigator.credentials.create&&navigator.credentials.get)}function n(){return t()&&"function"==typeof window.PublicKeyCredential.isConditionalMediationAvailable?window.PublicKeyCredential.isConditionalMediationAvailable().catch(function(){return!1}):Promise.resolve(!1)}function r(){return t()&&"function"==typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().catch(function(){return!1}):Promise.resolve(!1)}function a(e){for(var t=e.replace(/-/g,"+").replace(/_/g,"/");t.length%4;)t+="=";for(var n=window.atob(t),r=new Uint8Array(n.length),a=0;a form { + display: block; + flex-basis: 100%; + width: 100%; + margin: 0; + } + + /** + * The no-JS fallback needs the same full-width treatment, but deliberately + * without a `display` of its own: the browser hides `noscript` when scripting + * is enabled, and overriding that risks showing its contents as raw text. + */ + > noscript { + flex-basis: 100%; + width: 100%; + margin: 0; + } + } + + &.passkeys { + max-width: 900px; + + // The framework already makes this flex; it only needs spacing of its own + .form--inline { + gap: 0.5rem; + margin: 0; + } + + // Column headings are short labels; wrapping them reads as a mistake + thead th { + white-space: nowrap; + } + + .passkeys__date { + white-space: nowrap; + } + + /** + * A text input carries a browser default width of about twenty characters, + * which is what forces this table wider than a phone. Letting flex size it + * lets the column shrink with everything else; `min-width` has to go too, + * or the input refuses to shrink past that default. + */ + input[name="label"] { + // A small basis so the column can be narrow; it grows into what is spare + flex: 1 1 6rem; + min-width: 0; + } + + /** + * `.form--inline` is `row wrap`, which would drop the rename button onto a + * line of its own once the input grows to fill the cell. + */ + td:first-child .form--inline { + flex-wrap: nowrap; + width: 100%; + } + + /** + * Below this the four columns cannot fit - the rename input alone claims some + * twenty characters - and a table which scrolls sideways hides the controls + * people came for. Each row becomes a block instead, with every cell carrying + * the heading it lost. + */ + /** + * A last resort. If a long label or an awkward width still pushes the table + * past its container, it scrolls inside this rather than breaking the page. + */ + .passkeys__table { + overflow-x: auto; + } + + @media (max-width: 767px) { + + table, + tbody, + tr, + td { + display: block; + width: 100%; + } + + thead { + display: none; + } + + tr { + padding: var(--spacing-md, 1rem) 0; + border-bottom: 1px solid var(--color-border, #d1d5db); + } + + td { + padding: 0.25rem 0; + text-align: left; + border: 0; + } + + td[data-label]::before { + content: attr(data-label) ": "; + font-weight: var(--font-weight-bold, 600); + } + + .passkeys__actions { + margin-top: 0.5rem; + } + } + } +} + +/** + * `.form__feedback--invalid` sets `display: block`, which beats the `hidden` + * attribute's user-agent rule, so an empty placeholder would hold a row open + * permanently. Deliberately unscoped: apps rendering the helper's markup in + * their own views need it too, and the same guard protects the block wrapper + * from any app style which gives it a `display`. + */ +[data-passkey-error][hidden], +[data-passkey-block][hidden] { + display: none; +} diff --git a/auth/controllers/Login.php b/auth/controllers/Login.php index c4995833..156407fa 100644 --- a/auth/controllers/Login.php +++ b/auth/controllers/Login.php @@ -21,12 +21,14 @@ use Nails\Auth\Model\User\Password; use Nails\Auth\Resource; use Nails\Auth\Service\Authentication; +use Nails\Auth\Service\Passkey; use Nails\Auth\Service\SocialSignOn; use Nails\Auth\Validator\User\Identifier; use Nails\Auth\Validator\User\Identity; use Nails\Cdn\Service\Cdn; use Nails\Common\Exception\FactoryException; use Nails\Common\Exception\ValidationException; +use Nails\Common\Service\Asset; use Nails\Common\Service\Config; use Nails\Common\Service\FileCache; use Nails\Common\Service\FormValidation; @@ -178,13 +180,31 @@ public function index() // -------------------------------------------------------------------------- - $this->loadStyles(\Nails\Config::get('NAILS_APP_PATH') . 'application/modules/auth/views/login/form.php'); + /** @var Passkey $oPasskeyService */ + $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG); + $this->data['passkeys_enabled'] = $oPasskeyService->isEnabled(); + + // -------------------------------------------------------------------------- + + $sAppView = \Nails\Config::get('NAILS_APP_PATH') . 'application/modules/auth/views/login/form.php'; + + $this->loadStyles($sAppView); // Re-boot captcha as loadStyles clears everything if (appSetting('user_login_captcha_enabled', 'auth')) { $oCaptchaService->boot(); } + /** + * An app which has overridden the view owns its own assets; it can opt in + * with loadPasskeyAssets() from the passkey helper. + */ + if ($this->data['passkeys_enabled'] && !$this->isViewOverridden($sAppView)) { + /** @var Asset $oAsset */ + $oAsset = Factory::service('Asset'); + $oAsset->load('passkey.min.js', Constants::MODULE_SLUG, 'JS', false, true); + } + Factory::service('View') ->load([ 'structure/header/blank', @@ -256,12 +276,56 @@ protected function handleLogin(Resource\User $oUser, bool $bRemember = false, st // -------------------------------------------------------------------------- + if ($this->shouldNudgeForPasskey($oUser, $sProvider)) { + + /** @var Passkey $oPasskeyService */ + $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG); + $oPasskeyService->markNudged($sRedirectUrl); + + redirect('auth/passkeys/nudge'); + } + + // -------------------------------------------------------------------------- + redirect($sRedirectUrl); } } // -------------------------------------------------------------------------- + /** + * Whether to offer this user a passkey before sending them on their way + * + * Only a plain native login is nudged: a social login has no password to replace, + * and an MFA-challenged login never returns through here (J8). + * + * @throws FactoryException + */ + protected function shouldNudgeForPasskey(Resource\User $oUser, string $sProvider): bool + { + if ($sProvider !== 'native') { + return false; + } + + /** @var Passkey $oPasskeyService */ + $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG); + + if (!$oPasskeyService->isEnabled() || $oPasskeyService->isNudgeDismissed()) { + return false; + } + + if ($oPasskeyService->hasBeenNudged()) { + return false; + } + + /** @var \Nails\Auth\Model\User\Passkey $oPasskeyModel */ + $oPasskeyModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + return $oPasskeyModel->countForUser((int) $oUser->id) === 0; + } + + // -------------------------------------------------------------------------- + /** * Handle MFA redirect * diff --git a/auth/controllers/Passkeys.php b/auth/controllers/Passkeys.php new file mode 100644 index 00000000..b8a4da3c --- /dev/null +++ b/auth/controllers/Passkeys.php @@ -0,0 +1,220 @@ +passkeyService()->isEnabled()) { + show404(); + } + } + + // -------------------------------------------------------------------------- + + /** + * Lists, renames and removes the active user's passkeys + * + * The rename and remove actions are plain form posts so that the page keeps + * working without JavaScript; only adding a passkey needs the browser API. + * + * @throws FactoryException + */ + public function index(): void + { + /** @var Input $oInput */ + $oInput = Factory::service('Input'); + /** @var UserFeedback $oUserFeedback */ + $oUserFeedback = Factory::service('UserFeedback'); + /** @var View $oView */ + $oView = Factory::service('View'); + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + $sAction = (string) $oInput::post('action'); + + if ($sAction === 'rename' || $sAction === 'remove') { + + try { + + $oPasskey = $this->requireOwnedPasskey((int) $oInput::post('id')); + + if ($sAction === 'rename') { + + $sLabel = trim((string) $oInput::post('label')); + + if ($sLabel === '') { + throw new PasskeyException(lang('auth_passkeys_label_required')); + } + + $this->passkeyService()->rename($oPasskey, $sLabel); + $oUserFeedback->success(lang('auth_passkeys_renamed')); + + } else { + $this->passkeyService()->revoke($oPasskey); + $oUserFeedback->success(lang('auth_passkeys_removed')); + } + + } catch (PasskeyException $e) { + $oUserFeedback->error($e->getMessage()); + } + + redirect('auth/passkeys'); + } + + // -------------------------------------------------------------------------- + + $this->data['aPasskeys'] = $oModel->getByUserId((int) activeUser('id')); + + $this->oMetaData->setTitles([lang('auth_passkeys_title')]); + + $this->loadPageAssets('index'); + + $oView + ->load([ + 'structure/header/blank', + 'auth/passkeys/index', + 'structure/footer/blank', + ]); + } + + // -------------------------------------------------------------------------- + + /** + * Offers a passkey to a user who has just signed in with a password + * + * @throws FactoryException + */ + public function nudge(): void + { + /** @var Input $oInput */ + $oInput = Factory::service('Input'); + /** @var View $oView */ + $oView = Factory::service('View'); + + $oService = $this->passkeyService(); + $sReturnTo = $oService->consumeNudgeReturn() ?: (string) activeUser()->group_homepage; + + $sAction = (string) $oInput::post('action'); + + if ($sAction === 'skip' || $sAction === 'dismiss') { + + /** + * "skip" is this browser saying it has no platform authenticator, "dismiss" + * is the user saying no; either way, stop asking on this browser. + */ + $oService->setNudgeDismissed(); + + redirect($sReturnTo); + } + + // Reading it consumed it, so put it back for the form to post against + $oService->markNudged($sReturnTo); + + $this->data['sReturnTo'] = $sReturnTo; + + $this->oMetaData->setTitles([lang('auth_passkeys_nudge_title')]); + + $this->loadPageAssets('nudge'); + + $oView + ->load([ + 'structure/header/blank', + 'auth/passkeys/nudge', + 'structure/footer/blank', + ]); + } + + // -------------------------------------------------------------------------- + + /** + * Loads the styles and JavaScript this page needs, unless the app overrode it + * + * @throws FactoryException + */ + protected function loadPageAssets(string $sView): void + { + $sAppView = \Nails\Config::get('NAILS_APP_PATH') + . 'application/modules/auth/views/passkeys/' . $sView . '.php'; + + $this->loadStyles($sAppView); + + if (!$this->isViewOverridden($sAppView)) { + /** @var Asset $oAsset */ + $oAsset = Factory::service('Asset'); + $oAsset->load('passkey.min.js', Constants::MODULE_SLUG, 'JS', false, true); + } + } + + // -------------------------------------------------------------------------- + + /** + * Resolves a passkey ID, refusing anybody else's + * + * @throws FactoryException + * @throws PasskeyException + */ + protected function requireOwnedPasskey(int $iId): Resource\User\Passkey + { + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + /** @var Resource\User\Passkey|null $oPasskey */ + $oPasskey = $oModel->getById($iId); + + if (empty($oPasskey) || (int) $oPasskey->user_id !== (int) activeUser('id')) { + throw new PasskeyException(lang('auth_passkeys_not_found')); + } + + return $oPasskey; + } + + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + */ + protected function passkeyService(): PasskeyService + { + /** @var PasskeyService $oService */ + $oService = Factory::service('Passkey', Constants::MODULE_SLUG); + + return $oService; + } +} diff --git a/auth/language/english/auth_lang.php b/auth/language/english/auth_lang.php index 1ad7fab2..522ce7f8 100644 --- a/auth/language/english/auth_lang.php +++ b/auth/language/english/auth_lang.php @@ -175,3 +175,43 @@ $lang['auth_forgot_reminder'] = 'In case you forgot, your temporary password is %s. You won\'t be shown this message again.'; $lang['auth_forgot_reset_ok'] = 'Please log in using this temporary password:'; $lang['auth_forgot_action_proceed'] = 'Proceed to log in'; + +// -------------------------------------------------------------------------- + +// Passkey login +$lang['auth_login_passkey_button'] = 'Sign in with a passkey'; +$lang['auth_login_passkey_cancelled'] = 'Passkey sign in was cancelled.'; +$lang['auth_login_passkey_unsupported'] = 'This browser does not support passkeys.'; +$lang['auth_login_passkey_fail'] = 'Sorry, we could not sign you in with that passkey. Please try again, or use your password.'; + +// -------------------------------------------------------------------------- + +// Passkey management +$lang['auth_passkeys_title'] = 'Passkeys'; +$lang['auth_passkeys_intro'] = 'A passkey lets you sign in with your fingerprint, face, screen lock, or a security key, instead of a password.'; +$lang['auth_passkeys_add'] = 'Add a passkey'; +$lang['auth_passkeys_label'] = 'Name'; +$lang['auth_passkeys_label_placeholder'] = 'e.g. My laptop'; +$lang['auth_passkeys_label_help'] = 'Give this passkey a name so you can recognise it later.'; +$lang['auth_passkeys_label_required'] = 'Please give this passkey a name.'; +$lang['auth_passkeys_added'] = 'Added'; +$lang['auth_passkeys_last_used'] = 'Last used'; +$lang['auth_passkeys_never_used'] = 'Never'; +$lang['auth_passkeys_actions'] = 'Actions'; +$lang['auth_passkeys_rename'] = 'Rename'; +$lang['auth_passkeys_renamed'] = 'Your passkey was renamed.'; +$lang['auth_passkeys_remove'] = 'Remove'; +$lang['auth_passkeys_remove_confirm'] = 'Are you sure you want to remove this passkey?'; +$lang['auth_passkeys_removed'] = 'Your passkey was removed.'; +$lang['auth_passkeys_not_found'] = 'That passkey could not be found.'; +$lang['auth_passkeys_unsupported'] = 'This browser does not support passkeys, so one cannot be added here.'; +$lang['auth_passkeys_created'] = 'Your passkey was added.'; + +// -------------------------------------------------------------------------- + +// Passkey nudge +$lang['auth_passkeys_nudge_title'] = 'Sign in faster next time'; +$lang['auth_passkeys_nudge_body'] = 'Add a passkey and you can sign in with your fingerprint, face, or screen lock instead of typing your password.'; +$lang['auth_passkeys_nudge_add'] = 'Add a passkey'; +$lang['auth_passkeys_nudge_skip'] = 'Not now'; +$lang['auth_passkeys_nudge_continue'] = 'Continue'; diff --git a/auth/views/login/form.php b/auth/views/login/form.php index b612ce8e..cc7f35a5 100644 --- a/auth/views/login/form.php +++ b/auth/views/login/form.php @@ -25,7 +25,12 @@
load('auth/_components/alerts'); if ($social_signon_enabled) { @@ -86,8 +91,17 @@ break; } - $sFieldKey = 'identifier'; - $sFieldAttr = 'id="input-' . $sFieldKey . '" placeholder="' . $sFieldPlaceholder . '" class="form__control"'; + $sFieldKey = 'identifier'; + $sFieldAttr = 'id="input-' . $sFieldKey . '" placeholder="' . $sFieldPlaceholder . '" class="form__control"'; + + /** + * `webauthn` on the autocomplete token is what lets the browser offer a + * saved passkey in the field's own dropdown (conditional mediation). + */ + if (!empty($passkeys_enabled)) { + $sFieldAttr .= ' autocomplete="username webauthn" data-passkey-conditional'; + } + $sFieldValue = set_value($sFieldKey, $oInput->get('identity'), false); ?> @@ -138,6 +152,28 @@ ?>
+ + + +
+

+

+ +

+ load('auth/_components/alerts'); + + ?> +
+
+

+
+
+
+ + + +
+ + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + created))?> + last_used + ? 'title="' . toUserDatetime((string) $oPasskey->last_used) . '"' + : ''?>> + last_used + ? niceTime(strtotime((string) $oPasskey->last_used)) + : lang('auth_passkeys_never_used')?> + + + + + + +
+
+ +
diff --git a/auth/views/passkeys/nudge.php b/auth/views/passkeys/nudge.php new file mode 100644 index 00000000..c4622396 --- /dev/null +++ b/auth/views/passkeys/nudge.php @@ -0,0 +1,77 @@ + +
+
+
+

+ +

+
+
+ load('auth/_components/alerts'); + + ?> +

+ +

+
+ + + + + + + + + +
+
+
+
+isEnabled(); + } +} + +if (!function_exists('loadPasskeyAssets')) { + + /** + * Loads the passkey JavaScript, if passkeys are enabled + * + * Safe to call more than once; the asset service de-duplicates. + */ + function loadPasskeyAssets(): void + { + if (!passkeysEnabled()) { + return; + } + + /** @var Asset $oAsset */ + $oAsset = Factory::service('Asset'); + $oAsset->load('passkey.min.js', Constants::MODULE_SLUG, 'JS', false, true); + } +} + +if (!function_exists('passkeyLoginButton')) { + + /** + * Returns the "Sign in with a passkey" block: a rule, the button, and its error + * placeholder + * + * Place it below the password controls; a passkey is a different way in rather + * than a variant of the password. The whole block is hidden until the JavaScript + * establishes that the browser supports WebAuthn, so a browser which cannot use + * it is never left with a rule and nothing beneath it. + * + * @param string|null $sReturnTo Where to send the user once they are signed in + * @param string|null $sLabel Overrides the button's text + * @param string $sAttr Additional attributes for the button + * @param bool $bSeparator Whether to draw the rule above the button + */ + function passkeyLoginButton( + ?string $sReturnTo = null, + ?string $sLabel = null, + string $sAttr = 'class="btn btn--block btn--secondary"', + bool $bSeparator = true + ): string { + + if (!passkeysEnabled()) { + return ''; + } + + return sprintf( + '', + $bSeparator ? '
' : '', + htmlspecialchars((string) $sReturnTo, ENT_QUOTES), + $sAttr, + htmlspecialchars($sLabel ?: lang('auth_login_passkey_button'), ENT_QUOTES) + ); + } +} + +if (!function_exists('passkeyRegisterButton')) { + + /** + * Returns an "Add a passkey" button, plus its error placeholder + * + * @param string|null $sLabel The button's text + * @param string $sAttr Additional attributes for the button + */ + function passkeyRegisterButton( + ?string $sLabel = null, + string $sAttr = 'class="btn btn--primary"' + ): string { + + if (!passkeysEnabled()) { + return ''; + } + + return sprintf( + '' . + '', + $sAttr, + htmlspecialchars($sLabel ?: lang('auth_passkeys_add'), ENT_QUOTES) + ); + } +} diff --git a/services/services.php b/services/services.php index e313bb46..587d5ed0 100644 --- a/services/services.php +++ b/services/services.php @@ -14,6 +14,13 @@ return new Service\Authentication(); } }, + 'Passkey' => function (): Service\Passkey { + if (class_exists('\App\Auth\Service\Passkey')) { + return new \App\Auth\Service\Passkey(); + } else { + return new Service\Passkey(); + } + }, 'Session' => function (): Service\Session { if (class_exists('\App\Auth\Service\Session')) { return new \App\Auth\Service\Session(); @@ -135,6 +142,13 @@ return new Model\User\Import\Item(); } }, + 'UserPasskey' => function (): Model\User\Passkey { + if (class_exists('\App\Auth\Model\User\Passkey')) { + return new \App\Auth\Model\User\Passkey(); + } else { + return new Model\User\Passkey(); + } + }, 'UserPassword' => function (): Model\User\Password { // @todo (Pablo 2025-07-15) - this should be a service if (class_exists('\App\Auth\Model\User\Password')) { @@ -274,6 +288,13 @@ return new Resource\User\Import\Item($resource, $model); } }, + 'UserPasskey' => function ($resource, $model): Resource\User\Passkey { + if (class_exists('\App\Auth\Resource\User\Passkey')) { + return new \App\Auth\Resource\User\Passkey($resource, $model); + } else { + return new Resource\User\Passkey($resource, $model); + } + }, 'UserPasswordHistory' => function ($resource, $model): Resource\User\Password\History { if (class_exists('\App\Auth\Resource\User\Password\History')) { return new \App\Auth\Resource\User\Password\History($resource, $model); diff --git a/src/Api/Controller/Passkey.php b/src/Api/Controller/Passkey.php new file mode 100644 index 00000000..6a5d1436 --- /dev/null +++ b/src/Api/Controller/Passkey.php @@ -0,0 +1,601 @@ + + */ + public static function isAuthenticated($sHttpMethod = '', $sMethod = '') + { + if (in_array(strtolower((string) $sMethod), static::PUBLIC_METHODS, true)) { + return true; + } + + return isLoggedIn(); + } + + // -------------------------------------------------------------------------- + + /** + * Lists the active user's passkeys + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + public function getIndex(): ApiResponse + { + $this->assertEnabled(); + + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + return $this + ->response() + ->setData(array_map( + fn(Resource\User\Passkey $oPasskey): array => $this->formatPasskey($oPasskey), + $oModel->getByUserId((int) activeUser('id')) + )); + } + + // -------------------------------------------------------------------------- + + /** + * Begins registration: returns creation options and remembers the challenge + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + public function postRegister(): ApiResponse + { + $this->assertEnabled(); + $this->assertJsonRequest(); + $this->assertSameOriginRequest(); + + return $this->guard(function (): ApiResponse { + + $oOptions = $this + ->passkeyService() + ->createRegistrationOptions($this->activeUserResource()); + + return $this + ->response() + ->setData(['options' => $oOptions->options]); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Completes registration and stores the passkey + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + public function postAttest(): ApiResponse + { + $this->assertEnabled(); + $this->assertJsonRequest(); + $this->assertSameOriginRequest(); + + return $this->guard(function (): ApiResponse { + + $aData = $this->getRequestData(); + $oService = $this->passkeyService(); + $oUser = $this->activeUserResource(); + + $sChallenge = $oService->consumeChallenge( + PasskeyService::PURPOSE_REGISTRATION, + (int) $oUser->id + ); + + $oPasskey = $oService->completeRegistration( + $oUser, + $this->requireCredential($aData), + $sChallenge, + is_string($aData['label'] ?? null) ? $aData['label'] : null + ); + + // They have one now, so stop nudging them on this browser + $oService->setNudgeDismissed(); + + return $this + ->response() + ->setData(['passkey' => $this->formatPasskey($oPasskey)]); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Mints a discoverable authentication challenge for a logged out visitor + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + public function postChallenge(): ApiResponse + { + $this->assertEnabled(); + $this->assertJsonRequest(); + $this->assertSameOriginRequest(); + + return $this->guard(function (): ApiResponse { + + $oOptions = $this + ->passkeyService() + ->createAuthenticationOptions(null, PasskeyService::UV_REQUIRED); + + return $this + ->response() + ->setData(['options' => $oOptions->options]); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Verifies an assertion and logs the user in + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + public function postAssert(): ApiResponse + { + $this->assertEnabled(); + $this->assertJsonRequest(); + $this->assertSameOriginRequest(); + + return $this->guard(function (): ApiResponse { + + $aData = $this->getRequestData(); + $oService = $this->passkeyService(); + /** @var Authentication $oAuthService */ + $oAuthService = Factory::service('Authentication', Constants::MODULE_SLUG); + + $sChallenge = $oService->consumeChallenge(PasskeyService::PURPOSE_AUTHENTICATION, null); + + $oUser = $oAuthService->loginWithPasskey( + $this->requireCredential($aData), + $sChallenge, + (bool) ($aData['remember'] ?? false) + ); + + $this->welcome($oUser); + + createUserEvent('did_log_in', ['provider' => 'passkey']); + + return $this + ->response() + ->setData([ + 'redirect' => $this->sanitiseReturnTo( + is_string($aData['return_to'] ?? null) ? $aData['return_to'] : null, + $oUser + ), + ]); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Renames one of the active user's passkeys + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + public function postRename(): ApiResponse + { + $this->assertEnabled(); + $this->assertJsonRequest(); + $this->assertSameOriginRequest(); + + return $this->guard(function (): ApiResponse { + + $aData = $this->getRequestData(); + $oPasskey = $this->requireOwnedPasskey($aData); + $sLabel = trim((string) ($aData['label'] ?? '')); + + if ($sLabel === '') { + throw new ValidationException('A label is required.'); + } + + $this->passkeyService()->rename($oPasskey, $sLabel); + + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + /** @var Resource\User\Passkey|null $oUpdated */ + $oUpdated = $oModel->getById((int) $oPasskey->id); + + return $this + ->response() + ->setData(['passkey' => $this->formatPasskey($oUpdated ?? $oPasskey)]); + }); + } + + // -------------------------------------------------------------------------- + + /** + * Removes one of the active user's passkeys + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + public function postRevoke(): ApiResponse + { + $this->assertEnabled(); + $this->assertJsonRequest(); + $this->assertSameOriginRequest(); + + return $this->guard(function (): ApiResponse { + + $this->passkeyService()->revoke( + $this->requireOwnedPasskey($this->getRequestData()) + ); + + return $this->response(); + }); + } + + // -------------------------------------------------------------------------- + // Internals + // -------------------------------------------------------------------------- + + /** + * Runs an endpoint, translating passkey failures into API responses + * + * @param callable(): ApiResponse $cCallback + * + * @throws Api\Exception\ApiException + * @throws FactoryException + * @throws ValidationException + */ + protected function guard(callable $cCallback): ApiResponse + { + /** @var HttpCodes $oHttpCodes */ + $oHttpCodes = Factory::service('HttpCodes'); + + try { + + return $cCallback(); + + } catch (InvalidResponseException|ChallengeException|CredentialExistsException|OriginNotAllowedException $e) { + + throw new Api\Exception\ApiException( + $e->getMessage(), + $oHttpCodes::STATUS_BAD_REQUEST + ); + + } catch (IsLockedOutException|IsSuspendedException $e) { + + throw new Api\Exception\ApiException( + $e->getMessage(), + $oHttpCodes::STATUS_FORBIDDEN + ); + + } catch (NoUserException|InvalidCredentialsException $e) { + + // Deliberately generic: the caller must not learn which credential exists + throw new Api\Exception\ApiException( + $e->getMessage() ?: lang('auth_login_fail_general'), + $oHttpCodes::STATUS_UNAUTHORIZED + ); + + } catch (PasskeyException $e) { + + throw new Api\Exception\ApiException( + lang('auth_login_fail_general'), + $oHttpCodes::STATUS_UNAUTHORIZED + ); + } + } + + // -------------------------------------------------------------------------- + + /** + * 404s when passkeys are switched off, so the endpoints simply do not exist + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + protected function assertEnabled(): void + { + if (!$this->passkeyService()->isEnabled()) { + + /** @var HttpCodes $oHttpCodes */ + $oHttpCodes = Factory::service('HttpCodes'); + + throw new Api\Exception\ApiException( + 'Passkeys are not enabled.', + $oHttpCodes::STATUS_NOT_FOUND + ); + } + } + + // -------------------------------------------------------------------------- + + /** + * Requires a JSON body + * + * A form post cannot set this content type cross-origin without a preflight, so + * requiring it keeps these endpoints out of reach of a simple cross-site form. + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + protected function assertJsonRequest(): void + { + /** @var Input $oInput */ + $oInput = Factory::service('Input'); + /** @var HttpCodes $oHttpCodes */ + $oHttpCodes = Factory::service('HttpCodes'); + + $sContentType = strtolower(trim(explode(';', (string) $oInput::header('Content-Type'))[0])); + + if ($sContentType !== 'application/json' || !empty($oInput->post())) { + throw new Api\Exception\ApiException( + 'This endpoint requires a JSON request body.', + $oHttpCodes::STATUS_BAD_REQUEST + ); + } + } + + // -------------------------------------------------------------------------- + + /** + * Requires the request to have come from this site + * + * There is no CSRF token on API routes, so this leans on the browser's own + * fetch metadata, falling back to Origin/Referer where it is not sent. + * + * @throws Api\Exception\ApiException + * @throws FactoryException + */ + protected function assertSameOriginRequest(): void + { + /** @var Input $oInput */ + $oInput = Factory::service('Input'); + /** @var HttpCodes $oHttpCodes */ + $oHttpCodes = Factory::service('HttpCodes'); + + $sFetchSite = strtolower((string) $oInput::header('Sec-Fetch-Site')); + + if ($sFetchSite !== '') { + if (in_array($sFetchSite, ['same-origin', 'none'], true)) { + return; + } + + throw new Api\Exception\ApiException( + 'Cross-site requests are not permitted.', + $oHttpCodes::STATUS_BAD_REQUEST + ); + } + + $sHost = (string) parse_url((string) Config::get('BASE_URL'), PHP_URL_HOST); + $sSource = (string) ($oInput::header('Origin') ?: $oInput::header('Referer')); + + if ($sSource === '' || strtolower((string) parse_url($sSource, PHP_URL_HOST)) !== strtolower($sHost)) { + throw new Api\Exception\ApiException( + 'Cross-site requests are not permitted.', + $oHttpCodes::STATUS_BAD_REQUEST + ); + } + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aData + * + * @return array + * @throws InvalidResponseException + */ + protected function requireCredential(array $aData): array + { + $mCredential = $aData['credential'] ?? null; + + if (is_object($mCredential)) { + $mCredential = (array) $mCredential; + } + + if (!is_array($mCredential)) { + throw new InvalidResponseException('No credential was supplied.'); + } + + return $mCredential; + } + + // -------------------------------------------------------------------------- + + /** + * Resolves a passkey ID from the request, refusing anybody else's + * + * @param array $aData + * + * @throws FactoryException + * @throws ValidationException + */ + protected function requireOwnedPasskey(array $aData): Resource\User\Passkey + { + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + /** @var Resource\User\Passkey|null $oPasskey */ + $oPasskey = $oModel->getById((int) ($aData['id'] ?? 0)); + + if (empty($oPasskey) || (int) $oPasskey->user_id !== (int) activeUser('id')) { + throw new ValidationException('Unrecognised passkey.'); + } + + return $oPasskey; + } + + // -------------------------------------------------------------------------- + + /** + * Restricts a return URL to this site, falling back to the group homepage + * + * @throws FactoryException + */ + protected function sanitiseReturnTo(?string $sReturnTo, Resource\User $oUser): string + { + $sFallback = (string) ($oUser->group_homepage ?: siteUrl()); + $sReturnTo = trim((string) $sReturnTo); + + if ($sReturnTo === '') { + return $sFallback; + } + + // A protocol-relative URL would leave the site while looking relative + if (str_starts_with($sReturnTo, '//')) { + return $sFallback; + } + + $sHost = parse_url($sReturnTo, PHP_URL_HOST); + + if (empty($sHost)) { + return siteUrl(ltrim($sReturnTo, '/')); + } + + $sBaseHost = (string) parse_url((string) Config::get('BASE_URL'), PHP_URL_HOST); + + return strtolower((string) $sHost) === strtolower($sBaseHost) + ? $sReturnTo + : $sFallback; + } + + // -------------------------------------------------------------------------- + + /** + * Adds the same welcome message a password login would have shown + * + * @throws FactoryException + */ + protected function welcome(Resource\User $oUser): void + { + /** @var UserFeedback $oUserFeedback */ + $oUserFeedback = Factory::service('UserFeedback'); + + if ($oUser->last_login) { + $oUserFeedback->success(lang( + 'auth_login_ok_welcome', + [$oUser->first_name, toUserDatetime($oUser->last_login)] + )); + } else { + $oUserFeedback->success(lang('auth_login_ok_welcome_notime', [$oUser->first_name])); + } + } + + // -------------------------------------------------------------------------- + + /** + * @return array + */ + protected function formatPasskey(Resource\User\Passkey $oPasskey): array + { + return [ + 'id' => (int) $oPasskey->id, + 'label' => $oPasskey->label, + 'created' => (string) $oPasskey->created, + 'last_used' => $oPasskey->last_used ? (string) $oPasskey->last_used : null, + 'is_backed_up' => $oPasskey->is_backed_up, + 'transports' => $oPasskey->getTransports(), + 'aaguid' => $oPasskey->aaguid, + ]; + } + + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + */ + protected function passkeyService(): PasskeyService + { + /** @var PasskeyService $oService */ + $oService = Factory::service('Passkey', Constants::MODULE_SLUG); + + return $oService; + } + + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + */ + protected function activeUserResource(): Resource\User + { + /** @var Resource\User $oUser */ + $oUser = activeUser(); + + return $oUser; + } + + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + */ + protected function response(): ApiResponse + { + /** @var ApiResponse $oResponse */ + $oResponse = Factory::factory('ApiResponse', Api\Constants::MODULE_SLUG); + + return $oResponse; + } +} diff --git a/src/Auth/Admin/User/Tab/Passkeys.php b/src/Auth/Admin/User/Tab/Passkeys.php new file mode 100644 index 00000000..62eca4e5 --- /dev/null +++ b/src/Auth/Admin/User/Tab/Passkeys.php @@ -0,0 +1,139 @@ +load( + ['Accounts/edit/inc-passkeys'], + [ + 'oUser' => $oUser, + 'aPasskeys' => $oModel->getByUserId((int) $oUser->id), + 'bEnabled' => $oService->isEnabled(), + ], + true + ); + } + + // -------------------------------------------------------------------------- + + /** + * Returns additional markup, outside of the main
element + */ + public function getAdditionalMarkup(User $oUser): string + { + return ''; + } + + // -------------------------------------------------------------------------- + + /** + * Returns an array of validation rules compatible with Validator objects + * + * @return array + */ + public function getValidationRules(User $oUser): array + { + return []; + } + + // -------------------------------------------------------------------------- + + /** + * Revokes the checked passkeys + * + * Revocation happens here rather than through the returned array because a passkey + * is a row of its own, not a column on the user. + * + * @param array $aPost The POST array + * + * @return array + * @throws FactoryException + * @throws ModelException + */ + public function getPostData(User $oUser, array $aPost): array + { + /** @var PasskeyService $oService */ + $oService = Factory::service('Passkey', Constants::MODULE_SLUG); + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + $aRevoke = array_filter((array) ($aPost['passkey_revoke'] ?? [])); + + foreach ($aRevoke as $mId) { + + /** @var Resource\User\Passkey|null $oPasskey */ + $oPasskey = $oModel->getById((int) $mId); + + // Only ever this user's own; a stray ID must not revoke somebody else's + if (empty($oPasskey) || (int) $oPasskey->user_id !== (int) $oUser->id) { + continue; + } + + $oService->revoke($oPasskey, ['by_admin' => (int) activeUser('id')]); + } + + return []; + } +} diff --git a/src/Controller/Base.php b/src/Controller/Base.php index b73d809b..881eb0ec 100644 --- a/src/Controller/Base.php +++ b/src/Controller/Base.php @@ -43,11 +43,26 @@ public function __construct() protected function loadStyles($sView) { // Test if a view has been provided by the app - if (!is_file($sView)) { + if (!$this->isViewOverridden($sView)) { $oAsset = Factory::service('Asset'); $oAsset->clear(); $oAsset->load('nails.min.css', \Nails\Common\Constants::MODULE_SLUG); $oAsset->load('styles.min.css', Constants::MODULE_SLUG); } } + + // -------------------------------------------------------------------------- + + /** + * Whether the app has supplied its own copy of a view + * + * An app which has taken a view over owns its markup and its assets, so the + * module must not assume its own are wanted. + * + * @param string $sView Absolute path to the view the app would provide + */ + protected function isViewOverridden(string $sView): bool + { + return is_file($sView); + } } diff --git a/src/Database/Migration/Migration19.php b/src/Database/Migration/Migration19.php new file mode 100644 index 00000000..d89bcc14 --- /dev/null +++ b/src/Database/Migration/Migration19.php @@ -0,0 +1,69 @@ +query( + <<<'EOT' + CREATE TABLE `{{NAILS_DB_PREFIX}}user_passkey` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `label` varchar(100) NOT NULL DEFAULT '', + `credential_id` varchar(1400) CHARACTER SET ascii NOT NULL, + `public_key` text NOT NULL, + `sign_count` int unsigned NOT NULL DEFAULT 0, + `aaguid` char(36) NULL DEFAULT NULL, + `attestation_format` varchar(30) NULL DEFAULT NULL, + `transports` varchar(255) NULL DEFAULT NULL, + `is_discoverable` tinyint(1) unsigned NULL DEFAULT NULL, + `is_backup_eligible` tinyint(1) unsigned NOT NULL DEFAULT 0, + `is_backed_up` tinyint(1) unsigned NOT NULL DEFAULT 0, + `user_handle` varchar(64) CHARACTER SET ascii NOT NULL, + `last_used` datetime NULL DEFAULT NULL, + `last_used_ip` varchar(45) NULL DEFAULT NULL, + `created` datetime NOT NULL, + `created_by` int unsigned NULL DEFAULT NULL, + `modified` datetime NOT NULL, + `modified_by` int unsigned NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `credential_id` (`credential_id`), + KEY `user_id` (`user_id`), + KEY `created_by` (`created_by`), + KEY `modified_by` (`modified_by`), + CONSTRAINT `{{NAILS_DB_PREFIX}}user_passkey_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE CASCADE, + CONSTRAINT `{{NAILS_DB_PREFIX}}user_passkey_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL, + CONSTRAINT `{{NAILS_DB_PREFIX}}user_passkey_ibfk_3` FOREIGN KEY (`modified_by`) REFERENCES `{{NAILS_DB_PREFIX}}user` (`id`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + EOT + ); + } +} diff --git a/src/Exception/Passkey/ChallengeException.php b/src/Exception/Passkey/ChallengeException.php new file mode 100644 index 00000000..38224ab2 --- /dev/null +++ b/src/Exception/Passkey/ChallengeException.php @@ -0,0 +1,14 @@ +unsetUserData('id') ->unsetUserData('email') - ->unsetUserData('group_id'); + ->unsetUserData('group_id') + // Covers the MFA fail-closed path; logout() destroys the session outright + ->unsetUserData(Authentication::SESSION_KEY_LOGIN_METHOD); // Set the flag $this->bIsLoggedIn = false; @@ -1287,6 +1290,7 @@ public function update($iUserId = null, ?array $aData = null): bool // -------------------------------------------------------------------------- // Update the password if it has been supplied + $bPasswordUpdated = false; if (!empty($sNewPassword)) { $bIsTemp = (bool) getFromArray('temp_pw', $aData); if (!$oUserPasswordModel->change($iUserId, $sNewPassword, $bIsTemp)) { @@ -1295,6 +1299,7 @@ public function update($iUserId = null, ?array $aData = null): bool $oUserPasswordModel->lastError() ); } + $bPasswordUpdated = true; } // -------------------------------------------------------------------------- @@ -1441,6 +1446,7 @@ protected function getUserId($iUserId = null) public function setCacheUser($iUserId, $aData = []) { $this->unsetCacheUser($iUserId); + /** @var Resource\User|null $oUser */ $oUser = $this->getById($iUserId); if (empty($oUser)) { @@ -1496,7 +1502,9 @@ public function emailAdd( $iUserId = empty($iUserId) ? $this->activeUser('id') : $iUserId; $sEmail = trim(strtolower($sEmail)); - $oUser = $this->getById($iUserId); + + /** @var Resource\User|null $oUser */ + $oUser = $this->getById($iUserId); if (empty($oUser)) { $this->setError('Invalid User ID'); @@ -2251,7 +2259,7 @@ public function create(array $data = [], $bSendWelcome = true) $aUserData['group_id'] = $data['group_id']; } - /** @var Resource\User\Group $oGroup */ + /** @var Resource\User\Group|null $oGroup */ $oGroup = $oUserGroupModel->getById($aUserData['group_id']); if (empty($oGroup)) { @@ -2840,6 +2848,10 @@ protected function mergeUpdateColumns(array $aMap, int $iKeepId, array $aMergeId foreach ($aMap as $sTable => $aColumns) { + if (empty($aColumns)) { + continue; + } + foreach ($aColumns as $sColumn) { $oDb->set($sColumn, $iKeepId); } @@ -2849,12 +2861,20 @@ protected function mergeUpdateColumns(array $aMap, int $iKeepId, array $aMergeId $oDb->set('is_primary', false); } - $oDb->where_in($sColumn, $aMergeIds); + /** + * @todo (Pablo 2026-09-10) - this filters on the last mapped column only, + * which is wrong for a table referencing the user more than once (e.g. both + * created_by and modified_by). Previously it relied on the loop variable + * leaking; made explicit here without changing the behaviour. + */ + $sFilterColumn = end($aColumns); + + $oDb->where_in($sFilterColumn, $aMergeIds); if (!$oDb->update($sTable)) { throw new MergeException(sprintf( 'Failed to migrate column "%s" in table "%s"', - $sColumn, + $sFilterColumn, $sTable )); } diff --git a/src/Model/User/Group.php b/src/Model/User/Group.php index 91eb6b5e..48996f25 100644 --- a/src/Model/User/Group.php +++ b/src/Model/User/Group.php @@ -161,6 +161,7 @@ public function getDefaultGroupId() */ public function changeUserGroup(array $aUserIds, $iNewGroupId) { + /** @var \Nails\Auth\Resource\User\Group|null $oGroup */ $oGroup = $this->getById($iNewGroupId); if (empty($oGroup)) { $this->setError('"' . $iNewGroupId . '" is not a valid group ID.'); diff --git a/src/Model/User/Passkey.php b/src/Model/User/Passkey.php new file mode 100644 index 00000000..97cf3b83 --- /dev/null +++ b/src/Model/User/Passkey.php @@ -0,0 +1,112 @@ +hasOne('user', 'User', Constants::MODULE_SLUG); + } + + // -------------------------------------------------------------------------- + + /** + * Returns all the passkeys registered by a user, oldest first + * + * @return Resource\User\Passkey[] + * @throws FactoryException + * @throws ModelException + */ + public function getByUserId(int $iUserId): array + { + /** @var Resource\User\Passkey[] $aPasskeys */ + $aPasskeys = $this->getAll([ + new Where('user_id', $iUserId), + ]); + + return $aPasskeys; + } + + // -------------------------------------------------------------------------- + + /** + * Returns a passkey by its base64url encoded credential ID + * + * @throws FactoryException + * @throws ModelException + */ + public function getByCredentialId(string $sCredentialId): ?Resource\User\Passkey + { + if ($sCredentialId === '') { + return null; + } + + /** @var Resource\User\Passkey|null $oPasskey */ + $oPasskey = $this->getAll([ + new Where('credential_id', $sCredentialId), + ])[0] ?? null; + + return $oPasskey; + } + + // -------------------------------------------------------------------------- + + /** + * Returns how many passkeys a user has registered + * + * @throws FactoryException + * @throws ModelException + */ + public function countForUser(int $iUserId): int + { + return $this->countAll([ + new Where('user_id', $iUserId), + ]); + } + + // -------------------------------------------------------------------------- + + /** + * Records a successful use of a passkey + * + * @throws FactoryException + * @throws ModelException + */ + public function recordUse(int $iId, int $iSignCount, string $sIp): bool + { + /** @var \DateTime $oNow */ + $oNow = Factory::factory('DateTime'); + + return $this->update($iId, [ + 'sign_count' => $iSignCount, + 'last_used' => $oNow->format('Y-m-d H:i:s'), + 'last_used_ip' => $sIp ?: null, + ]); + } +} diff --git a/src/Model/User/Password.php b/src/Model/User/Password.php index 64823b6b..dbbacc1a 100644 --- a/src/Model/User/Password.php +++ b/src/Model/User/Password.php @@ -129,7 +129,7 @@ public function change(int $iUserId, string $sPassword, bool $bIsTemp = false) // -------------------------------------------------------------------------- - /** @var Resource\User $oUser */ + /** @var Resource\User|null $oUser */ $oUser = $oUserModel->getById($iUserId); if (empty($oUser)) { $this->setError('Invalid user ID.'); @@ -897,7 +897,7 @@ public function validateToken($sCode, $bGenerateNewPw) /** @var User\Email $oUserModel */ $oUserEmailModel = Factory::model('UserEmail', Constants::MODULE_SLUG); - /** @var Resource\User $oUser */ + /** @var Resource\User|null $oUser */ $oUser = $oUserModel ->skipCache() ->getFirst([ diff --git a/src/Resource/User.php b/src/Resource/User.php index dbb24860..f5752480 100644 --- a/src/Resource/User.php +++ b/src/Resource/User.php @@ -55,7 +55,7 @@ class User extends Entity /** @var string */ public $remember_code; - /** @var DateTime */ + /** @var DateTime|null Null until the user's second login */ public $last_login; /** @var DateTime */ diff --git a/src/Resource/User/Passkey.php b/src/Resource/User/Passkey.php new file mode 100644 index 00000000..f6232728 --- /dev/null +++ b/src/Resource/User/Passkey.php @@ -0,0 +1,106 @@ +user) && !empty($this->user_id)) { + + /** @var \Nails\Auth\Model\User $oModel */ + $oModel = Factory::model('User', Constants::MODULE_SLUG); + /** @var User|null $oUser */ + $oUser = $oModel->getById($this->user_id); + + $this->user = $oUser; + } + + return $this->user; + } + + // -------------------------------------------------------------------------- + + /** + * Returns the transports the authenticator reported at registration + * + * @return string[] + */ + public function getTransports(): array + { + if (empty($this->transports)) { + return []; + } + + $aTransports = json_decode($this->transports, true); + + return is_array($aTransports) + ? array_values(array_filter($aTransports, 'is_string')) + : []; + } + + // -------------------------------------------------------------------------- + + /** + * Returns the credential's public key in PEM format + */ + public function getPublicKeyPem(): string + { + return $this->public_key; + } + + // -------------------------------------------------------------------------- + + /** + * Names the model of authenticator this passkey lives in, if it is a known one + * + * @throws FactoryException + */ + public function getAuthenticatorName(): ?string + { + /** @var Service $oService */ + $oService = Factory::service('Passkey', Constants::MODULE_SLUG); + + return $oService->getAuthenticatorName($this->aaguid); + } + +} diff --git a/src/Routes.php b/src/Routes.php index d6b69c85..63d3bf23 100644 --- a/src/Routes.php +++ b/src/Routes.php @@ -20,7 +20,7 @@ class Routes implements RouteGenerator * Returns an array of routes for this module * @return array */ - public static function generate() + public static function generate(): array { return [ 'auth/override/login_as/(.+)/(.+)' => 'auth/sessionOverride/login_as', diff --git a/src/Service/Authentication.php b/src/Service/Authentication.php index 5e6e24bd..d28bd5bf 100644 --- a/src/Service/Authentication.php +++ b/src/Service/Authentication.php @@ -23,6 +23,7 @@ use Nails\Auth\Exception\Login\RequiresPasswordResetExpiredException; use Nails\Auth\Exception\Login\RequiresPasswordResetTempException; use Nails\Auth\Exception\Login\RequiresSocialException; +use Nails\Auth\Exception\Passkey\PasskeyException; use Nails\Auth\Model\User\Password; use Nails\Auth\Resource; use Nails\Common\Exception\Encrypt\DecodeException; @@ -35,6 +36,7 @@ use Nails\Common\Service\Database; use Nails\Common\Service\Encrypt; use Nails\Common\Service\Input; +use Nails\Common\Service\Session; use Nails\Common\Traits\ErrorHandling; use Nails\Environment; use Nails\Factory; @@ -79,6 +81,21 @@ class Authentication */ const LOCKOUT_DURATION = 300; + /** + * The session key recording how the current session authenticated + * + * @var string + */ + const SESSION_KEY_LOGIN_METHOD = 'auth-login-method'; + + /** + * Login methods reported by the above signal + * + * @var string + */ + const LOGIN_METHOD_PASSWORD = 'password'; + const LOGIN_METHOD_PASSKEY = 'passkey'; + // -------------------------------------------------------------------------- /** @@ -238,6 +255,12 @@ public function loginWithCredentials( $oUserModel->setRememberCookie($oUser->id, $oUser->password, $oUser->email); } + /** + * Must be recorded before setLoginData(), which fires USER_LOG_IN synchronously; + * listeners on that event read this signal. + */ + $this->recordLoginMethod(static::LOGIN_METHOD_PASSWORD, (int) $oUser->id); + $oUserModel->setLoginData($oUser->id); $oUserModel->updateLastLogin($oUser->id); @@ -246,6 +269,173 @@ public function loginWithCredentials( // -------------------------------------------------------------------------- + /** + * Log a user in using a passkey + * + * Mirrors loginWithCredentials(), less the password: the temporary and expired + * password checks are bypassed because no password took part in this login (J5). + * + * @param array $aAssertion The PublicKeyCredential.toJSON() payload + * @param string $sChallenge The challenge the assertion answers + * @param bool $bRemember Whether to 'remember' the user or not + * + * @throws FactoryException + * @throws InvalidCredentialsException + * @throws IsLockedOutException + * @throws IsSuspendedException + * @throws ModelException + * @throws NailsException + * @throws NoUserException + * @throws ReflectionException + */ + public function loginWithPasskey( + array $aAssertion, + string $sChallenge, + bool $bRemember = false + ): Resource\User { + + // Delay execution for a moment (reduces brute force efficiently) + if (Environment::not(Environment::ENV_DEV)) { + usleep(static::BRUTE_FORCE_DELAY); + } + + // -------------------------------------------------------------------------- + + /** @var \Nails\Auth\Model\User $oUserModel */ + $oUserModel = Factory::model('User', Constants::MODULE_SLUG); + /** @var Passkey $oPasskeyService */ + $oPasskeyService = Factory::service('Passkey', Constants::MODULE_SLUG); + + $oPasskey = $oPasskeyService->findByAssertion($aAssertion); + $oUser = $oPasskey ? $oPasskey->user() : null; + + /** + * An unrecognised credential cannot be attributed to a user, so there is nobody + * to rate limit; it gets the delay above and the same message as every other + * failure (J6). + */ + if (empty($oUser)) { + throw new NoUserException(lang('auth_login_fail_general')); + + } elseif ($this->isLockedOut($oUser)) { + + $oUserModel->incrementFailedLogin($oUser->id, static::LOCKOUT_DURATION); + $this->logLoginFailure($oUser, 'brute_force_block_in_affect'); + + throw new IsLockedOutException( + lang('auth_login_fail_blocked', ceil(static::LOCKOUT_DURATION / 60)) + ); + + } elseif ($this->isSuspended($oUser)) { + + $oUserModel->incrementFailedLogin($oUser->id, static::LOCKOUT_DURATION); + $this->logLoginFailure($oUser, 'suspended'); + + throw new IsSuspendedException( + lang('auth_login_fail_suspended') + ); + } + + try { + + $oPasskeyService->completeAuthentication($aAssertion, $sChallenge, $oUser, true); + + } catch (PasskeyException $e) { + + $oUserModel->incrementFailedLogin($oUser->id, static::LOCKOUT_DURATION); + $this->logLoginFailure($oUser, 'passkey_invalid'); + + throw new InvalidCredentialsException(lang('auth_login_fail_general')); + } + + // Successful login means we can forget about failures + $oUserModel->resetFailedLogin($oUser->id); + + /** + * The assertion above required user verification, so this login satisfies an + * MFA challenge; see the MFA module's requiresAuthentication(). + */ + $this->recordLoginMethod(static::LOGIN_METHOD_PASSKEY, (int) $oUser->id, true); + + // Note: a no-op for users without a password, as it always has been (J10) + if ($bRemember) { + $oUserModel->setRememberCookie($oUser->id, $oUser->password, $oUser->email); + } + + $oUserModel->setLoginData($oUser->id); + $oUserModel->updateLastLogin($oUser->id); + + return $oUser; + } + + // -------------------------------------------------------------------------- + + /** + * Records how the current session authenticated + * + * Must be called before setLoginData() so that USER_LOG_IN listeners can read it. + * + * @throws FactoryException + */ + public function recordLoginMethod(string $sMethod, int $iUserId, bool $bUserVerified = false): void + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + $oSession->setUserData(static::SESSION_KEY_LOGIN_METHOD, (object) [ + 'method' => $sMethod, + 'user_id' => $iUserId, + 'user_verified' => $bUserVerified, + 'at' => time(), + ]); + } + + // -------------------------------------------------------------------------- + + /** + * Returns the signal describing how the current session authenticated, if any + * + * @throws FactoryException + */ + public function getLoginMethod(): ?stdClass + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + + $mSignal = $oSession->getUserData(static::SESSION_KEY_LOGIN_METHOD); + + return is_object($mSignal) ? (object) $mSignal : null; + } + + // -------------------------------------------------------------------------- + + /** + * Forgets how the current session authenticated + * + * @throws FactoryException + */ + public function clearLoginMethod(): void + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + $oSession->unsetUserData(static::SESSION_KEY_LOGIN_METHOD); + } + + // -------------------------------------------------------------------------- + + /** + * Whether the current session authenticated with a user-verified credential + * + * @throws FactoryException + */ + public function isLoginUserVerified(): bool + { + $oSignal = $this->getLoginMethod(); + + return !empty($oSignal->user_verified); + } + + // -------------------------------------------------------------------------- + /** * Determines whether a user is currently locked out * diff --git a/src/Service/Passkey.php b/src/Service/Passkey.php new file mode 100644 index 00000000..884598e8 --- /dev/null +++ b/src/Service/Passkey.php @@ -0,0 +1,1235 @@ + + */ + const AUTHENTICATORS = [ + // Verified against a live registration + 'bada5566-a7aa-401f-bd96-45619a55120d' => '1Password', + // From the community AAGUID register + 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd' => 'iCloud Keychain', + 'adce0002-35bc-c60a-648b-0b25f1f05503' => 'Chrome on Mac', + 'ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4' => 'Google Password Manager', + 'd548826e-79b4-db40-a3d8-11116f7e8349' => 'Bitwarden', + '531126d6-e717-415c-9320-3d9aa6981239' => 'Dashlane', + '08987058-cadc-4b81-b6e1-30de50dcbe96' => 'Windows Hello', + '9ddd1817-af5a-4672-a2b9-3e3dd95000a9' => 'Windows Hello', + '6028b017-b1d4-4c02-b4b3-afcdafc96bb2' => 'Windows Hello', + 'cb69481e-8ff7-4039-93ec-0a2729a154a8' => 'YubiKey 5', + 'ee882879-721c-4913-9775-3dfcce97072a' => 'YubiKey 5', + 'fa2b99dc-9e39-4257-8f92-4a30d23c4118' => 'YubiKey 5 NFC', + '2fc0579f-8113-47ea-b116-bb5a8db9202a' => 'YubiKey 5 NFC', + ]; + + /** + * The cookie which suppresses the post-login nudge + * + * A cookie rather than user meta because the capability being nudged towards + * belongs to the browser, not to the account. + */ + const COOKIE_NUDGE = 'nails-passkey-nudge'; + const COOKIE_NUDGE_TTL = 31536000; + + /** + * Set once this session has been offered a passkey, so it is only offered once + */ + const SESSION_KEY_NUDGED = 'auth-passkey-nudged'; + + /** + * Where to send the user once they have answered the nudge + */ + const SESSION_KEY_NUDGE_RETURN = 'auth-passkey-nudge-return'; + + // -------------------------------------------------------------------------- + // Configuration + // -------------------------------------------------------------------------- + + /** + * Whether passkeys are available to this app + * + * @throws FactoryException + */ + public function isEnabled(): bool + { + return (bool) appSetting(static::SETTING_ENABLED, static::SETTING_GROUP) + && extension_loaded('openssl'); + } + + // -------------------------------------------------------------------------- + + /** + * Throws if passkeys are not enabled + * + * @throws FactoryException + * @throws NotEnabledException + */ + public function assertEnabled(): void + { + if (!$this->isEnabled()) { + throw new NotEnabledException('Passkeys are not enabled.'); + } + } + + // -------------------------------------------------------------------------- + + /** + * Returns the Relying Party ID: the config override, else the host of BASE_URL + * + * A single host per app is assumed; the RP ID must be the site's registrable + * domain or a suffix of it, and cannot include a scheme, port, or path. + */ + public function getRpId(): string + { + $sConfigured = Config::get(static::CONFIG_RP_ID); + if (is_string($sConfigured) && trim($sConfigured) !== '') { + return strtolower(trim($sConfigured)); + } + + return $this->extractHost((string) Config::get('BASE_URL')) ?? 'localhost'; + } + + // -------------------------------------------------------------------------- + + /** + * Returns the Relying Party name shown by the authenticator + */ + public function getRpName(): string + { + $sAppName = Config::get('APP_NAME'); + + return is_string($sAppName) && trim($sAppName) !== '' + ? trim($sAppName) + : $this->getRpId(); + } + + // -------------------------------------------------------------------------- + + /** + * Returns every origin a ceremony may legitimately be performed from + * + * @return string[] + */ + public function getAllowedOrigins(): array + { + $aOrigins = [ + $this->extractOrigin((string) Config::get('BASE_URL')), + $this->extractOrigin((string) Config::get('SECURE_BASE_URL')), + ]; + + $mConfigured = Config::get(static::CONFIG_ALLOWED_ORIGINS); + if (is_string($mConfigured)) { + $mConfigured = [$mConfigured]; + } + + if (is_array($mConfigured)) { + foreach ($mConfigured as $mOrigin) { + if (is_string($mOrigin)) { + $aOrigins[] = $this->extractOrigin($mOrigin); + } + } + } + + return array_values(array_unique(array_filter($aOrigins))); + } + + // -------------------------------------------------------------------------- + + /** + * Rejects an origin which is not one of the app's own + * + * The library performs a suffix match on the host alone; this is an exact match + * on the whole origin, so a lookalike host or a downgraded scheme is refused. + * + * @throws OriginNotAllowedException + */ + public function assertOriginAllowed(string $sOrigin): void + { + $sNormalised = $this->extractOrigin($sOrigin); + + if ($sNormalised === null || !in_array($sNormalised, $this->getAllowedOrigins(), true)) { + throw new OriginNotAllowedException( + sprintf('"%s" is not a permitted origin.', $sOrigin) + ); + } + } + + // -------------------------------------------------------------------------- + + /** + * Derives the opaque, stable user handle presented to the authenticator + * + * Deriving it rather than storing it keeps the `user` table untouched; see J3. + * The handle is written to each passkey row at registration, so verification + * still succeeds for existing credentials after a PRIVATE_KEY rotation. + */ + public function deriveUserHandle(Resource\User $oUser): string + { + return $this->base64UrlEncode( + hash_hmac( + 'sha256', + static::USER_HANDLE_PREFIX . $oUser->id, + (string) Config::get('PRIVATE_KEY'), + true + ) + ); + } + + /** + * Names the model of authenticator a passkey lives in, if it is a known one + * + * @return string|null Null when the authenticator did not say, or is not listed + */ + public function getAuthenticatorName(?string $sAaguid): ?string + { + if (empty($sAaguid)) { + return null; + } + + $aConfigured = Config::get(static::CONFIG_AUTHENTICATORS); + $aKnown = array_merge( + static::AUTHENTICATORS, + is_array($aConfigured) ? $aConfigured : [] + ); + + $sName = $aKnown[strtolower($sAaguid)] ?? null; + + return is_string($sName) && $sName !== '' ? $sName : null; + } + + + // -------------------------------------------------------------------------- + // Ceremony construction and verification; no database, no session + // -------------------------------------------------------------------------- + + /** + * The single point at which the WebAuthn library is constructed + * + * A new instance is returned every time: the library mints and caches one + * challenge per instance, so sharing one would re-issue a spent challenge. + * + * @throws PasskeyException + */ + public function getWebAuthn(): WebAuthn + { + try { + + return new WebAuthn( + $this->getRpName(), + $this->getRpId(), + [static::ATTESTATION_FORMAT_NONE], + true + ); + + } catch (WebAuthnException $e) { + throw new PasskeyException( + 'Failed to initialise WebAuthn: ' . $e->getMessage(), + $e->getCode(), + $e + ); + } + } + + // -------------------------------------------------------------------------- + + /** + * Builds the options for a registration ceremony + * + * @param string[] $aExcludeCredentialIds base64url credential IDs already registered + * + * @return stdClass&object{options: stdClass, challenge: string} + * @throws PasskeyException + */ + public function buildRegistrationOptions(Resource\User $oUser, array $aExcludeCredentialIds = []): stdClass + { + $oWebAuthn = $this->getWebAuthn(); + + try { + + $oArgs = $oWebAuthn->getCreateArgs( + $this->base64UrlDecode($this->deriveUserHandle($oUser)), + $this->getUserName($oUser), + $this->getUserDisplayName($oUser), + static::TIMEOUT, + 'preferred', + static::UV_PREFERRED, + null, + array_map( + fn(string $sId): string => $this->base64UrlDecode($sId), + array_values($aExcludeCredentialIds) + ) + ); + + } catch (WebAuthnException $e) { + throw new PasskeyException($e->getMessage(), $e->getCode(), $e); + } + + // Ask the browser whether the credential ended up discoverable + $oArgs->publicKey->extensions->credProps = true; + + return (object) [ + 'options' => $oArgs->publicKey, + 'challenge' => $this->base64UrlEncode($oWebAuthn->getChallenge()->getBinaryString()), + ]; + } + + // -------------------------------------------------------------------------- + + /** + * Builds the options for an authentication ceremony + * + * An empty allow-list produces a discoverable ("passwordless") request. + * + * @param string[] $aAllowCredentialIds base64url credential IDs + * + * @return stdClass&object{options: stdClass, challenge: string} + * @throws PasskeyException + */ + public function buildAuthenticationOptions( + array $aAllowCredentialIds = [], + string $sUserVerification = self::UV_REQUIRED + ): stdClass { + + $oWebAuthn = $this->getWebAuthn(); + + try { + + $oArgs = $oWebAuthn->getGetArgs( + array_map( + fn(string $sId): string => $this->base64UrlDecode($sId), + array_values($aAllowCredentialIds) + ), + static::TIMEOUT, + true, + true, + true, + true, + true, + $sUserVerification + ); + + } catch (WebAuthnException $e) { + throw new PasskeyException($e->getMessage(), $e->getCode(), $e); + } + + return (object) [ + 'options' => $oArgs->publicKey, + 'challenge' => $this->base64UrlEncode($oWebAuthn->getChallenge()->getBinaryString()), + ]; + } + + // -------------------------------------------------------------------------- + + /** + * Verifies a registration response and returns the data to store + * + * @param array $aClientResponse PublicKeyCredential.toJSON() output + * + * @return stdClass&object{credential_id: string, public_key: string, sign_count: int, aaguid: string|null, + * attestation_format: string|null, transports: string[], is_discoverable: bool|null, + * is_backup_eligible: bool, is_backed_up: bool, user_present: bool, + * user_verified: bool} + * @throws InvalidResponseException + * @throws OriginNotAllowedException + * @throws PasskeyException + * @throws VerificationFailedException + */ + public function verifyRegistration(array $aClientResponse, string $sChallenge): stdClass + { + $aResponse = $this->extractResponse($aClientResponse); + $sClientDataJson = $this->requireBinary($aResponse, 'clientDataJSON'); + $sAttestationObject = $this->requireBinary($aResponse, 'attestationObject'); + + $this->assertOriginAllowed( + $this->readOriginFromClientData($sClientDataJson) + ); + + $oWebAuthn = $this->getWebAuthn(); + + try { + + $oData = $oWebAuthn->processCreate( + $sClientDataJson, + $sAttestationObject, + $this->base64UrlDecode($sChallenge), + false, + true, + false, + false + ); + + } catch (WebAuthnException $e) { + throw new VerificationFailedException($e->getMessage(), $e->getCode(), $e); + } + + return (object) [ + 'credential_id' => $this->base64UrlEncode($oData->credentialId), + 'public_key' => (string) $oData->credentialPublicKey, + 'sign_count' => (int) ($oData->signatureCounter ?? 0), + 'aaguid' => $this->formatAaguid((string) $oData->AAGUID), + 'attestation_format' => $oData->attestationFormat ? (string) $oData->attestationFormat : null, + 'transports' => $this->extractTransports($aResponse), + 'is_discoverable' => $this->extractIsDiscoverable($aClientResponse), + 'is_backup_eligible' => (bool) $oData->isBackupEligible, + 'is_backed_up' => (bool) $oData->isBackedUp, + 'user_present' => (bool) $oData->userPresent, + 'user_verified' => (bool) $oData->userVerified, + ]; + } + + // -------------------------------------------------------------------------- + + /** + * Verifies an authentication response and returns the authenticator's sign count + * + * Returns 0 for authenticators which do not maintain a counter; the caller must + * only persist a count which has grown. + * + * @param array $aClientResponse PublicKeyCredential.toJSON() output + * + * @throws InvalidResponseException + * @throws OriginNotAllowedException + * @throws PasskeyException + * @throws VerificationFailedException + */ + public function verifyAuthentication( + array $aClientResponse, + string $sChallenge, + string $sPublicKeyPem, + int $iPrevSignCount, + string $sExpectedUserHandle, + bool $bRequireUserVerification + ): int { + + $aResponse = $this->extractResponse($aClientResponse); + $sClientDataJson = $this->requireBinary($aResponse, 'clientDataJSON'); + $sAuthenticatorData = $this->requireBinary($aResponse, 'authenticatorData'); + $sSignature = $this->requireBinary($aResponse, 'signature'); + + $this->assertOriginAllowed( + $this->readOriginFromClientData($sClientDataJson) + ); + + /** + * The library does not read the user handle back, so it is checked here: a + * handle which is present but belongs to somebody else must not authenticate. + */ + $sUserHandle = $aResponse['userHandle'] ?? null; + if (is_string($sUserHandle) && $sUserHandle !== '') { + if (!hash_equals($sExpectedUserHandle, $sUserHandle)) { + throw new VerificationFailedException('The credential belongs to a different user.'); + } + } + + $oWebAuthn = $this->getWebAuthn(); + + try { + + $oWebAuthn->processGet( + $sClientDataJson, + $sAuthenticatorData, + $sSignature, + $sPublicKeyPem, + $this->base64UrlDecode($sChallenge), + $iPrevSignCount, + $bRequireUserVerification, + true + ); + + } catch (WebAuthnException $e) { + throw new VerificationFailedException($e->getMessage(), $e->getCode(), $e); + } + + return $oWebAuthn->getSignatureCounter() ?? 0; + } + + // -------------------------------------------------------------------------- + // Orchestration; these touch the database and the session + // -------------------------------------------------------------------------- + + /** + * Mints registration options for a user, excluding the passkeys they already have + * + * @return stdClass&object{options: stdClass, challenge: string} + * @throws FactoryException + * @throws ModelException + * @throws NotEnabledException + * @throws PasskeyException + */ + public function createRegistrationOptions(Resource\User $oUser): stdClass + { + $this->assertEnabled(); + + $aExisting = array_map( + fn(Resource\User\Passkey $oPasskey): string => $oPasskey->credential_id, + $this->getModel()->getByUserId((int) $oUser->id) + ); + + $oOptions = $this->buildRegistrationOptions($oUser, $aExisting); + + $this->rememberChallenge( + static::PURPOSE_REGISTRATION, + $oOptions->challenge, + (int) $oUser->id + ); + + return $oOptions; + } + + // -------------------------------------------------------------------------- + + /** + * Verifies a registration response and stores the resulting passkey + * + * @param array $aClientResponse + * + * @throws CredentialExistsException + * @throws FactoryException + * @throws InvalidResponseException + * @throws ModelException + * @throws NotEnabledException + * @throws OriginNotAllowedException + * @throws PasskeyException + * @throws VerificationFailedException + */ + public function completeRegistration( + Resource\User $oUser, + array $aClientResponse, + string $sChallenge, + ?string $sLabel = null + ): Resource\User\Passkey { + + $this->assertEnabled(); + + $oModel = $this->getModel(); + $oResult = $this->verifyRegistration($aClientResponse, $sChallenge); + + if ($oModel->getByCredentialId($oResult->credential_id)) { + throw new CredentialExistsException('This passkey is already registered.'); + } + + /** @var Resource\User\Passkey|false $oPasskey */ + $oPasskey = $oModel->create( + [ + 'user_id' => (int) $oUser->id, + 'label' => $this->normaliseLabel( + trim((string) $sLabel) !== '' + ? $sLabel + // Naming it after the authenticator beats a list of "Passkey" + : $this->getAuthenticatorName($oResult->aaguid) + ), + 'credential_id' => $oResult->credential_id, + 'public_key' => $oResult->public_key, + 'sign_count' => $oResult->sign_count, + 'aaguid' => $oResult->aaguid, + 'attestation_format' => $oResult->attestation_format, + 'transports' => $oResult->transports ? json_encode($oResult->transports) : null, + 'is_discoverable' => $oResult->is_discoverable, + 'is_backup_eligible' => $oResult->is_backup_eligible, + 'is_backed_up' => $oResult->is_backed_up, + 'user_handle' => $this->deriveUserHandle($oUser), + ], + true + ); + + if (empty($oPasskey)) { + throw new PasskeyException('Failed to save the passkey.'); + } + + createUserEvent( + 'did_add_passkey', + ['passkey_id' => $oPasskey->id, 'label' => $oPasskey->label], + null, + (int) $oUser->id + ); + + return $oPasskey; + } + + // -------------------------------------------------------------------------- + + /** + * Mints authentication options + * + * Passing no user produces a discoverable request, which is what the passwordless + * button and the conditional-UI autofill both use. + * + * @return stdClass&object{options: stdClass, challenge: string} + * @throws FactoryException + * @throws ModelException + * @throws NotEnabledException + * @throws PasskeyException + */ + public function createAuthenticationOptions( + ?Resource\User $oUser = null, + string $sUserVerification = self::UV_REQUIRED + ): stdClass { + + $this->assertEnabled(); + + $aAllow = $oUser + ? array_map( + fn(Resource\User\Passkey $oPasskey): string => $oPasskey->credential_id, + $this->getModel()->getByUserId((int) $oUser->id) + ) + : []; + + $oOptions = $this->buildAuthenticationOptions($aAllow, $sUserVerification); + + $this->rememberChallenge( + static::PURPOSE_AUTHENTICATION, + $oOptions->challenge, + $oUser ? (int) $oUser->id : null + ); + + return $oOptions; + } + + // -------------------------------------------------------------------------- + + /** + * Looks up the passkey an assertion refers to + * + * @param array $aClientResponse + * + * @throws FactoryException + * @throws ModelException + */ + public function findByAssertion(array $aClientResponse): ?Resource\User\Passkey + { + $sId = $aClientResponse['rawId'] ?? $aClientResponse['id'] ?? null; + + return is_string($sId) + ? $this->getModel()->getByCredentialId($sId) + : null; + } + + // -------------------------------------------------------------------------- + + /** + * Verifies an assertion against a stored passkey + * + * @param array $aClientResponse + * + * @throws FactoryException + * @throws InvalidResponseException + * @throws ModelException + * @throws NotEnabledException + * @throws OriginNotAllowedException + * @throws PasskeyException + * @throws UnknownCredentialException + * @throws VerificationFailedException + */ + public function completeAuthentication( + array $aClientResponse, + string $sChallenge, + ?Resource\User $oRestrictToUser = null, + bool $bRequireUserVerification = true + ): Resource\User\Passkey { + + $this->assertEnabled(); + + $oPasskey = $this->findByAssertion($aClientResponse); + + if (empty($oPasskey)) { + throw new UnknownCredentialException('Unrecognised passkey.'); + + } elseif ($oRestrictToUser && (int) $oPasskey->user_id !== (int) $oRestrictToUser->id) { + throw new UnknownCredentialException('Unrecognised passkey.'); + } + + $iSignCount = $this->verifyAuthentication( + $aClientResponse, + $sChallenge, + $oPasskey->getPublicKeyPem(), + $oPasskey->sign_count, + $oPasskey->user_handle, + $bRequireUserVerification + ); + + /** @var Input $oInput */ + $oInput = Factory::service('Input'); + + /** + * Authenticators which do not keep a counter always report zero; writing that + * back would be indistinguishable from a clone, so only a count which grew is + * persisted. The timestamp is recorded either way. + */ + $this->getModel()->recordUse( + (int) $oPasskey->id, + max($iSignCount, $oPasskey->sign_count), + (string) $oInput->ipAddress() + ); + + return $oPasskey; + } + + // -------------------------------------------------------------------------- + + /** + * Renames a passkey + * + * @throws FactoryException + * @throws ModelException + */ + public function rename(Resource\User\Passkey $oPasskey, string $sLabel): bool + { + return $this->getModel()->update((int) $oPasskey->id, [ + 'label' => $this->normaliseLabel($sLabel), + ]); + } + + // -------------------------------------------------------------------------- + + /** + * Removes a passkey + * + * @param array $aEventData additional context to log against the event + * + * @throws FactoryException + * @throws ModelException + */ + public function revoke(Resource\User\Passkey $oPasskey, array $aEventData = []): bool + { + $bResult = $this->getModel()->delete((int) $oPasskey->id); + + if ($bResult) { + createUserEvent( + 'did_remove_passkey', + array_merge( + ['passkey_id' => $oPasskey->id, 'label' => $oPasskey->label], + $aEventData + ), + null, + (int) $oPasskey->user_id + ); + } + + return $bResult; + } + + // -------------------------------------------------------------------------- + // Adoption nudge + // -------------------------------------------------------------------------- + + /** + * Stops this browser being nudged again + * + * @throws FactoryException + */ + public function setNudgeDismissed(): void + { + /** @var Cookie $oCookie */ + $oCookie = Factory::service('Cookie'); + $oCookie->write( + static::COOKIE_NUDGE, + 'dismissed', + static::COOKIE_NUDGE_TTL, + '/', + '', + Functions::isPageSecure(), + true, + 'Lax' + ); + } + + // -------------------------------------------------------------------------- + + /** + * Whether this browser has already been nudged away + * + * @throws FactoryException + */ + public function isNudgeDismissed(): bool + { + /** @var Cookie $oCookie */ + $oCookie = Factory::service('Cookie'); + + return !empty($oCookie->read(static::COOKIE_NUDGE)); + } + + /** + * Records that this session has been offered a passkey, and where to return to + * + * @throws FactoryException + */ + public function markNudged(string $sReturnTo): void + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + $oSession + ->setUserData(static::SESSION_KEY_NUDGED, true) + ->setUserData(static::SESSION_KEY_NUDGE_RETURN, $sReturnTo); + } + + // -------------------------------------------------------------------------- + + /** + * Whether this session has already been offered a passkey + * + * @throws FactoryException + */ + public function hasBeenNudged(): bool + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + + return !empty($oSession->getUserData(static::SESSION_KEY_NUDGED)); + } + + // -------------------------------------------------------------------------- + + /** + * Reads, and forgets, where the nudge should return the user to + * + * @throws FactoryException + */ + public function consumeNudgeReturn(): ?string + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + + $mReturnTo = $oSession->getUserData(static::SESSION_KEY_NUDGE_RETURN); + $oSession->unsetUserData(static::SESSION_KEY_NUDGE_RETURN); + + return is_string($mReturnTo) && $mReturnTo !== '' ? $mReturnTo : null; + } + + // -------------------------------------------------------------------------- + // Challenge store + // -------------------------------------------------------------------------- + + /** + * Remembers the challenge for the ceremony's second request + * + * Ordinary session data rather than flash data: the ceremony spans two requests + * and the flash would be gone by the time the response comes back. + * + * @throws FactoryException + */ + public function rememberChallenge(string $sPurpose, string $sChallenge, ?int $iUserId): void + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + $oSession->setUserData(static::SESSION_KEY_CHALLENGE, (object) [ + 'purpose' => $sPurpose, + 'challenge' => $sChallenge, + 'user_id' => $iUserId, + 'at' => time(), + ]); + } + + // -------------------------------------------------------------------------- + + /** + * Spends the stored challenge, removing it whether or not it turns out to be valid + * + * @throws ChallengeException + * @throws FactoryException + */ + public function consumeChallenge(string $sPurpose, ?int $iUserId): string + { + /** @var Session $oSession */ + $oSession = Factory::service('Session'); + + $oStored = $oSession->getUserData(static::SESSION_KEY_CHALLENGE); + $oSession->unsetUserData(static::SESSION_KEY_CHALLENGE); + + if (!is_object($oStored) || !isset($oStored->challenge, $oStored->purpose, $oStored->at)) { + throw new ChallengeException('No passkey challenge is in progress; please try again.'); + + } elseif ($oStored->purpose !== $sPurpose) { + throw new ChallengeException('The passkey challenge was issued for something else.'); + + } elseif (($oStored->user_id ?? null) !== $iUserId) { + throw new ChallengeException('The passkey challenge was issued for a different user.'); + + } elseif ((time() - (int) $oStored->at) > static::CHALLENGE_TTL) { + throw new ChallengeException('The passkey challenge has expired; please try again.'); + } + + return (string) $oStored->challenge; + } + + // -------------------------------------------------------------------------- + // Encoding + // -------------------------------------------------------------------------- + + /** + * Encodes binary as base64url, without padding + */ + public function base64UrlEncode(string $sBinary): string + { + return rtrim(strtr(base64_encode($sBinary), '+/', '-_'), '='); + } + + // -------------------------------------------------------------------------- + + /** + * Decodes base64url, tolerating missing padding + */ + public function base64UrlDecode(string $sEncoded): string + { + return (string) base64_decode( + str_pad(strtr($sEncoded, '-_', '+/'), (int) (ceil(strlen($sEncoded) / 4) * 4), '='), + false + ); + } + + // -------------------------------------------------------------------------- + // Internals + // -------------------------------------------------------------------------- + + /** + * @throws FactoryException + */ + protected function getModel(): PasskeyModel + { + /** @var PasskeyModel $oModel */ + $oModel = Factory::model('UserPasskey', Constants::MODULE_SLUG); + + return $oModel; + } + + // -------------------------------------------------------------------------- + + /** + * Pulls the `response` object out of a client payload + * + * @param array $aClientResponse + * + * @return array + * @throws InvalidResponseException + */ + protected function extractResponse(array $aClientResponse): array + { + $mResponse = $aClientResponse['response'] ?? null; + + if (is_object($mResponse)) { + $mResponse = (array) $mResponse; + } + + if (!is_array($mResponse)) { + throw new InvalidResponseException('The passkey response is missing or malformed.'); + } + + return $mResponse; + } + + // -------------------------------------------------------------------------- + + /** + * Reads and decodes a required base64url field + * + * @param array $aResponse + * + * @throws InvalidResponseException + */ + protected function requireBinary(array $aResponse, string $sKey): string + { + $mValue = $aResponse[$sKey] ?? null; + + if (!is_string($mValue) || $mValue === '') { + throw new InvalidResponseException( + sprintf('The passkey response is missing "%s".', $sKey) + ); + } + + $sDecoded = $this->base64UrlDecode($mValue); + + if ($sDecoded === '') { + throw new InvalidResponseException( + sprintf('The passkey response field "%s" is not valid base64url.', $sKey) + ); + } + + return $sDecoded; + } + + // -------------------------------------------------------------------------- + + /** + * Reads the origin out of the client data + * + * @throws InvalidResponseException + */ + protected function readOriginFromClientData(string $sClientDataJson): string + { + $mClientData = json_decode($sClientDataJson); + + if (!is_object($mClientData) || !isset($mClientData->origin) || !is_string($mClientData->origin)) { + throw new InvalidResponseException('The passkey client data is malformed.'); + } + + return $mClientData->origin; + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aResponse + * + * @return string[] + */ + protected function extractTransports(array $aResponse): array + { + $mTransports = $aResponse['transports'] ?? null; + + return is_array($mTransports) + ? array_values(array_filter($mTransports, 'is_string')) + : []; + } + + // -------------------------------------------------------------------------- + + /** + * Reads credProps.rk; null when the browser did not say either way + * + * @param array $aClientResponse + */ + protected function extractIsDiscoverable(array $aClientResponse): ?bool + { + $mExtensions = $aClientResponse['clientExtensionResults'] ?? null; + + if (is_object($mExtensions)) { + $mExtensions = (array) $mExtensions; + } + + if (!is_array($mExtensions)) { + return null; + } + + $mCredProps = $mExtensions['credProps'] ?? null; + + if (is_object($mCredProps)) { + $mCredProps = (array) $mCredProps; + } + + if (!is_array($mCredProps) || !array_key_exists('rk', $mCredProps)) { + return null; + } + + return (bool) $mCredProps['rk']; + } + + // -------------------------------------------------------------------------- + + /** + * Formats a raw 16 byte AAGUID as a UUID; null when the authenticator withheld it + */ + protected function formatAaguid(string $sBinary): ?string + { + if (strlen($sBinary) !== 16 || trim($sBinary, "\x00") === '') { + return null; + } + + $sHex = bin2hex($sBinary); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($sHex, 0, 8), + substr($sHex, 8, 4), + substr($sHex, 12, 4), + substr($sHex, 16, 4), + substr($sHex, 20, 12) + ); + } + + // -------------------------------------------------------------------------- + + /** + * Normalises a user-supplied label, falling back to a generic one + */ + protected function normaliseLabel(?string $sLabel): string + { + $sLabel = trim((string) $sLabel); + + if ($sLabel === '') { + return 'Passkey'; + } + + return mb_substr($sLabel, 0, 100); + } + + // -------------------------------------------------------------------------- + + /** + * The name the authenticator shows for the account + */ + protected function getUserName(Resource\User $oUser): string + { + $sName = (string) ($oUser->email ?: $oUser->username ?: $oUser->id); + + return mb_substr($sName, 0, 64); + } + + // -------------------------------------------------------------------------- + + /** + * The human-friendly name the authenticator shows for the account + */ + protected function getUserDisplayName(Resource\User $oUser): string + { + $sName = trim((string) $oUser->name); + + if ($sName === '') { + $sName = trim(sprintf('%s %s', $oUser->first_name, $oUser->last_name)); + } + + return mb_substr($sName !== '' ? $sName : $this->getUserName($oUser), 0, 64); + } + + // -------------------------------------------------------------------------- + + /** + * Returns the lowercase host of a URL + */ + protected function extractHost(string $sUrl): ?string + { + $sHost = parse_url($sUrl, PHP_URL_HOST); + + return is_string($sHost) && $sHost !== '' ? strtolower($sHost) : null; + } + + // -------------------------------------------------------------------------- + + /** + * Reduces a URL to its origin: scheme://host[:port], with the default port dropped + */ + protected function extractOrigin(string $sUrl): ?string + { + $sUrl = trim($sUrl); + + if ($sUrl === '') { + return null; + } + + $aParts = parse_url($sUrl); + $sScheme = isset($aParts['scheme']) ? strtolower($aParts['scheme']) : null; + $sHost = isset($aParts['host']) ? strtolower($aParts['host']) : null; + + if (empty($sScheme) || empty($sHost)) { + return null; + } + + $iPort = $aParts['port'] ?? null; + $bIsDefault = ($sScheme === 'https' && $iPort === 443) || ($sScheme === 'http' && $iPort === 80); + + return $sScheme . '://' . $sHost . ($iPort && !$bIsDefault ? ':' . $iPort : ''); + } +} diff --git a/tests/Api/PasskeyControllerTest.php b/tests/Api/PasskeyControllerTest.php new file mode 100644 index 00000000..2788b356 --- /dev/null +++ b/tests/Api/PasskeyControllerTest.php @@ -0,0 +1,74 @@ + + */ + public static function publicMethods(): array + { + return [ + 'challenge' => ['challenge'], + 'assert' => ['assert'], + 'challenge, cased' => ['Challenge'], + 'assert, cased' => ['ASSERT'], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * @return array + */ + public static function protectedMethods(): array + { + return [ + 'index' => ['index'], + 'register' => ['register'], + 'attest' => ['attest'], + 'rename' => ['rename'], + 'revoke' => ['revoke'], + 'anything else' => ['somethingUnknown'], + 'no method given' => [''], + ]; + } + + // -------------------------------------------------------------------------- + + #[DataProvider('publicMethods')] + public function test_the_login_endpoints_are_reachable_when_logged_out(string $sMethod): void + { + self::assertTrue(Passkey::isAuthenticated('POST', $sMethod)); + } + + // -------------------------------------------------------------------------- + + /** + * These tests run without a session, so isLoggedIn() is false; anything which is + * not on the public list must therefore be refused. + */ + #[DataProvider('protectedMethods')] + public function test_the_management_endpoints_are_refused_when_logged_out(string $sMethod): void + { + self::assertFalse(Passkey::isAuthenticated('POST', $sMethod)); + } + + // -------------------------------------------------------------------------- + + public function test_the_public_list_is_exactly_the_two_login_endpoints(): void + { + self::assertSame(['challenge', 'assert'], Passkey::PUBLIC_METHODS); + } +} diff --git a/tests/RoutesTest.php b/tests/RoutesTest.php new file mode 100644 index 00000000..b10ad694 --- /dev/null +++ b/tests/RoutesTest.php @@ -0,0 +1,44 @@ +` directly, and the API router has its + * own dispatch. This pins that no route was added by accident, because a stray + * pattern here would shadow the existing auth URLs. + * + * @covers \Nails\Auth\Routes + */ +class RoutesTest extends TestCase +{ + public function test_the_generated_routes_are_unchanged(): void + { + self::assertSame( + [ + 'auth/override/login_as/(.+)/(.+)' => 'auth/sessionOverride/login_as', + 'auth/password/forgotten(/(.+))?' => 'auth/PasswordForgotten/$2', + 'auth/password/reset/(\d+)/(.+)' => 'auth/PasswordReset/$1/$2', + 'auth/mfa/device/(\d+)/(.+)/(.+)(/(.+))?' => 'auth/MfaDevice', + 'auth/mfa/question/(\d+)/(.+)/(.+)(/(.+))?' => 'auth/MfaQuestion', + ], + Routes::generate() + ); + } + + // -------------------------------------------------------------------------- + + public function test_no_route_shadows_the_passkey_urls(): void + { + foreach (array_keys(Routes::generate()) as $sPattern) { + self::assertSame( + 0, + preg_match('#^' . $sPattern . '$#', 'auth/passkeys'), + sprintf('The route "%s" would capture auth/passkeys', $sPattern) + ); + } + } +} diff --git a/tests/Service/AuthenticationLoginMethodTest.php b/tests/Service/AuthenticationLoginMethodTest.php new file mode 100644 index 00000000..c1cb8f27 --- /dev/null +++ b/tests/Service/AuthenticationLoginMethodTest.php @@ -0,0 +1,113 @@ +oService = $oService; + + $this->oService->clearLoginMethod(); + } + + // -------------------------------------------------------------------------- + + protected function tearDown(): void + { + $this->oService->clearLoginMethod(); + } + + // -------------------------------------------------------------------------- + + public function test_there_is_no_signal_until_a_login_records_one(): void + { + self::assertNull($this->oService->getLoginMethod()); + self::assertFalse($this->oService->isLoginUserVerified()); + } + + // -------------------------------------------------------------------------- + + public function test_a_recorded_signal_reads_back_intact(): void + { + $iBefore = time(); + + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 42, true); + + $oSignal = $this->oService->getLoginMethod(); + + self::assertNotNull($oSignal); + self::assertSame(Authentication::LOGIN_METHOD_PASSKEY, $oSignal->method); + self::assertSame(42, $oSignal->user_id); + self::assertTrue($oSignal->user_verified); + self::assertGreaterThanOrEqual($iBefore, $oSignal->at); + } + + // -------------------------------------------------------------------------- + + /** + * A password login is never user-verified in the WebAuthn sense, so it must not + * be mistaken for one which can stand in for a second factor. + */ + public function test_a_password_login_is_not_recorded_as_user_verified(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSWORD, 42); + + self::assertSame( + Authentication::LOGIN_METHOD_PASSWORD, + $this->oService->getLoginMethod()->method + ); + self::assertFalse($this->oService->isLoginUserVerified()); + } + + // -------------------------------------------------------------------------- + + public function test_a_user_verified_signal_is_reported_as_such(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 7, true); + + self::assertTrue($this->oService->isLoginUserVerified()); + } + + // -------------------------------------------------------------------------- + + public function test_recording_a_second_login_replaces_the_first(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 7, true); + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSWORD, 8); + + $oSignal = $this->oService->getLoginMethod(); + + self::assertSame(Authentication::LOGIN_METHOD_PASSWORD, $oSignal->method); + self::assertSame(8, $oSignal->user_id); + self::assertFalse($oSignal->user_verified); + } + + // -------------------------------------------------------------------------- + + public function test_clearing_the_signal_removes_it(): void + { + $this->oService->recordLoginMethod(Authentication::LOGIN_METHOD_PASSKEY, 7, true); + $this->oService->clearLoginMethod(); + + self::assertNull($this->oService->getLoginMethod()); + self::assertFalse($this->oService->isLoginUserVerified()); + } +} diff --git a/tests/Service/PasskeyTest.php b/tests/Service/PasskeyTest.php new file mode 100644 index 00000000..bd6ce9d4 --- /dev/null +++ b/tests/Service/PasskeyTest.php @@ -0,0 +1,864 @@ + */ + private array $aConfig = []; + + // -------------------------------------------------------------------------- + + protected function setUp(): void + { + /** @var Passkey $oService */ + $oService = Factory::service('Passkey', Constants::MODULE_SLUG); + $this->oService = $oService; + + foreach ([ + 'BASE_URL', + 'SECURE_BASE_URL', + 'APP_NAME', + Passkey::CONFIG_RP_ID, + Passkey::CONFIG_ALLOWED_ORIGINS, + Passkey::CONFIG_AUTHENTICATORS, + ] as $sKey) { + $this->aConfig[$sKey] = Config::get($sKey); + } + + Config::set('BASE_URL', self::ORIGIN . '/'); + Config::set('SECURE_BASE_URL', self::ORIGIN . '/'); + Config::set('APP_NAME', 'Test App'); + Config::set(Passkey::CONFIG_RP_ID, null); + Config::set(Passkey::CONFIG_ALLOWED_ORIGINS, null); + Config::set(Passkey::CONFIG_AUTHENTICATORS, null); + } + + // -------------------------------------------------------------------------- + + protected function tearDown(): void + { + foreach ($this->aConfig as $sKey => $mValue) { + Config::set($sKey, $mValue); + } + } + + // -------------------------------------------------------------------------- + + private function user(int $iId = 1): User + { + return new User((object) [ + 'id' => $iId, + 'email' => 'user' . $iId . '@example.com', + 'username' => 'user' . $iId, + 'first_name' => 'Test', + 'last_name' => 'User', + 'name' => 'Test User', + ]); + } + + // -------------------------------------------------------------------------- + // Configuration + // -------------------------------------------------------------------------- + + public function test_the_rp_id_defaults_to_the_host_of_the_base_url(): void + { + self::assertSame(self::RP_ID, $this->oService->getRpId()); + } + + // -------------------------------------------------------------------------- + + public function test_the_rp_id_can_be_overridden_by_config(): void + { + Config::set(Passkey::CONFIG_RP_ID, 'Sub.Example.Com'); + + // Lowercased: the RP ID is compared against a host, which is case insensitive + self::assertSame('sub.example.com', $this->oService->getRpId()); + } + + // -------------------------------------------------------------------------- + + public function test_the_rp_name_falls_back_to_the_rp_id(): void + { + self::assertSame('Test App', $this->oService->getRpName()); + + Config::set('APP_NAME', ''); + + self::assertSame(self::RP_ID, $this->oService->getRpName()); + } + + // -------------------------------------------------------------------------- + + public function test_the_allowed_origins_cover_both_base_urls_without_duplicates(): void + { + Config::set('SECURE_BASE_URL', 'https://secure.example.com/'); + + self::assertSame( + ['https://example.com', 'https://secure.example.com'], + $this->oService->getAllowedOrigins() + ); + } + + // -------------------------------------------------------------------------- + + public function test_the_allowed_origins_can_be_extended_by_config(): void + { + Config::set(Passkey::CONFIG_ALLOWED_ORIGINS, ['https://app.example.com:8443/somewhere']); + + // Reduced to an origin: a path cannot form part of one + self::assertContains('https://app.example.com:8443', $this->oService->getAllowedOrigins()); + } + + // -------------------------------------------------------------------------- + + public function test_a_default_port_is_dropped_when_normalising_an_origin(): void + { + Config::set('BASE_URL', 'https://example.com:443/'); + + self::assertSame(['https://example.com'], $this->oService->getAllowedOrigins()); + } + + // -------------------------------------------------------------------------- + + /** + * The library's own origin check is a suffix match on the host alone, which a + * lookalike domain satisfies; this is the check that actually protects us. + */ + public function test_a_lookalike_host_is_not_an_allowed_origin(): void + { + $this->expectException(OriginNotAllowedException::class); + + $this->oService->assertOriginAllowed('https://evil-example.com'); + } + + // -------------------------------------------------------------------------- + + public function test_a_subdomain_of_an_allowed_origin_is_not_itself_allowed(): void + { + $this->expectException(OriginNotAllowedException::class); + + $this->oService->assertOriginAllowed('https://evil.example.com'); + } + + // -------------------------------------------------------------------------- + + public function test_a_downgraded_scheme_is_not_an_allowed_origin(): void + { + $this->expectException(OriginNotAllowedException::class); + + $this->oService->assertOriginAllowed('http://example.com'); + } + + // -------------------------------------------------------------------------- + + public function test_the_apps_own_origin_is_allowed(): void + { + $this->oService->assertOriginAllowed(self::ORIGIN); + + self::assertTrue(true); + } + + // -------------------------------------------------------------------------- + // User handles + // -------------------------------------------------------------------------- + + public function test_a_user_handle_is_stable_for_a_user(): void + { + self::assertSame( + $this->oService->deriveUserHandle($this->user(7)), + $this->oService->deriveUserHandle($this->user(7)) + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_user_handle_differs_between_users(): void + { + self::assertNotSame( + $this->oService->deriveUserHandle($this->user(7)), + $this->oService->deriveUserHandle($this->user(8)) + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_user_handle_is_thirty_two_bytes(): void + { + $sHandle = $this->oService->deriveUserHandle($this->user()); + + self::assertSame(32, strlen($this->oService->base64UrlDecode($sHandle))); + + // It must also fit the column it is stored in + self::assertLessThanOrEqual(64, strlen($sHandle)); + } + + // -------------------------------------------------------------------------- + // Ceremony options + // -------------------------------------------------------------------------- + + public function test_registration_options_describe_the_relying_party_and_the_user(): void + { + $oResult = $this->oService->buildRegistrationOptions($this->user()); + $oOptions = $oResult->options; + + self::assertSame(self::RP_ID, $oOptions->rp->id); + self::assertSame('Test App', $oOptions->rp->name); + self::assertSame('user1@example.com', $oOptions->user->name); + self::assertSame('Test User', $oOptions->user->displayName); + self::assertSame( + $this->oService->deriveUserHandle($this->user()), + $oOptions->user->id->jsonSerialize() + ); + } + + // -------------------------------------------------------------------------- + + public function test_registration_options_ask_for_a_discoverable_credential_without_attestation(): void + { + $oOptions = $this->oService->buildRegistrationOptions($this->user())->options; + + self::assertSame('preferred', $oOptions->authenticatorSelection->residentKey); + self::assertSame('preferred', $oOptions->authenticatorSelection->userVerification); + self::assertSame('none', $oOptions->attestation); + self::assertTrue($oOptions->extensions->credProps); + self::assertSame(Passkey::TIMEOUT * 1000, $oOptions->timeout); + } + + // -------------------------------------------------------------------------- + + public function test_registration_options_exclude_the_credentials_already_registered(): void + { + $sExisting = $this->oService->base64UrlEncode('an-existing-credential'); + + $oOptions = $this->oService->buildRegistrationOptions($this->user(), [$sExisting])->options; + + self::assertCount(1, $oOptions->excludeCredentials); + self::assertSame($sExisting, $oOptions->excludeCredentials[0]->id->jsonSerialize()); + } + + // -------------------------------------------------------------------------- + + public function test_each_ceremony_mints_a_fresh_challenge(): void + { + self::assertNotSame( + $this->oService->buildRegistrationOptions($this->user())->challenge, + $this->oService->buildRegistrationOptions($this->user())->challenge + ); + } + + // -------------------------------------------------------------------------- + + public function test_authentication_options_without_an_allow_list_are_discoverable(): void + { + $oOptions = $this->oService->buildAuthenticationOptions()->options; + + self::assertObjectNotHasProperty('allowCredentials', $oOptions); + self::assertSame('required', $oOptions->userVerification); + self::assertSame(self::RP_ID, $oOptions->rpId); + } + + // -------------------------------------------------------------------------- + + public function test_authentication_options_carry_the_allow_list_they_are_given(): void + { + $sId = $this->oService->base64UrlEncode('a-credential'); + + $oOptions = $this->oService + ->buildAuthenticationOptions([$sId], Passkey::UV_PREFERRED) + ->options; + + self::assertCount(1, $oOptions->allowCredentials); + self::assertSame($sId, $oOptions->allowCredentials[0]->id->jsonSerialize()); + self::assertSame('preferred', $oOptions->userVerification); + } + + // -------------------------------------------------------------------------- + // Registration verification + // -------------------------------------------------------------------------- + + public function test_a_genuine_registration_is_accepted_and_described(): void + { + $oFixture = new WebAuthnFixture(null, hex2bin('0102030405060708090a0b0c0d0e0f10')); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $oResult = $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT + | WebAuthnFixture::FLAG_USER_VERIFIED + | WebAuthnFixture::FLAG_BACKUP_ELIGIBLE + | WebAuthnFixture::FLAG_BACKED_UP + | WebAuthnFixture::FLAG_ATTESTED_DATA + ), + $sChallenge + ); + + self::assertSame($oFixture->getCredentialIdBase64Url(), $oResult->credential_id); + self::assertSame(trim($oFixture->getPublicKeyPem()), trim($oResult->public_key)); + self::assertSame('none', $oResult->attestation_format); + self::assertSame('01020304-0506-0708-090a-0b0c0d0e0f10', $oResult->aaguid); + self::assertSame(['internal', 'hybrid'], $oResult->transports); + self::assertTrue($oResult->is_discoverable); + self::assertTrue($oResult->is_backup_eligible); + self::assertTrue($oResult->is_backed_up); + self::assertTrue($oResult->user_verified); + } + + // -------------------------------------------------------------------------- + + /** + * `fmt: none` authenticators zero the AAGUID out; that is "withheld", not an ID + * made of zeroes, so it is stored as unknown. + */ + public function test_a_withheld_aaguid_is_recorded_as_unknown(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $oResult = $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, self::ORIGIN, self::RP_ID), + $sChallenge + ); + + self::assertNull($oResult->aaguid); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_answering_a_different_challenge_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + + $sAnswered = $this->oService->buildRegistrationOptions($this->user())->challenge; + $sExpected = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sAnswered, self::ORIGIN, self::RP_ID), + $sExpected + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_from_another_origin_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(OriginNotAllowedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, 'https://evil.example.net', self::RP_ID), + $sChallenge + ); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_presented_as_a_registration_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $aResponse = $oFixture->createRegistrationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_ATTESTED_DATA, + 0, + ['type' => 'webauthn.get'] + ); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration($aResponse, $sChallenge); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_for_a_different_relying_party_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, self::ORIGIN, 'someone-else.example.com'), + $sChallenge + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_registration_without_user_presence_is_rejected(): void + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_ATTESTED_DATA + ), + $sChallenge + ); + } + + // -------------------------------------------------------------------------- + // Authentication verification + // -------------------------------------------------------------------------- + + /** + * @return array{0: WebAuthnFixture, 1: string, 2: string} + */ + private function registered(): array + { + $oFixture = new WebAuthnFixture(); + $sChallenge = $this->oService->buildRegistrationOptions($this->user())->challenge; + + $oResult = $this->oService->verifyRegistration( + $oFixture->createRegistrationResponse($sChallenge, self::ORIGIN, self::RP_ID), + $sChallenge + ); + + return [$oFixture, $oResult->public_key, $this->oService->deriveUserHandle($this->user())]; + } + + // -------------------------------------------------------------------------- + + public function test_a_genuine_assertion_is_accepted_and_reports_its_counter(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 42, + $sHandle + ), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + + self::assertSame(42, $iCount); + } + + // -------------------------------------------------------------------------- + + public function test_a_forged_signature_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + $aResponse = $oFixture->createAuthenticationResponse($sChallenge, self::ORIGIN, self::RP_ID); + + // Flip a byte of the signature; everything else stays genuine + $sSignature = $this->oService->base64UrlDecode($aResponse['response']['signature']); + $sSignature[10] = chr(ord($sSignature[10]) ^ 0xFF); + $aResponse['response']['signature'] = $this->oService->base64UrlEncode($sSignature); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication($aResponse, $sChallenge, $sPem, 0, $sHandle, true); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_signed_by_another_key_is_rejected(): void + { + [, $sPem, $sHandle] = $this->registered(); + + $oImposter = new WebAuthnFixture(); + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication( + $oImposter->createAuthenticationResponse($sChallenge, self::ORIGIN, self::RP_ID), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + } + + // -------------------------------------------------------------------------- + + /** + * A counter which has not moved is how a cloned authenticator gives itself away, + * and is also what a replayed assertion looks like. + */ + public function test_a_stale_signature_counter_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 5, + $sHandle + ), + $sChallenge, + $sPem, + 5, + $sHandle, + true + ); + } + + // -------------------------------------------------------------------------- + + /** + * Plenty of platform authenticators never implement a counter and always report + * zero; those must keep working rather than looking permanently stale. + */ + public function test_an_authenticator_which_keeps_no_counter_still_authenticates(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 0, + $sHandle + ), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + + self::assertSame(0, $iCount); + } + + // -------------------------------------------------------------------------- + + public function test_an_unverified_assertion_is_rejected_when_verification_is_required(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $aResponse = $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT, + 1, + $sHandle + ); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication($aResponse, $sChallenge, $sPem, 0, $sHandle, true); + } + + // -------------------------------------------------------------------------- + + public function test_an_unverified_assertion_is_accepted_as_a_second_factor(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT, + 3, + $sHandle + ), + $sChallenge, + $sPem, + 0, + $sHandle, + false + ); + + self::assertSame(3, $iCount); + } + + // -------------------------------------------------------------------------- + + /** + * The library does not read the user handle back, so this check lives in the + * service; without it a discoverable login could present somebody else's handle. + */ + public function test_an_assertion_carrying_another_users_handle_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $aResponse = $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 1, + $this->oService->deriveUserHandle($this->user(99)) + ); + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication($aResponse, $sChallenge, $sPem, 0, $sHandle, true); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_without_a_user_handle_is_accepted(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sChallenge = $this->oService->buildAuthenticationOptions()->challenge; + + $iCount = $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse( + $sChallenge, + self::ORIGIN, + self::RP_ID, + WebAuthnFixture::FLAG_USER_PRESENT | WebAuthnFixture::FLAG_USER_VERIFIED, + 1, + null + ), + $sChallenge, + $sPem, + 0, + $sHandle, + true + ); + + self::assertSame(1, $iCount); + } + + // -------------------------------------------------------------------------- + + public function test_an_assertion_answering_a_different_challenge_is_rejected(): void + { + [$oFixture, $sPem, $sHandle] = $this->registered(); + + $sAnswered = $this->oService->buildAuthenticationOptions()->challenge; + $sExpected = $this->oService->buildAuthenticationOptions()->challenge; + + $this->expectException(VerificationFailedException::class); + + $this->oService->verifyAuthentication( + $oFixture->createAuthenticationResponse($sAnswered, self::ORIGIN, self::RP_ID), + $sExpected, + $sPem, + 0, + $sHandle, + true + ); + } + + // -------------------------------------------------------------------------- + // Malformed input + // -------------------------------------------------------------------------- + + /** + * @return array}> + */ + public static function malformedResponses(): array + { + return [ + 'no response at all' => [[]], + 'response is not an array' => [['response' => 'nonsense']], + 'missing client data' => [['response' => ['signature' => 'AAAA', 'authenticatorData' => 'AAAA']]], + 'missing signature' => [['response' => ['clientDataJSON' => 'AAAA', 'authenticatorData' => 'AAAA']]], + 'empty client data' => [['response' => ['clientDataJSON' => '', 'authenticatorData' => 'AAAA', 'signature' => 'AAAA']]], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aResponse + */ + #[DataProvider('malformedResponses')] + public function test_a_malformed_assertion_is_reported_as_such(array $aResponse): void + { + $this->expectException(InvalidResponseException::class); + + $this->oService->verifyAuthentication($aResponse, 'AAAA', 'not-a-key', 0, 'handle', true); + } + + // -------------------------------------------------------------------------- + + public function test_client_data_which_is_not_json_is_reported_as_malformed(): void + { + $this->expectException(InvalidResponseException::class); + + $this->oService->verifyAuthentication( + [ + 'response' => [ + 'clientDataJSON' => $this->oService->base64UrlEncode('not json at all'), + 'authenticatorData' => 'AAAA', + 'signature' => 'AAAA', + ], + ], + 'AAAA', + 'not-a-key', + 0, + 'handle', + true + ); + } + + // -------------------------------------------------------------------------- + + public function test_a_malformed_registration_is_reported_as_such(): void + { + $this->expectException(InvalidResponseException::class); + + $this->oService->verifyRegistration(['response' => ['clientDataJSON' => 'AAAA']], 'AAAA'); + } + + // -------------------------------------------------------------------------- + // Describing an authenticator + // -------------------------------------------------------------------------- + + public function test_a_known_aaguid_is_named(): void + { + // Verified against a live 1Password registration + self::assertSame( + '1Password', + $this->oService->getAuthenticatorName('bada5566-a7aa-401f-bd96-45619a55120d') + ); + } + + // -------------------------------------------------------------------------- + + public function test_aaguid_matching_ignores_case(): void + { + self::assertSame( + '1Password', + $this->oService->getAuthenticatorName('BADA5566-A7AA-401F-BD96-45619A55120D') + ); + } + + // -------------------------------------------------------------------------- + + public function test_an_unknown_or_absent_aaguid_is_not_named(): void + { + self::assertNull($this->oService->getAuthenticatorName('00000000-0000-0000-0000-000000000000')); + self::assertNull($this->oService->getAuthenticatorName(null)); + self::assertNull($this->oService->getAuthenticatorName('')); + } + + // -------------------------------------------------------------------------- + + /** + * The register is maintained by hand, so an app must be able to name an + * authenticator we have not heard of without waiting for a release. + */ + public function test_an_app_can_name_an_authenticator_by_config(): void + { + $sAaguid = '11111111-2222-3333-4444-555555555555'; + + self::assertNull($this->oService->getAuthenticatorName($sAaguid)); + + Config::set(Passkey::CONFIG_AUTHENTICATORS, [$sAaguid => 'Acme Key']); + + self::assertSame('Acme Key', $this->oService->getAuthenticatorName($sAaguid)); + } + + // -------------------------------------------------------------------------- + + public function test_an_app_can_override_a_listed_authenticator(): void + { + Config::set(Passkey::CONFIG_AUTHENTICATORS, [ + 'bada5566-a7aa-401f-bd96-45619a55120d' => 'Our Password Manager', + ]); + + self::assertSame( + 'Our Password Manager', + $this->oService->getAuthenticatorName('bada5566-a7aa-401f-bd96-45619a55120d') + ); + } + + // -------------------------------------------------------------------------- + + // -------------------------------------------------------------------------- + // Encoding + // -------------------------------------------------------------------------- + + public function test_base64url_survives_a_round_trip_of_arbitrary_bytes(): void + { + $sBinary = random_bytes(64); + + self::assertSame( + $sBinary, + $this->oService->base64UrlDecode($this->oService->base64UrlEncode($sBinary)) + ); + } + + // -------------------------------------------------------------------------- + + public function test_base64url_encoding_is_url_safe_and_unpadded(): void + { + $sEncoded = $this->oService->base64UrlEncode(hex2bin('fbff00')); + + self::assertStringNotContainsString('+', $sEncoded); + self::assertStringNotContainsString('/', $sEncoded); + self::assertStringNotContainsString('=', $sEncoded); + } +} diff --git a/tests/Stub/Cbor.php b/tests/Stub/Cbor.php new file mode 100644 index 00000000..d411e804 --- /dev/null +++ b/tests/Stub/Cbor.php @@ -0,0 +1,138 @@ + $aValue + */ + public static function map(array $aValue): CborMap + { + return new CborMap($aValue); + } + + // -------------------------------------------------------------------------- + + /** + * Encodes a value as CBOR + * + * @param mixed $mValue + */ + public static function encode($mValue): string + { + if ($mValue instanceof CborMap) { + return self::encodeMap($mValue->aValue); + + } elseif ($mValue instanceof CborBytes) { + return self::head(self::MAJOR_BYTE_STRING, strlen($mValue->sValue)) . $mValue->sValue; + + } elseif (is_int($mValue)) { + return $mValue >= 0 + ? self::head(self::MAJOR_UNSIGNED_INT, $mValue) + : self::head(self::MAJOR_NEGATIVE_INT, -1 - $mValue); + + } elseif (is_string($mValue)) { + return self::head(self::MAJOR_TEXT_STRING, strlen($mValue)) . $mValue; + + } elseif (is_array($mValue)) { + return array_is_list($mValue) + ? self::encodeArray($mValue) + : self::encodeMap($mValue); + } + + throw new InvalidArgumentException( + sprintf('Cannot CBOR encode a value of type %s', get_debug_type($mValue)) + ); + } + + // -------------------------------------------------------------------------- + + /** + * Encodes a map, preserving the order the keys were given in + * + * @param array $aValue + */ + public static function encodeMap(array $aValue): string + { + $sOut = self::head(self::MAJOR_MAP, count($aValue)); + + foreach ($aValue as $mKey => $mItem) { + $sOut .= self::encode($mKey) . self::encode($mItem); + } + + return $sOut; + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aValue + */ + public static function encodeArray(array $aValue): string + { + $sOut = self::head(self::MAJOR_ARRAY, count($aValue)); + + foreach ($aValue as $mItem) { + $sOut .= self::encode($mItem); + } + + return $sOut; + } + + // -------------------------------------------------------------------------- + + /** + * Emits an item header: the major type, plus the shortest encoding of the value + */ + private static function head(int $iMajor, int $iValue): string + { + $iPrefix = $iMajor << 5; + + if ($iValue < 24) { + return chr($iPrefix | $iValue); + + } elseif ($iValue <= 0xFF) { + return chr($iPrefix | 24) . chr($iValue); + + } elseif ($iValue <= 0xFFFF) { + return chr($iPrefix | 25) . pack('n', $iValue); + + } elseif ($iValue <= 0xFFFFFFFF) { + return chr($iPrefix | 26) . pack('N', $iValue); + } + + return chr($iPrefix | 27) . pack('J', $iValue); + } +} diff --git a/tests/Stub/CborBytes.php b/tests/Stub/CborBytes.php new file mode 100644 index 00000000..912c3d7c --- /dev/null +++ b/tests/Stub/CborBytes.php @@ -0,0 +1,13 @@ + $aValue + */ + public function __construct(public readonly array $aValue) + { + } +} diff --git a/tests/Stub/WebAuthnFixture.php b/tests/Stub/WebAuthnFixture.php new file mode 100644 index 00000000..57a279ac --- /dev/null +++ b/tests/Stub/WebAuthnFixture.php @@ -0,0 +1,269 @@ + OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + + if ($oKey === false) { + throw new RuntimeException('Failed to generate a P-256 keypair: ' . openssl_error_string()); + } + + $this->oKey = $oKey; + $this->sCredentialId = $sCredentialId ?? random_bytes(32); + $this->sAaguid = $sAaguid ?? str_repeat("\x00", 16); + } + + // -------------------------------------------------------------------------- + + public function getCredentialId(): string + { + return $this->sCredentialId; + } + + // -------------------------------------------------------------------------- + + public function getCredentialIdBase64Url(): string + { + return self::base64UrlEncode($this->sCredentialId); + } + + // -------------------------------------------------------------------------- + + /** + * The credential's public key, in the PEM form the library hands back + */ + public function getPublicKeyPem(): string + { + $aDetails = openssl_pkey_get_details($this->oKey); + + if ($aDetails === false || !isset($aDetails['key'])) { + throw new RuntimeException('Failed to read the public key.'); + } + + return $aDetails['key']; + } + + // -------------------------------------------------------------------------- + + /** + * Builds a registration response, as PublicKeyCredential.toJSON() would render it + * + * @param array $aOverrides + * + * @return array + */ + public function createRegistrationResponse( + string $sChallengeB64, + string $sOrigin, + string $sRpId, + int $iFlags = self::FLAG_USER_PRESENT | self::FLAG_ATTESTED_DATA, + int $iSignCount = 0, + array $aOverrides = [] + ): array { + + $sClientDataJson = $this->buildClientDataJson('webauthn.create', $sChallengeB64, $sOrigin, $aOverrides); + + $sAuthData = $this->buildAuthenticatorData($sRpId, $iFlags, $iSignCount, true); + + $sAttestationObject = Cbor::encodeMap([ + 'fmt' => 'none', + 'attStmt' => Cbor::map([]), + 'authData' => Cbor::bytes($sAuthData), + ]); + + return [ + 'id' => $this->getCredentialIdBase64Url(), + 'rawId' => $this->getCredentialIdBase64Url(), + 'type' => 'public-key', + 'authenticatorAttachment' => 'platform', + 'clientExtensionResults' => ['credProps' => ['rk' => true]], + 'response' => [ + 'clientDataJSON' => self::base64UrlEncode($sClientDataJson), + 'attestationObject' => self::base64UrlEncode($sAttestationObject), + 'transports' => ['internal', 'hybrid'], + ], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * Builds an authentication response, signed with this fixture's private key + * + * @param array $aOverrides + * + * @return array + */ + public function createAuthenticationResponse( + string $sChallengeB64, + string $sOrigin, + string $sRpId, + int $iFlags = self::FLAG_USER_PRESENT | self::FLAG_USER_VERIFIED, + int $iSignCount = 1, + ?string $sUserHandle = null, + array $aOverrides = [] + ): array { + + $sClientDataJson = $this->buildClientDataJson('webauthn.get', $sChallengeB64, $sOrigin, $aOverrides); + + $sAuthData = $this->buildAuthenticatorData($sRpId, $iFlags, $iSignCount, false); + + // Exactly what the spec signs: authenticatorData || SHA-256(clientDataJSON) + $sSignature = $this->sign($sAuthData . hash('sha256', $sClientDataJson, true)); + + return [ + 'id' => $this->getCredentialIdBase64Url(), + 'rawId' => $this->getCredentialIdBase64Url(), + 'type' => 'public-key', + 'authenticatorAttachment' => 'platform', + 'clientExtensionResults' => [], + 'response' => [ + 'clientDataJSON' => self::base64UrlEncode($sClientDataJson), + 'authenticatorData' => self::base64UrlEncode($sAuthData), + 'signature' => self::base64UrlEncode($sSignature), + 'userHandle' => $sUserHandle, + ], + ]; + } + + // -------------------------------------------------------------------------- + + /** + * Signs data with the fixture's private key, as the authenticator would + */ + public function sign(string $sData): string + { + $sSignature = ''; + + if (!openssl_sign($sData, $sSignature, $this->oKey, OPENSSL_ALGO_SHA256)) { + throw new RuntimeException('Failed to sign: ' . openssl_error_string()); + } + + return $sSignature; + } + + // -------------------------------------------------------------------------- + + /** + * @param array $aOverrides + */ + private function buildClientDataJson( + string $sType, + string $sChallengeB64, + string $sOrigin, + array $aOverrides = [] + ): string { + + return (string) json_encode(array_merge([ + 'type' => $sType, + 'challenge' => $sChallengeB64, + 'origin' => $sOrigin, + 'crossOrigin' => false, + ], $aOverrides)); + } + + // -------------------------------------------------------------------------- + + /** + * Assembles authenticator data + * + * https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data + */ + private function buildAuthenticatorData( + string $sRpId, + int $iFlags, + int $iSignCount, + bool $bIncludeAttestedData + ): string { + + $sOut = hash('sha256', $sRpId, true) + . chr($iFlags) + . pack('N', $iSignCount); + + if ($bIncludeAttestedData) { + $sOut .= $this->sAaguid + . pack('n', strlen($this->sCredentialId)) + . $this->sCredentialId + . $this->buildCoseKey(); + } + + return $sOut; + } + + // -------------------------------------------------------------------------- + + /** + * Encodes the public key as a COSE_Key, the form the authenticator reports + */ + private function buildCoseKey(): string + { + $aDetails = openssl_pkey_get_details($this->oKey); + + if ($aDetails === false || !isset($aDetails['ec']['x'], $aDetails['ec']['y'])) { + throw new RuntimeException('Failed to read the EC coordinates.'); + } + + return Cbor::encodeMap([ + self::COSE_KTY => self::KTY_EC2, + self::COSE_ALG => self::ALG_ES256, + self::COSE_CRV => self::CRV_P256, + // Coordinates are fixed width; OpenSSL drops leading zero bytes + self::COSE_X => Cbor::bytes(str_pad($aDetails['ec']['x'], 32, "\x00", STR_PAD_LEFT)), + self::COSE_Y => Cbor::bytes(str_pad($aDetails['ec']['y'], 32, "\x00", STR_PAD_LEFT)), + ]); + } + + // -------------------------------------------------------------------------- + + public static function base64UrlEncode(string $sBinary): string + { + return rtrim(strtr(base64_encode($sBinary), '+/', '-_'), '='); + } +} diff --git a/webpack.config.js b/webpack.config.js index 935e5ecf..0f74c5db 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,7 @@ const path = require('path'); module.exports = { entry: { 'admin': './assets/js/admin.js', + 'passkey': './assets/js/passkey.js', 'styles': './assets/js/styles.js', }, output: {