diff --git a/Languages/en_US/Login.php b/Languages/en_US/Login.php index 7ff6537d8f..f6f936d1f5 100644 --- a/Languages/en_US/Login.php +++ b/Languages/en_US/Login.php @@ -2,6 +2,9 @@ // Version: 3.0 Alpha 4; Login +// Login form. +$txt['login_alternatives'] = 'Or sign in with:'; + // Registration agreement page. $txt['agreement_agree'] = 'I accept the terms of the agreement.'; $txt['policy_agree'] = 'I accept the terms of the privacy policy.'; @@ -178,3 +181,31 @@ $txt['logout_confirm'] = 'Are you sure you want to log out?'; $txt['logout_notice'] = 'You are about to be logged out of the forum and continue browsing as a guest!'; $txt['logout_return'] = 'Stay logged in and return to browsing as a member.'; + +// External authentication. +$txt['authext_failed'] = 'That sign in could not be completed. Please try again.'; +$txt['authext_declined'] = 'The sign in was cancelled.'; +$txt['authext_provider_unavailable'] = 'That sign in method is not available right now.'; +$txt['authext_no_account'] = 'There is no account here for that sign in, and this provider is not allowed to create one.'; +$txt['authext_not_activated'] = 'That account is not activated yet.'; + +// Passkeys. +$txt['passkey_login'] = 'Sign in with a passkey'; +$txt['passkey_unavailable'] = 'Passkeys are not available on this forum.'; +$txt['passkey_not_logged_in'] = 'You have to be signed in to add a passkey.'; +$txt['passkey_register_failed'] = 'That passkey could not be added.'; +$txt['passkey_already_registered'] = 'That passkey has already been added to an account here.'; +$txt['passkey_login_failed'] = 'That passkey could not sign you in.'; +$txt['passkey_not_activated'] = 'That account is not activated yet.'; + +// Signing up without a password. +$txt['registration_signing_in'] = 'Signing in'; +$txt['registration_signing_in_passkey'] = 'Your passkey is ready. Once this account exists, that is what will sign you in, so there is no password to choose.'; +$txt['registration_signing_in_provider'] = '{provider} has already told us who you are, so there is no password to choose. That is what will sign you in from now on.'; +$txt['passkey_signup'] = 'Use a passkey instead'; +$txt['passkey_signup_desc'] = 'Use the fingerprint reader, face scan, PIN or security key that unlocks this device instead of choosing a password.'; +$txt['passkey_signup_button'] = 'Create a passkey'; +$txt['passkey_signup_done'] = 'Your passkey is ready. Finish signing up below to start using it.'; +$txt['passkey_signup_failed'] = 'That passkey could not be created.'; +$txt['passkey_signup_unavailable'] = 'Signing up with a passkey is not available on this forum.'; +$txt['passkey_signup_needs_username'] = 'Choose a username first, so your device knows what to call this account.'; diff --git a/Languages/en_US/ManageSettings.php b/Languages/en_US/ManageSettings.php index 77b45c5e03..c856b804a9 100644 --- a/Languages/en_US/ManageSettings.php +++ b/Languages/en_US/ManageSettings.php @@ -487,3 +487,50 @@ $txt['export_min_diskspace_pct'] = 'Pause exports if free space on disk is less than'; $txt['export_rate'] = 'Rate at which to process posts & personal messages for export'; $txt['export_rate_desc'] = 'Higher values will compile exports more quickly, but could affect forum performance.'; + +// External authentication providers. +$txt['authentication_providers'] = 'Sign in providers'; +$txt['authentication_providers_desc'] = 'Lets members sign in with an external account instead of a password. Each provider has to be registered with them first, which is where the client ID and secret come from.'; +$txt['authentication_no_providers'] = 'No providers have been set up yet.'; +$txt['authentication_add'] = 'Add a provider'; +$txt['authentication_add_generic'] = 'Any OpenID Connect provider'; +$txt['authentication_provider'] = 'Provider'; +$txt['authentication_title'] = 'Name'; +$txt['authentication_title_desc'] = 'What the button on the login page says.'; +$txt['authentication_issuer'] = 'Issuer URL'; +$txt['authentication_issuer_desc'] = 'The provider\'s base URL. Everything else is read from its discovery document.'; +$txt['authentication_client_id'] = 'Client ID'; +$txt['authentication_client_secret'] = 'Client secret'; +$txt['authentication_client_secret_desc'] = 'Leave blank to keep the one already saved.'; +$txt['authentication_scopes'] = 'Scopes'; +$txt['authentication_scopes_desc'] = 'Space separated. Must include openid.'; +$txt['authentication_redirect_uri'] = 'Redirect URI'; +$txt['authentication_redirect_uri_desc'] = 'Give this to the provider when registering the forum. It has to match exactly.'; +$txt['authentication_redirect_uri_pending'] = 'Available once this provider has been saved.'; +$txt['authentication_enabled'] = 'Enabled'; +$txt['authentication_order'] = 'Sort order'; +$txt['authentication_policy'] = 'What a sign in may do'; +$txt['authentication_allow_registration'] = 'Allow new accounts'; +$txt['authentication_allow_registration_desc'] = 'Someone signing in with no account here is sent to the sign up form, still subject to the agreement, approval and age rules.'; +$txt['authentication_link_by_email'] = 'Claim accounts by matching email'; +$txt['authentication_link_by_email_desc'] = 'Only turn this on if you trust the provider to verify email addresses. Anyone who can get an address issued there could otherwise take over the account that uses it here.'; +$txt['authentication_allow_private_host'] = 'Allow a provider on a private address'; +$txt['authentication_allow_private_host_desc'] = 'Needed for a provider running on your own network. Leave off for anything on the internet.'; +$txt['authentication_test'] = 'Test'; +$txt['authentication_test_ok'] = 'The provider answered and its endpoints look usable.'; +$txt['authentication_test_failed'] = 'Could not read anything usable from this provider.'; +$txt['authentication_delete_confirm'] = 'Remove this provider? Anyone who signs in with it will have to use their password instead.'; +$txt['authentication_needs_title_and_issuer'] = 'A provider needs at least a name and an issuer URL.'; + +// Passkeys. +$txt['authentication_area'] = 'Authentication'; +$txt['passkey_settings'] = 'Passkeys'; +$txt['passkey_settings_desc'] = 'Passkeys let members sign in with the fingerprint reader, face scan, PIN or security key that unlocks their device, instead of a password.'; +$txt['passkey_settings_rp_id'] = 'Passkeys registered here will be tied to {rp_id}, taken from the forum URL. Changing the forum\'s domain later will stop every passkey working, and there is no way to move them.'; +$txt['passkey_settings_no_openssl'] = 'Passkeys need PHP\'s openssl extension, which is not installed. Nothing on this page will have any effect until it is.'; +$txt['webauthn_enabled'] = 'Allow passkeys'; +$txt['webauthn_enabled_subtext'] = 'Members can add passkeys from their profile, and sign in with one instead of a password.'; +$txt['webauthn_allow_signup'] = 'Allow signing up with a passkey'; +$txt['webauthn_allow_signup_subtext'] = 'New members can create an account with a passkey instead of a password, still subject to the agreement, approval and age rules. An account made this way has no password at all, so losing the device it was made on means recovering the account by email.'; +$txt['webauthn_allow_unverified'] = 'Accept devices that do not check who is using them'; +$txt['webauthn_allow_unverified_subtext'] = 'By default a passkey only counts once the device has asked for a PIN, fingerprint or face scan. Turning this on accepts mere possession of the device instead, which means anyone holding it can sign in.'; diff --git a/Languages/en_US/Profile.php b/Languages/en_US/Profile.php index ab22589ddd..34a392365f 100644 --- a/Languages/en_US/Profile.php +++ b/Languages/en_US/Profile.php @@ -676,3 +676,33 @@ $txt['export_download_original'] = 'Download original'; $txt['export_view_source_button'] = 'Toggle source view'; $txt['export_open_in_browser'] = 'Please open this file in a web browser to see a human readable version.'; + +// Linked accounts. +$txt['linked_accounts'] = 'Linked accounts'; +$txt['linked_accounts_desc'] = 'The external accounts you can sign in with.'; +$txt['linked_accounts_none'] = 'You have not linked any accounts yet.'; +$txt['linked_accounts_provider'] = 'Provider'; +$txt['linked_accounts_added'] = 'Linked'; +$txt['linked_accounts_last_used'] = 'Last used'; +$txt['linked_accounts_unlink'] = 'Unlink'; +$txt['linked_accounts_add'] = 'Link another account'; +$txt['linked_accounts_linked'] = 'That account is now linked.'; +$txt['linked_accounts_unlinked'] = 'That account is no longer linked.'; +$txt['linked_accounts_last_one'] = 'That is the only way you can sign in, so it cannot be unlinked. Set a password first.'; +$txt['linked_accounts_only_way_in'] = 'Your only way to sign in'; +$txt['linked_accounts_unknown_provider'] = 'Provider no longer configured'; + +// Passkeys. +$txt['passkeys'] = 'Passkeys'; +$txt['passkeys_desc'] = 'Passkeys let you sign in with the fingerprint reader, face scan, PIN or security key you already use to unlock your device. There is no password to remember, and a passkey cannot be used on a site pretending to be this one.'; +$txt['passkeys_none'] = 'You have not added any passkeys yet.'; +$txt['passkeys_name'] = 'Name'; +$txt['passkeys_name_desc'] = 'Something to tell this passkey apart from the others, such as the device it lives on.'; +$txt['passkeys_added'] = 'Added'; +$txt['passkeys_last_used'] = 'Last used'; +$txt['passkeys_remove'] = 'Remove'; +$txt['passkeys_add'] = 'Add a passkey'; +$txt['passkey_added'] = 'That passkey is ready to use.'; +$txt['passkey_default_title'] = 'Passkey'; +$txt['passkey_last_one'] = 'That is the only way you can sign in, so it cannot be removed. Set a password first.'; +$txt['passkey_only_way_in'] = 'Your only way to sign in'; diff --git a/Sources/Actions/Admin/ACP.php b/Sources/Actions/Admin/ACP.php index 060a995fa2..f062db7cc4 100644 --- a/Sources/Actions/Admin/ACP.php +++ b/Sources/Actions/Admin/ACP.php @@ -601,6 +601,19 @@ class ACP implements ActionInterface, Routable ], ], ], + 'authentication' => [ + 'label' => 'authentication_area', + 'function' => __NAMESPACE__ . '\\Authentication::call', + 'icon' => 'security', + 'subsections' => [ + 'list' => [ + 'label' => 'authentication_providers', + ], + 'passkeys' => [ + 'label' => 'passkey_settings', + ], + ], + ], 'maintain' => [ 'label' => 'maintain_title', 'function' => __NAMESPACE__ . '\\Maintenance::call', diff --git a/Sources/Actions/Admin/Authentication.php b/Sources/Actions/Admin/Authentication.php new file mode 100644 index 0000000000..c0e38633e9 --- /dev/null +++ b/Sources/Actions/Admin/Authentication.php @@ -0,0 +1,283 @@ + 'providerList', + 'edit' => 'edit', + 'save' => 'save', + 'delete' => 'delete', + 'test' => 'test', + 'passkeys' => 'passkeys', + ]; + + /**************** + * Public methods + ****************/ + + /** + * Dispatcher to whichever sub-action method is necessary. + */ + public function execute(): void + { + User::$me->isAllowedTo('admin_forum'); + + Theme::loadTemplate('Authentication'); + + $about = $this->subaction === 'passkeys' ? 'passkey_settings' : 'authentication_providers'; + + Utils::$context['page_title'] = Lang::getTxt($about, file: 'ManageSettings'); + + Menu::$loaded['admin']->tab_data = [ + 'title' => Lang::getTxt($about, file: 'ManageSettings'), + 'description' => Lang::getTxt($about . '_desc', file: 'ManageSettings'), + ]; + + $call = \is_string(self::$subactions[$this->subaction]) && method_exists($this, self::$subactions[$this->subaction]) ? [$this, self::$subactions[$this->subaction]] : Utils::getCallable(self::$subactions[$this->subaction]); + + if (!empty($call)) { + \call_user_func($call); + } + } + + /** + * Shows every configured provider. + */ + public function providerList(): void + { + Utils::$context['sub_template'] = 'authentication_list'; + Utils::$context['providers'] = Provider::loadAll(); + Utils::$context['presets'] = Provider::presets(); + } + + /** + * Shows the form for one provider. + */ + public function edit(): void + { + $provider = Provider::load((int) ($_REQUEST['provider'] ?? 0)) ?? new Provider(); + + // Starting from a preset just fills the form in; nothing is saved yet. + if ($provider->id === 0 && !empty($_REQUEST['preset'])) { + $preset = Provider::presets()[$_REQUEST['preset']] ?? []; + + foreach ($preset as $field => $value) { + $provider->{$field} = $value; + } + } + + Utils::$context['sub_template'] = 'authentication_edit'; + Utils::$context['provider'] = $provider; + Utils::$context['redirect_uri'] = $provider->id === 0 + ? Lang::getTxt('authentication_redirect_uri_pending', file: 'ManageSettings') + : $provider->redirectUri(); + + SecurityToken::create('admin-authp'); + } + + /** + * Saves one provider. + */ + public function save(): void + { + User::$me->checkSession(); + SecurityToken::validate('admin-authp'); + + $provider = Provider::load((int) ($_REQUEST['provider'] ?? 0)) ?? new Provider(); + + $provider->title = Utils::htmlTrim($_POST['title'] ?? ''); + $provider->issuer = Utils::htmlTrim($_POST['issuer'] ?? ''); + $provider->client_id = Utils::htmlTrim($_POST['client_id'] ?? ''); + $provider->scopes = Utils::htmlTrim($_POST['scopes'] ?? 'openid email profile'); + $provider->enabled = !empty($_POST['enabled']); + $provider->order = (int) ($_POST['provider_order'] ?? 0); + + // An empty secret box means "leave it alone", so that editing a provider + // does not require retyping a secret the admin may not have to hand. + if (($_POST['client_secret'] ?? '') !== '') { + $provider->client_secret = $_POST['client_secret']; + } + + $provider->settings['link_by_verified_email'] = !empty($_POST['link_by_verified_email']); + $provider->settings['allow_registration'] = !empty($_POST['allow_registration']); + $provider->settings['allow_private_host'] = !empty($_POST['allow_private_host']); + + if ($provider->title === '' || $provider->issuer === '') { + ErrorHandler::fatalLang('authentication_needs_title_and_issuer', false); + } + + // The issuer moved, so whatever we discovered about the old one is junk. + $provider->settings['discovery'] = []; + $provider->settings['discovered_at'] = 0; + + $provider->save(); + + Utils::redirectexit('action=admin;area=authentication;saved'); + } + + /** + * Removes a provider, and every credential that came from it. + */ + public function delete(): void + { + User::$me->checkSession('get'); + + $provider = Provider::load((int) ($_REQUEST['provider'] ?? 0)); + + if ($provider !== null) { + $provider->delete(); + } + + Utils::redirectexit('action=admin;area=authentication;deleted'); + } + + /** + * Settings for passkeys. + */ + public function passkeys(): void + { + $config_vars = self::passkeyConfigVars(); + + Utils::$context['sub_template'] = 'show_settings'; + Utils::$context['post_url'] = Config::$scripturl . '?action=admin;area=authentication;save;sa=passkeys'; + Utils::$context['settings_title'] = Lang::getTxt('passkey_settings', file: 'ManageSettings'); + + /* + * openssl is the one thing here that cannot be turned on from this page, + * so say so rather than letting the admin tick a box that will never do + * anything. Everything else about passkeys is a choice; this is not. + */ + Utils::$context['settings_message'] = Server::isAvailable() + ? Lang::getTxt('passkey_settings_rp_id', ['rp_id' => Server::relyingPartyId()], file: 'ManageSettings') + : Lang::getTxt('passkey_settings_no_openssl', file: 'ManageSettings'); + + if (isset($_GET['save'])) { + User::$me->checkSession(); + + ACP::saveDBSettings($config_vars); + $_SESSION['adm-save'] = true; + + Utils::redirectexit('action=admin;area=authentication;sa=passkeys;' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']); + } + + ACP::prepareDBSettingContext($config_vars); + } + + /** + * Fetches the discovery document, so the admin can see it working. + */ + public function test(): void + { + User::$me->checkSession('get'); + + $provider = Provider::load((int) ($_REQUEST['provider'] ?? 0)); + + if ($provider === null) { + Utils::redirectexit('action=admin;area=authentication'); + } + + $client = new OidcClient($provider); + $document = $client->discover(true); + + Utils::$context['sub_template'] = 'authentication_test'; + Utils::$context['provider'] = $provider; + Utils::$context['test_error'] = $client->error; + Utils::$context['test_endpoints'] = $document === [] ? [] : [ + 'authorization_endpoint' => $document['authorization_endpoint'] ?? '', + 'token_endpoint' => $document['token_endpoint'] ?? '', + 'userinfo_endpoint' => $document['userinfo_endpoint'] ?? '', + ]; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * The settings that govern passkeys. + * + * @return array The config variables. + */ + public static function passkeyConfigVars(): array + { + $config_vars = [ + ['check', 'webauthn_enabled', 'subtext' => Lang::getTxt('webauthn_enabled_subtext', file: 'ManageSettings'), 'disabled' => !Server::isAvailable()], + ['check', 'webauthn_allow_signup', 'subtext' => Lang::getTxt('webauthn_allow_signup_subtext', file: 'ManageSettings'), 'disabled' => !Server::isAvailable()], + ['check', 'webauthn_allow_unverified', 'subtext' => Lang::getTxt('webauthn_allow_unverified_subtext', file: 'ManageSettings'), 'disabled' => !Server::isAvailable()], + ]; + + IntegrationHook::call('integrate_passkey_settings', [&$config_vars]); + + return $config_vars; + } + + /****************** + * Internal methods + ******************/ + + /** + * Constructor. Protected to force instantiation via self::load(). + */ + protected function __construct() + { + if (!empty($_REQUEST['sa']) && isset(self::$subactions[$_REQUEST['sa']])) { + $this->subaction = $_REQUEST['sa']; + } + } +} diff --git a/Sources/Actions/AuthExternal.php b/Sources/Actions/AuthExternal.php new file mode 100644 index 0000000000..234b8e66b9 --- /dev/null +++ b/Sources/Actions/AuthExternal.php @@ -0,0 +1,415 @@ + 'start', + 'callback' => 'callback', + 'link' => 'link', + 'unlink' => 'unlink', + ]; + + /**************** + * Public methods + ****************/ + + public function isRestrictedGuestAccessAllowed(): bool + { + return true; + } + + public function canShowInMaintenanceMode(): bool + { + return true; + } + + public function isAgreementAction(): bool + { + return true; + } + + /** + * Dispatcher to whichever sub-action method is necessary. + */ + public function execute(): void + { + // Everything here hands credentials around, so insist on SSL if the + // forum does, exactly as the password form does. + if (!empty(Config::$modSettings['force_ssl']) && empty(Config::$maintenance) && !Sapi::httpsOn()) { + ErrorHandler::fatalLang('login_ssl_required', false); + } + + $call = \is_string(self::$subactions[$this->subaction]) && method_exists($this, self::$subactions[$this->subaction]) ? [$this, self::$subactions[$this->subaction]] : Utils::getCallable(self::$subactions[$this->subaction]); + + if (!empty($call)) { + \call_user_func($call); + } + } + + /** + * Sends the member off to the identity provider. + */ + public function start(): void + { + $provider = $this->loadProvider(); + + $client = new OidcClient($provider); + $begun = $client->beginAuthorization($_SESSION['login_url'] ?? ''); + + if ($begun === null) { + $this->fail($client->error, 'authext_provider_unavailable'); + } + + // Remember what we sent, so the callback can check what comes back. + $_SESSION['authext'] = $begun['state']; + + Utils::redirectexit($begun['url']); + } + + /** + * Handles the member coming back from the identity provider. + */ + public function callback(): void + { + $provider = $this->loadProvider(); + $state = $_SESSION['authext'] ?? []; + unset($_SESSION['authext']); + + // The provider says it went wrong, or the member said no. + if (!empty($_REQUEST['error'])) { + $this->fail( + 'provider returned ' . $_REQUEST['error'] . ': ' . ($_REQUEST['error_description'] ?? ''), + 'authext_declined', + ); + } + + if (empty($_REQUEST['code']) || empty($_REQUEST['state'])) { + $this->fail('callback without a code or state', 'authext_failed'); + } + + // Did this come from the request we started, in this session? + if ( + empty($state['state']) + || !hash_equals($state['state'], (string) $_REQUEST['state']) + || (int) ($state['provider'] ?? 0) !== $provider->id + ) { + $this->fail('state did not match the one we issued', 'authext_failed'); + } + + // Somebody could have left the tab open for a week. + if (($state['created'] ?? 0) < time() - 900) { + $this->fail('state expired', 'authext_failed'); + } + + $client = new OidcClient($provider); + $claims = $client->completeAuthorization((string) $_REQUEST['code'], $state); + + if ($claims === null) { + $this->fail($client->error, 'authext_failed'); + } + + $subject = (string) $claims['sub']; + + /* + * MOD AUTHORS: last chance to decide who this is. Set $id_member to + * take over the decision entirely; leave it alone to let SMF work it + * out from the rules below. + */ + $id_member = 0; + IntegrationHook::call('integrate_external_identity', [&$claims, &$id_member, $provider]); + + if ($id_member === 0) { + $id_member = Credential::findMember(Credential::TYPE_OIDC, $provider->id, $subject); + } + + // Somebody we already know. Straight in. + if ($id_member > 0) { + Credential::touch(Credential::TYPE_OIDC, $provider->id, $subject); + $this->logIn($id_member, $state['return_to'] ?? ''); + } + + // A member who is signed in already is attaching this to their account. + if (!User::$me->is_guest) { + Credential::add( + User::$me->id, + Credential::TYPE_OIDC, + $provider->id, + $subject, + $provider->title . ' (' . ($claims['email'] ?? $subject) . ')', + ); + + Utils::redirectexit('action=profile;area=linkedaccounts;linked'); + } + + // Nobody has claimed this identity, and nobody is signed in. + $this->claimOrRegister($provider, $claims, $subject, $state['return_to'] ?? ''); + } + + /** + * Starts linking a provider to the account that is already signed in. + */ + public function link(): void + { + User::$me->kickIfGuest(); + User::$me->checkSession('get'); + + $this->start(); + } + + /** + * Detaches a provider from the account that is signed in. + */ + public function unlink(): void + { + User::$me->kickIfGuest(); + User::$me->checkSession('get'); + + $removed = Credential::remove( + (int) ($_REQUEST['cred'] ?? 0), + User::$me->id, + User::$me->hasUsablePassword(), + ); + + Utils::redirectexit('action=profile;area=linkedaccounts;' . ($removed ? 'unlinked' : 'lastone')); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * The identity a provider vouched for while somebody was signing up. + * + * A provider saying who somebody is does not make them a member here: they + * still have to go through the sign up form, agreement and all. This is + * what is remembered in the meantime, so the form knows they have already + * been vouched for and does not ask them to invent a password as well. + * + * @return ?array The pending identity, or null if there is not one. + */ + public static function pendingIdentity(): ?array + { + $pending = $_SESSION['authext_pending'] ?? null; + + if ( + !\is_array($pending) + || empty($pending['subject']) + || ($pending['created'] ?? 0) < time() - self::PENDING_LIFETIME + ) { + return null; + } + + return $pending; + } + + /****************** + * Internal methods + ******************/ + + /** + * Constructor. Protected to force instantiation via self::load(). + */ + protected function __construct() + { + if (!empty($_REQUEST['sa']) && isset(self::$subactions[$_REQUEST['sa']])) { + $this->subaction = $_REQUEST['sa']; + } + } + + /** + * Loads the provider this request is about, or stops. + * + * @return \SMF\Authentication\Provider The provider. + */ + protected function loadProvider(): Provider + { + $provider = Provider::load((int) ($_REQUEST['provider'] ?? 0)); + + if ($provider === null || !$provider->enabled || !$provider->isUsable()) { + $this->fail('no usable provider ' . ($_REQUEST['provider'] ?? '(none)'), 'authext_provider_unavailable'); + } + + return $provider; + } + + /** + * Decides what to do with an identity nobody has claimed yet. + * + * @param \SMF\Authentication\Provider $provider Who vouched for them. + * @param array $claims What the provider said about them. + * @param string $subject The provider's ID for this person. + * @param string $return_to Where they were headed. + */ + protected function claimOrRegister(Provider $provider, array $claims, string $subject, string $return_to): void + { + $email = (string) ($claims['email'] ?? ''); + + /* + * Matching on email lets somebody who controls an address at the + * provider walk into the account that uses it here, so it happens only + * when the admin has asked for it and the provider states the address + * has been verified. + */ + if ( + $email !== '' + && !empty($provider->settings['link_by_verified_email']) + && !empty($claims['email_verified']) + ) { + $loaded = User::load($email, User::LOAD_BY_EMAIL, UserDataset::Basic); + + if ($loaded !== []) { + $member = reset($loaded); + + Credential::add( + $member->id, + Credential::TYPE_OIDC, + $provider->id, + $subject, + $provider->title . ' (' . $email . ')', + ); + + $this->logIn($member->id, $return_to); + } + } + + if (empty($provider->settings['allow_registration'])) { + $this->fail('no account for ' . $subject . ' and registration is off', 'authext_no_account'); + } + + // The forum is not taking new members at all, whatever the provider is + // allowed to do. Say so here rather than sending them to a form that + // will only tell them the same thing less helpfully. + if (!empty(Config::$modSettings['registration_method']) && Config::$modSettings['registration_method'] == 3) { + $this->fail('no account for ' . $subject . ' and registration is disabled', 'authext_no_account'); + } + + /* + * Hand over to the normal sign up form rather than creating an account + * behind the member's back: registration here still means the agreement, + * the privacy policy, COPPA and admin approval, and this is the one + * place that already gets all of that right. + */ + $_SESSION['authext_pending'] = [ + 'provider' => $provider->id, + 'title' => $provider->title, + 'subject' => $subject, + 'email' => $email, + 'name' => (string) ($claims['preferred_username'] ?? $claims['name'] ?? ''), + 'created' => time(), + ]; + + Utils::redirectexit('action=signup'); + } + + /** + * Finishes a successful sign in. + * + * @param int $id_member Who to log in. + * @param string $return_to Where they were headed. + */ + protected function logIn(int $id_member, string $return_to): void + { + $loaded = User::load($id_member, User::LOAD_BY_ID, UserDataset::Normal); + + if ($loaded === []) { + $this->fail('credential points at member ' . $id_member . ', who does not exist', 'authext_failed'); + } + + $member = reset($loaded); + + // Same activation rules a password login gets. + if ($member->is_activated % User::BANNED !== User::ACTIVATED) { + $this->fail('member ' . $id_member . ' is not activated', 'authext_not_activated'); + } + + if ($return_to !== '') { + $_SESSION['login_url'] = $return_to; + } + + Login2::completeLogin($member); + } + + /** + * Logs why a sign in did not happen, and tells the member something useful. + * + * The member never sees the detail: it usually says more about the provider + * than they need to know, and some of it is worth keeping to ourselves. + * + * @param string $detail What actually went wrong. + * @param string $message The language string to show. + */ + protected function fail(string $detail, string $message): void + { + ErrorHandler::log('External authentication: ' . $detail, 'general'); + + ErrorHandler::fatal(Lang::getTxt($message, file: 'Login'), false); + } +} diff --git a/Sources/Actions/Login.php b/Sources/Actions/Login.php index 5bf6bf3d80..e787a9fbea 100644 --- a/Sources/Actions/Login.php +++ b/Sources/Actions/Login.php @@ -22,6 +22,7 @@ use SMF\Theme; use SMF\User; use SMF\Utils; +use SMF\WebAuthn\Server; /** * Shows the login form. @@ -76,6 +77,19 @@ public function execute(): void Utils::$context['default_username'] = &$_REQUEST['u']; Utils::$context['default_password'] = ''; Utils::$context['never_expire'] = false; + Utils::$context['authentication_methods'] = parent::getAuthenticationMethods(); + + /* + * Nothing is rendered for passkeys here. The button only works with + * scripting, and only on a browser that has the API at all, so the + * script puts it on the page once it knows both are true rather than + * leaving a dead button for everyone else. + */ + Utils::$context['offer_passkeys'] = Server::isEnabled(); + + if (Utils::$context['offer_passkeys']) { + Theme::loadJavaScriptFile('webauthn.js', ['defer' => true, 'minimize' => true], 'smf_webauthn'); + } // Add the login chain to the link tree. Utils::$context['linktree'][] = [ diff --git a/Sources/Actions/Login2.php b/Sources/Actions/Login2.php index 112c636b4a..0fde599de1 100644 --- a/Sources/Actions/Login2.php +++ b/Sources/Actions/Login2.php @@ -18,6 +18,7 @@ use SMF\ActionInterface; use SMF\ActionRouter; use SMF\ActionTrait; +use SMF\Authentication\Provider; use SMF\Config; use SMF\Cookie; use SMF\Db\DatabaseApi as Db; @@ -179,13 +180,21 @@ public function checkCookie(): void User::$me->can_mod = User::$me->allowedTo('access_mod_center') || (!User::$me->is_guest && (User::$me->mod_cache['gq'] != '0=1' || User::$me->mod_cache['bq'] != '0=1' || (Config::$modSettings['postmod_active'] && !empty(User::$me->mod_cache['ap'])))); + /* + * Anything else they still have to prove before we let them all the way + * in? Check tfa_mode as well as the member, because User::verifyTfa() + * does, and sending them to ?action=logintfa when it won't is how you + * get "You are not allowed to access this section" instead of a login. + */ + $needs_second_factor = !empty(Config::$modSettings['tfa_mode']) && User::$me->hasSecondFactor(); + // Some whitelisting for login_url... if (empty($_SESSION['login_url'])) { - Utils::redirectexit(empty(User::$me->tfa_secret) ? '' : 'action=logintfa'); + Utils::redirectexit($needs_second_factor ? 'action=logintfa' : ''); } elseif (!empty($_SESSION['login_url']) && (!str_contains($_SESSION['login_url'], 'http://') && !str_contains($_SESSION['login_url'], 'https://'))) { unset($_SESSION['login_url']); - Utils::redirectexit(empty(User::$me->tfa_secret) ? '' : 'action=logintfa'); - } elseif (!empty(User::$me->tfa_secret)) { + Utils::redirectexit($needs_second_factor ? 'action=logintfa' : ''); + } elseif ($needs_second_factor) { Utils::redirectexit('action=logintfa'); } else { // Best not to clutter the session data too much... @@ -252,6 +261,7 @@ public function main(): void Utils::$context['never_expire'] = !empty($_POST['cookieneverexp']); Utils::$context['login_errors'] = [Lang::getTxt('error_occured', file: 'General')]; Utils::$context['page_title'] = Lang::getTxt('login', file: 'General'); + Utils::$context['authentication_methods'] = self::getAuthenticationMethods(); // Add the login chain to the link tree. Utils::$context['linktree'][] = [ @@ -311,6 +321,17 @@ public function main(): void $this->member = reset($loaded); + /* + * This account signs in some other way, so there is nothing here for a + * password to match against. Stop before checkPasswordFallbacks() gets a + * chance to compare the submitted password to an empty hash. + */ + if (!$this->member->hasUsablePassword()) { + Utils::$context['login_errors'] = [Lang::getTxt('invalid_credentials', file: 'General')]; + + return; + } + // Bad password! Thought you could fool the database?! if (!Security::hashVerifyPassword(Utils::htmlspecialcharsDecode($_POST['passwrd']), $this->member->passwd)) { // If the forum was recently upgraded, password might be encrypted @@ -456,6 +477,167 @@ public static function validatePasswordFlood(int $id_member, string $member_name $member->save(); } + /** + * Lists the ways to sign in that are offered alongside the password form. + * + * SMF has exactly one way to log in, so this is empty on a stock install. + * It exists so that anything adding another way, such as an external + * identity provider, has somewhere to say so and gets rendered in the same + * place as everything else rather than each mod inventing its own spot. + * + * Each entry should be an array with at least: + * - 'title': what to show on the button. Already escaped for output. + * - 'url': where the button goes. + * and may also carry an 'id' used as a CSS class, so a method can be styled + * with its own branding. + * + * @return array The available alternatives, which may be empty. + */ + public static function getAuthenticationMethods(): array + { + $methods = []; + + foreach (Provider::loadAll(true) as $provider) { + if (!$provider->isUsable()) { + continue; + } + + $methods['provider' . $provider->id] = [ + 'id' => 'provider' . $provider->id, + 'title' => Utils::htmlspecialchars($provider->title), + 'url' => Config::$scripturl . '?action=authext;sa=start;provider=' . $provider->id, + ]; + } + + /* + * MOD AUTHORS: Add your sign in method here to have it offered on the + * login form. Starting the flow, and everything after it, is up to you; + * finish by calling Login2::completeLogin() so that the member ends up + * logged in the same way a password would have left them. + */ + IntegrationHook::call('integrate_authentication_methods', [&$methods]); + + return $methods; + } + + /** + * Finishes logging a member in, once something has decided that they are who + * they say they are. + * + * This is everything that happens *after* the credentials check: the cookie, + * the session, the ban check, and the login history. The password form is + * only one way to get here, so anything else that can authenticate a member + * (an external identity provider, a passkey, a mod) should call this rather + * than reinventing it, or it will miss a step. + * + * Note that this does not perform any second factor check of its own. The + * redirect below goes through Login2::checkCookie(), which is what sends the + * member on to the second factor when they have one. + * + * @param \SMF\User $member The member to log in. + * @param bool $stay_logged_in Whether to use a long lived cookie. + * @param bool $redirect Whether to redirect once we are done. Pass false if + * the caller needs to send its own response, e.g. because it is answering + * an AJAX request. The caller is then responsible for making sure the + * member ends up somewhere sensible. + */ + public static function completeLogin(User $member, bool $stay_logged_in = false, bool $redirect = true): void + { + // Call login integration functions. + IntegrationHook::call( + 'integrate_login', + [ + $member->username, + null, + // This is divided by 60 for compatibility with old mods that + // expected a number of minutes rather than a number of seconds. + ($stay_logged_in ? Cookie::LENGTH_ONE_YEAR : Cookie::LENGTH_DEFAULT) / 60, + ], + ); + + // Get ready to set the cookie... + User::setMe($member->id); + User::$me->stay_logged_in = $stay_logged_in; + + // Bam! Cookie set. A session too, just in case. + Cookie::setLoginCookie(User::$me->stay_logged_in ? Cookie::LENGTH_ONE_YEAR : Cookie::LENGTH_DEFAULT, User::$me->id, Cookie::encrypt(User::$me->passwd, User::$me->password_salt)); + + // Reset the login threshold. + if (isset($_SESSION['failed_login'])) { + unset($_SESSION['failed_login']); + } + + // Are you banned? + User::$me->enforceBans(true); + + // Don't stick the language or theme after this point. + unset($_SESSION['language'], $_SESSION['id_theme']); + + // First login? + if (User::$me->last_login === 0) { + $_SESSION['first_login'] = true; + } else { + unset($_SESSION['first_login']); + } + + // You've logged in, haven't you? + User::$me->ip = IP::getUserIP(); + User::$me->ip2 = IP::getUserIPAlternative(); + User::$me->validation_code = ''; + + if (!User::$me->hasSecondFactor()) { + User::$me->last_login = time(); + } + + User::$me->save(); + + // Get rid of the online entry for that old guest.... + Db::$db->query( + 'DELETE FROM {db_prefix}log_online + WHERE session = {string:session}', + [ + 'session' => 'ip' . User::$me->ip, + ], + ); + $_SESSION['log_time'] = 0; + + // Log this entry, only if we have it enabled. + if (!empty(Config::$modSettings['loginHistoryDays'])) { + Db::$db->insert( + 'insert', + '{db_prefix}member_logins', + [ + 'id_member' => 'int', + 'time' => 'int', + 'ip' => 'inet', + 'ip2' => 'inet', + ], + [ + [ + User::$me->id, + time(), + User::$me->ip, + User::$me->ip2, + ], + ], + [ + 'id_member', 'time', + ], + ); + } + + if (!$redirect) { + return; + } + + // Just log you back out if it's in maintenance mode and you AREN'T an admin. + if (empty(Config::$maintenance) || User::$me->allowedTo('admin_forum')) { + Utils::redirectexit('action=login2;sa=check;member=' . User::$me->id, Sapi::needsLoginFix()); + } else { + Utils::redirectexit('action=logout;' . Utils::$context['session_var'] . '=' . Utils::$context['session_id'], Sapi::needsLoginFix()); + } + } + /****************** * Internal methods ******************/ @@ -774,94 +956,6 @@ protected function checkActivation(): bool */ protected function DoLogin(): void { - // Call login integration functions. - IntegrationHook::call( - 'integrate_login', - [ - $this->member->username, - null, - // This is divided by 60 for compatibility with old mods that - // expected a number of minutes rather than a number of seconds. - (!empty(Utils::$context['never_expire']) ? Cookie::LENGTH_ONE_YEAR : Cookie::LENGTH_DEFAULT) / 60, - ], - ); - - // Get ready to set the cookie... - User::setMe($this->member->id); - User::$me->stay_logged_in = !empty(Utils::$context['never_expire']); - - // Bam! Cookie set. A session too, just in case. - Cookie::setLoginCookie(User::$me->stay_logged_in ? Cookie::LENGTH_ONE_YEAR : Cookie::LENGTH_DEFAULT, User::$me->id, Cookie::encrypt(User::$me->passwd, User::$me->password_salt)); - - // Reset the login threshold. - if (isset($_SESSION['failed_login'])) { - unset($_SESSION['failed_login']); - } - - // Are you banned? - User::$me->enforceBans(true); - - // Don't stick the language or theme after this point. - unset($_SESSION['language'], $_SESSION['id_theme']); - - // First login? - if (User::$me->last_login === 0) { - $_SESSION['first_login'] = true; - } else { - unset($_SESSION['first_login']); - } - - // You've logged in, haven't you? - User::$me->ip = IP::getUserIP(); - User::$me->ip2 = IP::getUserIPAlternative(); - User::$me->validation_code = ''; - - if (empty(User::$me->tfa_secret)) { - User::$me->last_login = time(); - } - - User::$me->save(); - - // Get rid of the online entry for that old guest.... - Db::$db->query( - 'DELETE FROM {db_prefix}log_online - WHERE session = {string:session}', - [ - 'session' => 'ip' . User::$me->ip, - ], - ); - $_SESSION['log_time'] = 0; - - // Log this entry, only if we have it enabled. - if (!empty(Config::$modSettings['loginHistoryDays'])) { - Db::$db->insert( - 'insert', - '{db_prefix}member_logins', - [ - 'id_member' => 'int', - 'time' => 'int', - 'ip' => 'inet', - 'ip2' => 'inet', - ], - [ - [ - User::$me->id, - time(), - User::$me->ip, - User::$me->ip2, - ], - ], - [ - 'id_member', 'time', - ], - ); - } - - // Just log you back out if it's in maintenance mode and you AREN'T an admin. - if (empty(Config::$maintenance) || User::$me->allowedTo('admin_forum')) { - Utils::redirectexit('action=login2;sa=check;member=' . User::$me->id, Sapi::needsLoginFix()); - } else { - Utils::redirectexit('action=logout;' . Utils::$context['session_var'] . '=' . Utils::$context['session_id'], Sapi::needsLoginFix()); - } + self::completeLogin($this->member, !empty(Utils::$context['never_expire'])); } } diff --git a/Sources/Actions/Passkey.php b/Sources/Actions/Passkey.php new file mode 100644 index 0000000000..3dd9ba53fb --- /dev/null +++ b/Sources/Actions/Passkey.php @@ -0,0 +1,576 @@ + 'registerOptions', + 'register' => 'register', + 'loginoptions' => 'loginOptions', + 'login' => 'login', + 'signupoptions' => 'signupOptions', + 'signup' => 'signup', + ]; + + /**************** + * Public methods + ****************/ + + public function isRestrictedGuestAccessAllowed(): bool + { + return true; + } + + public function canShowInMaintenanceMode(): bool + { + return true; + } + + public function isAgreementAction(): bool + { + return true; + } + + /** + * Dispatcher to whichever sub-action method is necessary. + */ + public function execute(): void + { + if (!Server::isEnabled()) { + $this->fail('passkeys are not enabled on this forum', 'passkey_unavailable'); + } + + // A ceremony carries credentials, so insist on SSL if the forum does. + // Browsers insist on it too, localhost aside, so this rarely bites. + if (!empty(Config::$modSettings['force_ssl']) && empty(Config::$maintenance) && !Sapi::httpsOn()) { + $this->fail('passkey ceremony over plain HTTP', 'login_ssl_required'); + } + + User::$me->checkSession('post'); + + $call = \is_string(self::$subactions[$this->subaction]) && method_exists($this, self::$subactions[$this->subaction]) ? [$this, self::$subactions[$this->subaction]] : Utils::getCallable(self::$subactions[$this->subaction]); + + if (!empty($call)) { + \call_user_func($call); + } + } + + /** + * Hands the browser what it needs to make a passkey. + */ + public function registerOptions(): void + { + if (User::$me->is_guest) { + $this->fail('guest asked to register a passkey', 'passkey_not_logged_in'); + } + + /* + * Anything already registered goes in the exclusion list, so that an + * authenticator the member is holding says "you already did this" rather + * than quietly making a second credential they now have to tell apart + * from the first. + */ + $exclude = []; + $handle = null; + + foreach (Credential::listFor(User::$me->id, Credential::TYPE_WEBAUTHN) as $credential) { + $stored = $this->secretData($credential); + + if ($stored['id'] !== '') { + $exclude[] = $stored['id']; + } + + /* + * A passkey made during sign up was given a random handle, because + * there was no member ID to derive one from yet. Keep using it, so + * that this key joins the account the browser already knows about + * instead of appearing as a second one with the same name. + */ + if ($stored['user_handle'] !== '') { + $handle = Server::base64UrlDecode($stored['user_handle']); + } + } + + $this->respond([ + 'options' => Server::creationOptions( + User::$me->id, + User::$me->username, + User::$me->name, + $exclude, + $handle, + ), + ]); + } + + /** + * Keeps a passkey the browser just made. + */ + public function register(): void + { + if (User::$me->is_guest) { + $this->fail('guest tried to register a passkey', 'passkey_not_logged_in'); + } + + try { + $credential = Server::verifyCreation($this->credentialResponse()); + } catch (WebAuthnException $e) { + $this->fail('registration refused: ' . $e->getMessage(), 'passkey_register_failed'); + } + + /* + * The challenge remembered who asked for it. If that is not who is + * asking now, someone has been signed out and back in as somebody else + * part way through, and this key would end up on the wrong account. + */ + if ($credential['member'] !== User::$me->id) { + $this->fail('registration was begun by member ' . $credential['member'] . ', not ' . User::$me->id, 'passkey_register_failed'); + } + + $identifier = self::identifier($credential['id']); + + if (Credential::find(Credential::TYPE_WEBAUTHN, 0, $identifier) !== null) { + $this->fail('credential is already registered', 'passkey_already_registered'); + } + + $title = (string) Utils::htmlTrim(Utils::htmlspecialchars((string) ($_POST['title'] ?? ''))); + + if ($title === '') { + $title = Lang::getTxt('passkey_default_title', file: 'Profile'); + } + + Credential::add( + User::$me->id, + Credential::TYPE_WEBAUTHN, + 0, + $identifier, + Utils::entitySubstr($title, 0, 80), + $this->keepFrom($credential), + ); + + $this->respond([ + 'redirect' => Config::$scripturl . '?action=profile;area=passkeys;added', + ]); + } + + /** + * Hands the browser what it needs to sign in. + */ + public function loginOptions(): void + { + /* + * No list of credentials goes out: the member has not said who they are + * yet, and answering that question for them would let anybody ask this + * forum which passkeys a given account has. The browser knows which of + * its own passkeys belong to this site, so it is the one that chooses. + */ + $this->respond(['options' => Server::requestOptions()]); + } + + /** + * Signs a member in with the passkey they just used. + */ + public function login(): void + { + if (!User::$me->is_guest) { + $this->respond(['redirect' => Config::$scripturl]); + } + + $response = $this->credentialResponse(); + + $raw_id = Server::base64UrlDecode((string) ($_POST['rawId'] ?? '')); + + $credential = $raw_id === '' ? null : Credential::find(Credential::TYPE_WEBAUTHN, 0, self::identifier($raw_id)); + + if ($credential === null) { + $this->fail('no passkey registered with that ID', 'passkey_login_failed'); + } + + $stored = $this->secretData($credential); + + try { + $result = Server::verifyAssertion($response, $stored['key'], $stored['sign_count']); + } catch (WebAuthnException $e) { + $this->fail('assertion refused: ' . $e->getMessage(), 'passkey_login_failed'); + } + + /* + * A counter that has not moved forward is the one hint the standard + * gives that a credential has been copied off its device. It is only a + * hint -- plenty of authenticators never count at all, and report zero + * every time -- so it is written down rather than acted on. Refusing + * here would lock members out over an authenticator quirk. + */ + if (Server::signCountWentBackwards($stored['sign_count'], $result['sign_count'])) { + ErrorHandler::log( + 'Passkey sign count for member ' . $credential['id_member'] . ' went from ' . $stored['sign_count'] . ' to ' . $result['sign_count'] . ', which may mean the credential has been cloned.', + 'general', + ); + } + + $stored['sign_count'] = $result['sign_count']; + Credential::setSecretData((int) $credential['id_auth'], Utils::jsonEncode($stored)); + Credential::touch(Credential::TYPE_WEBAUTHN, 0, $credential['identifier']); + + $this->respond(['redirect' => $this->finishSignIn((int) $credential['id_member'])]); + } + + /** + * Hands the browser what it needs to make a passkey for an account that + * does not exist yet. + */ + public function signupOptions(): void + { + $this->checkSignUpAllowed(); + + /* + * An authenticator shows this name to whoever is holding it, and stores + * it forever, so it is worth having before the key is made rather than + * putting a placeholder on the member's phone for good. The sign up form + * has the field; the script sends whatever is in it. + */ + $username = Utils::htmlTrim(Utils::htmlspecialchars((string) ($_POST['user'] ?? ''))); + + if ($username === '') { + $this->fail('passkey sign up with no username', 'passkey_signup_needs_username'); + } + + /* + * There is no member to derive a handle from, so one is invented. It + * goes into the session alongside the challenge, which is where the + * answer to this ceremony picks it up again; from there it is stored + * with the credential, and every passkey the member adds later reuses + * it. See self::registerOptions(). + */ + $this->respond([ + 'options' => Server::creationOptions( + 0, + $username, + $username, + [], + random_bytes(32), + ), + ]); + } + + /** + * Holds on to a passkey until the account it belongs to has been created. + * + * Nothing is written to the database here. The member has not agreed to + * anything yet, has not been approved, and may never finish the form; the + * credential waits in the session until Register2 says an account exists to + * attach it to, and is forgotten along with the session if it does not. + */ + public function signup(): void + { + $this->checkSignUpAllowed(); + + try { + $credential = Server::verifyCreation($this->credentialResponse()); + } catch (WebAuthnException $e) { + $this->fail('sign up refused: ' . $e->getMessage(), 'passkey_register_failed'); + } + + // This challenge was issued to somebody registering a key on an account + // they were already signed in to, and it is not that route's answer. + if ($credential['member'] !== 0) { + $this->fail('sign up answered a challenge issued to member ' . $credential['member'], 'passkey_register_failed'); + } + + $identifier = self::identifier($credential['id']); + + if (Credential::find(Credential::TYPE_WEBAUTHN, 0, $identifier) !== null) { + $this->fail('credential is already registered', 'passkey_already_registered'); + } + + $_SESSION['webauthn_signup'] = [ + 'identifier' => $identifier, + 'secret_data' => $this->keepFrom($credential), + 'created' => time(), + ]; + + $this->respond(['ready' => true]); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Turns a credential ID into something the database can index. + * + * Credential IDs run to a kilobyte, and MySQL indexes the first 191 + * characters of the column they live in, so storing them as they come would + * let two different credentials collide on the part that gets indexed. + * Hashing gives a fixed length that is unique all the way along; the ID + * itself is kept beside it for the times a browser needs it back. + * + * @param string $raw_id The credential ID, as raw bytes. + * @return string What to store in the identifier column. + */ + public static function identifier(string $raw_id): string + { + return hash('sha256', $raw_id); + } + + /** + * Whether somebody with no account here may make one with a passkey. + * + * This is a separate choice from letting members add a passkey to an + * account they already have. An account made this way has no password at + * all, so the member's only way back in is the device holding the key, and + * an admin should decide that rather than inherit it from turning passkeys + * on. + * + * @return bool Whether to offer it on the sign up form. + */ + public static function isSignUpAllowed(): bool + { + return Server::isEnabled() + && !empty(Config::$modSettings['webauthn_allow_signup']) + && (empty(Config::$modSettings['registration_method']) || Config::$modSettings['registration_method'] != 3); + } + + /** + * The passkey somebody made while filling in the sign up form, if any. + * + * @return ?array The pending credential, or null if there is not one. + */ + public static function pendingSignUp(): ?array + { + $pending = $_SESSION['webauthn_signup'] ?? null; + + if ( + !\is_array($pending) + || empty($pending['identifier']) + || ($pending['created'] ?? 0) < time() - self::SIGNUP_LIFETIME + ) { + return null; + } + + return $pending; + } + + /****************** + * Internal methods + ******************/ + + /** + * Constructor. Protected to force instantiation via self::load(). + */ + protected function __construct() + { + if (!empty($_REQUEST['sa']) && isset(self::$subactions[$_REQUEST['sa']])) { + $this->subaction = $_REQUEST['sa']; + } + } + + /** + * Stops unless this request may create an account with a passkey. + */ + protected function checkSignUpAllowed(): void + { + if (!self::isSignUpAllowed()) { + $this->fail('passkey sign up is not enabled on this forum', 'passkey_signup_unavailable'); + } + + // Somebody with an account uses the profile page to add a passkey. This + // route makes an account, which they do not need a second of. + if (!User::$me->is_guest) { + $this->fail('member ' . User::$me->id . ' tried to sign up again', 'passkey_signup_unavailable'); + } + } + + /** + * Reads the part of the request the authenticator produced. + * + * @return array The response fields, still base64url encoded. + */ + protected function credentialResponse(): array + { + $response = []; + + // Everything here is base64url, so anything else cannot be one of ours + // and is dropped rather than passed along to be puzzled over later. + foreach (['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature', 'userHandle'] as $field) { + if (isset($_POST[$field]) && \is_string($_POST[$field]) && preg_match('~^[A-Za-z0-9_-]*={0,2}$~', $_POST[$field]) === 1) { + $response[$field] = $_POST[$field]; + } + } + + return $response; + } + + /** + * Reads what was kept alongside a stored passkey. + * + * @param array $credential The credential row. + * @return array The stored key, ID and sign count, whatever is in the row. + */ + protected function secretData(array $credential): array + { + $stored = Utils::jsonDecode($credential['secret_data'] ?? '', true); + + return [ + 'id' => (string) ($stored['id'] ?? ''), + 'key' => (string) ($stored['key'] ?? ''), + 'algorithm' => (int) ($stored['algorithm'] ?? 0), + 'sign_count' => (int) ($stored['sign_count'] ?? 0), + 'aaguid' => (string) ($stored['aaguid'] ?? ''), + 'user_handle' => (string) ($stored['user_handle'] ?? ''), + ]; + } + + /** + * Picks out the parts of a new credential that are worth storing. + * + * @param array $credential What SMF\WebAuthn\Server made of the ceremony. + * @return string The secret_data column's new contents. + */ + protected function keepFrom(array $credential): string + { + return Utils::jsonEncode([ + 'id' => Server::base64UrlEncode($credential['id']), + 'key' => $credential['key'], + 'algorithm' => $credential['algorithm'], + 'sign_count' => $credential['sign_count'], + 'aaguid' => $credential['aaguid'], + 'user_handle' => Server::base64UrlEncode($credential['user_handle']), + ]); + } + + /** + * Finishes a successful sign in. + * + * @param int $id_member Who to log in. + * @return string Where the browser should go next. + */ + protected function finishSignIn(int $id_member): string + { + $loaded = User::load($id_member, User::LOAD_BY_ID, UserDataset::Normal); + + if ($loaded === []) { + $this->fail('passkey belongs to member ' . $id_member . ', who does not exist', 'passkey_login_failed'); + } + + $member = reset($loaded); + + // Same activation rules a password login gets. + if ($member->is_activated % User::BANNED !== User::ACTIVATED) { + $this->fail('member ' . $id_member . ' is not activated', 'passkey_not_activated'); + } + + // Everything a password login does, minus the redirect: this request is + // answering a script, so the script is told where to go instead. + Login2::completeLogin($member, !empty($_POST['stay_logged_in']), false); + + if (!empty(Config::$maintenance) && !User::$me->allowedTo('admin_forum')) { + return Config::$scripturl . '?action=logout;' . Utils::$context['session_var'] . '=' . Utils::$context['session_id']; + } + + // Through login2, which is what sends anyone with a second factor on to + // prove it before they are really let in. + return Config::$scripturl . '?action=login2;sa=check;member=' . User::$me->id; + } + + /** + * Answers the script, and stops. + * + * @param array $data What to tell it. + */ + protected function respond(array $data): never + { + Utils::serverResponse(Utils::jsonEncode($data)); + + exit; + } + + /** + * Logs why a ceremony did not work, and tells the member something useful. + * + * The member never sees the detail. Saying which check failed tells whoever + * is trying exactly what to change, and the honest member cannot do + * anything with it either way. + * + * @param string $detail What actually went wrong. + * @param string $message The language string to show. + */ + protected function fail(string $detail, string $message): never + { + ErrorHandler::log('Passkey: ' . $detail, 'general'); + + $this->respond(['error' => Lang::getTxt($message, file: 'Login')]); + } +} diff --git a/Sources/Actions/Profile/LinkedAccounts.php b/Sources/Actions/Profile/LinkedAccounts.php new file mode 100644 index 0000000000..603296db8e --- /dev/null +++ b/Sources/Actions/Profile/LinkedAccounts.php @@ -0,0 +1,103 @@ +is_me; + Utils::$context['has_password'] = $member->hasUsablePassword(); + + $providers = Provider::loadAll(); + Utils::$context['linked_accounts'] = []; + + foreach (Credential::listFor($member->id, Credential::TYPE_OIDC) as $id_auth => $credential) { + $provider = $providers[(int) $credential['id_provider']] ?? null; + + Utils::$context['linked_accounts'][$id_auth] = [ + 'id' => $id_auth, + 'provider' => $provider === null + ? Lang::getTxt('linked_accounts_unknown_provider', file: 'Profile') + : $provider->title, + 'title' => $credential['title'], + 'date_created' => (int) $credential['date_created'], + 'date_last_used' => (int) $credential['date_last_used'], + ]; + } + + // What they could still add. Anything already linked is left out, since + // one provider account cannot be attached twice. + $linked_providers = array_map( + fn($credential) => (int) $credential['id_provider'], + Credential::listFor($member->id, Credential::TYPE_OIDC), + ); + + Utils::$context['available_providers'] = []; + + foreach ($providers as $provider) { + if (!$provider->enabled || !$provider->isUsable() || \in_array($provider->id, $linked_providers, true)) { + continue; + } + + Utils::$context['available_providers'][$provider->id] = $provider; + } + + // So the template can explain why the last one will not come off. + Utils::$context['is_only_way_in'] = !Utils::$context['has_password'] + && \count(Utils::$context['linked_accounts']) < 2; + } + + /****************** + * Internal methods + ******************/ + + /** + * Constructor. Protected to force instantiation via self::load(). + */ + protected function __construct() + { + if (!isset(Profile::$member)) { + Profile::load(); + } + } +} diff --git a/Sources/Actions/Profile/Main.php b/Sources/Actions/Profile/Main.php index a83fbd3cdb..cd7d6c48e7 100644 --- a/Sources/Actions/Profile/Main.php +++ b/Sources/Actions/Profile/Main.php @@ -17,6 +17,7 @@ use SMF\ActionInterface; use SMF\ActionTrait; +use SMF\Authentication\Provider; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\ErrorHandler; @@ -34,6 +35,7 @@ use SMF\Theme; use SMF\User; use SMF\Utils; +use SMF\WebAuthn\Server; /** * This class has the primary job of showing and editing people's profiles. @@ -339,6 +341,26 @@ class Main implements ActionInterface, Routable 'any' => ['profile_password_any'], ], ], + 'linkedaccounts' => [ + 'label' => 'linked_accounts', + 'function' => __NAMESPACE__ . '\\LinkedAccounts::call', + 'sub_template' => 'linked_accounts', + 'enabled' => true, + 'permission' => [ + 'own' => ['profile_password_own'], + 'any' => ['profile_password_any'], + ], + ], + 'passkeys' => [ + 'label' => 'passkeys', + 'function' => __NAMESPACE__ . '\\Passkeys::call', + 'sub_template' => 'passkeys', + 'enabled' => true, + 'permission' => [ + 'own' => ['profile_password_own'], + 'any' => ['profile_password_any'], + ], + ], 'forumprofile' => [ 'label' => 'forumprofile', 'function' => __NAMESPACE__ . '\\ForumProfile::call', @@ -693,17 +715,19 @@ public function execute(): void $password = $_POST['oldpasswrd'] ?? ''; - // You didn't even enter a password! - if (trim($password) == '') { - Profile::$member->save_errors[] = 'no_password'; - } - // Since the password got modified due to all the $_POST cleaning, lets undo it so we can get the correct password $password = Utils::htmlspecialcharsDecode($password); // Does the integration want to check passwords? $good_password = \in_array(true, IntegrationHook::call('integrate_verify_password', [Profile::$member->username, $password, false]), true); + // You didn't even enter a password! Asked after the hook, because + // a member who signs in without one has nothing to type here, and + // only the integration that signed them in can vouch for them. + if (!$good_password && trim($password) == '') { + Profile::$member->save_errors[] = 'no_password'; + } + // Bad password!!! if (!$good_password && !Security::hashVerifyPassword($password, Profile::$member->passwd)) { Profile::$member->save_errors[] = 'bad_password'; @@ -917,6 +941,11 @@ function (&$value, $key) { $this->profile_areas['edit_profile']['areas']['tfadisable']['enabled'] = !empty(Config::$modSettings['tfa_mode']); + // No point offering this when nobody has set up a provider to link to. + $this->profile_areas['edit_profile']['areas']['linkedaccounts']['enabled'] = Provider::loadAll(true) !== []; + + $this->profile_areas['edit_profile']['areas']['passkeys']['enabled'] = Server::isEnabled(); + $this->profile_areas['edit_profile']['areas']['ignoreboards']['enabled'] = !empty(Config::$modSettings['allow_ignore_boards']); $this->profile_areas['edit_profile']['areas']['lists']['enabled'] = !empty(Config::$modSettings['enable_buddylist']) && Profile::$member->is_me; diff --git a/Sources/Actions/Profile/Passkeys.php b/Sources/Actions/Profile/Passkeys.php new file mode 100644 index 0000000000..5d79ae03d6 --- /dev/null +++ b/Sources/Actions/Profile/Passkeys.php @@ -0,0 +1,123 @@ +is_me; + Utils::$context['has_password'] = $member->hasUsablePassword(); + Utils::$context['passkey_added'] = isset($_GET['added']); + Utils::$context['passkey_error'] = ''; + + if (Utils::$context['can_manage'] && isset($_GET['delete'])) { + $this->delete($member); + } + + Utils::$context['passkeys'] = []; + + foreach (Credential::listFor($member->id, Credential::TYPE_WEBAUTHN) as $id_auth => $credential) { + Utils::$context['passkeys'][$id_auth] = [ + 'id' => $id_auth, + 'title' => $credential['title'], + 'date_created' => (int) $credential['date_created'], + 'date_last_used' => (int) $credential['date_last_used'], + ]; + } + + // So the template can explain why the last one will not come off. + Utils::$context['is_only_way_in'] = !Utils::$context['has_password'] + && \count(Credential::listFor($member->id)) < 2; + + if (Utils::$context['can_manage']) { + Theme::loadJavaScriptFile('webauthn.js', ['defer' => true, 'minimize' => true], 'smf_webauthn'); + } + } + + /****************** + * Internal methods + ******************/ + + /** + * Constructor. Protected to force instantiation via self::load(). + */ + protected function __construct() + { + if (!isset(Profile::$member)) { + Profile::load(); + } + } + + /** + * Takes one of the member's passkeys away. + * + * Done from a link with a session check on it, the same way an identity + * provider is unlinked. The profile area's own save machinery is no use + * here: it redirects before the area's function is reached, so nothing this + * class did would ever run. + * + * @param \SMF\User $member Whose passkey it is. + */ + protected function delete(User $member): void + { + User::$me->checkSession('get'); + + $removed = Credential::remove( + (int) $_GET['delete'], + $member->id, + $member->hasUsablePassword(), + ); + + /* + * Refusing is not an error the member did anything to cause: it means + * this passkey is the only thing that can still get them in, so it says + * so rather than looking like a failure. + */ + if (!$removed) { + Utils::$context['passkey_error'] = Lang::getTxt('passkey_last_one', file: 'Profile'); + } + } +} diff --git a/Sources/Actions/Register.php b/Sources/Actions/Register.php index 17a34dfde4..21d12a334f 100644 --- a/Sources/Actions/Register.php +++ b/Sources/Actions/Register.php @@ -326,9 +326,31 @@ public function show(): void Utils::$context['visual_verification'] = false; } + /* + * Something may have vouched for whoever is filling this in already: an + * identity provider they have just come back from, or a passkey they + * made a moment ago. Either way there is no password to ask them for, + * and the form says how they will be signing in instead. + */ + $identity = AuthExternal::pendingIdentity(); + $passkey = Passkey::pendingSignUp(); + + Utils::$context['registration_passwordless'] = $identity !== null || $passkey !== null; + Utils::$context['registration_passkey_ready'] = $passkey !== null; + Utils::$context['registration_vouched_by'] = $identity === null ? '' : Utils::htmlspecialchars((string) ($identity['title'] ?? '')); + + // Nothing is printed for the passkey button here. It needs an API this + // browser may not have, so the script decides whether to offer it, the + // same way it does on the login form. + Utils::$context['offer_passkey_signup'] = !Utils::$context['registration_passwordless'] && Passkey::isSignUpAllowed(); + + if (Utils::$context['offer_passkey_signup']) { + Theme::loadJavaScriptFile('webauthn.js', ['defer' => true, 'minimize' => true], 'smf_webauthn'); + } + Utils::$context += [ - 'username' => isset($_POST['user']) ? Utils::htmlspecialchars($_POST['user']) : '', - 'email' => isset($_POST['email']) ? Utils::htmlspecialchars($_POST['email']) : '', + 'username' => isset($_POST['user']) ? Utils::htmlspecialchars($_POST['user']) : ($identity === null ? '' : Utils::htmlspecialchars((string) $identity['name'])), + 'email' => isset($_POST['email']) ? Utils::htmlspecialchars($_POST['email']) : ($identity === null ? '' : Utils::htmlspecialchars((string) $identity['email'])), 'notify_announcements' => !empty($_POST['notify_announcements']) ? 1 : 0, ]; diff --git a/Sources/Actions/Register2.php b/Sources/Actions/Register2.php index a8f44e8098..9cb2d458b4 100644 --- a/Sources/Actions/Register2.php +++ b/Sources/Actions/Register2.php @@ -15,6 +15,7 @@ namespace SMF\Actions; +use SMF\Authentication\Credential; use SMF\Config; use SMF\Cookie; use SMF\Db\DatabaseApi as Db; @@ -244,13 +245,24 @@ function (&$value, $key) { unset($_POST['lngfile']); } + /* + * Has something vouched for them already? An identity provider they + * came back from, or a passkey they made while filling this in. Either + * way the form did not ask for a password, so whatever is posted under + * that name is ignored rather than half believed. + */ + $identity = AuthExternal::pendingIdentity(); + $passkey = Passkey::pendingSignUp(); + $passwordless = $identity !== null || $passkey !== null; + // Set the options needed for registration. $reg_options = [ 'interface' => 'guest', 'username' => !empty($_POST['user']) ? $_POST['user'] : '', 'email' => !empty($_POST['email']) ? $_POST['email'] : '', - 'password' => !empty($_POST['passwrd1']) ? $_POST['passwrd1'] : '', - 'password_check' => !empty($_POST['passwrd2']) ? $_POST['passwrd2'] : '', + 'password' => $passwordless || empty($_POST['passwrd1']) ? '' : $_POST['passwrd1'], + 'password_check' => $passwordless || empty($_POST['passwrd2']) ? '' : $_POST['passwrd2'], + 'passwordless' => $passwordless, 'check_reserved_name' => true, 'check_password_strength' => true, 'check_email_ban' => true, @@ -388,6 +400,40 @@ function (&$value, $key) { /* @var int $member_id */ + /* + * Did an identity provider send them here to sign up? Attach it now, so + * the next time they arrive they are recognised rather than asked to + * register all over again. The account went through the ordinary sign up + * rules to get here, which is the whole point of sending them this way. + */ + if ($identity !== null) { + Credential::add( + $member_id, + Credential::TYPE_OIDC, + (int) $identity['provider'], + (string) $identity['subject'], + (string) ($identity['title'] ?? '') . ' (' . (string) (($identity['email'] ?? '') ?: $identity['subject']) . ')', + ); + } + + /* + * Or did they make a passkey on the way through? It has been waiting in + * the session since the ceremony, because until this moment there was no + * account for it to belong to. + */ + if ($passkey !== null) { + Credential::add( + $member_id, + Credential::TYPE_WEBAUTHN, + 0, + (string) $passkey['identifier'], + Lang::getTxt('passkey_default_title', file: 'Profile'), + (string) $passkey['secret_data'], + ); + } + + unset($_SESSION['authext_pending'], $_SESSION['webauthn_signup']); + // Do our spam protection now. Security::spamProtection('register'); @@ -446,6 +492,10 @@ function (&$value, $key) { * The function will adjust member statistics. * If an error is detected will fatal error on all errors unless return_errors is true. * + * Set the 'passwordless' option to create an account with no password at + * all, for when the caller has arranged some other way for the member to + * prove who they are and has already checked it. + * * @param array $reg_options An array of registration options * @param bool $return_errors Whether to return the errors * @return int|array The ID of the newly registered user or an array of error info if $return_errors is true @@ -455,6 +505,14 @@ public static function registerMember(array &$reg_options, bool $return_errors = // Put any errors in here. $reg_errors = []; + /* + * An account that will have no password at all, because something else + * vouches for whoever owns it. Only a caller that has already arranged + * that asks for it -- it is never taken from the request, since an + * account nobody can sign in to is not one to create by accident. + */ + $passwordless = !empty($reg_options['passwordless']); + // Registration from the admin center, let them sweat a little more. if ($reg_options['interface'] == 'admin') { User::$me->kickIfGuest(); @@ -498,17 +556,17 @@ public static function registerMember(array &$reg_options, bool $return_errors = } // If you haven't put in a password generate one. - if ($reg_options['interface'] == 'admin' && $reg_options['password'] == '') { + if (!$passwordless && $reg_options['interface'] == 'admin' && $reg_options['password'] == '') { $reg_options['password'] = Security::generatePassword(); $reg_options['password_check'] = $reg_options['password']; } // Does the first password match the second? - elseif ($reg_options['password'] != $reg_options['password_check']) { + elseif (!$passwordless && $reg_options['password'] != $reg_options['password_check']) { $reg_errors[] = ['lang', 'passwords_dont_match']; } // That's kind of easy to guess... - if ($reg_options['password'] == '') { + if (!$passwordless && $reg_options['password'] == '') { $reg_errors[] = ['lang', 'no_password']; } @@ -620,7 +678,9 @@ public static function registerMember(array &$reg_options, bool $return_errors = $reg_options['register_vars'] = [ 'member_name' => $reg_options['username'], 'email_address' => $reg_options['email'], - 'passwd' => Security::hashPassword($reg_options['password']), + // Nothing hashes to an empty string, so this is a value no password + // can ever match. See SMF\User::hasUsablePassword(). + 'passwd' => $passwordless ? '' : Security::hashPassword($reg_options['password']), 'password_salt' => bin2hex(random_bytes(16)), 'posts' => 0, 'date_registered' => time(), diff --git a/Sources/Authentication/Credential.php b/Sources/Authentication/Credential.php new file mode 100644 index 0000000000..672146ff4b --- /dev/null +++ b/Sources/Authentication/Credential.php @@ -0,0 +1,248 @@ +insert( + 'ignore', + '{db_prefix}member_auth', + [ + 'id_member' => 'int', + 'type' => 'string', + 'id_provider' => 'int', + 'identifier' => 'string', + 'secret_data' => 'string', + 'title' => 'string', + 'date_created' => 'int', + 'date_last_used' => 'int', + ], + [ + [ + $id_member, + $type, + $id_provider, + $identifier, + $secret_data, + $title, + time(), + time(), + ], + ], + ['id_auth'], + ); + } + + /** + * Finds the member who signs in with this credential. + * + * @param string $type One of this class's TYPE_ constants. + * @param int $id_provider Which provider it came from, or 0. + * @param string $identifier What the issuer calls this credential. + * @return int The member's ID, or 0 if nobody has claimed it. + */ + public static function findMember(string $type, int $id_provider, string $identifier): int + { + $request = Db::$db->query( + 'SELECT id_member + FROM {db_prefix}member_auth + WHERE type = {string:type} + AND id_provider = {int:provider} + AND identifier = {string:identifier} + LIMIT 1', + [ + 'type' => $type, + 'provider' => $id_provider, + 'identifier' => $identifier, + ], + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return (int) ($row['id_member'] ?? 0); + } + + /** + * Fetches one credential, with everything that was kept alongside it. + * + * @param string $type One of this class's TYPE_ constants. + * @param int $id_provider Which provider it came from, or 0. + * @param string $identifier What the issuer calls this credential. + * @return ?array The row, or null if nobody has claimed it. + */ + public static function find(string $type, int $id_provider, string $identifier): ?array + { + $request = Db::$db->query( + 'SELECT id_auth, id_member, type, id_provider, identifier, secret_data, title, date_created, date_last_used + FROM {db_prefix}member_auth + WHERE type = {string:type} + AND id_provider = {int:provider} + AND identifier = {string:identifier} + LIMIT 1', + [ + 'type' => $type, + 'provider' => $id_provider, + 'identifier' => $identifier, + ], + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return $row === false || $row === null ? null : $row; + } + + /** + * Replaces what was kept alongside a credential. + * + * @param int $id_auth The credential to update. + * @param string $secret_data What to keep instead. + */ + public static function setSecretData(int $id_auth, string $secret_data): void + { + Db::$db->query( + 'UPDATE {db_prefix}member_auth + SET secret_data = {string:secret_data} + WHERE id_auth = {int:id}', + [ + 'secret_data' => $secret_data, + 'id' => $id_auth, + ], + ); + } + + /** + * Lists what a member can sign in with. + * + * @param int $id_member The member. + * @param ?string $type Only this kind, or null for all of them. + * @return array The rows, newest last. + */ + public static function listFor(int $id_member, ?string $type = null): array + { + $credentials = []; + + $request = Db::$db->query( + 'SELECT id_auth, id_member, type, id_provider, identifier, secret_data, title, date_created, date_last_used + FROM {db_prefix}member_auth + WHERE id_member = {int:member}' . ($type === null ? '' : ' + AND type = {string:type}') . ' + ORDER BY date_created', + [ + 'member' => $id_member, + 'type' => (string) $type, + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $credentials[(int) $row['id_auth']] = $row; + } + + Db::$db->free_result($request); + + return $credentials; + } + + /** + * Notes that a credential was just used. + * + * @param string $type One of this class's TYPE_ constants. + * @param int $id_provider Which provider it came from, or 0. + * @param string $identifier What the issuer calls this credential. + */ + public static function touch(string $type, int $id_provider, string $identifier): void + { + Db::$db->query( + 'UPDATE {db_prefix}member_auth + SET date_last_used = {int:now} + WHERE type = {string:type} + AND id_provider = {int:provider} + AND identifier = {string:identifier}', + [ + 'now' => time(), + 'type' => $type, + 'provider' => $id_provider, + 'identifier' => $identifier, + ], + ); + } + + /** + * Removes one of a member's credentials. + * + * Refuses to remove the last one when the member has no password, since + * that would leave them with no way back in. + * + * @param int $id_auth The credential to remove. + * @param int $id_member Who it must belong to. + * @param bool $has_password Whether they can still log in without it. + * @return bool Whether it was removed. + */ + public static function remove(int $id_auth, int $id_member, bool $has_password): bool + { + if (!$has_password && \count(self::listFor($id_member)) < 2) { + return false; + } + + Db::$db->query( + 'DELETE FROM {db_prefix}member_auth + WHERE id_auth = {int:id} + AND id_member = {int:member}', + [ + 'id' => $id_auth, + 'member' => $id_member, + ], + ); + + return true; + } +} diff --git a/Sources/Authentication/OidcClient.php b/Sources/Authentication/OidcClient.php new file mode 100644 index 0000000000..7918d58db1 --- /dev/null +++ b/Sources/Authentication/OidcClient.php @@ -0,0 +1,483 @@ +provider = $provider; + } + + /** + * Fetches, and caches, the provider's discovery document. + * + * @param bool $force Whether to refetch even if we have one. + * @return array The document, or an empty array if it could not be had. + */ + public function discover(bool $force = false): array + { + $cached = $this->provider->settings['discovery'] ?? []; + + // A day is long enough to notice a provider moving an endpoint, and + // short enough not to hammer them on every login. + if ( + !$force + && $cached !== [] + && ($this->provider->settings['discovered_at'] ?? 0) > time() - 86400 + ) { + return $cached; + } + + $url = rtrim($this->provider->issuer, '/') . '/.well-known/openid-configuration'; + $body = $this->fetch($url); + + if ($body === null) { + // Stale endpoints beat no endpoints if the provider is briefly down. + return $cached; + } + + $document = Utils::jsonDecode($body, true); + + if (!\is_array($document) || empty($document['authorization_endpoint']) || empty($document['token_endpoint'])) { + $this->error = 'discovery document from ' . $url . ' is missing its endpoints'; + + return $cached; + } + + // The issuer has to agree with where we looked, or we are being told + // about somebody else's endpoints. + if (rtrim($document['issuer'] ?? '', '/') !== rtrim($this->provider->issuer, '/')) { + $this->error = 'discovery issuer ' . ($document['issuer'] ?? '(none)') . ' does not match ' . $this->provider->issuer; + + return $cached; + } + + $this->provider->settings['discovery'] = $document; + $this->provider->settings['discovered_at'] = time(); + $this->provider->save(); + + return $document; + } + + /** + * Builds the URL to send the member to, and the state to remember. + * + * @param string $return_to Where to put them once they are back. + * @return ?array The 'url' to send them to and the 'state' to stash in the + * session, or null if we could not work out where to send them. + */ + public function beginAuthorization(string $return_to = ''): ?array + { + $document = $this->discover(); + + if (empty($document['authorization_endpoint'])) { + return null; + } + + // The verifier never leaves this server; only its hash goes out, so an + // intercepted authorization code cannot be redeemed by anyone else. + $verifier = self::base64UrlEncode(random_bytes(32)); + + $state = [ + 'provider' => $this->provider->id, + 'state' => bin2hex(random_bytes(16)), + 'nonce' => bin2hex(random_bytes(16)), + 'verifier' => $verifier, + 'return_to' => $return_to, + 'created' => time(), + ]; + + $query = [ + 'response_type' => 'code', + 'client_id' => $this->provider->client_id, + 'redirect_uri' => $this->provider->redirectUri(), + 'scope' => $this->provider->scopes, + 'state' => $state['state'], + 'nonce' => $state['nonce'], + 'code_challenge' => self::base64UrlEncode(hash('sha256', $verifier, true)), + 'code_challenge_method' => 'S256', + ]; + + return [ + 'url' => $document['authorization_endpoint'] + . (str_contains($document['authorization_endpoint'], '?') ? '&' : '?') + . http_build_query($query, '', '&'), + 'state' => $state, + ]; + } + + /** + * Trades the authorization code for tokens, and returns the claims. + * + * @param string $code The code the provider sent back. + * @param array $state What beginAuthorization() stashed in the session. + * @return ?array The claims about the member, or null if anything is off. + */ + public function completeAuthorization(string $code, array $state): ?array + { + $document = $this->discover(); + + if (empty($document['token_endpoint'])) { + $this->error = 'no token endpoint'; + + return null; + } + + $body = $this->fetch( + $document['token_endpoint'], + [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->provider->redirectUri(), + 'code_verifier' => $state['verifier'] ?? '', + // Sent as well as the Basic header, because providers differ on + // which they accept and sending both is harmless. + 'client_id' => $this->provider->client_id, + 'client_secret' => $this->provider->client_secret, + ], + [ + 'Authorization: Basic ' . base64_encode( + rawurlencode($this->provider->client_id) . ':' . rawurlencode($this->provider->client_secret), + ), + ], + ); + + if ($body === null) { + return null; + } + + $token = Utils::jsonDecode($body, true); + + if (!\is_array($token) || empty($token['id_token'])) { + $this->error = 'token endpoint returned no id_token'; + + return null; + } + + $claims = self::decodeIdToken($token['id_token']); + + if ($claims === null) { + $this->error = 'could not read the id_token'; + + return null; + } + + if (!$this->claimsAreAcceptable($claims, $state)) { + return null; + } + + // Ask for the rest only if the token did not carry it. Some providers + // keep the ID token small and put the profile behind userinfo. + if (empty($claims['email']) && !empty($document['userinfo_endpoint']) && !empty($token['access_token'])) { + $body = $this->fetch( + $document['userinfo_endpoint'], + null, + ['Authorization: Bearer ' . $token['access_token']], + ); + + $userinfo = $body === null ? null : Utils::jsonDecode($body, true); + + // The sub has to be the same person we just authenticated. + if (\is_array($userinfo) && ($userinfo['sub'] ?? '') === $claims['sub']) { + $claims += $userinfo; + } + } + + return $claims; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Base64url, as the JOSE specifications use it. + * + * @param string $data Raw bytes. + * @return string The encoded form. + */ + public static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * The reverse of self::base64UrlEncode(). + * + * @param string $data The encoded form. + * @return string Raw bytes. + */ + public static function base64UrlDecode(string $data): string + { + return (string) base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - \strlen($data) % 4) % 4), true); + } + + /** + * Reads the claims out of an ID token without checking its signature. + * + * Only safe because of where the caller got the token; see the note on this + * class. Do not call this with a token that arrived any other way. + * + * @param string $id_token The JWT. + * @return ?array The payload, or null if it is not a readable JWT. + */ + public static function decodeIdToken(string $id_token): ?array + { + $parts = explode('.', $id_token); + + if (\count($parts) !== 3) { + return null; + } + + $claims = Utils::jsonDecode(self::base64UrlDecode($parts[1]), true); + + return \is_array($claims) && !empty($claims['sub']) ? $claims : null; + } + + /****************** + * Internal methods + ******************/ + + /** + * Checks the claims are about us, from who we asked, and still current. + * + * @param array $claims The decoded ID token payload. + * @param array $state What we stashed before sending the member away. + * @return bool Whether the claims can be trusted. + */ + protected function claimsAreAcceptable(array $claims, array $state): bool + { + if (rtrim($claims['iss'] ?? '', '/') !== rtrim($this->provider->issuer, '/')) { + $this->error = 'id_token issuer ' . ($claims['iss'] ?? '(none)') . ' is not ' . $this->provider->issuer; + + return false; + } + + // aud is either our client ID or a list containing it. + $audience = (array) ($claims['aud'] ?? []); + + if (!\in_array($this->provider->client_id, $audience, true)) { + $this->error = 'id_token was not issued for this client'; + + return false; + } + + // When more than one audience is named the provider must say which one + // it was really for, and it has to be us. + if (\count($audience) > 1 && ($claims['azp'] ?? $this->provider->client_id) !== $this->provider->client_id) { + $this->error = 'id_token authorized party is somebody else'; + + return false; + } + + if (!isset($claims['exp']) || (int) $claims['exp'] < time() - 60) { + $this->error = 'id_token has expired'; + + return false; + } + + // Ties this token to the request we started, so one obtained elsewhere + // cannot be replayed into this session. + if (($claims['nonce'] ?? '') !== ($state['nonce'] ?? '')) { + $this->error = 'id_token nonce does not match the one we sent'; + + return false; + } + + return true; + } + + /** + * Makes one back channel request to the provider. + * + * Deliberately not WebFetchApi::fetch(): that cannot set request headers, + * and it rewrites the host to a literal IP, which defeats the certificate + * check we need here. CurlFetcher would work but defaults to + * CURLOPT_SSL_VERIFYPEER false, which is not acceptable for a token + * exchange, so the options that matter are set explicitly instead. + * + * @param string $url Where to send it. + * @param ?array $post_data Form fields to post, or null for a GET. + * @param array $headers Extra request headers. + * @return ?string The response body, or null if the call failed. + */ + protected function fetch(string $url, ?array $post_data = null, array $headers = []): ?string + { + if (!\function_exists('curl_init')) { + $this->error = 'curl is not available'; + + return null; + } + + $parsed = Url::create($url, true); + + if (($parsed->scheme ?? '') !== 'https' && !$this->allowsInsecure($parsed)) { + $this->error = 'refusing to talk to ' . $url . ' without https'; + + return null; + } + + if (!$this->hostIsAllowed($parsed)) { + $this->error = $url . ' resolves to a private address and this provider does not allow that'; + + return null; + } + + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 20, + CURLOPT_USERAGENT => SMF_USER_AGENT, + // Not negotiable. See the note on this class. + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers), + ]); + + if ($post_data !== null) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data, '', '&')); + } + + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curl_error = curl_error($ch); + + curl_close($ch); + + if ($body === false) { + $this->error = 'request to ' . $url . ' failed: ' . $curl_error; + + return null; + } + + if ($code < 200 || $code > 299) { + $this->error = $url . ' answered ' . $code . ': ' . substr((string) $body, 0, 200); + + return null; + } + + return (string) $body; + } + + /** + * Whether this provider may be reached over plain http. + * + * Only ever true for a host that is already allowed to be private, which in + * practice means a provider on the same machine or network as the forum. + * + * @param \SMF\Url $url The URL in question. + * @return bool Whether to allow it. + */ + protected function allowsInsecure(Url $url): bool + { + return !empty($this->provider->settings['allow_private_host']) && !$this->resolvesGlobally($url); + } + + /** + * Whether we are willing to send this provider's traffic to this host. + * + * @param \SMF\Url $url The URL in question. + * @return bool Whether to allow it. + */ + protected function hostIsAllowed(Url $url): bool + { + return $this->resolvesGlobally($url) || !empty($this->provider->settings['allow_private_host']); + } + + /** + * Whether every address this host resolves to is a public one. + * + * @param \SMF\Url $url The URL in question. + * @return bool Whether it is out on the internet. + */ + protected function resolvesGlobally(Url $url): bool + { + if (empty($url->host)) { + return false; + } + + $ips = $url->getIPs(); + + if ($ips === []) { + return false; + } + + foreach ($ips as $ip) { + if (!$ip->isValid(FILTER_FLAG_GLOBAL_RANGE)) { + return false; + } + } + + return true; + } +} diff --git a/Sources/Authentication/Provider.php b/Sources/Authentication/Provider.php new file mode 100644 index 0000000000..c8a376adfe --- /dev/null +++ b/Sources/Authentication/Provider.php @@ -0,0 +1,376 @@ +settings = self::defaultSettings(); + + return; + } + + $this->id = (int) $row['id_provider']; + $this->type = $row['provider_type']; + $this->title = $row['title']; + $this->issuer = $row['issuer']; + $this->client_id = $row['client_id']; + $this->client_secret = $row['client_secret']; + $this->scopes = $row['scopes']; + $this->enabled = !empty($row['enabled']); + $this->order = (int) $row['provider_order']; + // array_merge, not +: with + the left hand side wins for keys present in + // both, which would quietly discard everything that was saved. + $this->settings = array_merge(self::defaultSettings(), (array) Utils::jsonDecode($row['settings'] ?? '', true)); + } + + /** + * Whether this provider has enough filled in to attempt a sign in. + * + * @return bool Whether it does. + */ + public function isUsable(): bool + { + return $this->issuer !== '' && $this->client_id !== '' && $this->client_secret !== ''; + } + + /** + * Where the identity provider sends the member back to. + * + * Registered with the provider, so it has to be stable and exact. Built + * from Config::$boardurl rather than the current request, because the two + * can differ and only one of them was registered. + * + * @return string The redirect URI. + */ + public function redirectUri(): string + { + return Config::$boardurl . '/index.php?action=authext;sa=callback;provider=' . $this->id; + } + + /** + * Saves this provider, inserting it if it is new. + * + * @return int This provider's ID. + */ + public function save(): int + { + $columns = [ + 'provider_type' => 'string', + 'title' => 'string', + 'issuer' => 'string', + 'client_id' => 'string', + 'client_secret' => 'string', + 'scopes' => 'string', + 'enabled' => 'int', + 'provider_order' => 'int', + 'settings' => 'string', + ]; + + $values = [ + $this->type, + $this->title, + rtrim($this->issuer, '/'), + $this->client_id, + $this->client_secret, + $this->scopes, + (int) $this->enabled, + $this->order, + json_encode($this->settings), + ]; + + if ($this->id === 0) { + $this->id = Db::$db->insert( + 'insert', + '{db_prefix}auth_providers', + $columns, + [$values], + ['id_provider'], + Db::INSERT_RETURN_MODE_SINGLE, + ); + + return $this->id; + } + + Db::$db->query( + 'UPDATE {db_prefix}auth_providers + SET + provider_type = {string:type}, + title = {string:title}, + issuer = {string:issuer}, + client_id = {string:client_id}, + client_secret = {string:client_secret}, + scopes = {string:scopes}, + enabled = {int:enabled}, + provider_order = {int:order}, + settings = {string:settings} + WHERE id_provider = {int:id}', + [ + 'type' => $this->type, + 'title' => $this->title, + 'issuer' => rtrim($this->issuer, '/'), + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'scopes' => $this->scopes, + 'enabled' => (int) $this->enabled, + 'order' => $this->order, + 'settings' => json_encode($this->settings), + 'id' => $this->id, + ], + ); + + return $this->id; + } + + /** + * Deletes this provider, and every credential that came from it. + */ + public function delete(): void + { + if ($this->id === 0) { + return; + } + + Db::$db->query( + 'DELETE FROM {db_prefix}member_auth + WHERE type = {string:type} + AND id_provider = {int:id}', + [ + 'type' => $this->type, + 'id' => $this->id, + ], + ); + + Db::$db->query( + 'DELETE FROM {db_prefix}auth_providers + WHERE id_provider = {int:id}', + [ + 'id' => $this->id, + ], + ); + + $this->id = 0; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * The settings every provider has, and what they mean. + * + * @return array The defaults. + */ + public static function defaultSettings(): array + { + return [ + // The discovery document, and when we fetched it. + 'discovery' => [], + 'discovered_at' => 0, + /* + * Whether an unrecognised sign in may claim an existing account + * because the email matches. Off by default and deliberately so: + * it is an account takeover waiting to happen at any provider that + * does not verify the addresses it hands out. Even when on, the + * claim is only honoured if the provider says email_verified. + */ + 'link_by_verified_email' => false, + // Whether a sign in may create an account that does not exist yet. + 'allow_registration' => true, + /* + * Whether to allow an issuer that resolves to a private address. + * Needed for a self hosted provider on the same network, and off + * by default so a public forum cannot be pointed inwards. + */ + 'allow_private_host' => false, + ]; + } + + /** + * Loads one provider. + * + * @param int $id The provider to load. + * @return ?self The provider, or null if there is no such thing. + */ + public static function load(int $id): ?self + { + $request = Db::$db->query( + 'SELECT * + FROM {db_prefix}auth_providers + WHERE id_provider = {int:id} + LIMIT 1', + [ + 'id' => $id, + ], + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return $row === false || $row === null ? null : new self($row); + } + + /** + * Loads every provider. + * + * @param bool $enabled_only Whether to skip the disabled ones. + * @return array Instances of this class, in display order. + */ + public static function loadAll(bool $enabled_only = false): array + { + $providers = []; + + $request = Db::$db->query( + 'SELECT * + FROM {db_prefix}auth_providers' . ($enabled_only ? ' + WHERE enabled = {int:one}' : '') . ' + ORDER BY provider_order, id_provider', + [ + 'one' => 1, + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $providers[(int) $row['id_provider']] = new self($row); + } + + Db::$db->free_result($request); + + return $providers; + } + + /** + * The issuer and scopes to start from for well known providers. + * + * Only fills in the parts that are the same for everyone. The client ID and + * secret still have to come from whoever registered the forum with them. + * + * @return array Preset name => the fields it sets. + */ + public static function presets(): array + { + return [ + 'google' => [ + 'title' => 'Google', + 'issuer' => 'https://accounts.google.com', + 'scopes' => 'openid email profile', + ], + 'microsoft' => [ + 'title' => 'Microsoft', + 'issuer' => 'https://login.microsoftonline.com/common/v2.0', + 'scopes' => 'openid email profile', + ], + 'apple' => [ + 'title' => 'Apple', + 'issuer' => 'https://appleid.apple.com', + 'scopes' => 'openid email name', + ], + ]; + } +} diff --git a/Sources/Authentication/index.php b/Sources/Authentication/index.php new file mode 100644 index 0000000000..2844a3b9e7 --- /dev/null +++ b/Sources/Authentication/index.php @@ -0,0 +1,8 @@ +name = 'auth_providers'; + + $this->columns = [ + 'id_provider' => new Column( + name: 'id_provider', + type: 'int', + unsigned: true, + not_null: true, + auto: true, + ), + // Which kind of provider this is. Only 'oidc' means anything today; + // the column is here so a second protocol does not need a new table. + 'provider_type' => new Column( + name: 'provider_type', + type: 'varchar', + size: 20, + not_null: true, + default: 'oidc', + ), + // What the button on the login form says. + 'title' => new Column( + name: 'title', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + // The issuer URL. Everything else is discovered from it. + 'issuer' => new Column( + name: 'issuer', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'client_id' => new Column( + name: 'client_id', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'client_secret' => new Column( + name: 'client_secret', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'scopes' => new Column( + name: 'scopes', + type: 'varchar', + size: 255, + not_null: true, + default: 'openid email profile', + ), + 'enabled' => new Column( + name: 'enabled', + type: 'tinyint', + unsigned: true, + not_null: true, + default: 0, + ), + // Not called 'order': that is reserved on both engines. + 'provider_order' => new Column( + name: 'provider_order', + type: 'smallint', + unsigned: true, + not_null: true, + default: 0, + ), + // JSON. The cached discovery document lives here, along with the + // per provider policy switches. + 'settings' => new Column( + name: 'settings', + type: 'text', + not_null: true, + ), + ]; + + $this->indexes = [ + 'primary' => new DbIndex( + type: 'primary', + columns: [ + [ + 'name' => 'id_provider', + ], + ], + ), + 'idx_enabled' => new DbIndex( + name: 'idx_enabled', + columns: [ + [ + 'name' => 'enabled', + ], + ], + ), + ]; + } +} diff --git a/Sources/Db/Schema/v3_0/MemberAuth.php b/Sources/Db/Schema/v3_0/MemberAuth.php new file mode 100644 index 0000000000..cebebbc39d --- /dev/null +++ b/Sources/Db/Schema/v3_0/MemberAuth.php @@ -0,0 +1,149 @@ +name = 'member_auth'; + + $this->columns = [ + 'id_auth' => new Column( + name: 'id_auth', + type: 'int', + unsigned: true, + not_null: true, + auto: true, + ), + 'id_member' => new Column( + name: 'id_member', + type: 'mediumint', + unsigned: true, + not_null: true, + default: 0, + ), + // What kind of credential this is, e.g. the name of the mod that + // owns it. Whoever writes the row decides, and is the only thing + // that should read it back. + 'type' => new Column( + name: 'type', + type: 'varchar', + size: 20, + not_null: true, + default: '', + ), + // Which configured provider this belongs to, for credential types + // that can have more than one. 0 when the type has no such concept. + 'id_provider' => new Column( + name: 'id_provider', + type: 'int', + unsigned: true, + not_null: true, + default: 0, + ), + // Whatever identifies this credential to the thing that issued it. + // Unique per type and provider, so it is what a lookup matches on. + // Note that MySQL indexes only the first 191 characters of this, so + // do not store something whose meaning lives beyond that; hash it + // down to something shorter first if it might. + 'identifier' => new Column( + name: 'identifier', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + // Anything else the owner needs to keep, as it sees fit. + 'secret_data' => new Column( + name: 'secret_data', + type: 'text', + not_null: true, + ), + // What the member calls this credential, when they can name it. + 'title' => new Column( + name: 'title', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'date_created' => new Column( + name: 'date_created', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + 'date_last_used' => new Column( + name: 'date_last_used', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + ]; + + $this->indexes = [ + 'primary' => new DbIndex( + type: 'primary', + columns: [ + [ + 'name' => 'id_auth', + ], + ], + ), + // One credential cannot belong to two members. + 'idx_credential' => new DbIndex( + name: 'idx_credential', + type: 'unique', + columns: [ + [ + 'name' => 'type', + ], + [ + 'name' => 'id_provider', + ], + [ + 'name' => 'identifier', + ], + ], + ), + 'idx_id_member' => new DbIndex( + name: 'idx_id_member', + columns: [ + [ + 'name' => 'id_member', + ], + ], + ), + ]; + } +} diff --git a/Sources/Forum.php b/Sources/Forum.php index cd5ed4f8bd..14d3f8fe31 100644 --- a/Sources/Forum.php +++ b/Sources/Forum.php @@ -90,6 +90,9 @@ class Forum 'attachapprove' => [ '', Actions\AttachmentApprove::class, ], + 'authext' => [ + '', Actions\AuthExternal::class, + ], 'boardindex' => [ '', Actions\BoardIndex::class, ], @@ -200,6 +203,9 @@ class Forum 'notifytopic' => [ '', Actions\NotifyTopic::class, ], + 'passkey' => [ + '', Actions\Passkey::class, + ], 'pm' => [ '', Actions\PersonalMessage::class, ], diff --git a/Sources/Maintenance/Migration/v3_0/CreateAuthProviders.php b/Sources/Maintenance/Migration/v3_0/CreateAuthProviders.php new file mode 100644 index 0000000000..b91a62953b --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/CreateAuthProviders.php @@ -0,0 +1,61 @@ +prefix, since + * the latter is database qualified while list_tables() reports bare names. + */ + public function isCandidate(): bool + { + $auth_providers = new Schema\v3_0\AuthProviders(); + + return !\in_array(Config::$db_prefix . $auth_providers->name, Db::$db->list_tables()); + } + + /** + * + */ + public function execute(): bool + { + $auth_providers = new Schema\v3_0\AuthProviders(); + $auth_providers->create(); + + return true; + } +} diff --git a/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php new file mode 100644 index 0000000000..c5bf168ce5 --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php @@ -0,0 +1,63 @@ +prefix. + * The latter is database qualified, e.g. `smf`.smf_, while list_tables() + * reports bare names, so it would never match. That mismatch is also why + * Table::exists() cannot be used here. + */ + public function isCandidate(): bool + { + $member_auth = new Schema\v3_0\MemberAuth(); + + return !\in_array(Config::$db_prefix . $member_auth->name, Db::$db->list_tables()); + } + + /** + * + */ + public function execute(): bool + { + $member_auth = new Schema\v3_0\MemberAuth(); + $member_auth->create(); + + return true; + } +} diff --git a/Sources/Maintenance/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index bda39f8ca0..9cea17efa8 100644 --- a/Sources/Maintenance/Tools/Upgrade.php +++ b/Sources/Maintenance/Tools/Upgrade.php @@ -182,6 +182,8 @@ class Upgrade extends ToolsBase implements ToolsInterface Migration\v3_0\PermissionChanges::class, Migration\v3_0\BoardPostsCount::class, Migration\v3_0\ValidationCodeLength::class, + Migration\v3_0\CreateMemberAuth::class, + Migration\v3_0\CreateAuthProviders::class, ], ]; diff --git a/Sources/User.php b/Sources/User.php index 5a858de578..e92e23da7a 100644 --- a/Sources/User.php +++ b/Sources/User.php @@ -2241,6 +2241,39 @@ public function enforceBans(bool $force_check = false, bool $post_kick = false, } } + /** + * Whether this member has any second authentication factor set up. + * + * @return bool Whether they do. + */ + public function hasSecondFactor(): bool + { + return self::getSecondFactors($this->id) !== []; + } + + /** + * Whether this member can log in by typing a password. + * + * SMF has always given every account a password, so most code can assume + * one exists. That stops being true as soon as something else can vouch for + * a member, so anywhere that asks for a password needs to cope with the + * answer being "they don't have one". + * + * An empty passwd is the marker. There is no separate flag column, because + * password_verify() already refuses to match anything against an empty + * hash; this method exists to say so out loud rather than relying on that. + * + * @return bool Whether asking this member for their password makes sense. + */ + public function hasUsablePassword(): bool + { + // passwd is a typed property, so it may not be populated for every + // dataset. Fall back to the raw profile data before giving up. + $passwd = $this->passwd ?? (self::$profiles[$this->id]['passwd'] ?? ''); + + return trim($passwd) !== ''; + } + /** * Check if the user is who he/she says he is. * @@ -2298,6 +2331,27 @@ public function validateSession(string $type = 'admin', bool $force = false): ?s } } + /* + * If this member has no password, asking them to retype it is not going + * to work, and the prompt below would lock them out of the admin and + * moderation areas entirely. + * + * MOD AUTHORS: if you let members sign in without a password, you must + * implement this hook as well, and re-verify them however they signed in + * originally. Return true once you are satisfied it is really them. + * Nothing in SMF itself creates a member without a password, so this + * hook is never reached on a stock install. + */ + if (!$this->hasUsablePassword()) { + if (\in_array(true, IntegrationHook::call('integrate_reauthenticate', [$type, $this->id]), true)) { + $_SESSION[$type . '_time'] = time(); + + unset($_SESSION['request_referer']); + + return null; + } + } + // Posting the password... check it. if (isset($_POST[$type . '_pass'])) { // Check to ensure we're forcing SSL for authentication @@ -2821,6 +2875,48 @@ public function groupsCanModerate(bool $ignore_protected = false): array * Public static methods ***********************/ + /** + * Lists the second authentication factors a member has set up. + * + * Two factor authentication used to mean exactly one thing, the time based + * codes in SMF\TOTP\Auth, so the rest of the code asked about it by looking + * at the tfa_secret column directly. Ask here instead, so that a mod adding + * another kind of factor is visible to those checks too. + * + * Keys are short identifiers for the factor, values describe it for display. + * The built in factor uses the key 'totp'. + * + * Note that this reports what the member has configured, not whether the + * forum currently wants a second factor from them. The tfa_mode setting is + * what decides that, and callers check it separately. + * + * This reads the loaded profile data rather than an instance's properties, + * because it has to work during self::loadMe(), where self::verifyTfa() runs + * before self::setProperties() has populated anything. + * + * @param int $id_member The member to ask about. + * @return array The factors this member has, which may be empty. + */ + public static function getSecondFactors(int $id_member): array + { + $factors = []; + + if (!empty(self::$profiles[$id_member]['tfa_secret'])) { + $factors['totp'] = Lang::getTxt('tfa_title', file: 'Profile'); + } + + /* + * MOD AUTHORS: Add your own second factor here. Doing so makes SMF treat + * this member as having two factor authentication set up, which means it + * will stop short of a full login and hand over to ?action=logintfa. You + * are responsible for verifying your own factor there, which is what the + * integrate_verify_tfa hook is for. + */ + IntegrationHook::call('integrate_second_factors', [&$factors, $id_member]); + + return $factors; + } + /** * Loads an array of users by ID, member_name, or email_address. * @@ -3601,6 +3697,8 @@ public static function delete(int|array $users, bool $protect_admins = false, bo // Delete these members. ['table' => 'members', 'col' => 'id_member'], ['table' => 'member_logins', 'col' => 'id_member'], + // Anything else they used to sign in with goes with them. + ['table' => 'member_auth', 'col' => 'id_member'], ['table' => 'user_alerts', 'col' => 'id_member'], ['table' => 'user_alerts', 'col' => 'id_member_started'], ['table' => 'user_alerts_prefs', 'col' => 'id_member'], @@ -4536,7 +4634,7 @@ protected function verifyTfa(): void } // If they've set up Two Factor Authentication, validate it. - if (!empty(self::$profiles[self::$my_id]['tfa_secret'])) { + if (self::getSecondFactors(self::$my_id) !== []) { // If they are performing the TFA login action itself, make sure // to reset their ID for security, but otherwise leave it to the // action to verify the TFA credentials. diff --git a/Sources/WebAuthn/AuthenticatorData.php b/Sources/WebAuthn/AuthenticatorData.php new file mode 100644 index 0000000000..1202976ae1 --- /dev/null +++ b/Sources/WebAuthn/AuthenticatorData.php @@ -0,0 +1,185 @@ +raw = $data; + $this->rp_id_hash = substr($data, 0, 32); + $this->flags = \ord($data[32]); + $this->sign_count = (int) unpack('N', substr($data, 33, 4))[1]; + + if (!$this->hasAttestedCredentialData()) { + return; + } + + if (\strlen($data) < 55) { + throw new WebAuthnException('authenticator data promises a credential but is too short to hold one'); + } + + $this->aaguid = substr($data, 37, 16); + + $id_length = (int) unpack('n', substr($data, 53, 2))[1]; + + // The specification caps this at 1023, so anything longer is either a + // misread or a length we have no business trusting. + if ($id_length < 1 || $id_length > 1023 || \strlen($data) < 55 + $id_length) { + throw new WebAuthnException('credential ID length of ' . $id_length . ' does not fit the data'); + } + + $this->credential_id = substr($data, 55, $id_length); + + /* + * The key runs to the end of the data unless extensions follow it, and + * there is no length in front of either. Decoding the key is the only + * way to find out where it stops, so read it and keep the bytes it used. + */ + $offset = 55 + $id_length; + $start = $offset; + + Cbor::decode($data, $offset); + + $this->credential_public_key = substr($data, $start, $offset - $start); + } + + /** + * Whether somebody was in front of the authenticator. + * + * @return bool Whether the user present flag is set. + */ + public function userPresent(): bool + { + return ($this->flags & self::FLAG_USER_PRESENT) !== 0; + } + + /** + * Whether the authenticator checked who that somebody was. + * + * @return bool Whether the user verified flag is set. + */ + public function userVerified(): bool + { + return ($this->flags & self::FLAG_USER_VERIFIED) !== 0; + } + + /** + * Whether a new credential is described. + * + * @return bool Whether the attested credential data flag is set. + */ + public function hasAttestedCredentialData(): bool + { + return ($this->flags & self::FLAG_ATTESTED_CREDENTIAL_DATA) !== 0; + } +} diff --git a/Sources/WebAuthn/Cbor.php b/Sources/WebAuthn/Cbor.php new file mode 100644 index 0000000000..b20b17cbd9 --- /dev/null +++ b/Sources/WebAuthn/Cbor.php @@ -0,0 +1,224 @@ +> 5; + $argument = $byte & 0x1F; + + // Major type 7 keeps its own meanings in the low bits, so it is read + // before the length rules that every other type shares. + if ($major === 7) { + return match ($argument) { + 20 => false, + 21 => true, + 22, 23 => null, + default => throw new WebAuthnException('unsupported CBOR simple value ' . $argument), + }; + } + + $value = self::readArgument($data, $offset, $argument); + + return match ($major) { + 0 => $value, + 1 => -1 - $value, + 2, 3 => self::takeBytes($data, $offset, $value), + 4 => self::decodeArray($data, $offset, $value), + 5 => self::decodeMap($data, $offset, $value), + // A tag decorates the item that follows it. Nothing here cares what + // the decoration means, so read the item and hand that back. + 6 => self::decode($data, $offset), + default => throw new WebAuthnException('unsupported CBOR major type ' . $major), + }; + } + + /** + * Reads one item from the start of a string. + * + * @param string $data The encoded bytes. + * @throws \SMF\WebAuthn\WebAuthnException If the data cannot be read, or if + * anything follows the item. + * @return mixed The decoded value. + */ + public static function decodeAll(string $data): mixed + { + $offset = 0; + $value = self::decode($data, $offset); + + if ($offset !== \strlen($data)) { + throw new WebAuthnException('CBOR data has ' . (\strlen($data) - $offset) . ' trailing bytes'); + } + + return $value; + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argument that follows an item's first byte. + * + * @param string $data The encoded bytes. + * @param int $offset Where to start, updated to just past what was read. + * @param int $argument The low five bits of the first byte. + * @throws \SMF\WebAuthn\WebAuthnException If the argument cannot be read. + * @return int The value of the argument. + */ + protected static function readArgument(string $data, int &$offset, int $argument): int + { + if ($argument < 24) { + return $argument; + } + + $length = match ($argument) { + 24 => 1, + 25 => 2, + 26 => 4, + 27 => 8, + default => throw new WebAuthnException('unsupported CBOR argument ' . $argument), + }; + + $bytes = self::takeBytes($data, $offset, $length); + $value = 0; + + foreach (str_split($bytes) as $byte) { + /* + * Eight byte lengths are legal and would overflow a signed integer. + * Nothing an authenticator sends is anywhere near that big, so it + * means the data is not what we think it is. + */ + if ($value > (\PHP_INT_MAX >> 8)) { + throw new WebAuthnException('CBOR value is too large to read'); + } + + $value = ($value << 8) | \ord($byte); + } + + return $value; + } + + /** + * Reads the given number of items. + * + * @param string $data The encoded bytes. + * @param int $offset Where to start, updated to just past what was read. + * @param int $count How many items to read. + * @throws \SMF\WebAuthn\WebAuthnException If the items cannot be read. + * @return array The decoded values. + */ + protected static function decodeArray(string $data, int &$offset, int $count): array + { + $items = []; + + for ($i = 0; $i < $count; $i++) { + $items[] = self::decode($data, $offset); + } + + return $items; + } + + /** + * Reads the given number of key and value pairs. + * + * @param string $data The encoded bytes. + * @param int $offset Where to start, updated to just past what was read. + * @param int $count How many pairs to read. + * @throws \SMF\WebAuthn\WebAuthnException If the pairs cannot be read. + * @return array The decoded map. + */ + protected static function decodeMap(string $data, int &$offset, int $count): array + { + $map = []; + + for ($i = 0; $i < $count; $i++) { + $key = self::decode($data, $offset); + + // COSE keys are negative integers, which is exactly why this cannot + // be handed to something expecting a list. + if (!\is_int($key) && !\is_string($key)) { + throw new WebAuthnException('CBOR map key is not an integer or a string'); + } + + $map[$key] = self::decode($data, $offset); + } + + return $map; + } + + /** + * Reads a single byte. + * + * @param string $data The encoded bytes. + * @param int $offset Where to read from, updated to just past it. + * @throws \SMF\WebAuthn\WebAuthnException If there is nothing there. + * @return int The value of the byte. + */ + protected static function takeByte(string $data, int &$offset): int + { + return \ord(self::takeBytes($data, $offset, 1)); + } + + /** + * Reads a run of bytes. + * + * @param string $data The encoded bytes. + * @param int $offset Where to read from, updated to just past them. + * @param int $length How many to read. + * @throws \SMF\WebAuthn\WebAuthnException If there are not that many left. + * @return string The bytes. + */ + protected static function takeBytes(string $data, int &$offset, int $length): string + { + if ($length < 0 || $offset + $length > \strlen($data)) { + throw new WebAuthnException('CBOR data ended before it should have'); + } + + $bytes = substr($data, $offset, $length); + $offset += $length; + + return $bytes; + } +} diff --git a/Sources/WebAuthn/CoseKey.php b/Sources/WebAuthn/CoseKey.php new file mode 100644 index 0000000000..f676358387 --- /dev/null +++ b/Sources/WebAuthn/CoseKey.php @@ -0,0 +1,235 @@ +algorithm = (int) ($key[3] ?? 0); + + $this->pem = match ($this->algorithm) { + self::ALG_ES256 => self::pemFromEc2($key), + self::ALG_RS256 => self::pemFromRsa($key), + default => throw new WebAuthnException('unsupported key algorithm ' . $this->algorithm), + }; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Checks a signature against a key kept when a credential was registered. + * + * Both algorithms SMF accepts sign with SHA-256 and are in the encoding + * openssl expects, so there is nothing to choose between here: an ECDSA + * signature arrives ASN.1 encoded and an RSA one as PKCS#1 v1.5, which is + * what openssl_verify() reads in either case. + * + * @param string $pem The PEM encoded public key. + * @param string $data What was signed. + * @param string $signature The signature over it. + * @throws \SMF\WebAuthn\WebAuthnException If the signature is not good. + */ + public static function checkSignature(string $pem, string $data, string $signature): void + { + if (openssl_verify($data, $signature, $pem, \OPENSSL_ALGO_SHA256) !== 1) { + throw new WebAuthnException('signature does not match the stored key'); + } + } + + /************************* + * Internal static methods + *************************/ + + /** + * Builds a PEM public key from a COSE elliptic curve key. + * + * @param array $key The decoded COSE_Key map. + * @throws \SMF\WebAuthn\WebAuthnException If the key cannot be used. + * @return string The PEM encoded key. + */ + protected static function pemFromEc2(array $key): string + { + // Curve 1 is P-256. ES256 is only ever defined over that one, so a key + // claiming anything else is not the key it says it is. + if ((int) ($key[-1] ?? 0) !== 1) { + throw new WebAuthnException('ES256 key is not on the P-256 curve'); + } + + $x = (string) ($key[-2] ?? ''); + $y = (string) ($key[-3] ?? ''); + + if (\strlen($x) !== 32 || \strlen($y) !== 32) { + throw new WebAuthnException('ES256 key coordinates are the wrong length'); + } + + /* + * The algorithm identifier for an uncompressed P-256 point never varies, + * so it goes in as the constant it is rather than being assembled from + * object identifiers we would only ever build one way. + */ + $algorithm = "\x30\x13\x06\x07\x2A\x86\x48\xCE\x3D\x02\x01\x06\x08\x2A\x86\x48\xCE\x3D\x03\x01\x07"; + + return self::pem( + self::der(0x30, $algorithm . self::der(0x03, "\x00\x04" . $x . $y)), + ); + } + + /** + * Builds a PEM public key from a COSE RSA key. + * + * @param array $key The decoded COSE_Key map. + * @throws \SMF\WebAuthn\WebAuthnException If the key cannot be used. + * @return string The PEM encoded key. + */ + protected static function pemFromRsa(array $key): string + { + $modulus = (string) ($key[-1] ?? ''); + $exponent = (string) ($key[-2] ?? ''); + + if ($modulus === '' || $exponent === '') { + throw new WebAuthnException('RS256 key is missing its modulus or exponent'); + } + + // rsaEncryption, with the NULL parameters PKCS#1 asks for. + $algorithm = "\x30\x0D\x06\x09\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01\x05\x00"; + + $public_key = self::der( + 0x30, + self::integer($modulus) . self::integer($exponent), + ); + + return self::pem( + self::der(0x30, $algorithm . self::der(0x03, "\x00" . $public_key)), + ); + } + + /** + * Wraps content in a DER tag and length. + * + * @param int $tag Which tag to use. + * @param string $content What to wrap. + * @return string The tagged content. + */ + protected static function der(int $tag, string $content): string + { + $length = \strlen($content); + + if ($length < 128) { + $header = \chr($length); + } else { + $bytes = ltrim(pack('N', $length), "\x00"); + $header = \chr(0x80 | \strlen($bytes)) . $bytes; + } + + return \chr($tag) . $header . $content; + } + + /** + * Encodes a big number as a DER integer. + * + * @param string $bytes The number, most significant byte first. + * @return string The DER integer. + */ + protected static function integer(string $bytes): string + { + $bytes = ltrim($bytes, "\x00"); + + if ($bytes === '') { + $bytes = "\x00"; + } + + // DER integers are signed, so a leading byte of 0x80 or above would read + // as a negative number without a zero in front of it. + if ((\ord($bytes[0]) & 0x80) !== 0) { + $bytes = "\x00" . $bytes; + } + + return self::der(0x02, $bytes); + } + + /** + * Wraps DER in the armour openssl expects. + * + * @param string $der The encoded key. + * @return string The PEM encoded key. + */ + protected static function pem(string $der): string + { + return "-----BEGIN PUBLIC KEY-----\n" + . chunk_split(base64_encode($der), 64, "\n") + . "-----END PUBLIC KEY-----\n"; + } +} diff --git a/Sources/WebAuthn/Server.php b/Sources/WebAuthn/Server.php new file mode 100644 index 0000000000..237a2fbc34 --- /dev/null +++ b/Sources/WebAuthn/Server.php @@ -0,0 +1,498 @@ +host ?? ''); + } + + /** + * The origin a ceremony has to have come from. + * + * @return string The forum's origin, as a browser reports it. + */ + public static function origin(): string + { + $url = new Url(Config::$boardurl); + + $scheme = (string) ($url->scheme ?? ''); + $port = $url->port ?? null; + + $origin = $scheme . '://' . ($url->host ?? ''); + + // A browser leaves the port out when it is the scheme's usual one. + if ( + $port !== null + && !($scheme === 'http' && $port === 80) + && !($scheme === 'https' && $port === 443) + ) { + $origin .= ':' . $port; + } + + return $origin; + } + + /** + * Builds what the browser needs to create a credential. + * + * The challenge is remembered in the session, since the answer has to be + * matched against the question we actually asked and nothing else. + * + * @param int $id_member Who is registering, or 0 if they have no account yet. + * @param string $username What to call them on their authenticator. + * @param string $display_name What to show them there. + * @param array $exclude Credential IDs they have already registered. + * @param ?string $handle How to identify them to their authenticator, for + * when self::userHandle() cannot say: either because there is no member + * to derive it from yet, or because they already have a handle that this + * credential should join rather than sit beside. + * @return array The options, ready to be sent as JSON. + */ + public static function creationOptions(int $id_member, string $username, string $display_name, array $exclude = [], ?string $handle = null): array + { + $challenge = random_bytes(self::CHALLENGE_LENGTH); + $handle ??= self::userHandle($id_member); + + $_SESSION['webauthn_register'] = [ + 'challenge' => $challenge, + 'member' => $id_member, + 'handle' => $handle, + 'created' => time(), + ]; + + return [ + 'challenge' => self::base64UrlEncode($challenge), + 'rp' => [ + 'id' => self::relyingPartyId(), + 'name' => Config::$mbname, + ], + 'user' => [ + 'id' => self::base64UrlEncode($handle), + 'name' => $username, + 'displayName' => $display_name, + ], + // Both of the algorithms CoseKey understands, best first. Offering + // anything else would mean accepting a key we cannot check. + 'pubKeyCredParams' => [ + ['type' => 'public-key', 'alg' => CoseKey::ALG_ES256], + ['type' => 'public-key', 'alg' => CoseKey::ALG_RS256], + ], + 'timeout' => self::CHALLENGE_LIFETIME * 1000, + 'attestation' => 'none', + 'authenticatorSelection' => [ + /* + * A discoverable credential is the whole point: it is what lets + * the browser offer the passkey before anybody has said who they + * are, which is what makes signing in without a password work. + * An authenticator too small to store one will refuse here, and + * that is a clearer answer than a passkey that silently cannot + * be used to log in. + */ + 'residentKey' => 'required', + 'requireResidentKey' => true, + 'userVerification' => self::requiresUserVerification() ? 'required' : 'preferred', + ], + // So the same authenticator cannot be enrolled twice over. + 'excludeCredentials' => array_map( + fn($id) => ['type' => 'public-key', 'id' => $id], + $exclude, + ), + ]; + } + + /** + * Checks a newly created credential and pulls out what is worth keeping. + * + * @param array $response What the browser sent back. + * @throws \SMF\WebAuthn\WebAuthnException If anything is not as it should be. + * @return array The credential: its ID, key, sign count, authenticator, and + * who we were talking to when we asked for it. + */ + public static function verifyCreation(array $response): array + { + $expected = self::takeChallenge('webauthn_register'); + + $attestation = Cbor::decodeAll(self::decodeField($response, 'attestationObject')); + + if (!\is_array($attestation) || !isset($attestation['authData'])) { + throw new WebAuthnException('attestation object has no authenticator data'); + } + + $data = new AuthenticatorData((string) $attestation['authData']); + + self::checkClientData($response, 'webauthn.create', $expected['challenge']); + self::checkAuthenticatorData($data); + + if (!$data->hasAttestedCredentialData()) { + throw new WebAuthnException('registration returned no credential'); + } + + // Throws unless it is a key we can verify signatures with later. + $key = new CoseKey($data->credential_public_key); + + return [ + 'id' => $data->credential_id, + 'key' => $key->pem, + 'algorithm' => $key->algorithm, + 'sign_count' => $data->sign_count, + 'aaguid' => bin2hex($data->aaguid), + 'user_verified' => $data->userVerified(), + 'member' => (int) $expected['member'], + 'user_handle' => (string) ($expected['handle'] ?? ''), + ]; + } + + /** + * Builds what the browser needs to sign in. + * + * @param array $allowed Credential IDs to accept, or none for any of them. + * @return array The options, ready to be sent as JSON. + */ + public static function requestOptions(array $allowed = []): array + { + $challenge = random_bytes(self::CHALLENGE_LENGTH); + + $_SESSION['webauthn_login'] = [ + 'challenge' => $challenge, + 'created' => time(), + ]; + + return [ + 'challenge' => self::base64UrlEncode($challenge), + 'rpId' => self::relyingPartyId(), + 'timeout' => self::CHALLENGE_LIFETIME * 1000, + 'userVerification' => self::requiresUserVerification() ? 'required' : 'preferred', + 'allowCredentials' => array_map( + fn($id) => ['type' => 'public-key', 'id' => $id], + $allowed, + ), + ]; + } + + /** + * Checks an assertion against the key we kept when it was registered. + * + * @param array $response What the browser sent back. + * @param string $pem The credential's public key. + * @param int $sign_count What the sign count was last time. + * @throws \SMF\WebAuthn\WebAuthnException If anything is not as it should be. + * @return array What was learned: the new sign count, and whether the + * authenticator verified who was using it. + */ + public static function verifyAssertion(array $response, string $pem, int $sign_count): array + { + $expected = self::takeChallenge('webauthn_login'); + + $data = new AuthenticatorData(self::decodeField($response, 'authenticatorData')); + + $client_data = self::decodeField($response, 'clientDataJSON'); + + self::checkClientData($response, 'webauthn.get', $expected['challenge']); + self::checkAuthenticatorData($data); + + // The signature covers the authenticator data with the hash of the + // client data stuck on the end, and nothing else. + CoseKey::checkSignature( + $pem, + $data->raw . hash('sha256', $client_data, true), + self::decodeField($response, 'signature'), + ); + + return [ + 'sign_count' => $data->sign_count, + 'user_verified' => $data->userVerified(), + ]; + } + + /** + * Whether a sign count says the credential has been copied. + * + * An authenticator that counts its signatures is meant to count up. One that + * does not count at all reports zero every time, which is allowed and says + * nothing either way. + * + * @param int $stored What it was last time. + * @param int $received What it is now. + * @return bool Whether this looks like a cloned credential. + */ + public static function signCountWentBackwards(int $stored, int $received): bool + { + return ($stored !== 0 || $received !== 0) && $received <= $stored; + } + + /** + * How the member is identified to their own authenticator. + * + * Nothing looks a credential up by this: an assertion is matched on the + * credential's own ID, which is what the signature is bound to. It exists + * because browsers group passkeys by it, so that a member with two of them + * sees one account rather than two. + * + * It is derived rather than stored so that nothing about the member leaks + * into it. The specification is explicit that this must not be something + * like a username or an email address, since authenticators may show it and + * it is readable by anyone holding the device. + * + * A credential made before the account existed cannot use this, because + * there was no ID to derive it from; that one carries a random handle + * instead, which every later credential for the same member then reuses so + * that the browser still sees one account rather than several. + * + * @param int $id_member The member. + * @return string 32 opaque bytes. + */ + public static function userHandle(int $id_member): string + { + return hash_hmac('sha256', 'webauthn-user:' . $id_member, Config::getAuthSecret(), true); + } + + /** + * Encodes bytes the way WebAuthn passes them around. + * + * @param string $data The bytes. + * @return string The base64url encoding, unpadded. + */ + public static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Decodes what the browser sent. + * + * @param string $data The base64url encoding, padded or not. + * @return string The bytes, or an empty string if it was not base64url. + */ + public static function base64UrlDecode(string $data): string + { + return (string) base64_decode(strtr($data, '-_', '+/'), true); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Whether the authenticator has to check who is using it. + * + * The setting is the way round it is so that the safe answer is the one an + * install gets without anybody choosing it: a passkey stands in for a + * password here, and a passkey that proves only that somebody was holding + * the device is a weaker thing than the password it replaced. + * + * @return bool Whether user verification is required. + */ + protected static function requiresUserVerification(): bool + { + return empty(Config::$modSettings['webauthn_allow_unverified']); + } + + /** + * Takes the challenge we set, and makes sure it cannot be used twice. + * + * @param string $key Which session entry holds it. + * @throws \SMF\WebAuthn\WebAuthnException If there is no usable challenge. + * @return array The stored entry. + */ + protected static function takeChallenge(string $key): array + { + $stored = $_SESSION[$key] ?? null; + + // Whatever happens next, this challenge is spent. Clearing it first + // means an answer that fails half way through cannot be tried again. + unset($_SESSION[$key]); + + if (!\is_array($stored) || empty($stored['challenge'])) { + throw new WebAuthnException('no challenge was issued for this ' . $key); + } + + if ($stored['created'] < time() - self::CHALLENGE_LIFETIME) { + throw new WebAuthnException('challenge expired ' . (time() - $stored['created']) . ' seconds ago'); + } + + return $stored; + } + + /** + * Checks the part of the answer the browser wrote. + * + * @param array $response What the browser sent back. + * @param string $type Which ceremony this is meant to be. + * @param string $challenge What we asked. + * @throws \SMF\WebAuthn\WebAuthnException If anything is not as it should be. + */ + protected static function checkClientData(array $response, string $type, string $challenge): void + { + $client_data = Utils::jsonDecode(self::decodeField($response, 'clientDataJSON'), true); + + if (!\is_array($client_data)) { + throw new WebAuthnException('client data is not JSON'); + } + + if (($client_data['type'] ?? '') !== $type) { + // A registration cannot be passed off as a sign in, or the reverse. + throw new WebAuthnException('client data is for ' . ($client_data['type'] ?? 'nothing') . ', not ' . $type); + } + + if (!hash_equals($challenge, self::base64UrlDecode((string) ($client_data['challenge'] ?? '')))) { + throw new WebAuthnException('client data answers a different challenge'); + } + + /* + * This is the check that makes a passkey unphishable: the browser writes + * the origin itself, and a site pretending to be this one cannot make it + * write ours. + */ + if (($client_data['origin'] ?? '') !== self::origin()) { + throw new WebAuthnException('ceremony came from ' . ($client_data['origin'] ?? 'nowhere') . ', not ' . self::origin()); + } + + if (!empty($client_data['crossOrigin'])) { + throw new WebAuthnException('ceremony was performed in a frame on another site'); + } + } + + /** + * Checks the part of the answer the authenticator signed. + * + * @param \SMF\WebAuthn\AuthenticatorData $data The authenticator data. + * @throws \SMF\WebAuthn\WebAuthnException If anything is not as it should be. + */ + protected static function checkAuthenticatorData(AuthenticatorData $data): void + { + if (!hash_equals(hash('sha256', self::relyingPartyId(), true), $data->rp_id_hash)) { + throw new WebAuthnException('credential belongs to another domain'); + } + + if (!$data->userPresent()) { + throw new WebAuthnException('nobody was present at the authenticator'); + } + + if (self::requiresUserVerification() && !$data->userVerified()) { + throw new WebAuthnException('authenticator did not verify who was using it'); + } + } + + /** + * Pulls one base64url field out of what the browser sent. + * + * @param array $response What the browser sent back. + * @param string $field Which field to read. + * @throws \SMF\WebAuthn\WebAuthnException If it is missing or not base64url. + * @return string The decoded bytes. + */ + protected static function decodeField(array $response, string $field): string + { + $encoded = $response[$field] ?? ''; + + if (!\is_string($encoded) || $encoded === '') { + throw new WebAuthnException('response has no ' . $field); + } + + $decoded = self::base64UrlDecode($encoded); + + if ($decoded === '') { + throw new WebAuthnException($field . ' is not base64url'); + } + + return $decoded; + } +} diff --git a/Sources/WebAuthn/WebAuthnException.php b/Sources/WebAuthn/WebAuthnException.php new file mode 100644 index 0000000000..3c7d15ddb4 --- /dev/null +++ b/Sources/WebAuthn/WebAuthnException.php @@ -0,0 +1,29 @@ + +
+', Lang::getTxt('authentication_no_providers', file: 'ManageSettings'), '
+| ', Lang::getTxt('authentication_title', file: 'ManageSettings'), ' | +', Lang::getTxt('authentication_issuer', file: 'ManageSettings'), ' | +', Lang::getTxt('authentication_enabled', file: 'ManageSettings'), ' | ++ |
|---|---|---|---|
| ', Utils::htmlspecialchars($provider->title), ' | +', Utils::htmlspecialchars($provider->issuer), ' | +', $provider->enabled ? Lang::getTxt('yes', file: 'General') : Lang::getTxt('no', file: 'General'), ' | ++ ', Lang::getTxt('modify', file: 'General'), ' + ', Lang::getTxt('authentication_test', file: 'ManageSettings'), ' + ', Lang::getTxt('delete', file: 'General'), ' + | +
+ ', Lang::getTxt('authentication_add_generic', file: 'ManageSettings'), ''; + + foreach (Utils::$context['presets'] as $key => $preset) { + echo ' + ', Utils::htmlspecialchars($preset['title']), ''; + } + + echo ' +
+', Utils::htmlspecialchars(Utils::$context['test_error']), '
+ ', Utils::htmlspecialchars($url), '', Lang::getTxt('login_alternatives', file: 'Login'), '
'; + + foreach (Utils::$context['authentication_methods'] as $method) { + echo ' + ', $method['title'], ''; + } + + echo ' +', Lang::getTxt('linked_accounts_none', file: 'Profile'), '
+| ', Lang::getTxt('linked_accounts_provider', file: 'Profile'), ' | +', Lang::getTxt('linked_accounts_added', file: 'Profile'), ' | +', Lang::getTxt('linked_accounts_last_used', file: 'Profile'), ' | ++ |
|---|---|---|---|
|
+ ', Utils::htmlspecialchars($account['provider']), '
+ ', Utils::htmlspecialchars($account['title']), ' + |
+ ', Time::create('@' . $account['date_created'])->format(null, false), ' | +', empty($account['date_last_used']) ? Lang::getTxt('never', file: 'General') : Time::create('@' . $account['date_last_used'])->format(null, false), ' | +'; + + if (Utils::$context['can_manage'] && !Utils::$context['is_only_way_in']) { + echo ' + ', Lang::getTxt('linked_accounts_unlink', file: 'Profile'), ''; + } elseif (Utils::$context['can_manage']) { + echo ' + ', Lang::getTxt('linked_accounts_only_way_in', file: 'Profile'), ''; + } + + echo ' + | +
'; + + foreach (Utils::$context['available_providers'] as $provider) { + echo ' + ', Utils::htmlspecialchars($provider->title), ''; + } + + echo ' +
+', Lang::getTxt('passkeys_none', file: 'Profile'), '
+| ', Lang::getTxt('passkeys_name', file: 'Profile'), ' | +', Lang::getTxt('passkeys_added', file: 'Profile'), ' | +', Lang::getTxt('passkeys_last_used', file: 'Profile'), ' | ++ |
|---|---|---|---|
| ', Utils::htmlspecialchars($passkey['title']), ' | +', Time::create('@' . $passkey['date_created'])->format(null, false), ' | +', empty($passkey['date_last_used']) ? Lang::getTxt('never', file: 'General') : Time::create('@' . $passkey['date_last_used'])->format(null, false), ' | +'; + + if (Utils::$context['can_manage'] && !Utils::$context['is_only_way_in']) { + echo ' + ', Lang::getTxt('passkeys_remove', file: 'Profile'), ''; + } elseif (Utils::$context['can_manage']) { + echo ' + ', Lang::getTxt('passkey_only_way_in', file: 'Profile'), ''; + } + + echo ' + | +