is_2fa_enabled_for_user( $user->ID ) ) {
return $user;
}
// Generate a token for the 2FA session.
$token = wp_generate_password( 32, false );
// Nonce not possible during 2FA authentication flow; transient token validates the session.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$redirect = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url();
set_transient( 'wpsp_2fa_' . $token, array(
'user_id' => $user->ID,
'redirect' => $redirect,
), 5 * MINUTE_IN_SECONDS );
// Redirect to 2FA form immediately.
wp_safe_redirect( add_query_arg( array(
'action' => 'wpsp_2fa',
'token' => $token,
), wp_login_url() ) );
exit;
}
/**
* Enforce 2FA setup for administrators.
*/
public function enforce_admin_2fa() {
// Skip AJAX requests.
if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
return;
}
$user = wp_get_current_user();
// Only apply to administrators.
if ( ! $user || ! user_can( $user, 'manage_options' ) ) {
return;
}
// Skip if already has 2FA enabled.
if ( $this->is_2fa_enabled_for_user( $user->ID ) ) {
return;
}
// Allow access to profile page for setup.
global $pagenow;
if ( 'profile.php' === $pagenow || 'admin-ajax.php' === $pagenow ) {
return;
}
// Redirect to profile page with notice.
wp_safe_redirect( add_query_arg( 'wpsp_2fa_required', '1', admin_url( 'profile.php' ) ) );
exit;
}
/**
* Check if we should render the 2FA form (for custom login URLs).
*/
public function maybe_render_2fa_form() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['action'] ) && 'wpsp_2fa' === $_GET['action'] ) {
$this->render_2fa_form();
}
}
/**
* Render 2FA verification form.
*/
public function render_2fa_form() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
if ( empty( $token ) ) {
wp_safe_redirect( wp_login_url() );
exit;
}
$data = get_transient( 'wpsp_2fa_' . $token );
if ( ! $data ) {
wp_safe_redirect( wp_login_url() );
exit;
}
$error = '';
// Handle form submission - 2FA verification uses transient token instead of nonce; user already authenticated via password.
if ( isset( $_POST['wpsp_2fa_code'] ) || isset( $_POST['wpsp_backup_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
// Check TOTP code first, then backup code.
$code = '';
if ( ! empty( $_POST['wpsp_2fa_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
$code = sanitize_text_field( wp_unslash( $_POST['wpsp_2fa_code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
} elseif ( ! empty( $_POST['wpsp_backup_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
$code = sanitize_text_field( wp_unslash( $_POST['wpsp_backup_code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
$user_id = $data['user_id'];
if ( ! empty( $code ) && $this->verify_code( $user_id, $code ) ) {
// Delete the token.
delete_transient( 'wpsp_2fa_' . $token );
// Log the user in.
wp_set_auth_cookie( $user_id, false );
wp_set_current_user( $user_id );
// Redirect.
wp_safe_redirect( $data['redirect'] );
exit;
}
$error = __( 'Invalid verification code.', 'security-pack' );
}
// Render the form.
login_header( __( 'Two-Factor Authentication', 'security-pack' ) );
?>
is_2fa_enabled_for_user( $user->ID );
// Show notice if 2FA is required.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['wpsp_2fa_required'] ) && ! $is_enabled ) :
?>
__( 'Permission denied.', 'security-pack' ) ) );
}
$secret = $this->generate_secret();
// Store temporarily (not enabled yet).
update_user_meta( $user_id, self::SECRET_META_KEY . '_pending', $secret );
$user = get_user_by( 'id', $user_id );
$site = wp_parse_url( home_url(), PHP_URL_HOST );
// Generate otpauth URL for QR code.
$otpauth = sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s',
rawurlencode( $site ),
rawurlencode( $user->user_email ),
$secret,
rawurlencode( $site )
);
wp_send_json_success( array(
'secret' => $secret,
'otpauth' => $otpauth,
) );
}
/**
* Verify 2FA setup via AJAX.
*/
public function ajax_verify_setup() {
check_ajax_referer( 'wpsp_2fa_setup' );
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
$code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'security-pack' ) ) );
}
$pending_secret = get_user_meta( $user_id, self::SECRET_META_KEY . '_pending', true );
if ( empty( $pending_secret ) ) {
wp_send_json_error( array( 'message' => __( 'No pending setup found.', 'security-pack' ) ) );
}
// Verify the code.
if ( ! $this->verify_totp( $pending_secret, $code ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid code. Please try again.', 'security-pack' ) ) );
}
// Enable 2FA.
update_user_meta( $user_id, self::SECRET_META_KEY, $pending_secret );
update_user_meta( $user_id, self::ENABLED_META_KEY, '1' );
delete_user_meta( $user_id, self::SECRET_META_KEY . '_pending' );
// Generate backup codes - show plain to user, store hashed.
$plain_codes = $this->generate_backup_codes();
$hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes );
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes );
wp_send_json_success( array(
'message' => __( '2FA enabled successfully. Save your backup codes now - they will not be shown again!', 'security-pack' ),
'backup_codes' => $plain_codes,
'show_codes' => true,
) );
}
/**
* Disable 2FA via AJAX.
*/
public function ajax_disable_2fa() {
check_ajax_referer( 'wpsp_2fa_setup' );
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'security-pack' ) ) );
}
delete_user_meta( $user_id, self::SECRET_META_KEY );
delete_user_meta( $user_id, self::ENABLED_META_KEY );
delete_user_meta( $user_id, self::BACKUP_CODES_META_KEY );
wp_send_json_success( array( 'message' => __( '2FA disabled.', 'security-pack' ) ) );
}
/**
* Regenerate backup codes via AJAX.
*/
public function ajax_regenerate_backup_codes() {
check_ajax_referer( 'wpsp_2fa_setup' );
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'security-pack' ) ) );
}
// Generate new codes.
$plain_codes = $this->generate_backup_codes();
// Hash them before storing.
$hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes );
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes );
// Return plain codes to show user ONCE.
wp_send_json_success( array(
'codes' => $plain_codes,
'message' => __( 'Backup codes regenerated. Save these codes now - they will not be shown again.', 'security-pack' ),
) );
}
/**
* Check if 2FA is enabled for a user.
*
* @param int $user_id User ID.
* @return bool
*/
public function is_2fa_enabled_for_user( $user_id ) {
return '1' === get_user_meta( $user_id, self::ENABLED_META_KEY, true );
}
/**
* Verify a code (TOTP or backup).
*
* @param int $user_id User ID.
* @param string $code Code to verify.
* @return bool
*/
public function verify_code( $user_id, $code ) {
$code = preg_replace( '/\s+/', '', $code );
// Try TOTP first.
$secret = get_user_meta( $user_id, self::SECRET_META_KEY, true );
if ( $this->verify_totp( $secret, $code ) ) {
return true;
}
// Try backup code.
return $this->verify_backup_code( $user_id, $code );
}
/**
* Verify TOTP code.
*
* @param string $secret Secret key.
* @param string $code Code to verify.
* @param int $window Time window tolerance.
* @return bool
*/
private function verify_totp( $secret, $code, $window = 1 ) {
if ( empty( $secret ) || empty( $code ) ) {
return false;
}
$time = floor( time() / self::TIME_STEP );
for ( $i = -$window; $i <= $window; $i++ ) {
$calculated = $this->calculate_totp( $secret, $time + $i );
if ( hash_equals( $calculated, $code ) ) {
return true;
}
}
return false;
}
/**
* Calculate TOTP code.
*
* @param string $secret Secret key.
* @param int $time Time counter.
* @return string
*/
private function calculate_totp( $secret, $time ) {
// Decode base32 secret.
$secret_decoded = $this->base32_decode( $secret );
// Pack time.
$time_packed = pack( 'N*', 0, $time );
// Calculate HMAC.
$hash = hash_hmac( 'sha1', $time_packed, $secret_decoded, true );
// Dynamic truncation.
$offset = ord( substr( $hash, -1 ) ) & 0x0F;
$code = ( ord( $hash[ $offset ] ) & 0x7F ) << 24;
$code |= ( ord( $hash[ $offset + 1 ] ) & 0xFF ) << 16;
$code |= ( ord( $hash[ $offset + 2 ] ) & 0xFF ) << 8;
$code |= ( ord( $hash[ $offset + 3 ] ) & 0xFF );
$code = $code % pow( 10, self::CODE_LENGTH );
return str_pad( $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
}
/**
* Generate secret key.
*
* @param int $length Length in bytes (16 = 26 base32 chars).
* @return string
*/
private function generate_secret( $length = 16 ) {
$random = wp_generate_password( $length, false, false );
return $this->base32_encode( $random );
}
/**
* Generate backup codes.
*
* @param int $count Number of codes.
* @return array
*/
private function generate_backup_codes( $count = 10 ) {
$codes = array();
for ( $i = 0; $i < $count; $i++ ) {
$codes[] = strtoupper( wp_generate_password( 8, false, false ) );
}
return $codes;
}
/**
* Get backup codes count for user (hashed codes stored).
*
* @param int $user_id User ID.
* @return int
*/
public function get_backup_codes_count( $user_id ) {
$codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true );
return is_array( $codes ) ? count( $codes ) : 0;
}
/**
* Get backup codes for user (internal use only).
*
* @param int $user_id User ID.
* @return array
*/
private function get_backup_codes( $user_id ) {
$codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true );
return is_array( $codes ) ? $codes : array();
}
/**
* Hash a backup code for secure storage.
*
* @param string $code Plain backup code.
* @return string Hashed code.
*/
private function hash_backup_code( $code ) {
return wp_hash( strtoupper( $code ) );
}
/**
* Verify and consume a backup code.
*
* @param int $user_id User ID.
* @param string $code Backup code.
* @return bool
*/
private function verify_backup_code( $user_id, $code ) {
$stored_codes = $this->get_backup_codes( $user_id );
$code = strtoupper( preg_replace( '/[^A-Z0-9]/', '', $code ) );
$hashed_input = $this->hash_backup_code( $code );
// Check against hashed codes.
foreach ( $stored_codes as $index => $stored_code ) {
// Support both hashed (new) and plain (legacy) codes.
if ( hash_equals( $stored_code, $hashed_input ) || hash_equals( $stored_code, $code ) ) {
// Remove used code.
unset( $stored_codes[ $index ] );
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, array_values( $stored_codes ) );
return true;
}
}
return false;
}
/**
* Base32 encode.
*
* @param string $data Data to encode.
* @return string
*/
private function base32_encode( $data ) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$binary = '';
$encoded = '';
foreach ( str_split( $data ) as $char ) {
$binary .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT );
}
$chunks = str_split( $binary, 5 );
foreach ( $chunks as $chunk ) {
$chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
$encoded .= $alphabet[ bindec( $chunk ) ];
}
return $encoded;
}
/**
* Base32 decode.
*
* @param string $data Data to decode.
* @return string
*/
private function base32_decode( $data ) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$data = strtoupper( $data );
$binary = '';
$decoded = '';
foreach ( str_split( $data ) as $char ) {
$pos = strpos( $alphabet, $char );
if ( false !== $pos ) {
$binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
}
}
$chunks = str_split( $binary, 8 );
foreach ( $chunks as $chunk ) {
if ( strlen( $chunk ) === 8 ) {
$decoded .= chr( bindec( $chunk ) );
}
}
return $decoded;
}
}