| @@ -1,176 +1,162 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | if (!defined('ABSPATH')) exit; |
| 3 | 3 | if (!class_exists('WPRWP2FA')) : |
| 4 | 4 | |
| 5 | -require_once dirname(__FILE__) . '/authenticator.php'; | |
| 6 | 5 | require_once dirname(__FILE__) . '/utils.php'; |
| 6 | +require_once dirname(__FILE__) . '/time_otp.php'; | |
| 7 | +require_once dirname(__FILE__) . '/time_otp_login.php'; | |
| 8 | +require_once dirname(__FILE__) . '/email_otp.php'; | |
| 9 | +require_once dirname(__FILE__) . '/email_otp_template.php'; | |
| 10 | +require_once dirname(__FILE__) . '/email_otp_sender.php'; | |
| 11 | +require_once dirname(__FILE__) . '/email_otp_login.php'; | |
| 7 | 12 | |
| 8 | 13 | class WPRWP2FA { |
| 9 | 14 | const FLAG_META_KEY = 'wpr_2fa_enabled'; |
| 10 | 15 | const SECRET_META_KEY = 'wpr_2fa_secret'; |
| 16 | + const METHOD_META_KEY = 'wpr_2fa_method'; | |
| 17 | + const EMAIL_CHALLENGE_META_KEY = 'wpr_2fa_email_challenge'; | |
| 18 | + const EMAIL_RATE_META_KEY = 'wpr_2fa_email_rate'; | |
| 19 | + const ATTEMPTS_META_KEY = 'wpr_2fa_attempts'; | |
| 20 | + const INVALID_CODE_MESSAGE = 'The 2FA code you entered is incorrect.'; | |
| 21 | + const TOOLTIP_MESSAGE = 'Please contact your administrator if you need assistance.'; | |
| 22 | + const CONTEXT_MESSAGE = 'Two-factor authentication is required in the standard sign-in page.'; | |
| 23 | + const CONFIG_MESSAGE = 'Please contact your administrator to login.'; | |
| 11 | 24 | |
| 12 | 25 | public static $cipher_algo = 'aes-256-cbc'; |
| 26 | + public static $wp_2fa_option = 'wprWp2faConf'; | |
| 27 | + | |
| 28 | + private static $whitelabel = null; | |
| 13 | 29 | |
| 30 | + public static function whitelabelMessage($key, $default) { | |
| 31 | + if (self::$whitelabel === null) { | |
| 32 | + $info = new WPRInfo(new WPRWPSettings()); | |
| 33 | + $values = $info->getLPWhitelabelInfo(); | |
| 34 | + self::$whitelabel = is_array($values) ? $values : array(); | |
| 35 | + } | |
| 36 | + | |
| 37 | + return (isset(self::$whitelabel[$key]) && is_string(self::$whitelabel[$key])) ? self::$whitelabel[$key] : $default; | |
| 38 | + } | |
| 39 | + | |
| 40 | + # Sent as a JSON failure rather than returned as a WP_Error. These are system | |
| 41 | + # states, not wrong credentials, and a WP_Error would be recorded as a failed | |
| 42 | + # login against the firewall's lockout counter. | |
| 43 | + public static function sendFailure($message, $data = array()) { | |
| 44 | + wp_send_json_error(array_merge($data, array('message' => $message))); | |
| 45 | + exit; | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static function humanWait($seconds) { | |
| 49 | + if ($seconds < 90) { | |
| 50 | + return sprintf('%d seconds', max(1, intval($seconds))); | |
| 51 | + } | |
| 52 | + | |
| 53 | + return sprintf('%d minutes', intval(ceil($seconds / MINUTE_IN_SECONDS))); | |
| 54 | + } | |
| 55 | + | |
| 56 | + public static function isEnabled($settings) { | |
| 57 | + $config = $settings->getOption(self::$wp_2fa_option); | |
| 58 | + | |
| 59 | + return (is_array($config) && array_key_exists('enabled', $config) && | |
| 60 | + $config['enabled'] === true); | |
| 61 | + } | |
| 62 | + | |
| 14 | 63 | public function init() { |
| 15 | 64 | add_filter('authenticate', array($this, 'authenticate'), 25, 3); |
| 16 | 65 | add_action('login_form', array($this, 'custom_login_form')); |
| 66 | + add_action('login_enqueue_scripts', array($this, 'enqueue_login_assets')); | |
| 17 | 67 | } |
| 18 | 68 | |
| 69 | + public function enqueue_login_assets() { | |
| 70 | + wp_enqueue_style('WPR-wp-2fa-login', plugin_dir_url(__FILE__) . 'css/login.css', array(), '1.4'); | |
| 71 | + wp_enqueue_script('WPR-wp-2fa-login', plugin_dir_url(__FILE__) . 'js/login.js', array(), '1.4', true); | |
| 72 | + } | |
| 73 | + | |
| 19 | 74 | public function authenticate($user, $username, $password) { |
| 20 | 75 | if (!($user instanceof WP_User)) { |
| 21 | 76 | return $user; |
| 22 | 77 | } |
| 23 | 78 | |
| 24 | - $has_2fa = get_user_meta($user->ID, WPRWP2FA::FLAG_META_KEY, true); | |
| 79 | + if ('1' !== get_user_meta($user->ID, self::FLAG_META_KEY, true)) { | |
| 80 | + return $user; | |
| 81 | + } | |
| 25 | 82 | |
| 26 | - if ('1' === $has_2fa) { | |
| 27 | - if (empty($_POST['twofa_code'])) { | |
| 28 | - wp_send_json_success(array('twofa_enabled' => true)); | |
| 29 | - exit; | |
| 30 | - } else { | |
| 31 | - $encoded_secret_info = get_user_meta($user->ID, WPRWP2FA::SECRET_META_KEY, true); | |
| 83 | + if (!self::isInteractiveLogin()) { | |
| 84 | + return new WP_Error('twofa_context', self::CONTEXT_MESSAGE); | |
| 85 | + } | |
| 32 | 86 | |
| 33 | - $secret_info = WPRWP2FAUtils::getSecretInfo($encoded_secret_info); | |
| 34 | - $secret = $secret_info['secret']; | |
| 35 | - $is_secret_encrypted = $secret_info['is_encrypted']; | |
| 87 | + $method = get_user_meta($user->ID, self::METHOD_META_KEY, true); | |
| 88 | + if ($method === 'email_otp') { | |
| 89 | + return WPRWP2FAEmailOTPLogin::authenticate($user); | |
| 90 | + } | |
| 91 | + if ($method !== '' && $method !== 'totp') { | |
| 92 | + self::sendFailure(self::CONFIG_MESSAGE); | |
| 93 | + } | |
| 36 | 94 | |
| 37 | - if (is_null($secret) || is_null($is_secret_encrypted)) { | |
| 38 | - return new WP_Error('invalid_2fa_configuration', __('Please contact your administrator to login.')); | |
| 39 | - } | |
| 95 | + return WPRWP2FATimeOTPLogin::authenticate($user); | |
| 96 | + } | |
| 40 | 97 | |
| 41 | - if (defined('SECURE_AUTH_KEY') && $is_secret_encrypted === true) { | |
| 42 | - $decryption_result = WPRHelper::opensslDecrypt($secret, self::$cipher_algo, SECURE_AUTH_KEY); | |
| 43 | - if ($decryption_result[0] === false) { | |
| 44 | - return new WP_Error('2fa_secret_key_decryption_error', __('Please contact your administrator to login.')); | |
| 45 | - } | |
| 46 | - $secret = $decryption_result[1]; | |
| 47 | - } | |
| 98 | + private static function isInteractiveLogin() { | |
| 99 | + global $pagenow; | |
| 48 | 100 | |
| 49 | - if (empty($secret) || !is_string($secret) || 32 !== strlen($secret)) { | |
| 50 | - return new WP_Error('invalid_2fa_configuration', __('Please contact your administrator to login.')); | |
| 51 | - } | |
| 52 | - | |
| 53 | - $submitted_code = sanitize_text_field($_POST['twofa_code']); | |
| 54 | - | |
| 55 | - if (is_string($submitted_code) && ctype_digit($submitted_code) && | |
| 56 | - true === WPRWP2FAAuthenticator::verifyCode($secret, $submitted_code)) { | |
| 57 | - | |
| 58 | - return $user; | |
| 59 | - } else { | |
| 60 | - return new WP_Error('invalid_2fa_code', __('The 2FA code you entered is incorrect.')); | |
| 61 | - } | |
| 62 | - } | |
| 101 | + if ((defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) || | |
| 102 | + (defined('REST_REQUEST') && REST_REQUEST) || | |
| 103 | + (defined('WP_CLI') && WP_CLI)) { | |
| 104 | + return false; | |
| 63 | 105 | } |
| 64 | 106 | |
| 65 | - return $user; | |
| 107 | + return $pagenow === 'wp-login.php'; | |
| 66 | 108 | } |
| 67 | 109 | |
| 68 | 110 | function custom_login_form() { |
| 69 | -?> | |
| 70 | - <script type="text/javascript"> | |
| 71 | - document.addEventListener('DOMContentLoaded', function() { | |
| 72 | - const loginForm = document.getElementById('loginform'); | |
| 73 | - const usernameField = document.getElementById('user_login'); | |
| 74 | - const passwordField = document.getElementById('user_pass'); | |
| 75 | - let loginError = document.getElementById('login_error'); | |
| 76 | - let isTwoFAEnabled = false; | |
| 111 | + $tooltip_message = self::whitelabelMessage('2fa_tooltip', self::TOOLTIP_MESSAGE); | |
| 112 | + $is_url = filter_var($tooltip_message, FILTER_VALIDATE_URL); | |
| 113 | + $allowed_tooltip_html = array( | |
| 114 | + 'a' => array( | |
| 115 | + 'class' => true, | |
| 116 | + 'href' => true, | |
| 117 | + 'rel' => true, | |
| 118 | + 'target' => true | |
| 119 | + ), | |
| 120 | + 'span' => array( | |
| 121 | + 'class' => true, | |
| 122 | + 'id' => true, | |
| 123 | + 'title' => true | |
| 124 | + ) | |
| 125 | + ); | |
| 77 | 126 | |
| 78 | - if (loginForm && usernameField && passwordField) { | |
| 79 | - loginForm.addEventListener('submit', handleSubmit); | |
| 80 | - } | |
| 127 | + $icon_html = '<span | |
| 128 | + id="twofa-help-icon" | |
| 129 | + class="dashicons dashicons-editor-help"></span>'; | |
| 81 | 130 | |
| 82 | - function handleSubmit(event) { | |
| 83 | - event.preventDefault(); | |
| 84 | - | |
| 85 | - const formData = new FormData(loginForm); | |
| 86 | - | |
| 87 | - fetch(loginForm.action, { | |
| 88 | - method: 'POST', | |
| 89 | - body: formData, | |
| 90 | - credentials: 'same-origin' | |
| 91 | - }).then(response => response.text()) | |
| 92 | - .then(text => { | |
| 93 | - try { | |
| 94 | - return JSON.parse(text); | |
| 95 | - } catch (e) { | |
| 96 | - return { success: false, html: text }; | |
| 97 | - } | |
| 98 | - }) | |
| 99 | - .then(data => { | |
| 100 | - if (data.success && data.data && data.data.twofa_enabled) { | |
| 101 | - isTwoFAEnabled = true; | |
| 102 | - showTwoFAField(); | |
| 103 | - clearLoginError(); | |
| 104 | - } else { | |
| 105 | - if (data.html) { | |
| 106 | - handleHtmlResponse(data.html); | |
| 107 | - } else if (data.data && data.data.message) { | |
| 108 | - displayError(data.data.message); | |
| 109 | - } else { | |
| 110 | - displayError('An unknown error occurred'); | |
| 111 | - } | |
| 112 | - if (isTwoFAEnabled) { | |
| 113 | - showTwoFAField(); | |
| 114 | - } | |
| 115 | - } | |
| 116 | - }) | |
| 117 | - .catch(error => { | |
| 118 | - displayError('An error occurred while processing your request'); | |
| 119 | - if (isTwoFAEnabled) { | |
| 120 | - showTwoFAField(); | |
| 121 | - } | |
| 122 | - }); | |
| 123 | - } | |
| 124 | - | |
| 125 | - function handleHtmlResponse(html) { | |
| 126 | - const parser = new DOMParser(); | |
| 127 | - const doc = parser.parseFromString(html, 'text/html'); | |
| 128 | - const errorElement = doc.getElementById('login_error'); | |
| 129 | - if (errorElement) { | |
| 130 | - displayError(errorElement.innerText.trim()); | |
| 131 | - } else { | |
| 132 | - proceedWithLogin(); | |
| 133 | - } | |
| 134 | - } | |
| 135 | - | |
| 136 | - function showTwoFAField() { | |
| 137 | - let twofaField = document.getElementById('twofa_code_field'); | |
| 138 | - if (!twofaField) { | |
| 139 | - twofaField = document.createElement('p'); | |
| 140 | - twofaField.id = 'twofa_code_field'; | |
| 141 | - twofaField.innerHTML = '<label for="twofa_code">2FA Code<br><input type="text" name="twofa_code" id="twofa_code" class="input" value="" size="20"></label>'; | |
| 142 | - passwordField.parentNode.insertBefore(twofaField, passwordField.nextSibling); | |
| 143 | - } | |
| 144 | - twofaField.style.display = 'block'; | |
| 145 | - document.getElementById('twofa_code').value = ''; | |
| 146 | - } | |
| 147 | - | |
| 148 | - function clearLoginError() { | |
| 149 | - if (loginError) { | |
| 150 | - loginError.style.display = 'none'; | |
| 151 | - } | |
| 152 | - } | |
| 153 | - | |
| 154 | - function displayError(message) { | |
| 155 | - if (!loginError) { | |
| 156 | - loginError = document.createElement('div'); | |
| 157 | - loginError.id = 'login_error'; | |
| 158 | - loginForm.parentNode.insertBefore(loginError, loginForm); | |
| 159 | - } | |
| 160 | - loginError.innerHTML = message; | |
| 161 | - loginError.style.display = 'block'; | |
| 162 | - } | |
| 163 | - | |
| 164 | - function proceedWithLogin() { | |
| 165 | - loginForm.removeEventListener('submit', handleSubmit); | |
| 166 | - loginForm.submit(); | |
| 167 | - } | |
| 168 | - }); | |
| 169 | - </script> | |
| 131 | + if ($is_url) { | |
| 132 | + $tooltip_html = '<a | |
| 133 | + href="' . esc_url($tooltip_message) . '" | |
| 134 | + target="_blank" | |
| 135 | + rel="noopener noreferrer" | |
| 136 | + class="twofa-help-link">' . $icon_html . '</a>'; | |
| 137 | + } else { | |
| 138 | + $tooltip_html = '<span | |
| 139 | + id="twofa-help-icon" | |
| 140 | + class="dashicons dashicons-editor-help" | |
| 141 | + title="' . esc_attr($tooltip_message) . '"></span>'; | |
| 142 | + } | |
| 143 | +?> | |
| 144 | + <div class="wp2fa-progress-bar"> | |
| 145 | + <div class="progress-bar-inner"></div> | |
| 146 | + </div> | |
| 147 | + <template id="twofa-field-template"> | |
| 148 | + <p id="twofa-code-field"> | |
| 149 | + <label for="twofa-code"> | |
| 150 | + 2FA Code | |
| 151 | + <?php echo wp_kses($tooltip_html, $allowed_tooltip_html); ?> | |
| 152 | + </label> | |
| 153 | + <input type="text" required name="twofa_code" id="twofa-code" class="input" value="" maxlength="6" minlength="6" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"> | |
| 154 | + <span id="twofa-destination" role="status" aria-live="polite"></span> | |
| 155 | + <button type="button" id="twofa-resend" class="button-link">Send a new code</button> | |
| 156 | + <span id="twofa-resend-status" role="status" aria-live="polite"></span> | |
| 157 | + </p> | |
| 158 | + </template> | |
| 170 | 159 | <?php |
| 171 | 160 | } |
| 172 | 161 | } |
| 173 | 162 | endif; |
| 174 | - | |
| 175 | -$wp_2fa = new WPRWP2FA(); | |
| 176 | -$wp_2fa->init(); | |