id;
}
/**
* Generates the setup form HTML for TOTP two-factor authentication.
*
* Displays either a deactivation form if 2FA is already enabled, or a setup form
* with QR code and manual secret for new users. The setup form allows users to
* scan a QR code with their authenticator app and verify the setup with a code.
*
* @param WP_User $user The WordPress user object to generate the form for.
*
* @return string HTML content for the setup form
*
* @since 1.0.0
*/
public function setup_form( WP_User $user ): string {
return '';
}
/**
* Retrieves the setup data required for TOTP 2FA configuration.
*
* Generates a new TOTP secret and corresponding QR code image for the user
* to scan with their authenticator app during the setup process.
*
* @param WP_User $user The WordPress user object to generate setup data for.
* @param string $pending_secret Secret from a failed activation attempt. Reusing it keeps the
* QR code the user already scanned. Ignored unless it is base32.
*
* @return array An associative array containing:
* - 'secret': The TOTP secret key.
* - 'qr_image': Data URI of the QR code image.
*
* @since 1.0.0
*/
public function get_setup_data( WP_User $user, string $pending_secret = '' ): array {
// Show setup form for new users.
$secret = get_user_meta( $user->ID, 'brain2fa_secret', true );
if ( ! $secret && 1 === preg_match( '/^[A-Z2-7]{16,128}$/', $pending_secret ) ) {
$secret = $pending_secret;
}
if ( ! $secret ) {
if ( class_exists( '\OTPHP\\TOTP' ) ) {
$t = \OTPHP\TOTP::generate( new SystemClock() );
$secret = $t->getSecret();
} else {
$secret = $this->random_base32( 26 );
}
}
$issuer = get_bloginfo( 'name' );
if ( ! empty( $user->user_email ) ) {
$account = $user->user_email;
} else {
$account = $user->user_login;
}
$uri = sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
rawurlencode( $issuer ),
rawurlencode( $account ),
rawurlencode( $secret ),
rawurlencode( $issuer )
);
// Generate QR code using bacon/bacon-qr-code.
$qr_data_uri = $this->generate_qr_code( $uri );
return array(
'secret' => $secret,
'qr_image' => $qr_data_uri,
);
}
/**
* Saves the TOTP 2FA setup configuration for a user.
*
* Handles both activation and deactivation of TOTP 2FA for a user account.
* When activating, validates the provided secret and verification code before
* storing the configuration. When deactivating, removes all stored 2FA data.
*
* @param WP_User $user The WordPress user object to configure 2FA for.
* @param array $data Setup data containing:
* - 'brain2fa_deactivate' (optional): If present, deactivates 2FA.
* - 'brain2fa_secret' (string): The TOTP secret key for activation.
* - 'brain2fa_code' (string): The verification code to validate setup.
*
* @return bool|array|WP_Error Returns true on successful deactivation,
* array with 'success' and 'recovery_codes' keys on activation,
* WP_Error on invalid code
*
* @since 1.0.0
*/
public function save_setup( WP_User $user, array $data ) {
if ( ! empty( $data['brain2fa_deactivate'] ) ) {
delete_user_meta( $user->ID, 'brain2fa_secret' );
delete_user_meta( $user->ID, 'brain2fa_enabled' );
delete_user_meta( $user->ID, 'brain2fa_recovery_codes' );
delete_user_meta( $user->ID, self::LAST_STEP_META_KEY );
Utils::forget_remembered_devices( $user->ID );
return true;
}
$secret = sanitize_text_field( $data['secret'] );
$code = preg_replace( '/[^0-9]/', '', $data['code'] );
if ( $this->verify( $user, $code, array( 'secret' => $secret ) ) ) {
update_user_meta( $user->ID, 'brain2fa_secret', $secret );
update_user_meta( $user->ID, 'brain2fa_enabled', 1 );
// Generate recovery codes on first setup and return them.
$existing_codes = get_user_meta( $user->ID, 'brain2fa_recovery_codes', true );
if ( empty( $existing_codes ) ) {
$recovery_codes = $this->generate_recovery_codes( $user );
return array(
'success' => true,
'recovery_codes' => $recovery_codes,
);
}
// Re-activation with existing recovery codes — still a success.
return array( 'success' => true );
}
return new \WP_Error( 'invalid', __( 'Invalid verification code.', 'brain-2fa' ) );
}
/**
* Sends a TOTP challenge to the user.
*
* Since TOTP (Time-based One-Time Password) is generated locally on the user's device,
* there is no actual challenge to send. This method always returns true to indicate
* that the challenge process is complete.
*
* @param WP_User $user The WordPress user object for whom the challenge is being sent.
* @param array $context Optional context data that may be used by other authentication methods.
*
* @return bool Always returns true as TOTP doesn't require server-side challenge sending.
*/
public function send_challenge( WP_User $user, array $context = array() ): bool {
// TOTP is generated on device; nothing to send.
return true;
}
/**
* Verifies a TOTP token for the given user.
*
* Validates a time-based one-time password token against the user's stored secret,
* then falls back to the user's recovery codes if TOTP verification fails.
*
* @param WP_User $user The WordPress user object to verify the token for.
* @param string $token The TOTP token to verify.
* @param array $context Optional context data that may contain a 'secret' key.
*
* @return bool True if the token is valid, false otherwise.
*
* @since 1.0.0
*/
public function verify( WP_User $user, string $token, array $context = array() ): bool {
$secret = $context['secret'] ?? get_user_meta( $user->ID, 'brain2fa_secret', true );
if ( ! $secret ) {
return false;
}
if ( $this->verify_totp_code( $user, (string) $secret, $token ) ) {
return true;
}
return $this->verify_recovery_code( $user, $token );
}
/**
* Verifies a six-digit TOTP code with clock-drift tolerance and replay protection.
*
* Accepts codes for the current time step and DRIFT_STEPS steps on either side.
* A code is accepted only for a time step later than the last one this user
* successfully used, so an intercepted code cannot be replayed within its
* validity window, and an older code cannot be used after a newer one.
*
* @param WP_User $user User whose last accepted time step is checked and updated.
* @param string $secret The base32-encoded TOTP secret key.
* @param string $token The submitted code.
*
* @return bool True if the code is valid and has not been used before.
*
* @since 1.0.2
*/
protected function verify_totp_code( WP_User $user, string $secret, string $token ): bool {
$token = (string) preg_replace( '/\s+/', '', $token );
if ( 1 !== preg_match( '/^\d{6}$/', $token ) ) {
return false;
}
$current_step = intdiv( time(), self::PERIOD );
$last_step = absint( get_user_meta( $user->ID, self::LAST_STEP_META_KEY, true ) );
for ( $step = $current_step - self::DRIFT_STEPS; $step <= $current_step + self::DRIFT_STEPS; $step++ ) {
if ( $step <= $last_step ) {
continue;
}
if ( hash_equals( $this->code_for_step( $secret, $step ), $token ) ) {
update_user_meta( $user->ID, self::LAST_STEP_META_KEY, $step );
return true;
}
}
return false;
}
/**
* Generates the TOTP code for a time step.
*
* Uses the OTPHP library when available, otherwise the built-in HOTP implementation.
*
* @param string $secret The base32-encoded TOTP secret key.
* @param int $step Time step (Unix time divided by the period).
*
* @return string Six-digit code, or an empty string if the secret is invalid.
*
* @since 1.0.2
*/
protected function code_for_step( string $secret, int $step ): string {
try {
if ( class_exists( '\OTPHP\\TOTP' ) ) {
return \OTPHP\TOTP::create( $secret, self::PERIOD, clock: new SystemClock() )->at( $step * self::PERIOD );
}
return $this->hotp( $secret, $step );
} catch ( \Throwable $e ) {
return '';
}
}
/**
* Generates a QR code data URI for TOTP setup.
*
* Uses the BaconQrCode library to render a styled SVG QR code with the plugin
* logo in a round badge in the center. Falls back to a plain PNG (GD back end)
* when the XMLWriter extension is missing. Returns an empty string if neither
* back end is available, or rendering fails.
*
* @param string $data The data to encode in the QR code (typically otpauth URI).
*
* @return string SVG or PNG data URI of the QR code image, or empty string on failure.
*
* @since 1.0.0
*/
protected function generate_qr_code( $data ): string {
try {
if ( class_exists( '\BaconQrCode\Writer' ) && class_exists( '\XMLWriter' ) ) {
return 'data:image/svg+xml;base64,' . base64_encode( $this->render_svg_qr_code( $data ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding an SVG for a data URI.
}
if ( class_exists( '\BaconQrCode\Writer' ) && extension_loaded( 'gd' ) ) {
$size = 300;
// Margin is in modules. Error correction Q (~25%) keeps the code readable with the logo covering the center.
$writer = new \BaconQrCode\Writer( new \BaconQrCode\Renderer\GDLibRenderer( $size, 2 ) );
$png = $writer->writeString( $data, \BaconQrCode\Encoder\Encoder::DEFAULT_BYTE_MODE_ENCODING, \BaconQrCode\Common\ErrorCorrectionLevel::Q() );
// Add logo to the center of the QR code.
$logo_path = BRAIN_2FA_PLUGIN_DIR . 'assets/images/qr-logo.png';
if ( file_exists( $logo_path ) ) {
$png = $this->add_qr_logo( $png, $logo_path, $size, 60 ); // Logo width (20% of QR code size).
}
return 'data:image/png;base64,' . base64_encode( $png ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding a PNG for a data URI.
}
} catch ( \Throwable $e ) {
do_action( 'brain2fa_qr_code_generation_failed', $e );
}
// No external fallback — leaking the otpauth URI (which contains the TOTP secret) to a
// third-party service would be a security issue. Return empty string; the view will show
// the manual secret key entry field instead.
return '';
}
/**
* Renders a styled QR code as SVG markup.
*
* Modules have rounded corners and the finder patterns are rounded squares,
* all in black on white. The modules under the logo are cleared in a circle,
* so the round badge never cuts a module in half; error correction level Q
* (~25%) recovers the cleared area.
*
* @param string $data The data to encode in the QR code (typically otpauth URI).
*
* @return string SVG markup.
*
* @since 1.1.0
*/
protected function render_svg_qr_code( string $data ): string {
$size = 300;
$margin = 2; // In modules.
$qr_code = \BaconQrCode\Encoder\Encoder::encode( $data, \BaconQrCode\Common\ErrorCorrectionLevel::Q(), \BaconQrCode\Encoder\Encoder::DEFAULT_BYTE_MODE_ENCODING );
$modules = $qr_code->getMatrix()->getWidth();
$logo_path = BRAIN_2FA_PLUGIN_DIR . 'assets/images/qr-logo.png';
$has_logo = file_exists( $logo_path );
$radius = $modules * 0.15; // Badge radius in modules: the badge spans 30% of the code.
if ( $has_logo ) {
$matrix = $qr_code->getMatrix();
$center = ( $modules - 1 ) / 2;
for ( $y = 0; $y < $modules; $y++ ) {
for ( $x = 0; $x < $modules; $x++ ) {
if ( ( ( $x - $center ) ** 2 ) + ( ( $y - $center ) ** 2 ) <= ( ( $radius + 0.5 ) ** 2 ) ) {
$matrix->set( $x, $y, 0 );
}
}
}
}
$fill = \BaconQrCode\Renderer\RendererStyle\Fill::uniformColor(
new \BaconQrCode\Renderer\Color\Rgb( 255, 255, 255 ),
new \BaconQrCode\Renderer\Color\Rgb( 0, 0, 0 )
);
$style = new \BaconQrCode\Renderer\RendererStyle\RendererStyle(
$size,
$margin,
new \BaconQrCode\Renderer\Module\RoundnessModule( \BaconQrCode\Renderer\Module\RoundnessModule::STRONG ),
new QrRoundedEye(),
$fill
);
$renderer = new \BaconQrCode\Renderer\ImageRenderer( $style, new \BaconQrCode\Renderer\Image\SvgImageBackEnd() );
$svg = $renderer->render( $qr_code );
if ( ! $has_logo ) {
return $svg;
}
// The code is centered, so the badge center is the image center.
$center_px = $size / 2;
$radius_px = $radius * $size / ( $modules + 2 * $margin );
$logo_px = $radius_px * 2 / 1.25; // The shield's corners stay inside the circle.
$logo = base64_encode( (string) file_get_contents( $logo_path ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Embedding a local plugin asset in the SVG.
$badge = sprintf(
'',
$center_px,
$radius_px,
$center_px - ( $logo_px / 2 ),
$logo_px,
$logo
);
return str_replace( '', $badge . '', $svg );
}
/**
* Composites a logo onto the center of a QR code PNG.
*
* The logo is resized to the given width and drawn on a round white punchout
* background for contrast. Returns the original PNG unchanged if the logo
* cannot be loaded.
*
* @param string $png Binary PNG data of the QR code.
* @param string $logo_path Absolute path to the logo image.
* @param int $size QR code image size in pixels.
* @param int $logo_width Target logo width in pixels.
*
* @return string Binary PNG data with the logo applied.
*
* @since 1.0.0
*/
protected function add_qr_logo( string $png, string $logo_path, int $size, int $logo_width ): string {
$qr = imagecreatefromstring( $png );
$logo = imagecreatefromstring( (string) file_get_contents( $logo_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin asset.
if ( ! $qr || ! $logo ) {
return $png;
}
$logo_height = (int) round( imagesy( $logo ) * $logo_width / imagesx( $logo ) );
$x = (int) ( ( $size - $logo_width ) / 2 );
$y = (int) ( ( $size - $logo_height ) / 2 );
// The circle is 25% wider than the logo so the shield's corners stay inside it.
$diameter = (int) round( max( $logo_width, $logo_height ) * 1.25 );
imagefilledellipse( $qr, (int) ( $size / 2 ), (int) ( $size / 2 ), $diameter, $diameter, imagecolorallocate( $qr, 255, 255, 255 ) );
imagecopyresampled( $qr, $logo, $x, $y, 0, 0, $logo_width, $logo_height, imagesx( $logo ), imagesy( $logo ) );
ob_start();
imagepng( $qr );
return (string) ob_get_clean();
}
/**
* Generates a random base32 string.
*
* Creates a cryptographically secure random string using the base32 alphabet.
* Used for generating TOTP secret keys when the OTPHP library is not available.
*
* @param int $length The length of the base32 string to generate. Default is 16.
*
* @return string A random base32-encoded string.
*
* @since 1.0.0
*/
protected function random_base32( $length = 16 ) {
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$s = '';
for ( $i = 0; $i < $length; $i++ ) {
$s .= $chars[ random_int( 0, strlen( $chars ) - 1 ) ];
}
return $s;
}
/**
* Decodes a Base32 encoded string to binary data.
*
* This method converts a Base32 encoded string (RFC 4648) back to its original
* binary representation. Base32 encoding uses a 32-character alphabet consisting
* of uppercase letters A-Z and digits 2-7.
*
* @param string $b32 The Base32 encoded string to decode.
*
* @return string The decoded binary data as a string.
*
* @since 1.0.0
*/
protected function base32_decode( $b32 ) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$b32 = strtoupper( $b32 );
$l = strlen( $b32 );
$n = 0;
$j = 0;
$binary = '';
for ( $i = 0; $i < $l; $i++ ) {
$n = ( $n << 5 ) + strpos( $alphabet, $b32[ $i ] );
$j += 5;
if ( $j >= 8 ) {
$j -= 8;
$binary .= chr( ( $n & ( 0xFF << $j ) ) >> $j );
}
}
return $binary;
}
/**
* Generates an HOTP (HMAC-based One-Time Password) token.
*
* Implements the HOTP algorithm as defined in RFC 4226. Uses HMAC-SHA1
* to generate a one-time password based on a shared secret and counter value.
*
* @param string $secret The base32-encoded shared secret key.
* @param int $counter The counter value for HOTP generation.
* @param int $digits The number of digits in the generated token. Default 6.
*
* @return string The generated HOTP token, zero-padded to specified digits.
*
* @since 1.0.0
*/
protected function hotp( $secret, $counter, $digits = 6 ) {
$key = $this->base32_decode( $secret );
$counter_bytes = pack( 'N*', 0 ) . pack( 'N*', $counter );
$hash = hash_hmac( 'sha1', $counter_bytes, $key, true );
$offset = ord( $hash[19] ) & 0xf;
$truncated = substr( $hash, $offset, 4 );
$value = unpack( 'N', $truncated )[1] & 0x7fffffff;
$mod = pow( 10, $digits );
return (string) str_pad( (string) ( $value % $mod ), $digits, '0', STR_PAD_LEFT );
}
/**
* Generates recovery codes for the user.
*
* Creates 10 unique recovery codes that can be used as backup authentication.
* Each code is 8 characters long using alphanumeric characters.
*
* @param WP_User $user The WordPress user object to generate codes for.
*
* @return array Array of recovery codes.
*
* @since 1.0.0
*/
public function generate_recovery_codes( WP_User $user ): array {
$codes = array();
$count = apply_filters( 'brain2fa_recovery_codes_count', 10 );
for ( $i = 0; $i < $count; $i++ ) {
$codes[] = $this->generate_single_recovery_code();
}
// Hash codes before storing.
$hashed_codes = array_map( 'wp_hash_password', $codes );
update_user_meta( $user->ID, 'brain2fa_recovery_codes', $hashed_codes );
return $codes;
}
/**
* Generates a single recovery code.
*
* Creates an 8-character alphanumeric code in format XXXX-XXXX.
*
* @return string The generated recovery code.
*
* @since 1.0.0
*/
protected function generate_single_recovery_code(): string {
$characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$code_part1 = '';
$code_part2 = '';
for ( $i = 0; $i < 4; $i++ ) {
$code_part1 .= $characters[ random_int( 0, strlen( $characters ) - 1 ) ];
}
for ( $i = 0; $i < 4; $i++ ) {
$code_part2 .= $characters[ random_int( 0, strlen( $characters ) - 1 ) ];
}
return $code_part1 . '-' . $code_part2;
}
/**
* Verifies a recovery code for the user.
*
* Checks if the provided code matches any of the user's stored recovery codes.
* If a match is found, the code is removed from storage (one-time use).
*
* @param WP_User $user The WordPress user object to verify the code for.
* @param string $code The recovery code to verify.
*
* @return bool True if the code is valid, false otherwise.
*
* @since 1.0.0
*/
protected function verify_recovery_code( WP_User $user, string $code ): bool {
$stored_codes = get_user_meta( $user->ID, 'brain2fa_recovery_codes', true );
if ( empty( $stored_codes ) || ! is_array( $stored_codes ) ) {
return false;
}
// Normalize the input code (remove spaces, dashes, convert to uppercase).
$code = strtoupper( str_replace( array( ' ', '-' ), '', $code ) );
// Check each stored code.
foreach ( $stored_codes as $index => $hashed_code ) {
// Try both with and without dash for comparison.
$code_with_dash = substr( $code, 0, 4 ) . '-' . substr( $code, 4 );
if ( wp_check_password( $code, $hashed_code ) || wp_check_password( $code_with_dash, $hashed_code ) ) {
// Remove the used code.
unset( $stored_codes[ $index ] );
update_user_meta( $user->ID, 'brain2fa_recovery_codes', array_values( $stored_codes ) );
return true;
}
}
return false;
}
/**
* Gets the remaining recovery codes for a user.
*
* @param WP_User $user The WordPress user object.
*
* @return int The number of remaining recovery codes.
*
* @since 1.0.0
*/
public function get_recovery_codes_count( WP_User $user ): int {
$stored_codes = get_user_meta( $user->ID, 'brain2fa_recovery_codes', true );
return is_array( $stored_codes ) ? count( $stored_codes ) : 0;
}
}