From 873f782975c1d1070c4bcdec86d0e9a9a445e92b Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Wed, 6 May 2020 22:12:22 +0300 Subject: [PATCH] Add email token expiry (#352) --- providers/class.two-factor-email.php | 78 ++++++++++++++++++-- tests/providers/class.two-factor-email.php | 84 ++++++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/providers/class.two-factor-email.php b/providers/class.two-factor-email.php index 818e1f15..35673c65 100644 --- a/providers/class.two-factor-email.php +++ b/providers/class.two-factor-email.php @@ -11,10 +11,17 @@ class Two_Factor_Email extends Two_Factor_Provider { /** * The user meta token key. * - * @type string + * @var string */ const TOKEN_META_KEY = '_two_factor_email_token'; + /** + * Store the timestamp when the token was generated. + * + * @var string + */ + const TOKEN_META_KEY_TIMESTAMP = '_two_factor_email_token_timestamp'; + /** * Name of the input field used for code resend. * @@ -65,7 +72,10 @@ public function get_label() { */ public function generate_token( $user_id ) { $token = $this->get_code(); + + update_user_meta( $user_id, self::TOKEN_META_KEY_TIMESTAMP, time() ); update_user_meta( $user_id, self::TOKEN_META_KEY, wp_hash( $token ) ); + return $token; } @@ -80,9 +90,65 @@ public function user_has_token( $user_id ) { if ( ! empty( $hashed_token ) ) { return true; - } else { + } + + return false; + } + + /** + * Has the user token validity timestamp expired. + * + * @param integer $user_id User ID. + * + * @return boolean + */ + public function user_token_has_expired( $user_id ) { + $token_lifetime = $this->user_token_lifetime( $user_id ); + $token_ttl = $this->user_token_ttl( $user_id ); + + // Invalid token lifetime is considered an expired token. + if ( is_int( $token_lifetime ) && $token_lifetime <= $token_ttl ) { return false; } + + return true; + } + + /** + * Get the lifetime of a user token in seconds. + * + * @param integer $user_id User ID. + * + * @return integer|null Return `null` if the lifetime can't be measured. + */ + public function user_token_lifetime( $user_id ) { + $timestamp = intval( get_user_meta( $user_id, self::TOKEN_META_KEY_TIMESTAMP, true ) ); + + if ( ! empty( $timestamp ) ) { + return time() - $timestamp; + } + + return null; + } + + /** + * Return the token time-to-live for a user. + * + * @param integer $user_id User ID. + * + * @return integer + */ + public function user_token_ttl( $user_id ) { + $token_ttl = 15 * MINUTE_IN_SECONDS; + + /** + * Number of seconds the token is considered valid + * after the generation. + * + * @param integer $token_ttl Token time-to-live in seconds. + * @param integer $user_id User ID. + */ + return (int) apply_filters( 'two_factor_token_ttl', $token_ttl, $user_id ); } /** @@ -119,7 +185,11 @@ public function validate_token( $user_id, $token ) { return false; } - // Ensure that the token can't be re-used. + if ( $this->user_token_has_expired( $user_id ) ) { + return false; + } + + // Ensure the token can be used only once. $this->delete_token( $user_id ); return true; @@ -184,7 +254,7 @@ public function authentication_page( $user ) { return; } - if ( ! $this->user_has_token( $user->ID ) ) { + if ( ! $this->user_has_token( $user->ID ) || $this->user_token_has_expired( $user->ID ) ) { $this->generate_and_email_token( $user ); } diff --git a/tests/providers/class.two-factor-email.php b/tests/providers/class.two-factor-email.php index c3285216..83001b1b 100644 --- a/tests/providers/class.two-factor-email.php +++ b/tests/providers/class.two-factor-email.php @@ -217,4 +217,88 @@ function test_pre_process_authentication() { $this->assertNotEquals( $token_original, $token_new, 'Failed to generate a new code as requested.' ); } + /** + * Ensure that a default TTL is set. + * + * @covers Two_Factor_Email::user_token_ttl + */ + public function test_user_token_has_ttl() { + $this->assertEquals( + 15 * 60, + $this->provider->user_token_ttl( 123 ), + 'Default TTL is 15 minutes' + ); + } + + /** + * Ensure the token generation time is stored. + * + * @covers Two_Factor_Email::user_token_lifetime + */ + public function test_tokens_have_generation_time() { + $user_id = $this->factory->user->create(); + + $this->assertFalse( + $this->provider->user_has_token( $user_id ), + 'User does not have a valid token before requesting it' + ); + + $this->assertNull( + $this->provider->user_token_lifetime( $user_id ), + 'Token lifetime is not present until a token is generated' + ); + + $this->provider->generate_token( $user_id ); + + $this->assertTrue( + $this->provider->user_has_token( $user_id ), + 'User has a token after requesting it' + ); + + $this->assertTrue( + is_int( $this->provider->user_token_lifetime( $user_id ) ), + 'Lifetime is a valid integer if present' + ); + + $this->assertFalse( + $this->provider->user_token_has_expired( $user_id ), + 'Fresh token do not expire' + ); + } + + /** + * Ensure the token generation time is stored. + * + * @covers Two_Factor_Email::user_token_has_expired + * @covers Two_Factor_Email::validate_token + */ + public function test_tokens_can_expire() { + $user_id = $this->factory->user->create(); + $token = $this->provider->generate_token( $user_id ); + + $this->assertFalse( + $this->provider->user_token_has_expired( $user_id ), + 'Fresh token have not expired' + ); + + $this->assertTrue( + $this->provider->validate_token( $user_id, $token ), + 'Fresh tokens are also valid' + ); + + // Update the generation time to one second before the TTL. + $expired_token_timestamp = time() - $this->provider->user_token_ttl( $user_id ) - 1; + update_user_meta( $user_id, Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP, $expired_token_timestamp ); + + $this->assertTrue( + $this->provider->user_token_has_expired( $user_id ), + 'Tokens expire after their TTL' + ); + + $this->assertFalse( + $this->provider->validate_token( $user_id, $token ), + 'Expired tokens are invalid' + ); + } + }