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);
+ }
+}