diff --git a/Languages/en_US/Login.php b/Languages/en_US/Login.php index 7ff6537d8f..2852549b45 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 5bf6bf3d80..024f190e05 100644 --- a/Sources/Actions/Login.php +++ b/Sources/Actions/Login.php @@ -76,6 +76,7 @@ public function execute(): void Utils::$context['default_username'] = &$_REQUEST['u']; Utils::$context['default_password'] = ''; Utils::$context['never_expire'] = false; + Utils::$context['authentication_methods'] = parent::getAuthenticationMethods(); // Add the login chain to the link tree. Utils::$context['linktree'][] = [ diff --git a/Sources/Actions/Login2.php b/Sources/Actions/Login2.php index 112c636b4a..8217177684 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 a83fbd3cdb..157d134b02 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 0000000000..cebebbc39d --- /dev/null +++ b/Sources/Db/Schema/v3_0/MemberAuth.php @@ -0,0 +1,149 @@ +name = 'member_auth'; + + $this->columns = [ + 'id_auth' => new Column( + name: 'id_auth', + type: 'int', + unsigned: true, + not_null: true, + auto: true, + ), + 'id_member' => new Column( + name: 'id_member', + type: 'mediumint', + unsigned: true, + not_null: true, + default: 0, + ), + // What kind of credential this is, e.g. the name of the mod that + // owns it. Whoever writes the row decides, and is the only thing + // that should read it back. + 'type' => new Column( + name: 'type', + type: 'varchar', + size: 20, + not_null: true, + default: '', + ), + // Which configured provider this belongs to, for credential types + // that can have more than one. 0 when the type has no such concept. + 'id_provider' => new Column( + name: 'id_provider', + type: 'int', + unsigned: true, + not_null: true, + default: 0, + ), + // Whatever identifies this credential to the thing that issued it. + // Unique per type and provider, so it is what a lookup matches on. + // Note that MySQL indexes only the first 191 characters of this, so + // do not store something whose meaning lives beyond that; hash it + // down to something shorter first if it might. + 'identifier' => new Column( + name: 'identifier', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + // Anything else the owner needs to keep, as it sees fit. + 'secret_data' => new Column( + name: 'secret_data', + type: 'text', + not_null: true, + ), + // What the member calls this credential, when they can name it. + 'title' => new Column( + name: 'title', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'date_created' => new Column( + name: 'date_created', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + 'date_last_used' => new Column( + name: 'date_last_used', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + ]; + + $this->indexes = [ + 'primary' => new DbIndex( + type: 'primary', + columns: [ + [ + 'name' => 'id_auth', + ], + ], + ), + // One credential cannot belong to two members. + 'idx_credential' => new DbIndex( + name: 'idx_credential', + type: 'unique', + columns: [ + [ + 'name' => 'type', + ], + [ + 'name' => 'id_provider', + ], + [ + 'name' => 'identifier', + ], + ], + ), + 'idx_id_member' => new DbIndex( + name: 'idx_id_member', + columns: [ + [ + 'name' => 'id_member', + ], + ], + ), + ]; + } +} diff --git a/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php new file mode 100644 index 0000000000..c5bf168ce5 --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/CreateMemberAuth.php @@ -0,0 +1,63 @@ +prefix. + * The latter is database qualified, e.g. `smf`.smf_, while list_tables() + * reports bare names, so it would never match. That mismatch is also why + * Table::exists() cannot be used here. + */ + public function isCandidate(): bool + { + $member_auth = new Schema\v3_0\MemberAuth(); + + return !\in_array(Config::$db_prefix . $member_auth->name, Db::$db->list_tables()); + } + + /** + * + */ + public function execute(): bool + { + $member_auth = new Schema\v3_0\MemberAuth(); + $member_auth->create(); + + return true; + } +} diff --git a/Sources/Maintenance/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index bda39f8ca0..8da54ba0f0 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 5a858de578..e92e23da7a 100644 --- a/Sources/User.php +++ b/Sources/User.php @@ -2241,6 +2241,39 @@ public function enforceBans(bool $force_check = false, bool $post_kick = false, } } + /** + * Whether this member has any second authentication factor set up. + * + * @return bool Whether they do. + */ + public function hasSecondFactor(): bool + { + return self::getSecondFactors($this->id) !== []; + } + + /** + * Whether this member can log in by typing a password. + * + * SMF has always given every account a password, so most code can assume + * one exists. That stops being true as soon as something else can vouch for + * a member, so anywhere that asks for a password needs to cope with the + * answer being "they don't have one". + * + * An empty passwd is the marker. There is no separate flag column, because + * password_verify() already refuses to match anything against an empty + * hash; this method exists to say so out loud rather than relying on that. + * + * @return bool Whether asking this member for their password makes sense. + */ + public function hasUsablePassword(): bool + { + // passwd is a typed property, so it may not be populated for every + // dataset. Fall back to the raw profile data before giving up. + $passwd = $this->passwd ?? (self::$profiles[$this->id]['passwd'] ?? ''); + + return trim($passwd) !== ''; + } + /** * Check if the user is who he/she says he is. * @@ -2298,6 +2331,27 @@ public function validateSession(string $type = 'admin', bool $force = false): ?s } } + /* + * If this member has no password, asking them to retype it is not going + * to work, and the prompt below would lock them out of the admin and + * moderation areas entirely. + * + * MOD AUTHORS: if you let members sign in without a password, you must + * implement this hook as well, and re-verify them however they signed in + * originally. Return true once you are satisfied it is really them. + * Nothing in SMF itself creates a member without a password, so this + * hook is never reached on a stock install. + */ + if (!$this->hasUsablePassword()) { + if (\in_array(true, IntegrationHook::call('integrate_reauthenticate', [$type, $this->id]), true)) { + $_SESSION[$type . '_time'] = time(); + + unset($_SESSION['request_referer']); + + return null; + } + } + // Posting the password... check it. if (isset($_POST[$type . '_pass'])) { // Check to ensure we're forcing SSL for authentication @@ -2821,6 +2875,48 @@ public function groupsCanModerate(bool $ignore_protected = false): array * Public static methods ***********************/ + /** + * Lists the second authentication factors a member has set up. + * + * Two factor authentication used to mean exactly one thing, the time based + * codes in SMF\TOTP\Auth, so the rest of the code asked about it by looking + * at the tfa_secret column directly. Ask here instead, so that a mod adding + * another kind of factor is visible to those checks too. + * + * Keys are short identifiers for the factor, values describe it for display. + * The built in factor uses the key 'totp'. + * + * Note that this reports what the member has configured, not whether the + * forum currently wants a second factor from them. The tfa_mode setting is + * what decides that, and callers check it separately. + * + * This reads the loaded profile data rather than an instance's properties, + * because it has to work during self::loadMe(), where self::verifyTfa() runs + * before self::setProperties() has populated anything. + * + * @param int $id_member The member to ask about. + * @return array The factors this member has, which may be empty. + */ + public static function getSecondFactors(int $id_member): array + { + $factors = []; + + if (!empty(self::$profiles[$id_member]['tfa_secret'])) { + $factors['totp'] = Lang::getTxt('tfa_title', file: 'Profile'); + } + + /* + * MOD AUTHORS: Add your own second factor here. Doing so makes SMF treat + * this member as having two factor authentication set up, which means it + * will stop short of a full login and hand over to ?action=logintfa. You + * are responsible for verifying your own factor there, which is what the + * integrate_verify_tfa hook is for. + */ + IntegrationHook::call('integrate_second_factors', [&$factors, $id_member]); + + return $factors; + } + /** * Loads an array of users by ID, member_name, or email_address. * @@ -3601,6 +3697,8 @@ public static function delete(int|array $users, bool $protect_admins = false, bo // Delete these members. ['table' => 'members', 'col' => 'id_member'], ['table' => 'member_logins', 'col' => 'id_member'], + // Anything else they used to sign in with goes with them. + ['table' => 'member_auth', 'col' => 'id_member'], ['table' => 'user_alerts', 'col' => 'id_member'], ['table' => 'user_alerts', 'col' => 'id_member_started'], ['table' => 'user_alerts_prefs', 'col' => 'id_member'], @@ -4536,7 +4634,7 @@ protected function verifyTfa(): void } // If they've set up Two Factor Authentication, validate it. - if (!empty(self::$profiles[self::$my_id]['tfa_secret'])) { + if (self::getSecondFactors(self::$my_id) !== []) { // If they are performing the TFA login action itself, make sure // to reset their ID for security, but otherwise leave it to the // action to verify the TFA credentials. diff --git a/Themes/default/Login.template.php b/Themes/default/Login.template.php index 5b03deb0e4..acf5507086 100644 --- a/Themes/default/Login.template.php +++ b/Themes/default/Login.template.php @@ -108,6 +108,22 @@ function template_login() '; + // Anything else offering to sign them in? Nothing does out of the box. + if (!empty(Utils::$context['authentication_methods'])) { + echo ' +
', Lang::getTxt('login_alternatives', file: 'Login'), '
'; + + foreach (Utils::$context['authentication_methods'] as $method) { + echo ' + ', $method['title'], ''; + } + + echo ' +