Skip to content

Add email verification before activation#788

Open
faisalahammad wants to merge 3 commits intoWordPress:masterfrom
faisalahammad:fix/778-email-verification-before-activation
Open

Add email verification before activation#788
faisalahammad wants to merge 3 commits intoWordPress:masterfrom
faisalahammad:fix/778-email-verification-before-activation

Conversation

@faisalahammad
Copy link

@faisalahammad faisalahammad commented Feb 13, 2026

Description

This PR implements a verification step for the Email provider in the Two-Factor plugin.

Previously, users could enable Email 2FA without confirming ownership of the email address, which posed a risk of account lockout if the email was incorrect or inaccessible. This change aligns the Email provider's activation flow with the TOTP provider by requiring successful code verification before the provider can be enabled.

Changes

  • User Options UI:
    • The "Email" provider section now displays a "Verify your e-mail address" button for unverified users.
    • Clicking this button initiates an AJAX request to send a verification code.
    • A new input field allows the user to enter the received code.
    • Upon successful verification, the provider is enabled, and the UI updates to show the standard "Enabled" checkbox state.
  • REST API:
    • Added POST /two-factor/1.0/email: Handles sending verification codes and validating them.
    • Added DELETE /two-factor/1.0/email: Handles resetting the verification status (if needed).
  • Verification Logic:
    • Two_Factor_Email::is_available_for_user() now returns true only if the user has verified their email (checked via _two_factor_email_verified user meta).
  • Backwards Compatibility:
    • Users who already have the Email provider enabled are considered "legacy verified" and can continue using it without re-verification.
  • Data Integrity:
    • Added a pre_user_options_update hook to prevent the Email provider from being enabled via the standard profile form save unless the user is verified.

How to Test

New User (Fresh Setup)

  1. Navigate to Users > Profile.
  2. Scroll to the Two-Factor Options section.
  3. Ensure the "Email" option is not enabled.
  4. Click the "Verify your e-mail address" button.
  5. Check your email for a verification code.
  6. Enter the code in the input field and click "Verify".
  7. Observe that the page updates, and the "Email" checkbox is now checked and enabled.

Legacy User (Existing Setup)

  1. Log in as a user who already has Email 2FA enabled.
  2. Navigate to Users > Profile.
  3. Confirm that the "Email" checkbox remains checked and functional.
  4. Verify that no re-verification prompt is shown.

Screenshot

Email TOTP

Technical Details

  • Class: Two_Factor_Email
  • New Methods:
    • register_rest_routes()
    • rest_setup_email()
    • rest_delete_email()
    • pre_user_options_update()
  • Modified Methods:
    • user_options(): updated to render the verification UI.
    • is_available_for_user(): added verification check (with legacy fallback).
    • generate_and_email_token(): updated to accept an $action argument ('login' vs 'verification_setup') to send context-appropriate emails.
  • New Constants:
    • VERIFIED_META_KEY: _two_factor_email_verified

Checklist

  • Code follows the WordPress Coding Standards.
  • Unit tests have been added/updated.
  • Verified manual testing of the new flow.
  • Verified backwards compatibility for existing users.

Fixes #778

@github-actions
Copy link

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: faisalahammad <faisalahammad@git.wordpress.org>
Co-authored-by: masteradhoc <masteradhoc@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@jeffpaul jeffpaul added this to the 0.16.0 milestone Feb 13, 2026
@jeffpaul jeffpaul requested a review from kasparsd February 13, 2026 16:21
@masteradhoc masteradhoc modified the milestones: 0.16.0, 0.17.0 Mar 2, 2026
Copy link
Collaborator

@georgestephanis georgestephanis left a comment

Choose a reason for hiding this comment

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

I want to see this go in, but I think to avoid merge conflicts it'll need to pause until #814 goes in, or that will need to pause for this. Or this can just start doing the newer external include. Either way.

Like the idea, but there's some extra changes in the PR that I don't think need to be in this PR? I'm looking at the distignore, and I'm not sure why it's changing from protected to public for the constructor ... possibly totally reasonable, I'm just trying to be thorough.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an email-verification step for the Email two-factor provider by introducing a “verified” user-meta flag, a REST-driven verification flow in the profile UI, and guardrails to prevent enabling Email 2FA unless verified (with legacy compatibility).

Changes:

  • Add VERIFIED_META_KEY and gate is_available_for_user() on verification (while allowing legacy-enabled users).
  • Introduce Email provider REST endpoints to send/verify codes and to deactivate/reset verification state.
  • Expand unit tests for email contents, availability gating, and profile-save behavior; update .distignore.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
providers/class-two-factor-email.php Adds verification meta key, REST endpoints, updated email content handling, UI changes, and profile-save enforcement.
tests/providers/class-two-factor-email.php Adds/updates tests for verification-context emails, availability rules, and pre_user_options_update() behavior.
.distignore Ignores two-factor.zip from distribution exports.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 50 to 56
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) );
add_action( 'personal_options_update', array( $this, 'pre_user_options_update' ), 5 );
add_action( 'edit_user_profile_update', array( $this, 'pre_user_options_update' ), 5 );
parent::__construct();
}
),
);
/* translators: $1$s: IP address of user, %2$s: `user_login` of authenticated user */
/* translators: $1$s: IP address of user, %2$s: `user_login` of authenticated user */
Comment on lines +658 to +671
public function pre_user_options_update( $user_id ) {
if ( isset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ) && is_array( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ) ) {
$enabled_providers = $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ];
if ( in_array( 'Two_Factor_Email', $enabled_providers, true ) ) {
$is_verified = get_user_meta( $user_id, self::VERIFIED_META_KEY, true );
$current_providers = get_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, true );

// If not verified, and NOT currently enabled (legacy), disallow enabling.
if ( ! $is_verified && ( ! is_array( $current_providers ) || ! in_array( 'Two_Factor_Email', $current_providers, true ) ) ) {
$enabled_providers = array_diff( $enabled_providers, array( 'Two_Factor_Email' ) );
$_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = $enabled_providers;
}
}
}
Comment on lines +191 to +203
// Mock REMOTE_ADDR for IP check
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

$this->provider->generate_and_email_token( $user, 'login' );

$content = $GLOBALS['phpmailer']->Body;

$this->assertStringContainsString( 'Enter', $content );
$this->assertStringContainsString( 'log in', $content );
// Check that IP is effectively in the message (and not the token key or something else)
$this->assertStringContainsString( '127.0.0.1', $content );
// Check that username is in the message
$this->assertStringContainsString( $user->user_login, $content );
Comment on lines +124 to +190
/**
* REST API endpoint for setting up Email.
*
* @param WP_REST_Request $request The Rest Request object.
* @return WP_Error|array Array of data on success, WP_Error on error.
*/
public function rest_setup_email( $request ) {
$user_id = $request['user_id'];
$user = get_user_by( 'id', $user_id );

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

// If no code, generate and email one.
if ( empty( $code ) ) {
if ( $this->generate_and_email_token( $user, 'verification_setup' ) ) {
return array( 'success' => true );
}
return new WP_Error( 'email_error', __( 'Unable to send email. Please check your server settings.', 'two-factor' ), array( 'status' => 500 ) );
}

// Verify code.
if ( ! $this->validate_token( $user_id, $code ) ) {
return new WP_Error( 'invalid_code', __( 'Invalid verification code.', 'two-factor' ), array( 'status' => 400 ) );
}

// Mark as verified.
update_user_meta( $user_id, self::VERIFIED_META_KEY, true );

if ( $request->get_param( 'enable_provider' ) && ! Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Email' ) ) {
return new WP_Error( 'db_error', __( 'Unable to enable Email provider for this user.', 'two-factor' ), array( 'status' => 500 ) );
}

ob_start();
$this->user_options( $user );
$html = ob_get_clean();

return array(
'success' => true,
'html' => $html,
);
}

/**
* Rest API endpoint for handling deactivation of Email.
*
* @param WP_REST_Request $request The Rest Request object.
* @return array Success array.
*/
public function rest_delete_email( $request ) {
$user_id = $request['user_id'];
$user = get_user_by( 'id', $user_id );

delete_user_meta( $user_id, self::VERIFIED_META_KEY );

if ( ! Two_Factor_Core::disable_provider_for_user( $user_id, 'Two_Factor_Email' ) ) {
return new WP_Error( 'db_error', __( 'Unable to disable Email provider for this user.', 'two-factor' ), array( 'status' => 500 ) );
}

ob_start();
$this->user_options( $user );
$html = ob_get_clean();

return array(
'success' => true,
'html' => $html,
);
}
Copy link
Collaborator

@masteradhoc masteradhoc left a comment

Choose a reason for hiding this comment

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

Hey @faisalahammad
Thanks for your PR :) as we've just merged #814 would you mind seperating your code as well to the seperate files that the PR added?

@faisalahammad faisalahammad force-pushed the fix/778-email-verification-before-activation branch from abc10ef to 2515e10 Compare March 19, 2026 07:58
@faisalahammad
Copy link
Author

I have updated the PR to address all the feedback:

  • Rebased on master: Aligned the branch with the recent PR Move inline JS to external script files #814 merge.
  • Code Separation: Moved the newly added inline JS for the Email provider into providers/js/email-admin.js and enqueued it properly, maintaining consistency with the new architecture.
  • Constructor Visibility: Reverted Two_Factor_Email::__construct() back to protected to preserve the singleton pattern.
  • .distignore: Reverted the exclusion of two-factor.zip.
  • PHPCS / Nonce: Added a phpcs:ignore for $_POST manipulation in pre_user_options_update(), as nonce validation is handled by core on the profile page.
  • Translators Comments: Fixed the duplicate translater comment block during the rebase.
  • Test State Leak: Fixed the leakage of $_SERVER['REMOTE_ADDR'] by restoring variables at the end of the test.
  • REST API Tests: Added comprehensive REST API tests covering permissions, empty code, invalid code, successful verification, and deleting setup in tests/providers/class-two-factor-email-rest-api.php.

Ready for another review!

Fixed 12 file(s) based on 14 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Require verification before activating Email TOTP

5 participants