diff --git a/Languages/en_US/Login.php b/Languages/en_US/Login.php index 7ff6537d8f..55a20f9d86 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,10 @@ $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.'; diff --git a/Languages/en_US/ManageSettings.php b/Languages/en_US/ManageSettings.php index 77b45c5e03..610e0a454e 100644 --- a/Languages/en_US/ManageSettings.php +++ b/Languages/en_US/ManageSettings.php @@ -487,3 +487,37 @@ $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.'; diff --git a/Languages/en_US/Profile.php b/Languages/en_US/Profile.php index ab22589ddd..1bd87f5f8b 100644 --- a/Languages/en_US/Profile.php +++ b/Languages/en_US/Profile.php @@ -676,3 +676,18 @@ $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'; diff --git a/Sources/Actions/Admin/ACP.php b/Sources/Actions/Admin/ACP.php index 060a995fa2..18f636dfd2 100644 --- a/Sources/Actions/Admin/ACP.php +++ b/Sources/Actions/Admin/ACP.php @@ -601,6 +601,11 @@ class ACP implements ActionInterface, Routable ], ], ], + 'authentication' => [ + 'label' => 'authentication_providers', + 'function' => __NAMESPACE__ . '\\Authentication::call', + 'icon' => 'security', + ], '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..0f8f8ed1bf --- /dev/null +++ b/Sources/Actions/Admin/Authentication.php @@ -0,0 +1,223 @@ + 'providerList', + 'edit' => 'edit', + 'save' => 'save', + 'delete' => 'delete', + 'test' => 'test', + ]; + + /**************** + * Public methods + ****************/ + + /** + * Dispatcher to whichever sub-action method is necessary. + */ + public function execute(): void + { + User::$me->isAllowedTo('admin_forum'); + + Theme::loadTemplate('Authentication'); + + Utils::$context['page_title'] = Lang::getTxt('authentication_providers', file: 'ManageSettings'); + + Menu::$loaded['admin']->tab_data = [ + 'title' => Lang::getTxt('authentication_providers', file: 'ManageSettings'), + 'description' => Lang::getTxt('authentication_providers_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'); + } + + /** + * 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'] ?? '', + ]; + } + + /****************** + * 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..425fa0251e --- /dev/null +++ b/Sources/Actions/AuthExternal.php @@ -0,0 +1,365 @@ + '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')); + } + + /****************** + * 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'); + } + + /* + * 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, + '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..024f190e05 100644 --- a/Sources/Actions/Login.php +++ b/Sources/Actions/Login.php @@ -76,6 +76,7 @@ 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(); // 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/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..c62a144533 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; @@ -339,6 +340,16 @@ 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'], + ], + ], 'forumprofile' => [ 'label' => 'forumprofile', 'function' => __NAMESPACE__ . '\\ForumProfile::call', @@ -693,17 +704,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 +930,9 @@ 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']['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/Register2.php b/Sources/Actions/Register2.php index a8f44e8098..c653c2f43f 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; @@ -388,6 +389,24 @@ 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 (!empty($_SESSION['authext_pending']['subject']) && $_SESSION['authext_pending']['created'] > time() - 3600) { + Credential::add( + $member_id, + Credential::TYPE_OIDC, + (int) $_SESSION['authext_pending']['provider'], + $_SESSION['authext_pending']['subject'], + (string) ($_SESSION['authext_pending']['email'] ?? ''), + ); + } + + unset($_SESSION['authext_pending']); + // Do our spam protection now. Security::spamProtection('register'); diff --git a/Sources/Authentication/Credential.php b/Sources/Authentication/Credential.php new file mode 100644 index 0000000000..6ca7226b1f --- /dev/null +++ b/Sources/Authentication/Credential.php @@ -0,0 +1,194 @@ +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); + } + + /** + * 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, 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..ecf25b6180 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, ], 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/Themes/default/Authentication.template.php b/Themes/default/Authentication.template.php new file mode 100644 index 0000000000..9c2c66adfe --- /dev/null +++ b/Themes/default/Authentication.template.php @@ -0,0 +1,230 @@ + +
+

', Lang::getTxt('authentication_providers', file: 'ManageSettings'), '

+
+
+ ', Lang::getTxt('authentication_providers_desc', file: 'ManageSettings'), ' +
'; + + if (empty(Utils::$context['providers'])) { + echo ' +
+

', Lang::getTxt('authentication_no_providers', file: 'ManageSettings'), '

+
'; + } else { + echo ' + + + + + + + + + + '; + + foreach (Utils::$context['providers'] as $provider) { + echo ' + + + + + + '; + } + + echo ' + +
', 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'), ' +
'; + } + + echo ' +
+

', Lang::getTxt('authentication_add', file: 'ManageSettings'), '

+
+
+

+ ', Lang::getTxt('authentication_add_generic', file: 'ManageSettings'), ''; + + foreach (Utils::$context['presets'] as $key => $preset) { + echo ' + ', Utils::htmlspecialchars($preset['title']), ''; + } + + echo ' +

+
+ '; +} + +/** + * The form for one identity provider. + */ +function template_authentication_edit() +{ + $provider = Utils::$context['provider']; + + echo ' +
+
+
+

', Lang::getTxt('authentication_provider', file: 'ManageSettings'), '

+
+
+
+
+ ', Lang::getTxt('authentication_title', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_title_desc', file: 'ManageSettings'), ' +
+
+ +
+
+ ', Lang::getTxt('authentication_issuer', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_issuer_desc', file: 'ManageSettings'), ' +
+
+ +
+
+ ', Lang::getTxt('authentication_client_id', file: 'ManageSettings'), ' +
+
+ +
+
+ ', Lang::getTxt('authentication_client_secret', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_client_secret_desc', file: 'ManageSettings'), ' +
+
+ +
+
+ ', Lang::getTxt('authentication_scopes', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_scopes_desc', file: 'ManageSettings'), ' +
+
+ +
+
+ ', Lang::getTxt('authentication_redirect_uri', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_redirect_uri_desc', file: 'ManageSettings'), ' +
+
+ ', Utils::htmlspecialchars(Utils::$context['redirect_uri']), ' +
+
+ ', Lang::getTxt('authentication_enabled', file: 'ManageSettings'), ' +
+
+ enabled ? ' checked' : '', '> +
+
+ ', Lang::getTxt('authentication_order', file: 'ManageSettings'), ' +
+
+ +
+
+
+

', Lang::getTxt('authentication_policy', file: 'ManageSettings'), '

+
+
+
+ ', Lang::getTxt('authentication_allow_registration', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_allow_registration_desc', file: 'ManageSettings'), ' +
+
+ settings['allow_registration']) ? ' checked' : '', '> +
+
+ ', Lang::getTxt('authentication_link_by_email', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_link_by_email_desc', file: 'ManageSettings'), ' +
+
+ settings['link_by_verified_email']) ? ' checked' : '', '> +
+
+ ', Lang::getTxt('authentication_allow_private_host', file: 'ManageSettings'), '
+ ', Lang::getTxt('authentication_allow_private_host_desc', file: 'ManageSettings'), ' +
+
+ settings['allow_private_host']) ? ' checked' : '', '> +
+
+
+ +
+
+ + +
+
'; +} + +/** + * The result of asking a provider to describe itself. + */ +function template_authentication_test() +{ + echo ' +
+
+

', Lang::getTxt('authentication_test', file: 'ManageSettings'), ' — ', Utils::htmlspecialchars(Utils::$context['provider']->title), '

+
+
'; + + if (empty(Utils::$context['test_endpoints'])) { + echo ' +
+ ', Lang::getTxt('authentication_test_failed', file: 'ManageSettings'), ' +
', Utils::htmlspecialchars(Utils::$context['test_error']), ' +
'; + } else { + echo ' +
', Lang::getTxt('authentication_test_ok', file: 'ManageSettings'), '
+
'; + + foreach (Utils::$context['test_endpoints'] as $name => $url) { + echo ' +
', $name, '
+
', Utils::htmlspecialchars($url), '
'; + } + + echo ' +
'; + } + + echo ' +

', Lang::getTxt('back', file: 'General'), '

+
+
'; +} diff --git a/Themes/default/Login.template.php b/Themes/default/Login.template.php index 5b03deb0e4..acf5507086 100644 --- a/Themes/default/Login.template.php +++ b/Themes/default/Login.template.php @@ -108,6 +108,22 @@ function template_login() '; + // Anything else offering to sign them in? Nothing does out of the box. + if (!empty(Utils::$context['authentication_methods'])) { + echo ' +
+
+

', Lang::getTxt('login_alternatives', file: 'Login'), '

'; + + foreach (Utils::$context['authentication_methods'] as $method) { + echo ' + ', $method['title'], ''; + } + + echo ' +
'; + } + if (!empty(Utils::$context['can_register'])) { echo '
diff --git a/Themes/default/Profile.template.php b/Themes/default/Profile.template.php index 66d56031b6..2950536243 100644 --- a/Themes/default/Profile.template.php +++ b/Themes/default/Profile.template.php @@ -17,6 +17,7 @@ use SMF\Profile; use SMF\Security; use SMF\Theme; +use SMF\Time; use SMF\User; use SMF\Utils; @@ -3434,3 +3435,93 @@ function template_export_profile_data() '; } + +/** + * Lists the identity providers this member can sign in with. + */ +function template_linked_accounts() +{ + echo ' +
+

', Lang::getTxt('linked_accounts', file: 'Profile'), '

+
+
+ ', Lang::getTxt('linked_accounts_desc', file: 'Profile'), ' +
'; + + if (isset($_GET['linked'])) { + echo ' +
', Lang::getTxt('linked_accounts_linked', file: 'Profile'), '
'; + } elseif (isset($_GET['unlinked'])) { + echo ' +
', Lang::getTxt('linked_accounts_unlinked', file: 'Profile'), '
'; + } elseif (isset($_GET['lastone'])) { + echo ' +
', Lang::getTxt('linked_accounts_last_one', file: 'Profile'), '
'; + } + + if (empty(Utils::$context['linked_accounts'])) { + echo ' +
+

', Lang::getTxt('linked_accounts_none', file: 'Profile'), '

+
'; + } else { + echo ' + + + + + + + + + + '; + + foreach (Utils::$context['linked_accounts'] as $account) { + echo ' + + + + + + '; + } + + echo ' + +
', 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 ' +
'; + } + + if (Utils::$context['can_manage'] && !empty(Utils::$context['available_providers'])) { + echo ' +
+

', Lang::getTxt('linked_accounts_add', file: 'Profile'), '

+
+
+

'; + + foreach (Utils::$context['available_providers'] as $provider) { + echo ' + ', Utils::htmlspecialchars($provider->title), ''; + } + + echo ' +

+
'; + } +}