From 7b1edddd553ac0a235b32bc90e7c95a8c659742d Mon Sep 17 00:00:00 2001 From: albertlast Date: Sat, 8 Aug 2026 12:53:27 +0200 Subject: [PATCH 1/4] Lets something other than a password log a member in Everything about signing in assumes the password form did it. The steps that follow a successful check live in Login2::DoLogin(), which is protected and reads its member from a private property, so nothing else can reuse them; two factor authentication is looked up by reading the tfa_secret column wherever the question comes up; and every account is assumed to have a password worth asking for. None of that is a problem until something else can vouch for a member, at which point each one has to be worked around rather than used. So: Moves the body of DoLogin() to Login2::completeLogin(), taking the member and the cookie lifetime as arguments. DoLogin() now just calls it, so the password path is unchanged, and anything else that authenticates a member can finish the job the same way instead of setting the cookie by hand and missing the ban check or the login history. Adds User::getSecondFactors(), which reports the factors a member has and lets a mod add its own, and asks it instead of reading tfa_secret. It reads the loaded profile rather than object properties because verifyTfa() runs before setProperties() does. Checking it in Login2::checkCookie() now also checks tfa_mode, as verifyTfa() already did; without that a member could be sent to ?action=logintfa when nothing was going to ask them for a code, which ends in "You are not allowed to access this section" rather than a login. Adds User::hasUsablePassword() for accounts that have no password to give. The login form refuses them before the legacy hash fallbacks get to compare anything against an empty string, and validateSession() offers integrate_reauthenticate so such a member is not simply locked out of the admin areas. Nothing here creates such an account yet. Adds a member_auth table for whatever credentials those accounts sign in with, dropped along with the member, and a login form slot that renders the methods registered through integrate_authentication_methods. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Languages/en_US/Login.php | 3 + Sources/Actions/Login.php | 1 + Sources/Actions/Login2.php | 265 ++++++++++++------ Sources/Actions/Profile/Main.php | 12 +- Sources/Db/Schema/v3_0/MemberAuth.php | 149 ++++++++++ .../Migration/v3_0/CreateMemberAuth.php | 46 +++ Sources/Maintenance/Tools/Upgrade.php | 1 + Sources/User.php | 100 ++++++- Themes/default/Login.template.php | 16 ++ 9 files changed, 495 insertions(+), 98 deletions(-) create mode 100644 Sources/Db/Schema/v3_0/MemberAuth.php create mode 100644 Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php diff --git a/Languages/en_US/Login.php b/Languages/en_US/Login.php index 7ff6537d8f5..2852549b45f 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.'; diff --git a/Sources/Actions/Login.php b/Sources/Actions/Login.php index 5bf6bf3d809..024f190e056 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 112c636b4a3..8217177684f 100644 --- a/Sources/Actions/Login2.php +++ b/Sources/Actions/Login2.php @@ -179,13 +179,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 +260,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 +320,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 +476,155 @@ 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 = []; + + /* + * 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 +943,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/Main.php b/Sources/Actions/Profile/Main.php index a83fbd3cdbf..157d134b02d 100644 --- a/Sources/Actions/Profile/Main.php +++ b/Sources/Actions/Profile/Main.php @@ -693,17 +693,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'; diff --git a/Sources/Db/Schema/v3_0/MemberAuth.php b/Sources/Db/Schema/v3_0/MemberAuth.php new file mode 100644 index 00000000000..cebebbc39de --- /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/Maintenance/Migration/v3_0/CreateMemberAuth.php b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php new file mode 100644 index 00000000000..c54c543cc1c --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php @@ -0,0 +1,46 @@ +create(); + + return true; + } +} diff --git a/Sources/Maintenance/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index bda39f8ca07..8da54ba0f0a 100644 --- a/Sources/Maintenance/Tools/Upgrade.php +++ b/Sources/Maintenance/Tools/Upgrade.php @@ -182,6 +182,7 @@ 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, ], ]; diff --git a/Sources/User.php b/Sources/User.php index 5a858de578b..e92e23da7ac 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/Login.template.php b/Themes/default/Login.template.php index 5b03deb0e4a..acf55070862 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 ' +
+ '; + } + if (!empty(Utils::$context['can_register'])) { echo '
From 0e3500e3868fe360caf416b30be64a3d8709777d Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 10 Aug 2026 08:06:34 +0200 Subject: [PATCH 2/4] Skips creating the auth table when it is already there Migrations can say whether they still apply, and the upgrader reports the step as skipped when they do not, which keeps a re-run honest instead of relying on create() quietly ignoring the table it finds. Compares against Config::$db_prefix rather than Db::$db->prefix, since the latter is database qualified while list_tables() reports bare names, and so would never match. That same mismatch is why Table::exists() is no use here either. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .../Migration/v3_0/CreateMemberAuth.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php index c54c543cc1c..c5bf168ce56 100644 --- a/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php +++ b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php @@ -15,6 +15,8 @@ namespace SMF\Maintenance\Migration\v3_0; +use SMF\Config; +use SMF\Db\DatabaseApi as Db; use SMF\Db\Schema; use SMF\Maintenance\Migration\MigrationBase; @@ -33,6 +35,21 @@ class CreateMemberAuth extends MigrationBase * Public methods ****************/ + /** + * Nothing to do if the table is already there, so a re-run says "skipped". + * + * Note this compares against Config::$db_prefix rather than Db::$db->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()); + } + /** * */ From f3f1143899c9997ab1601517eb1a3651f59507bf Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 10 Aug 2026 11:29:15 +0200 Subject: [PATCH 3/4] Lets members sign in with an OpenID Connect provider Adds sign in with an external identity provider, so a forum can hand authentication to Google, Microsoft, Keycloak, Authentik or anything else that speaks the protocol, rather than being the only thing that knows a member's password. Uses the authorization code flow with PKCE and a confidential client. The ID token is read from the response to our own back channel POST to the token endpoint, over TLS with the certificate verified and the client authenticated, which is the case OpenID Connect Core 3.1.3.7 item 6 allows signature validation to be skipped in. That is why the certificate check in OidcClient::fetch() is not optional, and why the token is never taken from the redirect. Doing it this way keeps JWKS handling, and a hard dependency on openssl that SMF does not currently have, out of it entirely. Deliberately does not go through WebFetchApi. That cannot set request headers, which the token and userinfo endpoints need, and it rewrites the host to a literal IP, which defeats the certificate check. CurlFetcher would allow headers but defaults to CURLOPT_SSL_VERIFYPEER false, which is not something to inherit for a token exchange. Whose account a sign in belongs to is decided narrowly. The provider's subject claim is the key and email never is, unless an admin turns that on per provider and the provider states the address is verified; otherwise anyone able to get an address issued there could walk into the account using it here. Somebody with no account is handed to the ordinary sign up form rather than having one made for them, so the agreement, the privacy policy, COPPA and admin approval all still apply, and the credential is attached once that finishes. Members manage their own links from their profile, where the last one cannot be removed while it is the only way they can get in. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Languages/en_US/Login.php | 7 + Languages/en_US/ManageSettings.php | 34 ++ Languages/en_US/Profile.php | 15 + Sources/Actions/Admin/ACP.php | 5 + Sources/Actions/Admin/Authentication.php | 223 ++++++++ Sources/Actions/AuthExternal.php | 365 +++++++++++++ Sources/Actions/Login2.php | 13 + Sources/Actions/Profile/LinkedAccounts.php | 103 ++++ Sources/Actions/Profile/Main.php | 14 + Sources/Actions/Register2.php | 19 + Sources/Authentication/Credential.php | 194 +++++++ Sources/Authentication/OidcClient.php | 483 ++++++++++++++++++ Sources/Authentication/Provider.php | 376 ++++++++++++++ Sources/Db/Schema/v3_0/AuthProviders.php | 135 +++++ Sources/Forum.php | 3 + .../Migration/v3_0/CreateAuthProviders.php | 61 +++ Sources/Maintenance/Tools/Upgrade.php | 1 + Themes/default/Authentication.template.php | 230 +++++++++ Themes/default/Profile.template.php | 91 ++++ 19 files changed, 2372 insertions(+) create mode 100644 Sources/Actions/Admin/Authentication.php create mode 100644 Sources/Actions/AuthExternal.php create mode 100644 Sources/Actions/Profile/LinkedAccounts.php create mode 100644 Sources/Authentication/Credential.php create mode 100644 Sources/Authentication/OidcClient.php create mode 100644 Sources/Authentication/Provider.php create mode 100644 Sources/Db/Schema/v3_0/AuthProviders.php create mode 100644 Sources/Maintenance/Migration/v3_0/CreateAuthProviders.php create mode 100644 Themes/default/Authentication.template.php diff --git a/Languages/en_US/Login.php b/Languages/en_US/Login.php index 2852549b45f..55a20f9d865 100644 --- a/Languages/en_US/Login.php +++ b/Languages/en_US/Login.php @@ -181,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 77b45c5e03f..610e0a454e3 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 ab22589ddd6..1bd87f5f8ba 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 060a995fa27..18f636dfd27 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 00000000000..0f8f8ed1bfa --- /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 00000000000..425fa0251ec --- /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/Login2.php b/Sources/Actions/Login2.php index 8217177684f..0fde599de14 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; @@ -496,6 +497,18 @@ 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; diff --git a/Sources/Actions/Profile/LinkedAccounts.php b/Sources/Actions/Profile/LinkedAccounts.php new file mode 100644 index 00000000000..603296db8e5 --- /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 157d134b02d..c62a1445336 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', @@ -919,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 a8f44e80987..c653c2f43ff 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 00000000000..6ca7226b1f1 --- /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 00000000000..7918d58db11 --- /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 00000000000..c8a376adfe1 --- /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/Db/Schema/v3_0/AuthProviders.php b/Sources/Db/Schema/v3_0/AuthProviders.php new file mode 100644 index 00000000000..790fda06c14 --- /dev/null +++ b/Sources/Db/Schema/v3_0/AuthProviders.php @@ -0,0 +1,135 @@ +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/Forum.php b/Sources/Forum.php index cd5ed4f8bd9..ecf25b61806 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 00000000000..b91a62953b8 --- /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/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index 8da54ba0f0a..9cea17efa82 100644 --- a/Sources/Maintenance/Tools/Upgrade.php +++ b/Sources/Maintenance/Tools/Upgrade.php @@ -183,6 +183,7 @@ class Upgrade extends ToolsBase implements ToolsInterface Migration\v3_0\BoardPostsCount::class, Migration\v3_0\ValidationCodeLength::class, Migration\v3_0\CreateMemberAuth::class, + Migration\v3_0\CreateAuthProviders::class, ], ]; diff --git a/Themes/default/Authentication.template.php b/Themes/default/Authentication.template.php new file mode 100644 index 00000000000..9c2c66adfed --- /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/Profile.template.php b/Themes/default/Profile.template.php index 66d56031b69..2950536243b 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 ' +

+
'; + } +} From c4a1aa4aeb0fce99a9bb11c1e5563b9e4446a54f Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 10 Aug 2026 11:32:20 +0200 Subject: [PATCH 4/4] Adds the missing index file to the new directory Every directory carries one, and check-smf-index.php enforces it. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- Sources/Authentication/index.php | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Sources/Authentication/index.php diff --git a/Sources/Authentication/index.php b/Sources/Authentication/index.php new file mode 100644 index 00000000000..2844a3b9e7b --- /dev/null +++ b/Sources/Authentication/index.php @@ -0,0 +1,8 @@ +