diff --git a/admin/admin_account.php b/admin/admin_account.php index 37eb6b263e..f9d65cdf5c 100644 --- a/admin/admin_account.php +++ b/admin/admin_account.php @@ -99,6 +99,9 @@ + + +   @@ -128,11 +131,35 @@   - - - - - + + setTimestamp($user_mfa_data['generated_at'])->setTimezone((new DateTime)->getTimezone())->format('Y-m-d H:i:s') : ''; + $mfa_email = !empty($user_mfa_data['via_email']); + $mfa_exempt = !empty($user_mfa_data['exempt']); + $mfa_status_msg = TEXT_MFA_NOT_SET; + if (!empty($user_mfa_data['generated_at'])) { + $mfa_status_msg = sprintf(TEXT_MFA_ENABLED_DATE, zen_date_short($mfa_date)); + } elseif (!empty($user_mfa_data['via_email'])) { + $mfa_status_msg = TEXT_MFA_BY_EMAIL; + } elseif ($mfa_exempt) { + $mfa_status_msg = TEXT_MFA_EXEMPT; + } + ?> + + + + + + + + + + + diff --git a/admin/includes/classes/MultiFactorAuth.php b/admin/includes/classes/MultiFactorAuth.php new file mode 100644 index 0000000000..8262c79ed5 --- /dev/null +++ b/admin/includes/classes/MultiFactorAuth.php @@ -0,0 +1,186 @@ + */ + private static array $_base32; + + /** @var array */ + private static array $_base32lookup = []; + + public function __construct( + private int $codeLength = 6, + private int $period = 30, + private string $algorithm = 'sha1', + private ?string $issuer = null, + ) { + if ($this->codeLength <= 0) { + throw new ValueError('codeLength must be int > 0'); + } + + if ($this->period <= 0) { + throw new ValueError('Period must be int > 0'); + } + + self::$_base32 = str_split(self::$_base32dict); + self::$_base32lookup = array_flip(self::$_base32); + } + + /** + * Create a new secret + * @throws Exception + */ + public function createSecret(int $bits = 160): string + { + $secret = ''; + $bytes = (int)ceil($bits / 5); // We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32) + $rnd = random_bytes($bytes); + for ($i = 0; $i < $bytes; $i++) { + $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values + } + return $secret; + } + + /** + * Calculate the code with given secret and point in time + */ + public function getCode(string $secret, ?int $time = null): string + { + $secretkey = $this->base32Decode($secret); + + $timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($time ?? time())); // Pack time into binary string + $hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true); // Hash it with users secret key + $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result + $value = unpack('N', $hashpart); // Unpack binary value + $value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits + + return str_pad((string)($value % 10 ** $this->codeLength), $this->codeLength, '0', STR_PAD_LEFT); + } + + /** + * Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now + */ + public function verifyCode(string $secret, string $code, int $discrepancy = 1, ?int $time = null, ?int &$timeslice = 0): bool + { + $timeslice = 0; + + // To keep safe from timing-attacks we iterate *all* possible codes even though we already may have + // verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice + // of the match. Each iteration we either set the timeslice variable to the timeslice of the match + // or set the value to itself. This is an effort to maintain constant execution time for the code. + for ($i = -$discrepancy; $i <= $discrepancy; $i++) { + $ts = ($time ?? time()) + ($i * $this->period); + $slice = $this->getTimeSlice($ts); + $timeslice = hash_equals($this->getCode($secret, $ts), $code) ? $slice : $timeslice; + } + + return $timeslice > 0; + } + + /** + * Set the code length, should be >=6. + */ + public function setCodeLength(int $length): static + { + $this->codeLength = $length; + + return $this; + } + + public function getCodeLength(): int + { + return $this->codeLength; + } + + private function getTimeSlice(?int $time = null, int $offset = 0): int + { + return (int)floor($time / $this->period) + ($offset * $this->period); + } + + private function base32Decode(string $value): string + { + if ($value === '') { + return ''; + } + + if (preg_match('/[^' . preg_quote(self::$_base32dict, '/') . ']/', $value) !== 0) { + throw new ValueError('Invalid base32 string'); + } + + $buffer = ''; + foreach (str_split($value) as $char) { + if ($char !== '=') { + $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, '0', STR_PAD_LEFT); + } + } + $length = strlen($buffer); + $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' ')); + + $output = ''; + foreach (explode(' ', $blocks) as $block) { + $output .= chr(bindec(str_pad($block, 8, '0', STR_PAD_RIGHT))); + } + return $output; + } + + /** + * Builds a string to be encoded in a QR code + */ + public function getQRText(string $label, string $secret): string + { + return 'otpauth://totp/' . rawurlencode($label) + . '?secret=' . rawurlencode($secret) + . '&issuer=' . rawurlencode((string)$this->issuer) + . '&period=' . $this->period + . '&algorithm=' . rawurlencode(strtoupper($this->algorithm)) + . '&digits=' . $this->codeLength; + } + + /** + * Get QR-Code URL for image from QRserver.com. + * See https://goqr.me/api/doc/create-qr-code/ + */ + public function getQrCodeQrServerUrl(string $domain, string $secretkey, int $size = 200): string + { + $queryParameters = [ + 'size' => $size . 'x' . $size, + 'ecc' => 'L', + 'margin' => 4, + 'qzone' => 1, + 'format' => 'png', // 'svg' + 'data' => $this->getQRText($domain, $secretkey), + ]; + + return 'https://api.qrserver.com/v1/create-qr-code/?' . http_build_query($queryParameters); + } + + /** + * See http://qrickit.com/qrickit_apps/qrickit_api.php + */ + public function getQrCodeQRicketUrl(string $domain, string $secretkey, int $size = 200): string + { + $queryParameters = [ + 'qrsize' => $size, + 'e' => 'l', + 'bgdcolor' => 'ffffff', + 'fgdcolor' => '000000', + 't' => 'p', // png + 'd' => $this->getQRText($domain, $secretkey), + ]; + + return 'https://qrickit.com/api/qr?' . http_build_query($queryParameters); + } + +} diff --git a/admin/includes/functions/admin_access.php b/admin/includes/functions/admin_access.php index ee330aa832..01accdff95 100644 --- a/admin/includes/functions/admin_access.php +++ b/admin/includes/functions/admin_access.php @@ -297,8 +297,13 @@ function zen_update_user($name, $email, $id, $profile): array */ function zen_read_user(string $name): bool|array { - global $db; - $sql = "SELECT admin_id, admin_name, admin_email, admin_pass, pwd_last_change_date, reset_token, failed_logins, lockout_expires, admin_profile + global $db, $sniffer; + + if (!$sniffer->field_exists(TABLE_ADMIN, 'mfa')) { + $db->Execute('ALTER TABLE ' . TABLE_ADMIN . ' ADD COLUMN mfa TEXT DEFAULT NULL'); + } + + $sql = "SELECT admin_id, admin_name, admin_email, admin_pass, pwd_last_change_date, reset_token, failed_logins, lockout_expires, admin_profile, mfa FROM " . TABLE_ADMIN . " WHERE admin_name = :adminname: "; $sql = $db->bindVars($sql, ':adminname:', $name, 'stringIgnoreNull'); $result = $db->Execute($sql, 1); @@ -372,10 +377,10 @@ function zen_validate_user_login(string $admin_name, string $admin_pass): array // BEGIN 2-factor authentication if ($error === false && defined('ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE') && ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE !== '') { if (function_exists(ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE)) { - $response = zen_call_function(ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE, [$result['admin_id'], $result['admin_email'], $result['admin_name']]); + $response = zen_call_function(ZC_ADMIN_TWO_FACTOR_AUTHENTICATION_SERVICE, ['admin_id' => $result['admin_id'], 'email' => $result['admin_email'], 'admin_name' => $result['admin_name'], 'mfa' => $result['mfa']]); if ($response !== true) { $error = true; - $message = ERROR_WRONG_LOGIN; + $message = TEXT_MFA_ERROR; zen_record_admin_activity('TFA Failure - Two-factor authentication failed', 'warning'); } else { zen_record_admin_activity('TFA Passed - Two-factor authentication passed', 'warning'); @@ -976,3 +981,24 @@ function admin_menu_name_sort_callback($a, $b): int } return 1; } + +function zen_check_if_mfa_token_is_reused(string $token, ?string $admin_name): bool +{ + global $db; + // cleanup all expired tokens + $sql = 'DELETE FROM ' . TABLE_ADMIN_EXPIRED_TOKENS . " WHERE used_date <= NOW() - INTERVAL 24 HOUR"; + $db->Execute($sql); + + if (empty($admin_name)) { + $admin_name = zen_get_admin_name($_SESSION['admin_id']); + } + + // check for current token + $sql = 'SELECT * FROM ' . TABLE_ADMIN_EXPIRED_TOKENS . " WHERE admin_name = '" . zen_db_input($admin_name) . "' AND otp_code = '" . zen_db_input($token) . "'"; + $results = $db->Execute($sql, 1); + + // if re-used record is found then EOF is not true (if no records found, EOF is true) + $token_is_re_used = !$results->EOF; + + return $token_is_re_used; +} diff --git a/admin/includes/functions/functions_mfa.php b/admin/includes/functions/functions_mfa.php new file mode 100644 index 0000000000..3cad5bb987 --- /dev/null +++ b/admin/includes/functions/functions_mfa.php @@ -0,0 +1,153 @@ +createSecret(); + if (empty($user_mfa_data['secret'])) { + $_SESSION['mfa']['secret_not_yet_persisted'] = true; + $domain = str_replace(['http'.'://', 'https://'], '', HTTP_SERVER); + + $qrCode = $ga->getQrCodeQrServerUrl($domain, $secret); +// $qrCode = $ga->getQrCodeQRicketUrl($domain, $secret) + + $_SESSION['mfa']['qrcode'] = sprintf('', $qrCode); + } + + // set system to expect MFA confirmation, so that login won't progress past getting this confirmation + $_SESSION['mfa']['pending'] = true; + + $_SESSION['mfa']['secret'] = $secret; + $_SESSION['mfa']['length'] = $ga->getCodeLength(); + $_SESSION['mfa']['type'] = 'digits'; + $_SESSION['mfa']['admin_id'] = (int)$admin_info['admin_id']; + $_SESSION['mfa']['admin_name'] = $admin_info['admin_id'] . ':' . $admin_info['admin_name']; + + return true; +} + +/** + * Prepare to do MFA validation via email + */ +function zen_mfa_by_email(array $admin_info = []): bool +{ + if (!isset($_SESSION['mfa'])) { + $_SESSION['mfa'] = []; + } + + // set system to expect MFA confirmation, so that login won't progress past getting this confirmation + $_SESSION['mfa']['pending'] = true; + + // if token already exists and isn't expired, re-use it + if (!empty($_SESSION['mfa']['expires']) && $_SESSION['mfa']['expires'] > time()) { + return true; + } + + // generate a token to be used to supply confirmation + $num_digits = 6; + $_SESSION['mfa']['token'] = $token = zen_create_random_value($num_digits, 'digits'); + $_SESSION['mfa']['length'] = $num_digits; + $_SESSION['mfa']['type'] = 'digits'; + $_SESSION['mfa']['expires'] = strtotime('5 min'); + $_SESSION['mfa']['admin_id'] = (int)$admin_info['admin_id']; + $_SESSION['mfa']['admin_name'] = $admin_info['admin_id'] . ':' . $admin_info['admin_name']; + + // prepare email to send token + $text_msg = sprintf(TEXT_MFA_EMAIL_BODY, $token, $_SERVER['REMOTE_ADDR']); + $html_msg = [ + 'EMAIL_CUSTOMERS_NAME' => $admin_info['email'], + 'EMAIL_MESSAGE_HTML' => sprintf(TEXT_MFA_EMAIL_BODY, $token, $_SERVER['REMOTE_ADDR']), + ]; + // send email + $email_response = zen_mail($admin_info['admin_name'], $admin_info['email'], TEXT_MFA_EMAIL_SUBJECT, $text_msg, STORE_NAME, EMAIL_FROM, $html_msg, 'no_archive'); + + // The email response must be a blank string (it will be false on abort, or error message string on failure) + return $email_response === ''; +} diff --git a/admin/includes/init_includes/init_admin_auth.php b/admin/includes/init_includes/init_admin_auth.php index 2caa2788da..06b5e2a7d7 100644 --- a/admin/includes/init_includes/init_admin_auth.php +++ b/admin/includes/init_includes/init_admin_auth.php @@ -63,6 +63,13 @@ } } + // Do MFA validation + if (basename($PHP_SELF) !== FILENAME_MFA . '.php' && basename($PHP_SELF) !== FILENAME_LOGOFF . '.php' + && (!empty($_SESSION['mfa']['pending']) || !empty($_SESSION['mfa']['setup_required'])) + ) { + zen_redirect(zen_href_link(FILENAME_MFA, zen_get_all_get_params('action'))); + } + // check page authorization access if (!in_array($page, [FILENAME_DEFAULT, FILENAME_ADMIN_ACCOUNT, FILENAME_LOGOFF, FILENAME_ALERT_PAGE, FILENAME_PASSWORD_FORGOTTEN, FILENAME_DENIED, FILENAME_ALT_NAV], true) && !zen_is_superuser()) diff --git a/admin/includes/init_includes/init_general_funcs.php b/admin/includes/init_includes/init_general_funcs.php index 37131f3244..b0a5117daf 100644 --- a/admin/includes/init_includes/init_general_funcs.php +++ b/admin/includes/init_includes/init_general_funcs.php @@ -18,6 +18,7 @@ require DIR_FS_CATALOG . DIR_WS_FUNCTIONS . 'database.php'; require DIR_WS_FUNCTIONS . 'general.php'; +require DIR_WS_FUNCTIONS . 'functions_mfa.php'; require DIR_FS_CATALOG . DIR_WS_FUNCTIONS . 'functions_general_shared.php'; require DIR_FS_CATALOG . DIR_WS_FUNCTIONS . 'functions_attributes.php'; diff --git a/admin/includes/languages/english/lang.admin_account.php b/admin/includes/languages/english/lang.admin_account.php index 592a1e8e81..dcf95fc56f 100644 --- a/admin/includes/languages/english/lang.admin_account.php +++ b/admin/includes/languages/english/lang.admin_account.php @@ -9,6 +9,7 @@ $define = [ 'HEADING_TITLE' => 'Admin Account', 'SUCCESS_PASSWORD_UPDATED' => 'Password updated.', + 'TEXT_PASS_LAST_CHANGED' => 'Pwd Last Change', ]; return $define; diff --git a/admin/includes/languages/english/lang.email_extras.php b/admin/includes/languages/english/lang.email_extras.php index ded398933a..1ffb6ad404 100644 --- a/admin/includes/languages/english/lang.email_extras.php +++ b/admin/includes/languages/english/lang.email_extras.php @@ -35,6 +35,12 @@ 'TEXT_EMAIL_SUBJECT_ADMIN_USER_DELETED' => 'Admin Alert: An admin user has been deleted.', 'TEXT_EMAIL_MESSAGE_ADMIN_USER_DELETED' => 'Administrative alert: An admin user (%s) has been DELETED from your store by %s.' . "\n\n" . 'If you or an authorized administrator did not initiate this change, it is advised that you verify your site security immediately.', 'TEXT_EMAIL_SUBJECT_ADMIN_USER_CHANGED' => 'Admin Alert: Admin user details have been changed.', + 'TEXT_EMAIL_SUBJECT_ADMIN_MFA_DELETED' => 'Admin Alert: MFA for an admin user has been disabled.', + 'TEXT_EMAIL_MESSAGE_ADMIN_MFA_DELETED' => 'Administrative alert: MFA settings for an admin user (%s) have been DISABLED by %s.' . "\n\n" . 'If you or an authorized administrator did not initiate this change, it is advised that you verify your site security immediately.', + 'TEXT_EMAIL_SUBJECT_ADMIN_MFA_EXEMPTED' => 'Admin Alert: MFA for an admin user has been marked as EXEMPT.', + 'TEXT_EMAIL_MESSAGE_ADMIN_MFA_EXEMPTED' => 'Administrative alert: MFA settings for an admin user (%s) have been EXEMPTED by %s.' . "\n\n" . 'If you or an authorized administrator did not initiate this change, it is advised that you verify your site security immediately.', + 'TEXT_EMAIL_SUBJECT_ADMIN_MFA_UNEXEMPTED' => 'Admin Alert: MFA for an admin user has been marked as NO LONGER EXEMPT.', + 'TEXT_EMAIL_MESSAGE_ADMIN_MFA_UNEXEMPTED' => 'Administrative alert: MFA settings for an admin user (%s) have been marked as NO LONGER EXEMPT by %s.' . "\n\n" . 'If you or an authorized administrator did not initiate this change, it is advised that you verify your site security immediately.', 'TEXT_EMAIL_ALERT_ADM_EMAIL_CHANGED' => 'Admin alert: Admin user (%s) email address has been changed from (%s) to (%s) by (%s)', 'TEXT_EMAIL_ALERT_ADM_NAME_CHANGED' => 'Admin alert: Admin user (%s) username has been changed from (%s) to (%s) by (%s)', 'TEXT_EMAIL_ALERT_ADM_PROFILE_CHANGED' => 'Admin alert: Admin user (%s) security profile has been changed from (%s) to (%s) by (%s)', diff --git a/admin/includes/languages/english/lang.mfa.php b/admin/includes/languages/english/lang.mfa.php new file mode 100644 index 0000000000..00d116debb --- /dev/null +++ b/admin/includes/languages/english/lang.mfa.php @@ -0,0 +1,21 @@ + 'Admin Login Confirmation', + 'TEXT_MFA_INTRO' => 'Please enter the OTP code from your Authenticator app below.', + 'TEXT_MFA_BOTTOM' => 'If you have lost access to your Authenticator app, please contact the storeowner.', + 'TEXT_SUBMIT' => 'Submit', + 'TEXT_MFA_INPUT' => 'enter code', + 'TEXT_MFA_SELECT' => 'Select a method for Multi-Factor Authentication:', + 'ERROR_WRONG_CODE' => 'The token you entered is invalid.', + 'ERROR_SECURITY_ERROR' => 'There was a security error when trying to login.', + 'TEXT_ERROR_ATTEMPTED_MFA_LOGIN_WITHOUT_CSRF_TOKEN' => 'Invalid CSRF token during MFA validation', + 'TEXT_ERROR_ATTEMPTED_ADMIN_MFA_LOGIN_WITH_INVALID_CODE' => 'Invalid MFA token during two-factor-auth', +]; + +return $define; diff --git a/admin/includes/languages/english/lang.users.php b/admin/includes/languages/english/lang.users.php index 48031382a0..6dd308518f 100644 --- a/admin/includes/languages/english/lang.users.php +++ b/admin/includes/languages/english/lang.users.php @@ -13,7 +13,11 @@ 'TEXT_CHOOSE_PROFILE' => 'Choose Profile', 'TEXT_NO_USERS_FOUND' => 'No Admin Users found', 'TEXT_PASS_LAST_CHANGED' => 'Pwd Last Change', + 'TEXT_BUTTON_EXEMPT' => 'Exempt', 'TEXT_CONFIRM_DELETE' => 'Delete requested. Please confirm: ', + 'TEXT_CONFIRM_EXEMPT' => 'Exempt requested. Please confirm: ', + 'TEXT_CONFIRM_UNEXEMPT' => 'Exemption Reset requested. Please confirm: ', + 'TEXT_CONFIRM_RESET' => 'Reset requested. Please confirm: ', 'ERROR_NO_USER_DEFINED' => 'The option requested requires a username to be specified.', 'ERROR_USER_MUST_HAVE_PROFILE' => 'User must be assigned a profile.', 'ERROR_DUPLICATE_USER' => 'Sorry, an admin user of that name already exists. Please select another name.', diff --git a/admin/includes/languages/lang.english.php b/admin/includes/languages/lang.english.php index 74fff2934a..d71accf4e8 100644 --- a/admin/includes/languages/lang.english.php +++ b/admin/includes/languages/lang.english.php @@ -453,6 +453,7 @@ 'TEXT_BANNERS_BANNER_VIEWS' => 'Banner Impressions', 'TEXT_BOOLEAN_VALIDATE' => 'The value is required to be a boolean value or equivalent.', 'TEXT_BUTTON_RESET_ACTIVITY_LOG' => 'View Activity Log', + 'TEXT_BUTTON_SET_UP' => 'Set Up', 'TEXT_CALL_FOR_PRICE' => 'Call for Price', 'TEXT_CANCEL' => 'Cancel', 'TEXT_CATEGORIES_PRODUCTS' => 'Select a Category with products (indicated by an asterisk) / move between the products', @@ -554,6 +555,19 @@ 'TEXT_LETTERS_FREE' => ' Letter(s) free ', 'TEXT_LINKED_PRODUCTS' => 'Linked Products:', 'TEXT_MASTER_CATEGORIES_ID' => 'Product Master Category:', + 'TEXT_MFA_EMAIL_BODY' => "Here is your Admin login verification code:\n\n%s\n\nThis code is for the login which was attempted via IP address: %s\n\nIf this was not you, please change your Admin password promptly.", + 'TEXT_MFA_EMAIL_SUBJECT' => 'Admin login verification code', + 'TEXT_MFA_ERROR' => 'An error occurred when initiating the MFA login verification', + 'TEXT_MFA_ENTER_OTP_CODE' => 'Type in the generated 6-digit code from your Authenticator app.', + 'TEXT_MFA_SCAN_QR_CODE' => 'Scan this QR Code into your Authenticator app, and type in the generated 6-digit code below.', + 'TEXT_MFA_METHOD_EMAIL' => 'Email (a code is emailed for every login attempt)', + 'TEXT_MFA_METHOD_TOTP' => 'OTP (One-Time Passcode) via an Authenticator app', + 'TEXT_MFA_STATUS' => 'Multi Factor Auth
Status', + 'TEXT_MFA_BY_EMAIL' => 'Via Email', + 'TEXT_MFA_BY_TOTP' => 'TOTP', + 'TEXT_MFA_ENABLED_DATE' => 'Enabled since %s', + 'TEXT_MFA_EXEMPT' => 'Exempt', + 'TEXT_MFA_NOT_SET' => 'Not Enabled', 'TEXT_MODEL' => 'Model:', 'TEXT_NEW_PRODUCT' => 'Product in Category: %s', 'TEXT_NO' => 'No', diff --git a/admin/mfa.php b/admin/mfa.php new file mode 100644 index 0000000000..d01870d0d0 --- /dev/null +++ b/admin/mfa.php @@ -0,0 +1,188 @@ + 'totp', 'text' => TEXT_MFA_METHOD_TOTP], + ['id' => 'email', 'text' => TEXT_MFA_METHOD_EMAIL], +]; + +// If a manual token was generated, check its expiry; if the token has expired, reset and redirect to login via logout. +if (isset($_SESSION['mfa']['expires']) && $_SESSION['mfa']['expires'] < time()) { + unset($_SESSION['mfa'], $_SESSION['admin_id']); + zen_redirect(zen_href_link(FILENAME_LOGOFF)); +} + +if (empty($_SESSION['mfa']) || str_starts_with($_POST['action'] ?? '', 'setup') || $setup_required) { + $user = zen_read_user(zen_get_admin_name($_SESSION['admin_id'])); + $user_mfa_data = json_decode($user['mfa'] ?? '', true, 2); + $mfa_status_of_store = MFA_ENABLED === 'True'; + $mfa_otp_status = !empty($user_mfa_data['generated_at']) && !empty($user_mfa_data['secret']); + $mfa_email_status = !empty($user_mfa_data['via_email']); + $mfa_exempt = !empty($user_mfa_data['exempt']); + + $setup_required = ($mfa_status_of_store && !$mfa_exempt && !$mfa_email_status && !$mfa_otp_status); +} + +if (!empty($_POST['action'])) { + // CSRF + if (!isset($_SESSION['securityToken'], $_POST['securityToken']) || ($_SESSION['securityToken'] !== $_POST['securityToken'])) { + $error = true; + $message = ERROR_SECURITY_ERROR; + zen_record_admin_activity(TEXT_ERROR_ATTEMPTED_MFA_LOGIN_WITHOUT_CSRF_TOKEN, 'warning'); + } elseif ($_POST['action'] === 'otp' . $_SESSION['securityToken']) { + // validate manual-generated token such as one sent via email + if (isset($_SESSION['mfa']['expires']) && !empty($_SESSION['mfa']['token'])) { + if (trim($_POST['mfa_code']) === $_SESSION['mfa']['token']) { + // check for re-used code + if (zen_check_if_mfa_token_is_reused($_POST['mfa_code'], $_SESSION['mfa']['admin_name'] ?? zen_get_admin_name($_SESSION['admin_id']))) { + // re-use of already-used token is a security violation, so log the user out + zen_redirect(zen_href_link(FILENAME_LOGOFF)); + } + // store used token code as expired, to prevent re-use by hijacked devices + zen_db_perform(TABLE_ADMIN_EXPIRED_TOKENS, ['admin_name' => $_SESSION['mfa']['admin_name'] ?? zen_get_admin_name($_SESSION['admin_id']), 'otp_code' => $_POST['mfa_code']]); + + unset($_SESSION['mfa']); + $camefrom = $_GET['camefrom'] ?? FILENAME_DEFAULT; + $redirect = zen_href_link($camefrom, zen_get_all_get_params(['camefrom']), 'SSL'); + zen_redirect($redirect); + } + } else { + // validate OTP token + if ($ga->verifyCode($_SESSION['mfa']['secret'], $_POST['mfa_code'], 2) === true) { + // check for re-used code + if (zen_check_if_mfa_token_is_reused($_POST['mfa_code'], $_SESSION['mfa']['admin_name'] ?? zen_get_admin_name($_SESSION['admin_id']))) { + // re-use of already-used token is a security violation, so log the user out + zen_redirect(zen_href_link(FILENAME_LOGOFF)); + } + // store secret if this is first validated otp code from qr code + if (!empty($_SESSION['mfa']['secret_not_yet_persisted'])) { + // store validated secret + zen_db_perform(TABLE_ADMIN, ['mfa' => json_encode(['secret' => $_SESSION['mfa']['secret'], 'generated_at' => time()])], 'update', "admin_id = " . (int)$_SESSION['mfa']['admin_id']); + } + // store used token code as expired, to prevent re-use by hijacked devices + zen_db_perform(TABLE_ADMIN_EXPIRED_TOKENS, ['admin_name' => $_SESSION['mfa']['admin_name'] ?? zen_get_admin_name($_SESSION['admin_id']), 'otp_code' => $_POST['mfa_code']]); + + unset($_SESSION['mfa']); + $camefrom = $_GET['camefrom'] ?? FILENAME_DEFAULT; + $redirect = zen_href_link($camefrom, zen_get_all_get_params(['camefrom']), 'SSL'); + zen_redirect($redirect); + } + } + + // bad code was entered; let them try again + sleep(2); + $error = true; + $message = ERROR_WRONG_CODE; + zen_record_admin_activity(TEXT_ERROR_ATTEMPTED_ADMIN_MFA_LOGIN_WITH_INVALID_CODE, 'warning'); + + } elseif ($_POST['action'] === 'setup' . $_SESSION['securityToken']) { + if ($_POST['selected'] === 'email') { + zen_db_perform(TABLE_ADMIN, ['mfa' => json_encode(['via_email' => true])], 'update', "admin_id = " . (int)$_SESSION['admin_id']); + $camefrom = $_GET['camefrom'] ?? FILENAME_DEFAULT; + $redirect = zen_href_link($camefrom, zen_get_all_get_params(['camefrom']), 'SSL'); + zen_redirect($redirect); + } + // else set up OTP + $setup_required = false; + zen_mfa_by_totp(['admin_id' => $user['admin_id'], 'email' => $user['admin_email'], 'admin_name' => $user['admin_name'], 'mfa' => $user['mfa']]); + } +} + +// set some field validation attributes +$length = (int)($_SESSION['mfa']['length'] ?? 0); +if ($length > 0) { + $fieldAttributes = ' size="' . ($length + 1) . '" maxlength="' . $length . '"'; +} +$fieldAttributes .= match ($_SESSION['mfa']['type'] ?? 'digits') { + 'digits' => 'inputmode="numeric" pattern="[0-9]*"', + 'alphanum' => 'pattern="[a-zA-Z0-9]*"', + 'alpha' => 'pattern="[a-zA-Z]*"', + default => '', +}; +?> + +> + + + + + +
+
+
+
+ + + + + +

+
+ +
+ + + +

+
+

+
+ + +
+ +
+
+ +
+ +
+ + +
+
+ + + +
+ + +
+ ' . PHP_EOL; ?> +
+ +

+ +
+

+
+
+
+
+
+ + + +add_session(ERROR_NO_USER_DEFINED, 'error'); zen_redirect(zen_href_link(FILENAME_USERS)); } @@ -54,6 +60,39 @@ zen_delete_user($_POST['user']); } break; + case 'deletemfa_confirm': // remove mfa from user account + if (isset($_POST['user'])) { + zen_db_perform(TABLE_ADMIN, ['mfa' => 'NULL'], 'update', 'admin_id = ' . (int)$_POST['user']); + $uname = preg_replace('/[^\w._-]/', '*', zen_get_admin_name($_POST['user'])) . ' [id: ' . (int)$_POST['user'] . ']'; + $admname = '{' . preg_replace('/[^\w._-]/', '*', zen_get_admin_name()) . ' [id: ' . (int)$_SESSION['admin_id'] . ']}'; + zen_record_admin_activity(sprintf(TEXT_EMAIL_MESSAGE_ADMIN_MFA_DELETED, $uname, $admname), 'warning'); + $email_text = sprintf(TEXT_EMAIL_MESSAGE_ADMIN_MFA_DELETED, $uname, $admname); + $block = ['EMAIL_MESSAGE_HTML' => $email_text]; + zen_mail(STORE_NAME, STORE_OWNER_EMAIL_ADDRESS, TEXT_EMAIL_SUBJECT_ADMIN_MFA_DELETED, $email_text, STORE_NAME, EMAIL_FROM, $block, 'admin_settings_changed'); + } + break; + case 'exemptmfa_confirm': // mark user account to be excluded from mfa + if (isset($_POST['user'])) { + zen_db_perform(TABLE_ADMIN, ['mfa' => json_encode(['exempt' => true])], 'update', 'admin_id = ' . (int)$_POST['user']); + $uname = preg_replace('/[^\w._-]/', '*', zen_get_admin_name($_POST['user'])) . ' [id: ' . (int)$_POST['user'] . ']'; + $admname = '{' . preg_replace('/[^\w._-]/', '*', zen_get_admin_name()) . ' [id: ' . (int)$_SESSION['admin_id'] . ']}'; + zen_record_admin_activity(sprintf(TEXT_EMAIL_MESSAGE_ADMIN_MFA_EXEMPTED, $uname, $admname), 'warning'); + $email_text = sprintf(TEXT_EMAIL_MESSAGE_ADMIN_MFA_EXEMPTED, $uname, $admname); + $block = ['EMAIL_MESSAGE_HTML' => $email_text]; + zen_mail(STORE_NAME, STORE_OWNER_EMAIL_ADDRESS, TEXT_EMAIL_SUBJECT_ADMIN_MFA_EXEMPTED, $email_text, STORE_NAME, EMAIL_FROM, $block, 'admin_settings_changed'); + } + break; + case 'unexemptmfa_confirm': // undo mfa exemption + if (isset($_POST['user'])) { + zen_db_perform(TABLE_ADMIN, ['mfa' => json_encode(['exempt' => false])], 'update', 'admin_id = ' . (int)$_POST['user']); + $uname = preg_replace('/[^\w._-]/', '*', zen_get_admin_name($_POST['user'])) . ' [id: ' . (int)$_POST['user'] . ']'; + $admname = '{' . preg_replace('/[^\w._-]/', '*', zen_get_admin_name()) . ' [id: ' . (int)$_SESSION['admin_id'] . ']}'; + zen_record_admin_activity(sprintf(TEXT_EMAIL_MESSAGE_ADMIN_MFA_UNEXEMPTED, $uname, $admname), 'warning'); + $email_text = sprintf(TEXT_EMAIL_MESSAGE_ADMIN_MFA_UNEXEMPTED, $uname, $admname); + $block = ['EMAIL_MESSAGE_HTML' => $email_text]; + zen_mail(STORE_NAME, STORE_OWNER_EMAIL_ADDRESS, TEXT_EMAIL_SUBJECT_ADMIN_MFA_UNEXEMPTED, $email_text, STORE_NAME, EMAIL_FROM, $block, 'admin_settings_changed'); + } + break; case 'insert': // insert new user into database. Post data is prep'd for db in the first function call $errors = zen_insert_user($_POST['name'], $_POST['email'], $_POST['password'], $_POST['confirm'], $_POST['profile']); if (count($errors) > 0) { @@ -116,7 +155,7 @@

- + + + @@ -148,6 +190,9 @@ + + + @@ -176,6 +221,72 @@ + setTimestamp($user_mfa_data['generated_at'])->setTimezone((new DateTime)->getTimezone())->format('Y-m-d H:i:s') : ''; + $mfa_status_msg = TEXT_MFA_NOT_SET; + if (!empty($user_mfa_data['generated_at'])) { + $mfa_status_msg = sprintf(TEXT_MFA_ENABLED_DATE, zen_date_short($mfa_date)); + } elseif (!empty($user_mfa_data['via_email'])) { + $mfa_status_msg = TEXT_MFA_BY_EMAIL; + } elseif ($mfa_exempt) { + $mfa_status_msg = TEXT_MFA_EXEMPT; + } + ?> + + + + + ' . TEXT_CONFIRM_RESET : '') . ($btn_class === '' ? '' : '') ?> + + + + '; ?> + + + + ' . TEXT_CONFIRM_EXEMPT : '') . ($btn_class === '' ? '' : '') ?> + + + + '; ?> + + + + ' . TEXT_CONFIRM_UNEXEMPT : '') . ($btn_class === '' ? '' : '') ?> + + + + '; ?> + + + diff --git a/includes/database_tables.php b/includes/database_tables.php index e663b81159..3b7eee939c 100644 --- a/includes/database_tables.php +++ b/includes/database_tables.php @@ -20,6 +20,7 @@ define('TABLE_ADMIN_PAGES', DB_PREFIX . 'admin_pages'); define('TABLE_ADMIN_PAGES_TO_PROFILES', DB_PREFIX . 'admin_pages_to_profiles'); define('TABLE_ADMIN_PROFILES', DB_PREFIX . 'admin_profiles'); +define('TABLE_ADMIN_EXPIRED_TOKENS', DB_PREFIX . 'admin_expired_tokens'); define('TABLE_AUTHORIZENET', DB_PREFIX . 'authorizenet'); define('TABLE_BANNERS', DB_PREFIX . 'banners'); define('TABLE_BANNERS_HISTORY', DB_PREFIX . 'banners_history'); diff --git a/includes/filenames.php b/includes/filenames.php index dadafa90a2..745c46ce60 100644 --- a/includes/filenames.php +++ b/includes/filenames.php @@ -105,6 +105,7 @@ define('FILENAME_MAIL', 'mail'); define('FILENAME_MAIN_PRODUCT_IMAGE', 'main_product_image'); define('FILENAME_MANUFACTURERS', 'manufacturers'); +define('FILENAME_MFA', 'mfa'); define('FILENAME_META_TAGS', 'meta_tags'); define('FILENAME_MODULES', 'modules'); define('FILENAME_NEWSLETTERS', 'newsletters'); diff --git a/zc_install/includes/systemChecks.yml b/zc_install/includes/systemChecks.yml index 6440072b52..3f6ba6a1a7 100644 --- a/zc_install/includes/systemChecks.yml +++ b/zc_install/includes/systemChecks.yml @@ -307,6 +307,12 @@ systemChecks: methods: dbVersionChecker: parameters: + - checkType: configKeyExists + keyName: MFA_ENABLED + - checkType: fieldSchema + tableName: admin_expired_tokens + fieldName: otp_code + fieldCheck: Exists checkDBVersion200: runLevel: dbVersion diff --git a/zc_install/sql/install/mysql_zencart.sql b/zc_install/sql/install/mysql_zencart.sql index d8f12231bb..3a0f093e94 100644 --- a/zc_install/sql/install/mysql_zencart.sql +++ b/zc_install/sql/install/mysql_zencart.sql @@ -100,6 +100,7 @@ CREATE TABLE admin ( lockout_expires int(11) NOT NULL default '0', last_failed_attempt datetime NOT NULL default '0001-01-01 00:00:00', last_failed_ip varchar(45) NOT NULL default '', + mfa TEXT DEFAULT NULL, PRIMARY KEY (admin_id), KEY idx_admin_name_zen (admin_name), KEY idx_admin_email_zen (admin_email), @@ -107,6 +108,20 @@ CREATE TABLE admin ( ) ENGINE=MyISAM; +# -------------------------------------------------------- +# +# Table structure for table 'admin_expired_tokens' +# + +DROP TABLE IF EXISTS admin_expired_tokens; +CREATE TABLE admin_expired_tokens ( + admin_name varchar(44) NOT NULL default '', + otp_code varchar(32) NOT NULL default '', + used_date timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (used_date, otp_code, admin_name), + KEY idx_admin_name_otp_code_zen (admin_name, otp_code) +); + # -------------------------------------------------------- # @@ -2445,6 +2460,7 @@ INSERT INTO configuration (configuration_title, configuration_key, configuration INSERT INTO configuration (configuration_title, configuration_key, configuration_value, configuration_description, configuration_group_id, sort_order, date_added, set_function) VALUES ('Wholesale Pricing', 'WHOLESALE_PRICING_CONFIG', 'false', 'Should Wholesale Pricing be enabled for your site? Choose false (the default) if you don\'t want that feature enabled. Otherwise, choose Tax Exempt to enable with tax-exemptions for all wholesale customers or Pricing Only to apply tax as usual for wholesale customers.', 1, 23, now(), 'zen_cfg_select_option([\'false\', \'Tax Exempt\', \'Pricing Only\'],'); +INSERT INTO configuration (configuration_title, configuration_key, configuration_value, configuration_description, configuration_group_id, sort_order, date_added, set_function) VALUES ('MFA Multi-Factor Authentication Required', 'MFA_ENABLED', 'False', '2-Factor authentication for Admin users', 1, 29, now(), 'zen_cfg_select_option([\'True\', \'False\'],'); INSERT INTO configuration (configuration_title, configuration_key, configuration_value, configuration_description, configuration_group_id, sort_order, last_modified, date_added, use_function, set_function) VALUES ('PA-DSS Admin Session Timeout Enforced?', 'PADSS_ADMIN_SESSION_TIMEOUT_ENFORCED', '1', 'PA-DSS Compliance requires that any Admin login sessions expire after 15 minutes of inactivity. Disabling this makes your site NON-COMPLIANT with PA-DSS rules, thus invalidating any certification.', 1, 30, now(), now(), NULL, 'zen_cfg_select_drop_down(array(array(\'id\'=>\'0\', \'text\'=>\'Non-Compliant\'), array(\'id\'=>\'1\', \'text\'=>\'On\')),'); INSERT INTO configuration (configuration_title, configuration_key, configuration_value, configuration_description, configuration_group_id, sort_order, last_modified, date_added, use_function, set_function) VALUES ('PA-DSS Strong Password Rules Enforced?', 'PADSS_PWD_EXPIRY_ENFORCED', '1', 'PA-DSS Compliance requires that admin passwords must be changed after 90 days and cannot re-use the last 4 passwords. Disabling this makes your site NON-COMPLIANT with PA-DSS rules, thus invalidating any certification.', 1, 30, now(), now(), NULL, 'zen_cfg_select_drop_down(array(array(\'id\'=>\'0\', \'text\'=>\'Non-Compliant\'), array(\'id\'=>\'1\', \'text\'=>\'On\')),'); INSERT INTO configuration (configuration_title, configuration_key, configuration_value, configuration_description, configuration_group_id, sort_order, last_modified, date_added, use_function, set_function) VALUES ('PA-DSS Ajax Checkout?', 'PADSS_AJAX_CHECKOUT', '1', 'PA-DSS Compliance requires that for some inbuilt payment methods, that we use ajax to draw the checkout confirmation screen. While this will only happen if one of those payment methods is actually present, some people may want the traditional checkout flow Disabling this makes your site NON-COMPLIANT with PA-DSS rules, thus invalidating any certification.', 1, 30, now(), now(), NULL, 'zen_cfg_select_drop_down(array(array(\'id\'=>\'0\', \'text\'=>\'Non-Compliant\'), array(\'id\'=>\'1\', \'text\'=>\'On\')),'); diff --git a/zc_install/sql/updates/mysql_upgrade_zencart_210.sql b/zc_install/sql/updates/mysql_upgrade_zencart_210.sql index f667d4d324..539f9f7441 100644 --- a/zc_install/sql/updates/mysql_upgrade_zencart_210.sql +++ b/zc_install/sql/updates/mysql_upgrade_zencart_210.sql @@ -39,6 +39,19 @@ TRUNCATE TABLE db_cache; ALTER TABLE email_archive ADD COLUMN errorinfo TEXT DEFAULT NULL; ALTER TABLE email_archive ADD INDEX idx_email_date_sent_zen (date_sent); +#PROGRESS_FEEDBACK:!TEXT=Updating table structures! +ALTER TABLE admin ADD COLUMN mfa TEXT DEFAULT NULL; +DROP TABLE IF EXISTS admin_expired_tokens; +CREATE TABLE admin_expired_tokens ( + admin_name varchar(44) NOT NULL default '', + otp_code varchar(32) NOT NULL default '', + used_date timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (used_date, otp_code, admin_name), + KEY idx_admin_name_otp_code_zen (admin_name, otp_code) +); +INSERT INTO configuration (configuration_title, configuration_key, configuration_value, configuration_description, configuration_group_id, sort_order, date_added, set_function) VALUES ('MFA Multi-Factor Authentication Required', 'MFA_ENABLED', 'False', '2-Factor authentication for Admin users', 1, 29, now(), 'zen_cfg_select_option([\'True\', \'False\'],'); + +