Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Be more tolerant of user input for auth codes #518

Merged
merged 19 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fa9f6cc
Add placeholder examples on authcode prompt screen.
dd32 Feb 14, 2023
2d83998
Be more tollerant of user input, allowing for authentication apps whi…
dd32 Feb 14, 2023
a97dde0
Don't use `sanitize_text_field()` to 'sanitize' the authcodes provide…
dd32 Feb 14, 2023
f924e30
Add the placeholder and space stripping to the TOTP setup process.
dd32 Feb 14, 2023
edf38aa
In 893bd8a7f39e21146468ffbbebb7971208dd7a73 the Backup code class was…
dd32 Feb 14, 2023
e29bb27
TOTP: Add a unit test that validates invalid characters in the authco…
dd32 Feb 14, 2023
f499193
Tests: Verify that Backup codes validate_authentication fails appropr…
dd32 Feb 15, 2023
f4bf159
Switch from using a placeholder with english-terms, to simply using t…
dd32 Feb 15, 2023
820cd31
Replace all whitespace mid-string in the authcode, and add a common f…
dd32 Feb 15, 2023
5c0b8d8
Tests: Add a test for Two_Factor_Provider::sanitize_code_from_request…
dd32 Feb 15, 2023
3ed9089
Switch from input[type=tel] to input[type=text][inputmode=numeric] fo…
dd32 Feb 15, 2023
4403b4a
Tests: Remove the test for Two_Factor_Provider::get_code() as it's fa…
dd32 Feb 15, 2023
2fd9e65
Apply the whitespace removal to the REST API TOTP setup process too.
dd32 Feb 15, 2023
7665882
Fix typo.
dd32 Feb 15, 2023
e54dbf2
Merge branch 'master' into fix/user-tolerant-input
dd32 Feb 17, 2023
af303dd
Switch to including a space in the digit placeholder, '123 456' and a…
dd32 Feb 17, 2023
103dd01
Automatically submit the 2FA form upon the correct length code being …
dd32 Feb 17, 2023
e0741a2
Fix comment indentation.
dd32 Mar 3, 2023
86e4395
Merge branch 'WordPress:master' into fix/user-tolerant-input
dd32 Mar 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -804,8 +804,48 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg
#login form p.two-factor-prompt {
margin-bottom: 1em;
}
.input.authcode {
letter-spacing: .3em;
}
.input.authcode::placeholder {
opacity: 0.5;
}
</style>

<script>
(function() {
// Enforce numeric-only input for numeric inputmode elements.
const form = document.querySelector( '#loginform' ),
inputEl = document.querySelector( 'input.authcode[inputmode="numeric"]' ),
expectedLength = inputEl?.dataset.digits || 0;

if ( inputEl ) {
let spaceInserted = false;
inputEl.addEventListener(
'input',
function() {
let value = this.value.replace( /[^0-9 ]/g, '' ).trimStart();

if ( ! spaceInserted && expectedLength && value.length === Math.floor( expectedLength / 2 ) ) {
value += ' ';
iandunn marked this conversation as resolved.
Show resolved Hide resolved
spaceInserted = true;
} else if ( spaceInserted && ! this.value ) {
spaceInserted = false;
}

this.value = value;

// Auto-submit if it's the expected length.
iandunn marked this conversation as resolved.
Show resolved Hide resolved
if ( expectedLength && value.replace( / /g, '' ).length == expectedLength ) {
if ( undefined !== form.requestSubmit ) {
form.requestSubmit();
form.submit.disabled = "disabled";
}
}
}
);
}
})();
</script>
<?php
if ( ! function_exists( 'login_footer' ) ) {
include_once TWO_FACTOR_DIR . 'includes/function.login-footer.php';
Expand Down
8 changes: 6 additions & 2 deletions providers/class-two-factor-backup-codes.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ public function authentication_page( $user ) {
<p class="two-factor-prompt"><?php esc_html_e( 'Enter a backup verification code.', 'two-factor' ); ?></p>
<p>
<label for="authcode"><?php esc_html_e( 'Verification Code:', 'two-factor' ); ?></label>
<input type="tel" name="two-factor-backup-code" id="authcode" class="input" value="" size="20" pattern="[0-9]*" />
<input type="text" inputmode="numeric" name="two-factor-backup-code" id="authcode" class="input authcode" value="" size="20" pattern="[0-9 ]*" placeholder="1234 5678" data-digits="8" />
</p>
<?php
submit_button( __( 'Submit', 'two-factor' ) );
Expand All @@ -347,7 +347,11 @@ public function authentication_page( $user ) {
* @return boolean
*/
public function validate_authentication( $user ) {
$backup_code = isset( $_POST['two-factor-backup-code'] ) ? sanitize_text_field( wp_unslash( $_POST['two-factor-backup-code'] ) ) : '';
$backup_code = $this->sanitize_code_from_request( 'two-factor-backup-code' );
iandunn marked this conversation as resolved.
Show resolved Hide resolved
if ( ! $backup_code ) {
return false;
}

return $this->validate_code( $user, $backup_code );
}

Expand Down
8 changes: 3 additions & 5 deletions providers/class-two-factor-email.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ public function authentication_page( $user ) {
<p class="two-factor-prompt"><?php esc_html_e( 'A verification code has been sent to the email address associated with your account.', 'two-factor' ); ?></p>
<p>
<label for="authcode"><?php esc_html_e( 'Verification Code:', 'two-factor' ); ?></label>
<input type="tel" name="two-factor-email-code" id="authcode" class="input" value="" size="20" />
<input type="text" inputmode="numeric" name="two-factor-email-code" id="authcode" class="input authcode" value="" size="20" pattern="[0-9 ]*" placeholder="1234 5678" data-digits="8" />
<?php submit_button( __( 'Log In', 'two-factor' ) ); ?>
</p>
<p class="two-factor-email-resend">
Expand Down Expand Up @@ -313,13 +313,11 @@ public function pre_process_authentication( $user ) {
* @return boolean
*/
public function validate_authentication( $user ) {
if ( ! isset( $user->ID ) || ! isset( $_REQUEST['two-factor-email-code'] ) ) {
$code = $this->sanitize_code_from_request( 'two-factor-email-code' );
if ( ! isset( $user->ID ) || ! $code ) {
return false;
}

// Ensure there are no spaces or line breaks around the code.
$code = trim( sanitize_text_field( $_REQUEST['two-factor-email-code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, handled by the core method already.

return $this->validate_token( $user->ID, $code );
}

Expand Down
23 changes: 23 additions & 0 deletions providers/class-two-factor-provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,27 @@ public static function get_code( $length = 8, $chars = '1234567890' ) {
}
return $code;
}

/**
* Sanitizes a numeric code to be used as an auth code.
*
* @param string $field The _REQUEST field to check for the code.
* @param int $length The valid expected length of the field.
* @return false|string Auth code on success, false if the field is not set or not expected length.
*/
public static function sanitize_code_from_request( $field, $length = 0 ) {
if ( empty( $_REQUEST[ $field ] ) ) {
return false;
}

$code = wp_unslash( $_REQUEST[ $field ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, handled by the core method already.
$code = preg_replace( '/\s+/', '', $code );

// Maybe validate the length.
if ( $length && strlen( $code ) !== $length ) {
return false;
}

return (string) $code;
}
}
18 changes: 10 additions & 8 deletions providers/class-two-factor-totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ public function rest_setup_totp( $request ) {
$user = get_user_by( 'id', $user_id );

$key = $request['key'];
$code = $request['code'];
$code = preg_replace( '/\s+/', '', $request['code'] );

if ( ! $this->is_valid_key( $key ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid Two Factor Authentication secret key.', 'two-factor' ), array( 'status' => 400 ) );
Expand Down Expand Up @@ -338,7 +338,11 @@ public function user_two_factor_options( $user ) {
<input type="hidden" id="two-factor-totp-key" name="two-factor-totp-key" value="<?php echo esc_attr( $key ); ?>" />
<label for="two-factor-totp-authcode">
<?php esc_html_e( 'Authentication Code:', 'two-factor' ); ?>
<input type="tel" name="two-factor-totp-authcode" id="two-factor-totp-authcode" class="input" value="" size="20" pattern="[0-9]*" />
<?php
/* translators: Example auth code. */
$placeholder = sprintf( __( 'eg. %s', 'two-factor' ), '123456' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$placeholder = sprintf( __( 'eg. %s', 'two-factor' ), '123456' );
$placeholder = sprintf( __( 'eg. %s', 'two-factor' ), '123 456' );

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've taken a slightly different approach, which I'd like your review on @iandunn.

Instead of treating it like a string number, or groups of two digits, I've instead increased the width between all the characters, which both makes it easier to read, and due to the increased space, "feels like" (to me) that there's less of a requirement for spaces on the input (as it's auto-spaced).

Adding a space between the chunks like this is still possible, and ends up like this:

Current with extra space user input
Screenshot 2023-02-15 at 11 52 57 am Screenshot 2023-02-15 at 11 55 46 am
(no user-input spaces)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 , I like this better than e.g., 123 456. I lean towards 123 456 being best, though. IMO blocks of numbers like in auth codes have two problems:

  1. they're hard to read because the numbers all run together
  2. they're hard to remember because the sequence is too long

Adding extra (uniform) space between each letter solves 1, but not 2. Grouping the numbers into small blocks like 123 456 solves both problems IMO.

I don't feel strongly, though. I think 1 2 3 4 5 6 is an improvement on the current situation 👍🏻

?>
<input type="tel" name="two-factor-totp-authcode" id="two-factor-totp-authcode" class="input" value="" size="20" pattern="[0-9 ]*" placeholder="<?php echo esc_attr( $placeholder ); ?>" />
</label>
<input type="submit" class="button totp-submit" name="two-factor-totp-submit" value="<?php esc_attr_e( 'Submit', 'two-factor' ); ?>" />
</p>
Expand Down Expand Up @@ -468,14 +472,12 @@ public function is_valid_key( $key ) {
* @return bool Whether the user gave a valid code
*/
public function validate_authentication( $user ) {
if ( empty( $_REQUEST['authcode'] ) ) {
$code = $this->sanitize_code_from_request( 'authcode', self::DEFAULT_DIGIT_COUNT );
if ( ! $code ) {
return false;
}

return $this->validate_code_for_user(
$user,
sanitize_text_field( $_REQUEST['authcode'] )
);
return $this->validate_code_for_user( $user, $code );
}

/**
Expand Down Expand Up @@ -667,7 +669,7 @@ public function authentication_page( $user ) {
</p>
<p>
<label for="authcode"><?php esc_html_e( 'Authentication Code:', 'two-factor' ); ?></label>
<input type="tel" autocomplete="one-time-code" name="authcode" id="authcode" class="input" value="" size="20" pattern="[0-9]*" />
<input type="text" inputmode="numeric" autocomplete="one-time-code" name="authcode" id="authcode" class="input authcode" value="" size="20" pattern="[0-9 ]*" placeholder="123 456" data-digits="<?php echo esc_attr( self::DEFAULT_DIGIT_COUNT ); ?>" />
</p>
<script type="text/javascript">
setTimeout( function(){
Expand Down
12 changes: 8 additions & 4 deletions tests/providers/class-two-factor-backup-codes.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,17 @@ public function test_authentication_page() {
* @covers Two_Factor_Backup_Codes::validate_authentication
*/
public function test_validate_authentication() {
$user = new WP_User( self::factory()->user->create() );
$code = $this->provider->generate_codes( $user, array( 'number' => 1 ) );
$_POST['two-factor-backup-code'] = $code[0];
$user = new WP_User( self::factory()->user->create() );
$code = $this->provider->generate_codes( $user, array( 'number' => 1 ) );

// Verify authentication correctly fails without a set code.
$this->assertFalse( $this->provider->validate_authentication( $user ) );

// Set the code and verify it passes.
$_REQUEST['two-factor-backup-code'] = $code[0];
$this->assertTrue( $this->provider->validate_authentication( $user ) );

unset( $_POST['two-factor-backup-code'] );
unset( $_REQUEST['two-factor-backup-code'] );
}

/**
Expand Down
31 changes: 31 additions & 0 deletions tests/providers/class-two-factor-provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,35 @@ function test_get_code() {
$code = Two_Factor_Provider::get_code( 8 );
$this->assertEquals( 8, strlen( $code ) );
}

/**
* Validate that sanitize_code_from_request() works as intended.
*
* @covers Two_Factor_Provider::sanitize_code_from_request
* @dataProvider provider_sanitize_code_from_request
*/
function test_sanitize_code_from_request( $expected, $field, $value, $length = 0) {
$_REQUEST[ $field ] = '';
if ( $value ) {
$_REQUEST[ $field ] = $value;
}

$this->assertEquals( $expected, Two_Factor_Provider::sanitize_code_from_request( $field, $length ) );

unset( $_REQUEST[ $field ] );
}

function provider_sanitize_code_from_request() {
return [
[ '123123', 'authcode', '123123', 6 ],
[ false, 'authcode', '123123123', 6 ],
[ '123123', 'code', '123 123' ],
[ '123123', 'code', "\n123123\n" ],
[ '123123', 'code', "123\t123", 6 ],
[ false, 'code', '' ],
[ 'helloworld', 'code', 'helloworld' ],
[ false, false, false ],
];
}

}
27 changes: 27 additions & 0 deletions tests/providers/class-two-factor-totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,33 @@ function test_validate_authentication() {
$this->assertFalse( $this->provider->validate_authentication( $user ) );
}

/**
* Validate that a TOTP code works even if presented with spaces, but invalid characters cause a rejection.
*
* @covers Two_Factor_Totp::validate_authentication
*/
function test_validate_authentication_invalid_chars_spaces() {
$user = new WP_User( self::factory()->user->create() );
$key = $this->provider->generate_key();

// Configure secret for the user.
$this->provider->set_user_totp_key( $user->ID, $key );

$authcode = $this->provider->calc_totp( $key );

// Validate that an authcode with HTML in the string is not accepted.
$_REQUEST['authcode'] = '<strong>' . $authcode . '</strong>';
$this->assertFalse( $this->provider->validate_authentication( $user ), $_REQUEST['authcode'] );

// Validate that an authcode with leading encoded spaces aren't accepted.
$_REQUEST['authcode'] = '%20%20' . $authcode;
$this->assertFalse( $this->provider->validate_authentication( $user ), $_REQUEST['authcode'] );

// Validate that an authcode with leading, trailing, and middle whitespace is accepted.
$_REQUEST['authcode'] = ' ' . substr( $authcode, 0, 3 ) . ' ' . substr( $authcode, 3 ) . " \n"; // eg ' 123 456 \n'
$this->assertTrue( $this->provider->validate_authentication( $user ), $_REQUEST['authcode'] );
}

/**
* Test that the validation function works.
*
Expand Down