From 416dd107fd27da484180c17d3ff2bbdeec1b30bc Mon Sep 17 00:00:00 2001 From: Alex <40072887+alexdcrane@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:43:18 -0700 Subject: [PATCH] Add remember me feature (#7750) --- core/authentication/app_languages.php | 27 +++ .../resources/classes/authentication.php | 172 +++++++++++++++++- .../resources/classes/plugins/database.php | 8 +- core/authentication/resources/views/login.htm | 8 + core/default_settings/app_config.php | 8 + core/user_logs/app_config.php | 10 + .../user_logs/resources/classes/user_logs.php | 3 +- logout.php | 12 +- resources/check_auth.php | 4 +- resources/login.php | 12 +- 10 files changed, 256 insertions(+), 8 deletions(-) diff --git a/core/authentication/app_languages.php b/core/authentication/app_languages.php index 30b58432e1..304f36717c 100644 --- a/core/authentication/app_languages.php +++ b/core/authentication/app_languages.php @@ -135,6 +135,33 @@ $text['label-password_description']['zh-cn'] = "輸入您的密碼。"; $text['label-password_description']['ja-jp'] = "パスワードを入力してください。"; $text['label-password_description']['ko-kr'] = "비밀번호를 입력하세요."; +$text['label-remember_me']['en-us'] = "Remember Me"; +$text['label-remember_me']['en-gb'] = "Remember Me"; +$text['label-remember_me']['ar-eg'] = "تذكرني"; +$text['label-remember_me']['de-at'] = "Angemeldet bleiben"; +$text['label-remember_me']['de-ch'] = "Angemeldet bleiben"; +$text['label-remember_me']['de-de'] = "Angemeldet bleiben"; +$text['label-remember_me']['el-gr'] = "Θύστε μου"; +$text['label-remember_me']['es-cl'] = "Recuérdame"; +$text['label-remember_me']['es-mx'] = "Recuérdame"; +$text['label-remember_me']['fr-ca'] = "Se souvenir de moi"; +$text['label-remember_me']['fr-fr'] = "Se souvenir de moi"; +$text['label-remember_me']['he-il'] = "זכור אותי"; +$text['label-remember_me']['it-it'] = "Ricordami"; +$text['label-remember_me']['ka-ge'] = "დაიხმარე შემთხვევით"; +$text['label-remember_me']['nl-nl'] = "Onthoud me"; +$text['label-remember_me']['pl-pl'] = "Pamiętaj mnie"; +$text['label-remember_me']['pt-br'] = "Lembre-me"; +$text['label-remember_me']['pt-pt'] = "Lembre-me"; +$text['label-remember_me']['ro-ro'] = "Ține-mă minte"; +$text['label-remember_me']['ru-ru'] = "Запомнить меня"; +$text['label-remember_me']['sv-se'] = "Kom ihåg mig"; +$text['label-remember_me']['uk-ua'] = "Пам'ятати мене"; +$text['label-remember_me']['tr-tr'] = "Beni hatırla"; +$text['label-remember_me']['zh-cn'] = "记住我"; +$text['label-remember_me']['ja-jp'] = "私を覚えて"; +$text['label-remember_me']['ko-kr'] = "나를 기억해줘"; + $text['description-totp']['en-us'] = "Scan the code with an authentication application or password manager. Then use it to generate the token for the login."; $text['description-totp']['en-gb'] = "Scan the code with an authentication application or password manager. Then use it to generate the token for the login."; $text['description-totp']['ar-eg'] = "امسح الرمز ضوئيًا باستخدام تطبيق المصادقة أو مدير كلمات المرور. ثم استخدمه لإنشاء الرمز المميز لتسجيل الدخول."; diff --git a/core/authentication/resources/classes/authentication.php b/core/authentication/resources/classes/authentication.php index 5575e71001..0fc9943190 100644 --- a/core/authentication/resources/classes/authentication.php +++ b/core/authentication/resources/classes/authentication.php @@ -17,7 +17,7 @@ The Initial Developer of the Original Code is Mark J Crane - Portions created by the Initial Developer are Copyright (C) 2008-2024 + Portions created by the Initial Developer are Copyright (C) 2008-2026 the Initial Developer. All Rights Reserved. Contributor(s): @@ -101,8 +101,122 @@ class authentication { //check if contacts app exists $contacts_exists = file_exists(dirname(__DIR__, 4) . '/core/contacts/'); + //check for remember me cookie + if (isset($_COOKIE['remember'])) { + //set variables + $plugin_name = 'remember'; + $remote_address = $_SERVER['REMOTE_ADDR'] ?? ''; + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + list($cookie_selector, $cookie_validator) = explode(":", $_COOKIE['remember']); + + //get user logs + $sql = "select user_uuid, remember_validator from v_user_logs "; + $sql .= "where remember_selector = :remember_selector \n"; + $sql .= "and remote_address = :remote_address "; + $sql .= "and user_agent = :user_agent "; + $sql .= "and timestamp > NOW() - INTERVAL '7 days' "; + $sql .= "and result = 'success' "; + $sql .= "limit 1 "; + $parameters['remember_selector'] = $cookie_selector; + $parameters['remote_address'] = $remote_address; + $parameters['user_agent'] = $user_agent; + $user_logs = $this->database->select($sql, $parameters, 'row'); + unset($sql, $parameters); + + //validate the token + if (!empty($user_logs) && password_verify($cookie_validator, $user_logs['remember_validator'])) { + //get the user details + $sql = "select \n"; + $sql .= "u.domain_uuid, \n"; + $sql .= "d.domain_name, \n"; + $sql .= "u.user_uuid, \n"; + $sql .= "u.username, \n"; + $sql .= "u.contact_uuid \n"; + $sql .= "from v_users as u, v_domains as d \n"; + $sql .= "where user_uuid = :user_uuid \n"; + $sql .= "and u.domain_uuid = d.domain_uuid \n"; + $sql .= "and u.user_enabled = 'true' \n"; + $parameters['user_uuid'] = $user_logs['user_uuid']; + $row = $this->database->select($sql, $parameters, 'row'); + unset($sql, $parameters); + + //get the contact details + if ($contacts_exists && !empty($row["contact_uuid"])) { + $sql = "select * from v_contacts \n"; + $sql .= "where contact_uuid = :contact_uuid \n"; + $sql .= "and domain_uuid = :domain_uuid \n"; + $parameters['contact_uuid'] = $row["contact_uuid"]; + $parameters['domain_uuid'] = $row["domain_uuid"]; + $contact = $this->database->select($sql, $parameters, 'row'); + unset($sql, $parameters); + } + + //build a result array + $result['plugin'] = $plugin_name; + $result['domain_name'] = $row["domain_name"]; + $result['username'] = $row['username']; + $result['user_uuid'] = $row['user_uuid']; + $result['contact_uuid'] = $row["contact_uuid"]; + if ($contacts_exists) { + $result["contact_organization"] = $contact["contact_organization"] ?? ''; + $result["contact_name_given"] = $contact["contact_name_given"] ?? ''; + $result["contact_name_family"] = $contact["contact_name_family"] ?? ''; + $result["contact_image"] = $contact["contact_image"] ?? ''; + } + $result['domain_uuid'] = $row['domain_uuid']; + $result['authorized'] = true; + + //set the domain_uuid + $this->domain_uuid = $row["domain_uuid"]; + + //set the user_uuid + $this->user_uuid = $row["user_uuid"]; + + //save the result to the authentication plugin + $_SESSION['authentication']['methods'] = []; + $_SESSION['authentication']['methods'][] = $plugin_name; + $_SESSION['authentication']['plugin'] = []; + $_SESSION['authentication']['plugin'][$plugin_name] = $result; + + //create the session + self::create_user_session($result, $this->settings); + + //generate new token + $selector = uuid(); + $validator = generate_password(32); + $hashed_validator = password_hash($validator, PASSWORD_DEFAULT); + $token = $selector.':'.$validator; + + //update the user logs + $sql = "update v_user_logs "; + $sql .= "set remember_selector = :remember_selector, "; + $sql .= "remember_validator = :remember_validator "; + $sql .= "where remember_selector = :cookie_selector "; + $parameters['remember_selector'] = $selector; + $parameters['remember_validator'] = $hashed_validator; + $parameters['cookie_selector'] = $cookie_selector; + $this->database->execute($sql, $parameters); + unset($sql, $parameters); + + //set the cookie + setcookie('remember', $token, [ + 'expires' => strtotime('+7 days'), + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict' + ]); + + } + } + //use the authentication plugins foreach ($_SESSION['authentication']['methods'] as $name) { + //skip the loop if already authorized + if (isset($result['authorized']) && $result['authorized']) { + break; + } + //already processed the plugin move to the next plugin if (!empty($_SESSION['authentication']['plugin'][$name]['authorized']) && $_SESSION['authentication']['plugin'][$name]['authorized']) { continue; @@ -178,7 +292,7 @@ class authentication { } //debug information - //view_array($_SESSION['authentication'], false); + // view_array($_SESSION['authentication'], false); //set authorized to false if any authentication method failed $authorized = false; @@ -212,6 +326,60 @@ class authentication { } } + //create remember me token + if ($authorized && isset($_SESSION['username']) && isset($_SESSION['remember'])) { + //set session variables + $input_username = $_SESSION['username']; + $remember = $_SESSION['remember']; + + //match the username + $sql = "select user_uuid from v_users "; + $sql .= "where username = :username"; + $parameters['username'] = $input_username; + $user = $this->database->select($sql, $parameters, 'row'); + unset($sql, $parameters); + + if ($remember && $user) { + //generate the token + $selector = uuid(); + $validator = generate_password(32); + $hashed_validator = password_hash($validator, PASSWORD_DEFAULT); + $token = $selector.':'.$validator; + $remote_address = $_SERVER['REMOTE_ADDR'] ?? ''; + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + + //save token to the user logs + $sql = "update v_user_logs "; + $sql .= "set remember_selector = :remember_selector, "; + $sql .= "remember_validator = :remember_validator "; + $sql .= "where user_log_uuid = ( "; + $sql .= " select user_log_uuid FROM v_user_logs "; + $sql .= " where result = 'success' "; + $sql .= " and remote_address = :remote_address "; + $sql .= " and user_agent = :user_agent "; + $sql .= " and user_uuid = :user_uuid "; + $sql .= " and timestamp > NOW() - INTERVAL '7 days' "; + $sql .= " order by timestamp desc limit 1 "; + $sql .= ") "; + $parameters['remember_selector'] = $selector; + $parameters['remember_validator'] = $hashed_validator; + $parameters['remote_address'] = $remote_address; + $parameters['user_agent'] = $user_agent; + $parameters['user_uuid'] = $user['user_uuid']; + $this->database->execute($sql, $parameters); + unset($sql, $parameters); + + //set the cookie + setcookie('remember', $token, [ + 'expires' => strtotime('+7 days'), + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict' + ]); + } + } + //set a session variable to indicate whether or not we are authorized $_SESSION['authorized'] = $authorized; diff --git a/core/authentication/resources/classes/plugins/database.php b/core/authentication/resources/classes/plugins/database.php index 65881bc46b..cba93c1c9e 100644 --- a/core/authentication/resources/classes/plugins/database.php +++ b/core/authentication/resources/classes/plugins/database.php @@ -17,7 +17,7 @@ The Initial Developer of the Original Code is Mark J Crane - Portions created by the Initial Developer are Copyright (C) 2008-2024 + Portions created by the Initial Developer are Copyright (C) 2008-2026 the Initial Developer. All Rights Reserved. Contributor(s): @@ -68,6 +68,7 @@ class plugin_database { $theme_background_video = (isset($background_videos[0])) ? $background_videos[0] : ''; $login_domain_name_visible = $settings->get('login', 'domain_name_visible', false); $login_domain_name = $settings->get('login', 'domain_name'); + $login_remember_me = $settings->get('login', 'remember_me'); $login_destination = $settings->get('login', 'destination'); $users_unique = $settings->get('users', 'unique', ''); @@ -115,6 +116,7 @@ class plugin_database { $view->assign("label_username", $text['label-username']); $view->assign("label_password", $text['label-password']); $view->assign("label_domain", $text['label-domain']); + $view->assign("label_remember_me", $text['label-remember_me']); $view->assign("button_login", $text['button-login']); //assign default values to the template @@ -122,6 +124,7 @@ class plugin_database { $view->assign("login_destination_url", $login_destination); $view->assign("login_domain_name_visible", $login_domain_name_visible); $view->assign("login_domain_names", $login_domain_name); + $view->assign("login_remember_me", $login_remember_me); $view->assign("login_password_reset_enabled", $login_password_reset_enabled); $view->assign("favicon", $theme_favicon); $view->assign("login_logo_width", $theme_login_logo_width); @@ -186,6 +189,9 @@ class plugin_database { if (isset($_REQUEST["password"])) { $this->password = $_REQUEST["password"]; } + if (isset($_POST["remember"])) { + $_SESSION['remember'] = $_POST["remember"]; + } if (isset($_REQUEST["key"])) { $this->key = $_REQUEST["key"]; } diff --git a/core/authentication/resources/views/login.htm b/core/authentication/resources/views/login.htm index 2b10129c5a..bcf1ed5036 100644 --- a/core/authentication/resources/views/login.htm +++ b/core/authentication/resources/views/login.htm @@ -91,6 +91,14 @@ {/if} {/if} + {if !empty($login_remember_me)} +
+ + + + +
+ {/if}

{if !empty($login_password_reset_enabled) && $login_password_reset_enabled} diff --git a/core/default_settings/app_config.php b/core/default_settings/app_config.php index 45cd02cdca..106b5ea2e8 100644 --- a/core/default_settings/app_config.php +++ b/core/default_settings/app_config.php @@ -249,6 +249,14 @@ $apps[$x]['default_settings'][$y]['default_setting_enabled'] = "false"; $apps[$x]['default_settings'][$y]['default_setting_description'] = "Set the domain to use in the Password Reset link sent via email."; $y++; + $apps[$x]['default_settings'][$y]['default_setting_uuid'] = "d8b18b38-8510-489a-9bba-33534550ddef"; + $apps[$x]['default_settings'][$y]['default_setting_category'] = "login"; + $apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "remember_me"; + $apps[$x]['default_settings'][$y]['default_setting_name'] = "boolean"; + $apps[$x]['default_settings'][$y]['default_setting_value'] = "true"; + $apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true"; + $apps[$x]['default_settings'][$y]['default_setting_description'] = "Display a Remember Me checkbox on the login box."; + $y++; $apps[$x]['default_settings'][$y]['default_setting_uuid'] = "962ac32c-74ce-4cce-b1d9-89f4d921493d"; $apps[$x]['default_settings'][$y]['default_setting_category'] = "login"; $apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "domain_name_visible"; diff --git a/core/user_logs/app_config.php b/core/user_logs/app_config.php index 487c9b0de1..575588548d 100644 --- a/core/user_logs/app_config.php +++ b/core/user_logs/app_config.php @@ -93,6 +93,16 @@ $apps[$x]['db'][$y]['fields'][$z]['search_by'] = 'true'; $apps[$x]['db'][$y]['fields'][$z]['description']['en-us'] = ''; $z++; + $apps[$x]['db'][$y]['fields'][$z]['name'] = 'remember_selector'; + $apps[$x]['db'][$y]['fields'][$z]['type']['pgsql'] = "uuid"; + $apps[$x]['db'][$y]['fields'][$z]['type']['sqlite'] = "text"; + $apps[$x]['db'][$y]['fields'][$z]['type']['mysql'] = "char(36)"; + $apps[$x]['db'][$y]['fields'][$z]['description']['en-us'] = "Remember me selector"; + $z++; + $apps[$x]['db'][$y]['fields'][$z]['name'] = 'remember_validator'; + $apps[$x]['db'][$y]['fields'][$z]['type'] = 'text'; + $apps[$x]['db'][$y]['fields'][$z]['description']['en-us'] = "Remember me validator"; + $z++; $apps[$x]['db'][$y]['fields'][$z]['name'] = "insert_date"; $apps[$x]['db'][$y]['fields'][$z]['type']['pgsql'] = 'timestamptz'; $apps[$x]['db'][$y]['fields'][$z]['type']['sqlite'] = 'date'; diff --git a/core/user_logs/resources/classes/user_logs.php b/core/user_logs/resources/classes/user_logs.php index 390486c968..b706fdaf8a 100644 --- a/core/user_logs/resources/classes/user_logs.php +++ b/core/user_logs/resources/classes/user_logs.php @@ -17,7 +17,7 @@ The Initial Developer of the Original Code is Mark J Crane - Portions created by the Initial Developer are Copyright (C) 2019-2024 + Portions created by the Initial Developer are Copyright (C) 2019-2026 the Initial Developer. All Rights Reserved. Contributor(s): @@ -113,6 +113,7 @@ class user_logs { $array['user_logs'][0]["remote_address"] = $_SERVER['REMOTE_ADDR']; $array['user_logs'][0]["user_agent"] = $_SERVER['HTTP_USER_AGENT']; $array['user_logs'][0]["session_id"] = session_id(); + $array['user_logs'][0]["remember_token"] = $result['remember_token']; if ($result["authorized"]) { $array['user_logs'][0]["result"] = 'success'; } else { diff --git a/logout.php b/logout.php index 213505653d..4edbc27951 100644 --- a/logout.php +++ b/logout.php @@ -17,7 +17,7 @@ The Initial Developer of the Original Code is Mark J Crane - Portions created by the Initial Developer are Copyright (C) 2008-2019 + Portions created by the Initial Developer are Copyright (C) 2008-2026 the Initial Developer. All Rights Reserved. Contributor(s): @@ -30,6 +30,16 @@ //use custom logout destination if set otherwise redirect to the index page $logout_destination = $settings->get('login', 'logout_destination', PROJECT_PATH.'/'); +//remove remember me token + setcookie('remember', '', time() - 3600, '/'); + $sql = "update v_user_logs "; + $sql .= "set remember_selector = null, "; + $sql .= "remember_validator = null "; + $sql .= "where user_uuid = :user_uuid "; + $parameters['user_uuid'] = $_SESSION['user_uuid']; + $database->execute($sql, $parameters); + unset($sql, $parameters); + //destroy session session_unset(); session_destroy(); diff --git a/resources/check_auth.php b/resources/check_auth.php index 4e7ff6e10b..3101907bda 100644 --- a/resources/check_auth.php +++ b/resources/check_auth.php @@ -17,7 +17,7 @@ The Initial Developer of the Original Code is Mark J Crane - Portions created by the Initial Developer are Copyright (C) 2008-2025 + Portions created by the Initial Developer are Copyright (C) 2008-2026 the Initial Developer. All Rights Reserved. Contributor(s): @@ -145,7 +145,7 @@ settings::clear_cache(); //if logged in, redirect to login destination - if (!isset($_REQUEST["key"])) { + if (!isset($_REQUEST["key"]) && !isset($_COOKIE['remember'])) { //connect to the settings object $settings = new settings(['database' => $database, 'domain_uuid' => $domain_uuid, 'user_uuid' => $user_uuid]); diff --git a/resources/login.php b/resources/login.php index 224f635c89..ea706cddc5 100644 --- a/resources/login.php +++ b/resources/login.php @@ -34,7 +34,7 @@ //set the current domain_uuid $domain_uuid = $_SESSION['domain_uuid'] ?? ''; -//initialize the settigns object +//initialize the settings object $settings = new settings(['database' => $database, 'domain_uuid' => $domain_uuid]); //get action, if any - define, request, reset @@ -259,6 +259,16 @@ $database->execute($sql, $parameters); unset($sql, $parameters); + //remove remember me token + setcookie('remember', '', time() - 3600, '/'); + $sql = "update v_user_logs "; + $sql .= "set remember_selector = null, "; + $sql .= "remember_validator = null "; + $sql .= "where username = :username "; + $parameters['username'] = $_SESSION['valid_username']; + $database->execute($sql, $parameters); + unset($sql, $parameters); + //build the user log array $log_array['type'] = 'Password Changed'; $log_array['domain_uuid'] = $_SESSION['valid_domain'];