diff --git a/inc/spbc-auth.php b/inc/spbc-auth.php index 1c60b3a99..9ecb2e58b 100644 --- a/inc/spbc-auth.php +++ b/inc/spbc-auth.php @@ -1,12 +1,14 @@ settings['2fa__enable'] ) { - if (Server::inUriCanonical('wp-login.php') && !empty($_POST)) { + // Registered by the POST field, not by URI: the login page could be renamed + if (isset($_POST['spbc_2fa'])) { add_action('init', 'spbc_2fa_rate_limit', 10, 1); } add_action('login_form_login', 'spbc_2fa__authenticate', 1); // Authenticate with Code add_action('login_form', 'spbc_2fa__show_field', 10); add_action('after_password_reset', 'spbc_2fa__2fa_app_replace_meta', 10, 1); + add_action('after_password_reset', 'spbc_2fa__user_rate_limiter_reset', 10, 1); + // A challenge issued for the old password must not complete the login + add_action('after_password_reset', 'spbc_2fa__challenge_revoke', 10, 1); // Profile page hook add_action('show_user_profile', 'spbc_2fa__SelfUserProfileEdit', 1); add_action('edit_user_profile', 'spbc_2fa__SelfUserProfileEdit', 1); @@ -74,7 +80,300 @@ */ function spbc_2fa__2fa_app_replace_meta(\WP_User $user) { - return delete_user_meta($user->ID, 'spbc_2fa_type') && delete_user_meta($user->ID, 'spbc_2fa_app_token'); + delete_user_meta($user->ID, 'spbc_2fa_type'); + delete_user_meta($user->ID, 'spbc_2fa_app_token'); + delete_user_meta($user->ID, 'spbc_2fa_last_step'); + + return get_user_meta($user->ID, 'spbc_2fa_type', true) === '' + && get_user_meta($user->ID, 'spbc_2fa_app_token', true) === ''; +} + +/** + * Issue a one-time token proving that the first factor (password) has been passed. + * Stored hashed in user meta (one record per user, overwritten on each login) and sent to the browser as HttpOnly cookie. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__step_token_issue(\WP_User $user) +{ + $token = bin2hex(random_bytes(32)); + $expires = time() + SPBC_2FA_KEY_TTL; + + update_user_meta( + $user->ID, + 'spbc_2fa_step', + array( + 'hash' => hash('sha256', $token), + 'expires' => $expires, + 'fails' => 0, + ) + ); + + spbc_2fa__step_cookie_set($token, $expires); +} + +/** + * @param string $value + * @param int $expires + * + * @return void + */ +function spbc_2fa__step_cookie_set($value, $expires) +{ + if ( headers_sent() ) { + return; + } + + /** + * COOKIE_DOMAIN is false by default in WordPress, despite Psalm's string type + * @psalm-suppress RedundantCast + */ + $domain = defined('COOKIE_DOMAIN') ? (string) COOKIE_DOMAIN : ''; + + \CleantalkSP\Variables\Cookie::set( + 'spbc_2fa_step', + $value, + $expires, + '/', + $domain, + is_ssl(), + true, + 'Lax' + ); +} + +/** + * @return string Token from the request or empty string + */ +function spbc_2fa__step_token_get_from_request() +{ + /** + * $_COOKIE value may be an array (spbc_2fa_step[]=...), despite Psalm's string type + * @psalm-suppress RedundantCondition + */ + $token = isset($_COOKIE['spbc_2fa_step']) && is_string($_COOKIE['spbc_2fa_step']) + ? $_COOKIE['spbc_2fa_step'] + : ''; + + return preg_match('/^[a-f0-9]{64}$/', $token) ? $token : ''; +} + +/** + * Check that the current request carries a valid 2FA step token for the user. + * + * @param \WP_User $user + * + * @return bool + */ +function spbc_2fa__step_token_is_valid(\WP_User $user) +{ + $token = spbc_2fa__step_token_get_from_request(); + if ( $token === '' ) { + return false; + } + + $step = get_user_meta($user->ID, 'spbc_2fa_step', true); + if ( ! is_array($step) || empty($step['hash']) || empty($step['expires']) ) { + return false; + } + + if ( (int) $step['expires'] < time() || (int) $step['fails'] >= SPBC_2FA_MAX_ATTEMPTS ) { + delete_user_meta($user->ID, 'spbc_2fa_step'); + return false; + } + + return hash_equals((string) $step['hash'], hash('sha256', $token)); +} + +/** + * Atomically replace (or delete) the stored step token record, only if it still equals $old. + * Of concurrent requests holding the same record exactly one succeeds. + * + * @param \WP_User $user + * @param array $old Record read by this request + * @param array|null $new New record or null to delete + * + * @return bool + */ +function spbc_2fa__step_token_swap(\WP_User $user, array $old, $new = null) +{ + global $wpdb; + + if ( $new === null ) { + $result = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s", + $user->ID, + 'spbc_2fa_step', + maybe_serialize($old) + ) + ); + } else { + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->usermeta} SET meta_value = %s WHERE user_id = %d AND meta_key = %s AND meta_value = %s", + maybe_serialize($new), + $user->ID, + 'spbc_2fa_step', + maybe_serialize($old) + ) + ); + } + + wp_cache_delete($user->ID, 'user_meta'); + + return (int) $result > 0; +} + +/** + * Atomically count a code attempt before the code check, so parallel requests can't exceed the per-token limit. + * + * @param \WP_User $user + * + * @return array|false Reserved record or false if the token was changed by a concurrent request + */ +function spbc_2fa__step_token_reserve_attempt(\WP_User $user) +{ + $step = get_user_meta($user->ID, 'spbc_2fa_step', true); + if ( ! is_array($step) ) { + return false; + } + + $reserved = $step; + $reserved['fails'] = (int) $step['fails'] + 1; + + return spbc_2fa__step_token_swap($user, $step, $reserved) ? $reserved : false; +} + +/** + * Atomically consume the token on the correct code. Only one of concurrent requests may log in. + * + * @param \WP_User $user + * @param array $reserved Record returned by spbc_2fa__step_token_reserve_attempt() + * + * @return bool + */ +function spbc_2fa__step_token_claim(\WP_User $user, array $reserved) +{ + return spbc_2fa__step_token_swap($user, $reserved, null); +} + +/** + * Handle a wrong code. Destroys the step token when attempts are exhausted. + * + * @param \WP_User $user + * @param array $reserved Record returned by spbc_2fa__step_token_reserve_attempt() + * + * @return bool True if the step token is still usable + */ +function spbc_2fa__step_token_after_fail(\WP_User $user, array $reserved) +{ + if ( (int) $reserved['fails'] < SPBC_2FA_MAX_ATTEMPTS ) { + return true; + } + + spbc_2fa__challenge_revoke($user); + spbc_2fa__step_cookie_set('', time() - 3600); + + return false; +} + +/** + * Revoke the pending 2FA challenge: the step token and the e-mail code. + * The password has to be entered again to get a new one. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__challenge_revoke(\WP_User $user) +{ + global $spbc; + + delete_user_meta($user->ID, 'spbc_2fa_step'); + + if ( isset($spbc->data['2fa_keys'][ $user->user_login ]) ) { + unset($spbc->data['2fa_keys'][ $user->user_login ]); + $spbc->save('data'); + } +} + +/** + * Per-user limiter of 2FA code attempts. Not reset by a new password login and not bound to IP. + * + * @param \WP_User $user + * + * @return SpbcUserRateLimiter + */ +function spbc_2fa__user_rate_limiter(\WP_User $user) +{ + return new SpbcUserRateLimiter( + new RateLimiterConfig('2fa_user_attempts', SPBC_2FA_USER_MAX_ATTEMPTS, SPBC_2FA_USER_LOCK_PERIOD), + $user->ID + ); +} + +/** + * Unlock 2FA step after the password reset: the user has proved access to the account e-mail. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__user_rate_limiter_reset(\WP_User $user) +{ + spbc_2fa__user_rate_limiter($user)->reset(); +} + +/** + * Check 2FA application (TOTP) code with replay protection. + * + * @param \WP_User $user + * @param string $secret + * @param string $code + * @param bool $is_reused Set to true if the code is correct but its time step was already used + * @param bool $mark_used Mark the time step as used right away. Pass false to call spbc_2fa__app_code_mark_used() + * later, after the step token is claimed + * + * @return bool + */ +function spbc_2fa__app_code_check(\WP_User $user, $secret, $code, &$is_reused = false, $mark_used = true) +{ + $ga = new GoogleAuthenticator(); + $time_step = (int) floor(time() / 30); + $is_reused = false; + + if ( ! hash_equals((string) $ga->getCode($secret, $time_step), (string) $code) ) { + return false; + } + + // Reject the code if it (or a newer one) was already used + $last_step = (int) get_user_meta($user->ID, 'spbc_2fa_last_step', true); + if ( $time_step <= $last_step ) { + $is_reused = true; + return false; + } + + if ( $mark_used ) { + spbc_2fa__app_code_mark_used($user); + } + + return true; +} + +/** + * Mark the current time step as used. If the step has changed since the check, the newer one is marked, + * which rejects the checked code as well. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__app_code_mark_used(\WP_User $user) +{ + update_user_meta($user->ID, 'spbc_2fa_last_step', (int) floor(time() / 30)); } /** @@ -223,6 +522,7 @@ function spbc_authenticate($user, $username) if ( $type2fa !== '2fa_application' ) { spbc_2fa__send_mail($user); } + spbc_2fa__step_token_issue($user); $woocommerce_additional_query_string = ''; if ( spbc_is_plugin_active('woocommerce/woocommerce.php') && @@ -867,7 +1167,8 @@ function spbc_2fa__show_field() if ( $spbc_2fa_user ) { $user = spbc_get_user_by('login', rawurldecode($spbc_2fa_user)); - if ( ! $user ) { + // The code form is shown only to the one who has just passed the password check + if ( ! $user || ! spbc_2fa__step_token_is_valid($user) ) { return; } @@ -887,7 +1188,30 @@ function spbc_2fa__show_field() } // Error displaying - if ( isset($_GET['spbc_2fa_error']) ) { + if ( Get::getString('spbc_2fa_error') === '2' && $type2fa === '2fa_application' ) { + // Correct code, but its time step is already used + $json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; + echo TextPlateStatic::render( + ' + + ', + array( + 'title' => wp_json_encode(__('CODE ALREADY USED: ', 'security-malware-firewall'), $json_flags), + 'text' => wp_json_encode(__('Please, wait for the next code in your 2FA Code application and try again in 30 seconds.', 'security-malware-firewall'), $json_flags), + ) + ); + } elseif ( isset($_GET['spbc_2fa_error']) ) { echo '')); + $this->assertStringContainsString('textContent', $error_script); + $this->assertStringNotContainsString('innerHTML', $error_script); + } + + public function testReusedCodeMessageIsNotShownForEmailCode() + { + $_GET['spbc_2fa_user'] = $this->email_user->user_login; + $_GET['spbc_2fa_error'] = '2'; + $this->giveStepToken($this->email_user); + + list(, $output) = $this->captureOutput('spbc_2fa__show_field'); + + $this->assertStringNotContainsString('CODE ALREADY USED', $output); + } + + // ---------------------------------------------------------------- 2FA application setup / removal + + public function testReplaceMetaRemovesSecretEvenWithoutType() + { + delete_user_meta($this->app_user->ID, 'spbc_2fa_type'); + update_user_meta($this->app_user->ID, 'spbc_2fa_last_step', 123); + + $this->assertTrue(spbc_2fa__2fa_app_replace_meta($this->app_user)); + $this->assertSame('', get_user_meta($this->app_user->ID, 'spbc_2fa_app_token', true)); + $this->assertSame('', get_user_meta($this->app_user->ID, 'spbc_2fa_last_step', true)); + } + + public function testQrEndpointDoesNotExposeActivatedSecret() + { + $this->asAjaxUser($this->app_user); + + list($result, $output) = $this->captureOutput('spbc_2fa__Get2FAAppQrCode'); + + $this->assertSame('die', $result[0]); + $response = json_decode($output, true); + $this->assertFalse($response['success']); + $this->assertStringNotContainsString(self::SECRET, $output); + $this->assertSame(self::SECRET, get_user_meta($this->app_user->ID, 'spbc_2fa_app_token', true)); + } + + public function testQrEndpointIssuesFreshSecretOnEachSetup() + { + delete_user_meta($this->email_user->ID, 'spbc_2fa_type'); + $this->asAjaxUser($this->email_user); + + $secrets = array(); + for ( $i = 0; $i < 2; $i++ ) { + list(, $output) = $this->captureOutput('spbc_2fa__Get2FAAppQrCode'); + $response = json_decode($output, true); + + $this->assertTrue($response['success']); + $this->assertRegExp('/^[A-Z2-7]{32}$/', $response['data']['code']); + $this->assertSame($response['data']['code'], get_user_meta($this->email_user->ID, 'spbc_2fa_app_token', true)); + $secrets[] = $response['data']['code']; + } + + $this->assertNotSame($secrets[0], $secrets[1]); + } + + public function testQrEndpointRequiresNonce() + { + $this->asAjaxUser($this->email_user); + $_REQUEST['security'] = 'wrong'; + + list($result) = $this->captureOutput('spbc_2fa__Get2FAAppQrCode'); + + $this->assertSame(array('die', 403), $result); + $this->assertSame('', get_user_meta($this->email_user->ID, 'spbc_2fa_app_token', true)); + } + + public function testActivationCodeMarksTimeStepAsUsed() + { + $this->inOneTimeStep(function ($code) { + delete_user_meta($this->app_user->ID, 'spbc_2fa_type'); + $this->asAjaxUser($this->app_user); + $_POST['code'] = $code; + + list(, $output) = $this->captureOutput('spbc_2fa__Check2FAAppCode'); + + $this->assertTrue(json_decode($output, true)['success']); + $this->assertSame('2fa_application', get_user_meta($this->app_user->ID, 'spbc_2fa_type', true)); + $this->assertSame( + $this->timeStep(), + (int) get_user_meta($this->app_user->ID, 'spbc_2fa_last_step', true) + ); + + // The code used for activation can't be used to log in + $this->giveStepToken($this->app_user); + $this->assertCodeError($this->postCode($this->app_user->user_login, $code), 2, $this->app_user); + }); + } + + public function testEmailCodeIsSixDigits() + { + global $spbc; + + for ( $i = 0; $i < 20; $i++ ) { + spbc_2fa__key_generate_and_store($this->email_user); + $this->assertRegExp( + '/^[1-9]\d{5}$/', + (string) $spbc->data['2fa_keys'][$this->email_user->user_login]['val'] + ); + } + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/G2FA/TestGoogleAuthenticator.php b/tests/lib/CleantalkSP/SpbctWP/G2FA/TestGoogleAuthenticator.php new file mode 100644 index 000000000..0c031a28f --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/G2FA/TestGoogleAuthenticator.php @@ -0,0 +1,38 @@ +assertSame('287082', $ga->getCode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1)); + } + + public function testCodeIsSixDigits() + { + $ga = new GoogleAuthenticator(); + + $this->assertRegExp('/^\d{6}$/', $ga->getCode($ga->generateSecret())); + } + + public function testSecretIs160BitBase32() + { + $ga = new GoogleAuthenticator(); + + $secrets = array(); + for ( $i = 0; $i < 50; $i++ ) { + $secret = $ga->generateSecret(); + $this->assertRegExp('/^[A-Z2-7]{32}$/', $secret); + $secrets[] = $secret; + } + + $this->assertCount(50, array_unique($secrets)); + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/RateLimiter/TestSpbcUserRateLimiter.php b/tests/lib/CleantalkSP/SpbctWP/RateLimiter/TestSpbcUserRateLimiter.php new file mode 100644 index 000000000..b3cd41007 --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/RateLimiter/TestSpbcUserRateLimiter.php @@ -0,0 +1,151 @@ +createTable(SPBC_TBL_RATE_LIMITS); + $this->deleteRows(); + + $this->saved_server = $_SERVER; + $this->setRequest('10.0.0.1', 'PHPUnit'); + } + + protected function tearDown(): void + { + $this->deleteRows(); + $_SERVER = $this->saved_server; + $this->resetRequestCache(); + + parent::tearDown(); + } + + private function deleteRows() + { + global $wpdb; + $wpdb->query($wpdb->prepare('DELETE FROM ' . SPBC_TBL_RATE_LIMITS . ' WHERE type = %s', self::TYPE)); + } + + private function resetRequestCache() + { + Server::getInstance()->variables = array(); + \CleantalkSP\SpbctWP\Helpers\IP::getInstance()->ips_stored = array(); + } + + private function setRequest($ip, $ua) + { + $_SERVER['REMOTE_ADDR'] = $ip; + $_SERVER['HTTP_USER_AGENT'] = $ua; + $this->resetRequestCache(); + } + + private function limiter($user_id, $limit = 2) + { + return new SpbcUserRateLimiter(new RateLimiterConfig(self::TYPE, $limit, 60), $user_id); + } + + public function testLocksAfterLimit() + { + $this->assertTrue($this->limiter(1)->checkPassed()); + $this->assertTrue($this->limiter(1)->checkPassed()); + + $limiter = $this->limiter(1); + $this->assertFalse($limiter->checkPassed()); + $this->assertTrue($limiter->process_ok); + } + + public function testCounterIsPerUser() + { + for ( $i = 0; $i < 3; $i++ ) { + $this->limiter(1)->checkPassed(); + } + + $this->assertFalse($this->limiter(1)->checkPassed()); + $this->assertTrue($this->limiter(2)->checkPassed()); + } + + public function testIpChangeDoesNotResetCounter() + { + for ( $i = 1; $i <= 2; $i++ ) { + $this->setRequest('10.0.0.' . $i, 'PHPUnit'); + $this->assertTrue($this->limiter(1)->checkPassed()); + } + + $this->setRequest('10.0.0.3', 'PHPUnit'); + $this->assertFalse($this->limiter(1)->checkPassed()); + } + + public function testResetUnlocksOnlyGivenUser() + { + for ( $i = 0; $i < 3; $i++ ) { + $this->limiter(1)->checkPassed(); + $this->limiter(2)->checkPassed(); + } + + $this->limiter(1)->reset(); + + $this->assertTrue($this->limiter(1)->checkPassed()); + $this->assertFalse($this->limiter(2)->checkPassed()); + } + + public function hostileUserAgentProvider() + { + return array( + 'long ascii' => array(str_repeat('A', 1000)), + 'multibyte char split at 200' => array(str_repeat('A', 199) . "\xC3\xA9"), + 'invalid utf-8 byte' => array('Mozilla' . "\xFF\xFE"), + '4-byte utf-8 char' => array('Mozilla ' . "\xF0\x9F\x98\x80"), + 'long multibyte' => array(str_repeat("\xD0\x96", 500)), + ); + } + + /** + * An attacker-controlled UA must not fail the insert: the limiter fails open on DB errors. + * + * @dataProvider hostileUserAgentProvider + */ + public function testHostileUserAgentDoesNotBreakCounting($ua) + { + $this->assertLockedAfterOneAttempt($ua); + } + + /** + * @dataProvider hostileUserAgentProvider + */ + public function testHostileUserAgentDoesNotBreakCountingInStrictMode($ua) + { + global $wpdb; + + $sql_mode = $wpdb->get_var('SELECT @@SESSION.sql_mode'); + $wpdb->query("SET SESSION sql_mode = 'STRICT_ALL_TABLES'"); + + try { + $this->assertLockedAfterOneAttempt($ua); + } finally { + $wpdb->query($wpdb->prepare('SET SESSION sql_mode = %s', $sql_mode)); + } + } + + private function assertLockedAfterOneAttempt($ua) + { + $this->setRequest('10.0.0.1', $ua); + + $this->assertTrue($this->limiter(1, 1)->checkPassed()); + + $limiter = $this->limiter(1, 1); + $this->assertFalse($limiter->checkPassed(), 'limiter must not fail open on a hostile UA'); + $this->assertTrue($limiter->process_ok); + } +}