| @@ -1,38 +1,57 @@ | ||
| 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'; | |
| 11 | 20 | const INVALID_CODE_MESSAGE = 'The 2FA code you entered is incorrect.'; |
| 12 | 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.'; | |
| 13 | 24 | |
| 14 | 25 | public static $cipher_algo = 'aes-256-cbc'; |
| 15 | 26 | public static $wp_2fa_option = 'wprWp2faConf'; |
| 16 | 27 | |
| 17 | - private $bvinfo; | |
| 18 | - private $settings; | |
| 19 | - private $invalid_code_message = self::INVALID_CODE_MESSAGE; | |
| 20 | - private $tooltip_message = self::TOOLTIP_MESSAGE; | |
| 28 | + private static $whitelabel = null; | |
| 21 | 29 | |
| 22 | - public function __construct() { | |
| 23 | - $this->settings = new WPRWPSettings(); | |
| 24 | - $this->bvinfo = new WPRInfo($this->settings); | |
| 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 | + } | |
| 25 | 36 | |
| 26 | - $whitelabel_info = $this->bvinfo->getLPWhitelabelInfo(); | |
| 37 | + return (isset(self::$whitelabel[$key]) && is_string(self::$whitelabel[$key])) ? self::$whitelabel[$key] : $default; | |
| 38 | + } | |
| 27 | 39 | |
| 28 | - if (isset($whitelabel_info['2fa_error_message']) && is_string($whitelabel_info['2fa_error_message'])) { | |
| 29 | - $this->invalid_code_message = $whitelabel_info['2fa_error_message']; | |
| 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))); | |
| 30 | 51 | } |
| 31 | 52 | |
| 32 | - if (isset($whitelabel_info['2fa_tooltip']) && is_string($whitelabel_info['2fa_tooltip'])) { | |
| 33 | - $this->tooltip_message = $whitelabel_info['2fa_tooltip']; | |
| 34 | - } | |
| 53 | + return sprintf('%d minutes', intval(ceil($seconds / MINUTE_IN_SECONDS))); | |
| 35 | 54 | } |
| 36 | 55 | |
| 37 | 56 | public static function isEnabled($settings) { |
| 38 | 57 | $config = $settings->getOption(self::$wp_2fa_option); |
| @@ -41,15 +60,16 @@ | ||
| 41 | 60 | $config['enabled'] === true); |
| 42 | 61 | } |
| 43 | 62 | |
| 44 | 63 | public function init() { |
| 45 | - add_action('wp_enqueue_scripts', array($this, 'enqueue_dashicons')); | |
| 46 | 64 | add_filter('authenticate', array($this, 'authenticate'), 25, 3); |
| 47 | 65 | add_action('login_form', array($this, 'custom_login_form')); |
| 66 | + add_action('login_enqueue_scripts', array($this, 'enqueue_login_assets')); | |
| 48 | 67 | } |
| 49 | 68 | |
| 50 | - public function enqueue_dashicons() { | |
| 51 | - wp_enqueue_style('dashicons'); | |
| 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); | |
| 52 | 72 | } |
| 53 | 73 | |
| 54 | 74 | public function authenticate($user, $username, $password) { |
| 55 | 75 | if (!($user instanceof WP_User)) { |
| @@ -55,236 +75,88 @@ | ||
| 55 | 75 | if (!($user instanceof WP_User)) { |
| 56 | 76 | return $user; |
| 57 | 77 | } |
| 58 | 78 | |
| 59 | - $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 | + } | |
| 60 | 82 | |
| 61 | - if ('1' === $has_2fa) { | |
| 62 | - if (empty($_POST['twofa_code'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing | |
| 63 | - wp_send_json_success(array('twofa_enabled' => true)); | |
| 64 | - exit; | |
| 65 | - } else { | |
| 66 | - $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 | + } | |
| 67 | 86 | |
| 68 | - $secret_info = WPRWP2FAUtils::getSecretInfo($encoded_secret_info); | |
| 69 | - $secret = $secret_info['secret']; | |
| 70 | - $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 | + } | |
| 71 | 94 | |
| 72 | - if (is_null($secret) || is_null($is_secret_encrypted)) { | |
| 73 | - return new WP_Error('invalid_2fa_configuration', 'Please contact your administrator to login.'); | |
| 74 | - } | |
| 95 | + return WPRWP2FATimeOTPLogin::authenticate($user); | |
| 96 | + } | |
| 75 | 97 | |
| 76 | - if (defined('SECURE_AUTH_KEY') && $is_secret_encrypted === true) { | |
| 77 | - $decryption_result = WPRHelper::opensslDecrypt($secret, self::$cipher_algo, SECURE_AUTH_KEY); | |
| 78 | - if ($decryption_result[0] === false) { | |
| 79 | - return new WP_Error('2fa_secret_key_decryption_error', 'Please contact your administrator to login.'); | |
| 80 | - } | |
| 81 | - $secret = $decryption_result[1]; | |
| 82 | - } | |
| 98 | + private static function isInteractiveLogin() { | |
| 99 | + global $pagenow; | |
| 83 | 100 | |
| 84 | - if (empty($secret) || !is_string($secret) || 32 !== strlen($secret)) { | |
| 85 | - return new WP_Error('invalid_2fa_configuration', 'Please contact your administrator to login.'); | |
| 86 | - } | |
| 87 | - | |
| 88 | - $submitted_code = WPRHelper::getRawParam('POST', 'twofa_code'); | |
| 89 | - | |
| 90 | - if (is_string($submitted_code) && ctype_digit($submitted_code) && | |
| 91 | - true === WPRWP2FAAuthenticator::verifyCode($secret, $submitted_code)) { | |
| 92 | - | |
| 93 | - return $user; | |
| 94 | - } else { | |
| 95 | - return new WP_Error('invalid_2fa_code', esc_html($this->invalid_code_message)); | |
| 96 | - } | |
| 97 | - } | |
| 101 | + if ((defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) || | |
| 102 | + (defined('REST_REQUEST') && REST_REQUEST) || | |
| 103 | + (defined('WP_CLI') && WP_CLI)) { | |
| 104 | + return false; | |
| 98 | 105 | } |
| 99 | 106 | |
| 100 | - return $user; | |
| 107 | + return $pagenow === 'wp-login.php'; | |
| 101 | 108 | } |
| 102 | 109 | |
| 103 | 110 | function custom_login_form() { |
| 104 | - $tooltip_message = $this->tooltip_message; | |
| 111 | + $tooltip_message = self::whitelabelMessage('2fa_tooltip', self::TOOLTIP_MESSAGE); | |
| 105 | 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 | + ); | |
| 106 | 126 | |
| 107 | - $icon_css = 'font-size: 20px; color: #2271b1; cursor: pointer;'; | |
| 108 | 127 | $icon_html = '<span |
| 109 | - id="twofa_help_icon" | |
| 110 | - class="dashicons dashicons-editor-help" | |
| 111 | - style="' . esc_attr($icon_css) . '"></span>'; | |
| 128 | + id="twofa-help-icon" | |
| 129 | + class="dashicons dashicons-editor-help"></span>'; | |
| 112 | 130 | |
| 113 | 131 | if ($is_url) { |
| 114 | 132 | $tooltip_html = '<a |
| 115 | - href="' . esc_url($tooltip_message) . '" | |
| 116 | - target="_blank" | |
| 117 | - style="text-decoration: none;">' . $icon_html . '</a>'; | |
| 133 | + href="' . esc_url($tooltip_message) . '" | |
| 134 | + target="_blank" | |
| 135 | + rel="noopener noreferrer" | |
| 136 | + class="twofa-help-link">' . $icon_html . '</a>'; | |
| 118 | 137 | } else { |
| 119 | 138 | $tooltip_html = '<span |
| 120 | - id="twofa_help_icon" | |
| 121 | - class="dashicons dashicons-editor-help" | |
| 122 | - title="' . esc_attr($tooltip_message) . '" | |
| 123 | - style="' . esc_attr($icon_css) . '"></span>'; | |
| 139 | + id="twofa-help-icon" | |
| 140 | + class="dashicons dashicons-editor-help" | |
| 141 | + title="' . esc_attr($tooltip_message) . '"></span>'; | |
| 124 | 142 | } |
| 125 | - // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- not the best way | |
| 126 | 143 | ?> |
| 127 | - <style> | |
| 128 | - .wp2fa-progress-bar { | |
| 129 | - width: 100%; | |
| 130 | - background-color: #f3f3f3; | |
| 131 | - display: none; | |
| 132 | - margin-bottom: 10px; | |
| 133 | - } | |
| 134 | - | |
| 135 | - .wp2fa-progress-bar.show { | |
| 136 | - display: block; | |
| 137 | - } | |
| 138 | - | |
| 139 | - .wp2fa-progress-bar .progress-bar-inner { | |
| 140 | - width: 0; | |
| 141 | - height: 5px; | |
| 142 | - background-color: #2271b1; | |
| 143 | - animation: loader 1s ease infinite; | |
| 144 | - } | |
| 145 | - | |
| 146 | - @keyframes loader { | |
| 147 | - 100% {width: 100%} | |
| 148 | - } | |
| 149 | - </style> | |
| 150 | - | |
| 151 | 144 | <div class="wp2fa-progress-bar"> |
| 152 | 145 | <div class="progress-bar-inner"></div> |
| 153 | 146 | </div> |
| 154 | - | |
| 155 | - <script type="text/javascript"> | |
| 156 | - document.addEventListener('DOMContentLoaded', function() { | |
| 157 | - const loginForm = document.getElementById('loginform'); | |
| 158 | - const usernameField = document.getElementById('user_login'); | |
| 159 | - const passwordField = document.getElementById('user_pass'); | |
| 160 | - const loginButton = document.getElementById('wp-submit'); | |
| 161 | - let loginError = document.getElementById('login_error'); | |
| 162 | - let isTwoFAEnabled = false; | |
| 163 | - const progressBar = document.getElementsByClassName('wp2fa-progress-bar')[0]; | |
| 164 | - | |
| 165 | - if (loginForm && usernameField && passwordField && loginButton) { | |
| 166 | - loginForm.addEventListener('submit', handleSubmit); | |
| 167 | - } | |
| 168 | - | |
| 169 | - function handleSubmit(event) { | |
| 170 | - event.preventDefault(); | |
| 171 | - showProgressBar(); | |
| 172 | - disableLoginButton(); | |
| 173 | - | |
| 174 | - const formData = new FormData(loginForm); | |
| 175 | - | |
| 176 | - fetch(loginForm.action, { | |
| 177 | - method: 'POST', | |
| 178 | - body: formData, | |
| 179 | - credentials: 'same-origin' | |
| 180 | - }).then(response => response.text()) | |
| 181 | - .then(text => { | |
| 182 | - try { | |
| 183 | - return JSON.parse(text); | |
| 184 | - } catch (e) { | |
| 185 | - return { success: false, html: text }; | |
| 186 | - } | |
| 187 | - }) | |
| 188 | - .then(data => { | |
| 189 | - if (data.success && data.data && data.data.twofa_enabled) { | |
| 190 | - isTwoFAEnabled = true; | |
| 191 | - showTwoFAField(); | |
| 192 | - clearLoginError(); | |
| 193 | - } else { | |
| 194 | - if (data.html) { | |
| 195 | - handleHtmlResponse(data.html); | |
| 196 | - } else if (data.data && data.data.message) { | |
| 197 | - displayError(data.data.message); | |
| 198 | - } else { | |
| 199 | - displayError('An unknown error occurred'); | |
| 200 | - } | |
| 201 | - if (isTwoFAEnabled) { | |
| 202 | - showTwoFAField(); | |
| 203 | - } | |
| 204 | - } | |
| 205 | - }) | |
| 206 | - .catch(error => { | |
| 207 | - displayError('An error occurred while processing your request'); | |
| 208 | - if (isTwoFAEnabled) { | |
| 209 | - showTwoFAField(); | |
| 210 | - } | |
| 211 | - }) | |
| 212 | - .finally(() => { | |
| 213 | - hideProgressBar(); | |
| 214 | - enableLoginButton(); | |
| 215 | - }) | |
| 216 | - } | |
| 217 | - | |
| 218 | - function handleHtmlResponse(html) { | |
| 219 | - const parser = new DOMParser(); | |
| 220 | - const doc = parser.parseFromString(html, 'text/html'); | |
| 221 | - const errorElement = doc.getElementById('login_error'); | |
| 222 | - if (errorElement) { | |
| 223 | - displayError(errorElement.innerText.trim()); | |
| 224 | - } else { | |
| 225 | - proceedWithLogin(); | |
| 226 | - } | |
| 227 | - } | |
| 228 | - | |
| 229 | - function showTwoFAField() { | |
| 230 | - let twofaField = document.getElementById('twofa_code_field'); | |
| 231 | - if (!twofaField) { | |
| 232 | - twofaField = document.createElement('p'); | |
| 233 | - twofaField.id = 'twofa_code_field'; | |
| 234 | - twofaField.innerHTML = ` | |
| 235 | - <label for="twofa_code" style="position: relative; display: block;"> | |
| 236 | - 2FA Code | |
| 237 | - <?php echo $tooltip_html; ?> | |
| 238 | - </label> | |
| 239 | - <input type="text" required name="twofa_code" id="twofa_code" class="input" value="" maxlength="6" minlength="6"> | |
| 240 | - `; | |
| 241 | - passwordField.parentNode.insertBefore(twofaField, passwordField.nextSibling); | |
| 242 | - } | |
| 243 | - twofaField.style.display = 'block'; | |
| 244 | - document.getElementById('twofa_code').value = ''; | |
| 245 | - } | |
| 246 | - | |
| 247 | - function clearLoginError() { | |
| 248 | - if (loginError) { | |
| 249 | - loginError.style.display = 'none'; | |
| 250 | - } | |
| 251 | - } | |
| 252 | - | |
| 253 | - function displayError(message) { | |
| 254 | - if (!loginError) { | |
| 255 | - loginError = document.createElement('div'); | |
| 256 | - loginError.id = 'login_error'; | |
| 257 | - loginForm.parentNode.insertBefore(loginError, loginForm); | |
| 258 | - } | |
| 259 | - loginError.textContent = message; | |
| 260 | - loginError.style.display = 'block'; | |
| 261 | - } | |
| 262 | - | |
| 263 | - function proceedWithLogin() { | |
| 264 | - loginForm.removeEventListener('submit', handleSubmit); | |
| 265 | - loginForm.submit(); | |
| 266 | - } | |
| 267 | - | |
| 268 | - function showProgressBar() { | |
| 269 | - progressBar.classList.add('show'); | |
| 270 | - } | |
| 271 | - | |
| 272 | - function hideProgressBar() { | |
| 273 | - progressBar.classList.remove('show'); | |
| 274 | - } | |
| 275 | - | |
| 276 | - function disableLoginButton() { | |
| 277 | - loginButton.disabled = true; | |
| 278 | - loginButton.value = 'Verifying...'; | |
| 279 | - } | |
| 280 | - | |
| 281 | - function enableLoginButton() { | |
| 282 | - loginButton.disabled = false; | |
| 283 | - loginButton.value = 'Log In'; | |
| 284 | - } | |
| 285 | - }); | |
| 286 | - </script> | |
| 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> | |
| 287 | 159 | <?php |
| 288 | 160 | } |
| 289 | 161 | } |
| 290 | -endif; | |
| 162 | +endif; | |