Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions includes/Modules/Sign_In_With_Google.php
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ public function handle_comments_form() {
* Adds custom errors if Google auth flow failed.
*
* @since 1.140.0
* @since n.e.x.t Added the two-factor authentication error message.
*
* @param WP_Error $error WP_Error instance.
* @return WP_Error $error WP_Error instance.
Expand All @@ -349,6 +350,16 @@ public function handle_login_errors( $error ) {
case Authenticator::ERROR_SIGNIN_FAILED:
$error->add( self::MODULE_SLUG, __( 'The user is not registered on this site.', 'google-site-kit' ) );
break;
case Authenticator::ERROR_TWO_FACTOR_ENABLED:
$error->add(
self::MODULE_SLUG,
sprintf(
/* translators: %s: Sign in with Google service name */
__( 'An account with that email address uses two-factor authentication. To use %s, log in with your username and password, then connect your Google account on your profile page.', 'google-site-kit' ),
_x( 'Sign in with Google', 'Service name', 'google-site-kit' )
)
);
break;
default:
break;
}
Expand Down
60 changes: 48 additions & 12 deletions includes/Modules/Sign_In_With_Google/Authenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ class Authenticator implements Authenticator_Interface {
/**
* Error codes.
*/
const ERROR_INVALID_REQUEST = 'googlesitekit_auth_invalid_request';
const ERROR_SIGNIN_FAILED = 'googlesitekit_auth_failed';
const ERROR_INVALID_REQUEST = 'googlesitekit_auth_invalid_request';
const ERROR_SIGNIN_FAILED = 'googlesitekit_auth_failed';
const ERROR_TWO_FACTOR_ENABLED = 'googlesitekit_auth_two_factor_enabled';

/**
* User meta key marking users created via Sign in with Google.
Expand Down Expand Up @@ -96,15 +97,15 @@ public function authenticate_user( Input $input ) {
$payload = $this->profile_reader->get_profile_data( $credential );
if ( ! is_wp_error( $payload ) ) {
$user = $this->find_user( $payload );
if ( ! $user instanceof WP_User ) {
if ( null === $user ) {
// We haven't found the user using their Google user id and email. Thus we need to create
// a new user. But if the registration is closed, we need to return an error to identify
// that the sign in process failed.
if ( ! $this->is_registration_open() ) {
return $this->get_error_redirect_url( self::ERROR_SIGNIN_FAILED );
} else {
$user = $this->create_user( $payload );
}

$user = $this->create_user( $payload );
}
}

Expand Down Expand Up @@ -181,6 +182,7 @@ protected function get_redirect_url( $user, $input ) {
* Signs in the user.
*
* @since 1.145.0
* @since n.e.x.t Skips the Two-Factor plugin's login challenge for this request.
*
* @param WP_User $user User object.
* @return WP_Error|null WP_Error if an error occurred, null otherwise.
Expand All @@ -201,6 +203,18 @@ protected function sign_in_user( $user ) {
// Set the user to be the current user.
wp_set_current_user( $user->ID, $user->user_login );

// Google already checked the second factor, so skip the Two-Factor
// challenge for this login. Setting this user's primary provider to
// empty turns the challenge off for this user only and keeps their
// saved settings. Don't empty the enabled providers instead: the
// plugin turns email codes back on when that list is empty.
add_filter(
'two_factor_primary_provider_for_user',
fn ( $provider, $user_id ) => $user_id === $user->ID ? '' : $provider,
10,
2
);

// Set the authentication cookies and trigger the wp_login action.
wp_set_auth_cookie( $user->ID );
/** This filter is documented in wp-login.php */
Expand All @@ -213,9 +227,10 @@ protected function sign_in_user( $user ) {
* Finds an existing user using the Google user ID and email.
*
* @since 1.145.0
* @since n.e.x.t Returns a WP_Error when the email-matched user uses two-factor authentication and isn't connected to the Google account.
*
* @param array $payload Google auth payload.
* @return WP_User|null User object if found, null otherwise.
* @return WP_User|WP_Error|null User object when found, WP_Error when the matched user has to connect their Google account first, null otherwise.
*/
protected function find_user( $payload ) {
// Check if there are any existing WordPress users connected to this Google account.
Expand All @@ -237,15 +252,22 @@ protected function find_user( $payload ) {

// Find an existing user that matches the email and link to their Google account by store their user ID in user meta.
$user = get_user_by( 'email', $payload['email'] );
if ( $user ) {
$user_options = clone $this->user_options;
$user_options->switch_user( $user->ID );
$user_options->set( Hashed_User_ID::OPTION, $google_user_hashed_id );
if ( ! $user ) {
return null;
}

return $user;
// Connecting the accounts here would let a user with two-factor
// authentication sign in without their challenge. They connect from
// their profile page instead, where they sign in first.
if ( $this->user_has_two_factor_enabled( $user->ID ) ) {
return new WP_Error( self::ERROR_TWO_FACTOR_ENABLED );
}

return null;
$user_options = clone $this->user_options;
$user_options->switch_user( $user->ID );
$user_options->set( Hashed_User_ID::OPTION, $google_user_hashed_id );

return $user;
}

/**
Expand Down Expand Up @@ -296,6 +318,20 @@ protected function create_user( $payload ) {
return get_user_by( 'id', $user_id );
}

/**
* Checks whether the given user has two-factor authentication enabled.
*
* Returns false when the optional Two-Factor plugin isn't active.
*
* @since n.e.x.t
*
* @param int $user_id User ID.
* @return bool True when the user has two-factor authentication enabled, false otherwise.
*/
protected function user_has_two_factor_enabled( $user_id ) {
return class_exists( 'Two_Factor_Core' ) && \Two_Factor_Core::is_user_using_two_factor( $user_id );
}

/**
* Gets the hashed Google user ID from the provided payload.
*
Expand Down
100 changes: 100 additions & 0 deletions tests/phpunit/includes/Two_Factor_Core_Fake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php
/**
* Two_Factor_Core_Fake
*
* @package Google\Site_Kit\Tests
* @copyright 2026 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
* @link https://sitekit.withgoogle.com
*/

namespace Google\Site_Kit\Tests;

/**
* Stands in for the Two-Factor plugin's Two_Factor_Core class.
*
* The plugin isn't part of the test environment. Two_Factor_Plugin_Trait
* aliases this class to Two_Factor_Core so the plugin counts as active.
*
* The provider lookup applies the same two filters, in the same order, as
* Two_Factor_Core, including the fallback to emailed codes. Both filters decide
* whether the plugin challenges the user, so this fake keeps both. A fake with
* only one would let a test pass that the real plugin fails.
*
* @since n.e.x.t
* @access private
* @ignore
*/
class Two_Factor_Core_Fake {

/**
* User meta key the Two-Factor plugin stores the enabled providers under.
*
* @since n.e.x.t
* @var string
*/
const ENABLED_PROVIDERS_USER_META_KEY = '_two_factor_enabled_providers';

/**
* Provider the Two-Factor plugin turns on when a user's stored providers
* resolve to nothing, so a user who asked for two-factor never loses it.
*
* @since n.e.x.t
* @var string
*/
const FALLBACK_PROVIDER = 'Two_Factor_Email';

/**
* Checks whether the given user has to pass a two-factor challenge.
*
* @since n.e.x.t
*
* @param int $user_id Optional. User ID. Default null.
* @return bool True when the user has a provider enabled, false otherwise.
*/
public static function is_user_using_two_factor( $user_id = null ) {
return '' !== self::get_primary_provider_for_user( $user_id );
}

/**
* Gets the provider that runs the challenge for the given user.
*
* @since n.e.x.t
*
* @param int $user_id User ID.
* @return string Provider key, or an empty string when the user has none.
*/
private static function get_primary_provider_for_user( $user_id ) {
$available = self::get_available_providers_for_user( $user_id );
if ( empty( $available ) ) {
return '';
}

$provider = apply_filters( 'two_factor_primary_provider_for_user', reset( $available ), $user_id );

return in_array( $provider, $available, true ) ? $provider : '';
}

/**
* Gets the providers the given user can pass a challenge with.
*
* @since n.e.x.t
*
* @param int $user_id User ID.
* @return string[] Provider keys.
*/
private static function get_available_providers_for_user( $user_id ) {
$stored = get_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, true );
$stored = is_array( $stored ) ? $stored : array();

$enabled = apply_filters( 'two_factor_enabled_providers_for_user', $stored, $user_id );

// The plugin falls back to emailed codes rather than let a user who
// turned two-factor on end up with no challenge at all.
if ( empty( $enabled ) && ! empty( $stored ) ) {
$enabled = array( self::FALLBACK_PROVIDER );
}

return $enabled;
}
}
88 changes: 88 additions & 0 deletions tests/phpunit/includes/Two_Factor_Plugin_Trait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
/**
* Trait Google\Site_Kit\Tests\Two_Factor_Plugin_Trait
*
* @package Google\Site_Kit\Tests
* @copyright 2026 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
* @link https://sitekit.withgoogle.com
*/

namespace Google\Site_Kit\Tests;

/**
* Trait for testing against the optional Two-Factor plugin.
*
* @since n.e.x.t
* @access private
* @ignore
*/
trait Two_Factor_Plugin_Trait {

/**
* Gets the two-factor provider the test users turn on.
*
* A trait can't hold a constant before PHP 8.2, and the plugin supports
* PHP 7.4, so this is a static method.
*
* @since n.e.x.t
*
* @return string Provider key.
*/
protected static function two_factor_provider() {
return 'Two_Factor_Totp';
}

/**
* Makes the Two-Factor plugin count as active.
*
* PHP keeps a class for the rest of the process, so a test calling this
* needs the runInSeparateProcess annotation. Without it the alias reaches
* the tests that expect the plugin to be absent.
*
* @since n.e.x.t
*/
protected function activate_two_factor_plugin() {
if ( ! class_exists( 'Two_Factor_Core' ) ) {
class_alias( Two_Factor_Core_Fake::class, 'Two_Factor_Core' );
}
}

/**
* Sets the given user's enabled two-factor provider, so the user counts as using two-factor authentication.
*
* Picks a provider other than the one the plugin falls back to, so a test
* can tell the user's own choice apart from the fallback.
*
* @since n.e.x.t
*
* @param int $user_id User ID.
*/
protected function enable_two_factor_for_user( $user_id ) {
update_user_meta( $user_id, Two_Factor_Core_Fake::ENABLED_PROVIDERS_USER_META_KEY, array( self::two_factor_provider() ) );
}

/**
* Gets the two-factor providers the given user has turned on.
*
* @since n.e.x.t
*
* @param int $user_id User ID.
* @return string[]|string Provider keys, or an empty string when the user turned none on.
*/
protected function get_two_factor_providers_for_user( $user_id ) {
return get_user_meta( $user_id, Two_Factor_Core_Fake::ENABLED_PROVIDERS_USER_META_KEY, true );
}

/**
* Checks whether the Two-Factor plugin would challenge the given user.
*
* @since n.e.x.t
*
* @param int $user_id User ID.
* @return bool True when the plugin would run its challenge, false otherwise.
*/
protected function two_factor_challenges_user( $user_id ) {
return Two_Factor_Core_Fake::is_user_using_two_factor( $user_id );
}
}
Loading
Loading