From 09acd049b55840f96705d43622ceb4279feb7b8a Mon Sep 17 00:00:00 2001 From: albertlast Date: Sat, 8 Aug 2026 12:53:27 +0200 Subject: [PATCH 1/2] 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 6ae8781cae30cfe21f6ffa0df6678e7f8d753ed2 Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 10 Aug 2026 08:06:34 +0200 Subject: [PATCH 2/2] 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()); + } + /** * */