This commit is contained in:
Javier Casares 2026-06-05 14:04:51 +00:00
commit f936aeb807
118 changed files with 15710 additions and 0 deletions

View file

@ -0,0 +1,415 @@
<?php
/**
* Admin settings management.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\Admin;
use Robotstxt\TwoFA\Frequency_Options;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles plugin settings page and enforcement logic configuration.
*/
class Settings_Page {
/**
* Option group for settings registration.
*/
private const OPTION_GROUP = 'robotstxt_2fa_settings_group';
/**
* Option name for storing settings.
*/
public const OPTION_NAME = 'robotstxt_2fa_settings';
/**
* Settings page slug.
*/
private const PAGE_SLUG = 'robotstxt-2fa-settings';
/**
* Register admin-related hooks.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'admin_menu', array( $this, 'register_menu' ) );
if ( is_multisite() ) {
add_action( 'network_admin_menu', array( $this, 'register_network_menu' ) );
}
add_action( 'admin_init', array( $this, 'register_settings' ) );
}
/**
* Register plugin settings page on single-site dashboards.
*
* @return void
*/
public function register_menu(): void {
$this->register_admin_page( false );
}
/**
* Register plugin settings page on the network dashboard.
*
* @return void
*/
public function register_network_menu(): void {
$this->register_admin_page( true );
}
/**
* Render plugin settings page.
*
* @return void
*/
public function render_page(): void {
$capability = $this->get_required_capability();
if ( ! current_user_can( $capability ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'robotstxt-2fa' ) );
}
settings_errors( self::OPTION_NAME );
?>
<div class="wrap">
<h1><?php esc_html_e( 'Two-Factor Authentication settings', 'robotstxt-2fa' ); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields( self::OPTION_GROUP );
do_settings_sections( self::PAGE_SLUG );
submit_button();
?>
</form>
</div>
<?php
}
/**
* Register settings and sections.
*
* @return void
*/
public function register_settings(): void {
register_setting(
self::OPTION_GROUP,
self::OPTION_NAME,
array(
'type' => 'array',
'sanitize_callback' => array( $this, 'sanitize_settings' ),
'default' => $this->get_default_settings(),
)
);
add_settings_section(
'robotstxt_2fa_general',
__( 'General options', 'robotstxt-2fa' ),
array( $this, 'render_general_section' ),
self::PAGE_SLUG
);
add_settings_field(
'robotstxt_2fa_force_roles',
__( 'Forced roles', 'robotstxt-2fa' ),
array( $this, 'render_force_roles_field' ),
self::PAGE_SLUG,
'robotstxt_2fa_general'
);
add_settings_field(
'robotstxt_2fa_frequency',
__( 'Verification frequency', 'robotstxt-2fa' ),
array( $this, 'render_frequency_field' ),
self::PAGE_SLUG,
'robotstxt_2fa_general'
);
add_settings_field(
'robotstxt_2fa_force_frequency',
__( 'Enforce frequency', 'robotstxt-2fa' ),
array( $this, 'render_force_frequency_field' ),
self::PAGE_SLUG,
'robotstxt_2fa_general'
);
}
/**
* Render general settings section description.
*
* @return void
*/
public function render_general_section(): void {
echo '<p>' . esc_html__( 'Control how two-factor authentication is enforced for user roles.', 'robotstxt-2fa' ) . '</p>';
if ( $this->has_network_settings() && ! $this->is_network_context() ) {
echo '<p class="description">' . esc_html__( 'These settings are locked by a network administrator. Any changes made here will be ignored.', 'robotstxt-2fa' ) . '</p>';
}
}
/**
* Render forced roles field.
*
* @return void
*/
public function render_force_roles_field(): void {
$settings = $this->get_settings();
$selected_roles = array();
$disabled_attr = '';
if ( isset( $settings['force_roles'] ) && is_array( $settings['force_roles'] ) ) {
$selected_roles = $settings['force_roles'];
}
if ( $this->has_network_settings() && ! $this->is_network_context() ) {
$disabled_attr = disabled( true, true, false );
}
$roles = get_editable_roles();
if ( empty( $roles ) ) {
echo '<p class="description">' . esc_html__( 'No editable roles were found.', 'robotstxt-2fa' ) . '</p>';
return;
}
foreach ( $roles as $role_key => $role_details ) {
printf(
'<label><input type="checkbox" name="%1$s[force_roles][]" value="%2$s" %3$s %4$s/> %5$s</label><br />',
esc_attr( self::OPTION_NAME ),
esc_attr( $role_key ),
checked( in_array( $role_key, $selected_roles, true ), true, false ),
$disabled_attr,
esc_html( translate_user_role( $role_details['name'] ) )
);
}
echo '<p class="description">' . esc_html__( 'Users assigned to the selected roles must enable two-factor authentication. Leave all roles unchecked to allow each user to decide whether to activate 2FA.', 'robotstxt-2fa' ) . '</p>';
}
/**
* Sanitize settings before saving them.
*
* @param mixed $raw_settings Raw settings submitted by the user.
*
* @return array<string, mixed>
*/
public function sanitize_settings( $raw_settings ): array {
$capability = $this->get_required_capability();
if ( ! current_user_can( $capability ) ) {
add_settings_error(
self::OPTION_NAME,
'robotstxt_2fa_capability',
__( 'You are not allowed to update these settings.', 'robotstxt-2fa' )
);
return $this->get_settings();
}
$raw_settings = is_array( $raw_settings ) ? $raw_settings : array();
$sanitized = $this->get_default_settings();
$current_settings = $this->get_settings();
$sanitized['force_roles'] = array();
if ( isset( $raw_settings['force_roles'] ) && is_array( $raw_settings['force_roles'] ) ) {
$editable_roles = array_keys( get_editable_roles() );
foreach ( $raw_settings['force_roles'] as $role_key ) {
$role_key = sanitize_key( $role_key );
if ( in_array( $role_key, $editable_roles, true ) ) {
$sanitized['force_roles'][] = $role_key;
}
}
}
$sanitized['force_roles'] = array_values( array_unique( $sanitized['force_roles'] ) );
$frequency_value = $sanitized['frequency'];
if ( isset( $current_settings['frequency'] ) ) {
$frequency_value = (string) $current_settings['frequency'];
}
if ( isset( $raw_settings['frequency'] ) ) {
$frequency_value = (string) $raw_settings['frequency'];
}
$sanitized['frequency'] = Frequency_Options::sanitize( $frequency_value );
if ( $this->has_network_settings() && ! $this->is_network_context() ) {
$sanitized['force_frequency'] = ! empty( $current_settings['force_frequency'] ?? false );
} elseif ( array_key_exists( 'force_frequency', $raw_settings ) ) {
$sanitized['force_frequency'] = '1' === (string) $raw_settings['force_frequency'];
}
return $sanitized;
}
/**
* Retrieve plugin settings.
*
* @return array<string, mixed>
*/
public function get_settings(): array {
$options = array();
if ( is_multisite() ) {
/** @var array<string, mixed>|false $network_options */
$network_options = get_site_option( self::OPTION_NAME, false );
if ( false !== $network_options && is_array( $network_options ) ) {
$options = $network_options;
}
}
if ( empty( $options ) ) {
/** @var array<string, mixed> $options */
$options = get_option( self::OPTION_NAME, array() );
}
return wp_parse_args( $options, $this->get_default_settings() );
}
/**
* Retrieve default settings.
*
* @return array<string, mixed>
*/
private function get_default_settings(): array {
return array(
'force_roles' => array(),
'default_method' => 'email',
'frequency' => Frequency_Options::FREQUENCY_SESSION,
'force_frequency' => false,
);
}
/**
* Render verification frequency field.
*
* @return void
*/
public function render_frequency_field(): void {
$settings = $this->get_settings();
$selected = Frequency_Options::sanitize( (string) $settings['frequency'] );
$disabled_attr = '';
if ( $this->has_network_settings() && ! $this->is_network_context() ) {
$disabled_attr = disabled( true, true, false );
}
$options = Frequency_Options::get_options();
echo '<select name="' . esc_attr( self::OPTION_NAME ) . '[frequency]" ' . $disabled_attr . '>';
foreach ( $options as $value => $labels ) {
printf(
'<option value="%1$s" %2$s>%3$s</option>',
esc_attr( $value ),
selected( $selected, $value, false ),
esc_html( $labels['label'] )
);
}
echo '</select>';
if ( isset( $options[ $selected ]['description'] ) && '' !== $options[ $selected ]['description'] ) {
echo '<p class="description">' . esc_html( $options[ $selected ]['description'] ) . '</p>';
}
}
/**
* Render frequency enforcement checkbox.
*
* @return void
*/
public function render_force_frequency_field(): void {
$settings = $this->get_settings();
$is_checked = ! empty( $settings['force_frequency'] );
$disabled_attr = '';
if ( $this->has_network_settings() && ! $this->is_network_context() ) {
$disabled_attr = disabled( true, true, false );
}
if ( '' === $disabled_attr ) {
printf(
'<input type="hidden" name="%1$s[force_frequency]" value="0" />',
esc_attr( self::OPTION_NAME )
);
}
printf(
'<label><input type="checkbox" name="%1$s[force_frequency]" value="1" %2$s %3$s/> %4$s</label>',
esc_attr( self::OPTION_NAME ),
checked( $is_checked, true, false ),
$disabled_attr,
esc_html__( 'Prevent users from overriding the global verification frequency.', 'robotstxt-2fa' )
);
echo '<p class="description">' . esc_html__( 'When enabled, users will see the profile frequency control disabled and must follow the global schedule.', 'robotstxt-2fa' ) . '</p>';
}
/**
* Determine the capability required to manage settings.
*
* @return string
*/
private function get_required_capability(): string {
return 'manage_options';
}
/**
* Check if the current request runs in a network admin context.
*
* @return bool
*/
private function is_network_context(): bool {
return is_multisite() && is_network_admin();
}
/**
* Determine if network-level settings have been stored.
*
* @return bool
*/
private function has_network_settings(): bool {
if ( ! is_multisite() ) {
return false;
}
return false !== get_site_option( self::OPTION_NAME, false );
}
/**
* Register the plugin admin page in the desired context.
*
* @param bool $network Whether the menu is registered for the network dashboard.
*
* @return void
*/
private function register_admin_page( bool $network ): void {
unset( $network );
$capability = $this->get_required_capability();
add_menu_page(
__( '2FA (by ROBOTSTXT)', 'robotstxt-2fa' ),
__( '2FA', 'robotstxt-2fa' ),
$capability,
self::PAGE_SLUG,
array( $this, 'render_page' ),
'dashicons-shield'
);
}
}

View file

@ -0,0 +1,110 @@
<?php
/**
* Normalize and expose two-factor verification frequency options.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Utility helpers for two-factor verification frequency handling.
*/
class Frequency_Options {
/**
* Frequency key representing per-session verification.
*/
public const FREQUENCY_SESSION = 'session';
/**
* Frequency key representing at most one verification per day.
*/
public const FREQUENCY_DAILY = 'daily';
/**
* Frequency key representing at most one verification per week.
*/
public const FREQUENCY_WEEKLY = 'weekly';
/**
* Frequency key representing at most one verification per 28 days.
*/
public const FREQUENCY_MONTHLY = 'monthly';
/**
* Retrieve the available frequency options for display.
*
* @return array<string, array<string, string>>
*/
public static function get_options(): array {
return array(
self::FREQUENCY_SESSION => array(
'label' => __( 'Every login', 'robotstxt-2fa' ),
'description' => __( 'Always require a new verification code after the password step.', 'robotstxt-2fa' ),
),
self::FREQUENCY_DAILY => array(
'label' => __( 'Daily', 'robotstxt-2fa' ),
'description' => __( 'Remember successful verifications for 24 hours per device.', 'robotstxt-2fa' ),
),
self::FREQUENCY_WEEKLY => array(
'label' => __( 'Weekly', 'robotstxt-2fa' ),
'description' => __( 'Remember successful verifications for 7 days per device.', 'robotstxt-2fa' ),
),
self::FREQUENCY_MONTHLY => array(
'label' => __( 'Monthly', 'robotstxt-2fa' ),
'description' => __( 'Remember successful verifications for 28 days per device.', 'robotstxt-2fa' ),
),
);
}
/**
* Retrieve the allowed option keys.
*
* @return array<int, string>
*/
public static function get_allowed_keys(): array {
return array_keys( self::get_options() );
}
/**
* Sanitize a frequency key against the supported values.
*
* @param string $frequency Submitted frequency value.
*
* @return string
*/
public static function sanitize( string $frequency ): string {
$frequency = sanitize_key( $frequency );
if ( in_array( $frequency, self::get_allowed_keys(), true ) ) {
return $frequency;
}
return self::FREQUENCY_SESSION;
}
/**
* Resolve the interval in seconds represented by a frequency.
*
* @param string $frequency Frequency identifier.
*
* @return int
*/
public static function get_interval_seconds( string $frequency ): int {
switch ( $frequency ) {
case self::FREQUENCY_DAILY:
return DAY_IN_SECONDS;
case self::FREQUENCY_WEEKLY:
return WEEK_IN_SECONDS;
case self::FREQUENCY_MONTHLY:
return 28 * DAY_IN_SECONDS;
case self::FREQUENCY_SESSION:
default:
return 0;
}
}
}

120
includes/class-plugin.php Normal file
View file

@ -0,0 +1,120 @@
<?php
/**
* Core plugin bootstrap.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA;
use Robotstxt\TwoFA\Admin\Settings_Page;
use Robotstxt\TwoFA\Login\Login_Form_Manager;
use Robotstxt\TwoFA\User\Profile_Settings;
use Robotstxt\TwoFA\User\Recovery_Codes;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Main plugin controller.
*/
class Plugin {
/**
* Plugin instance.
*
* @var Plugin|null
*/
private static ?Plugin $instance = null;
/**
* Login form manager.
*
* @var Login_Form_Manager
*/
private Login_Form_Manager $login_manager;
/**
* Profile settings manager.
*
* @var Profile_Settings
*/
private Profile_Settings $profile_settings;
/**
* Recovery codes manager.
*
* @var Recovery_Codes
*/
private Recovery_Codes $recovery_codes;
/**
* Settings page handler.
*
* @var Settings_Page
*/
private Settings_Page $settings_page;
/**
* Get singleton instance.
*
* @return Plugin
*/
public static function get_instance(): Plugin {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
public function __construct() {
$this->login_manager = new Login_Form_Manager();
$this->profile_settings = new Profile_Settings();
$this->recovery_codes = new Recovery_Codes();
$this->settings_page = new Settings_Page();
}
/**
* Plugin initialization.
*
* @return void
*/
public function init(): void {
$this->maybe_load_textdomain();
$this->login_manager->register_hooks();
$this->profile_settings->register_hooks();
$this->recovery_codes->register_hooks();
$this->settings_page->register_hooks();
}
/**
* Runs on activation.
*
* @return void
*/
public static function activate(): void {
// Placeholder for activation tasks (option creation, migrations, etc.).
}
/**
* Runs on deactivation.
*
* @return void
*/
public static function deactivate(): void {
// Placeholder for deactivation cleanup logic.
}
/**
* Load text domain for translations.
*
* @return void
*/
private function maybe_load_textdomain(): void {
load_plugin_textdomain( 'robotstxt-2fa', false, dirname( plugin_basename( ROBOTSTXT_2FA_FILE ) ) . '/languages/' );
}
}

View file

@ -0,0 +1,149 @@
<?php
/**
* Email verification code sender.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\Login;
use Robotstxt\TwoFA\User\User_Settings_Repository;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles generation and delivery of email-based verification codes.
*/
class Email_Code_Sender {
/**
* Length of the generated numeric code.
*/
private const CODE_LENGTH = 7;
/**
* Number of seconds the generated code remains valid.
*/
private const EXPIRATION_SECONDS = 600;
/**
* Minimum delay between email deliveries in seconds.
*/
private const RESEND_INTERVAL = 60;
/**
* User settings repository.
*
* @var User_Settings_Repository
*/
private User_Settings_Repository $user_settings_repository;
/**
* Constructor.
*/
public function __construct() {
$this->user_settings_repository = new User_Settings_Repository();
}
/**
* Retrieve the expected code length.
*
* @return int
*/
public function get_code_length(): int {
return self::CODE_LENGTH;
}
/**
* Send a verification code to the provided user.
*
* @param \WP_User $user User who is requesting authentication.
*
* @return bool True when the email was sent or throttled successfully, false on failure.
*/
public function send_code( \WP_User $user ): bool {
if ( empty( $user->user_email ) || ! is_email( $user->user_email ) ) {
return false;
}
$challenge = $this->user_settings_repository->get_email_challenge( $user->ID );
$current_time = time();
if ( ! empty( $challenge['hash'] ) && $challenge['expires'] > $current_time && $challenge['sent_at'] >= ( $current_time - self::RESEND_INTERVAL ) ) {
return true;
}
$code = $this->generate_code();
$expires = $current_time + self::EXPIRATION_SECONDS;
$blog_name = wp_specialchars_decode( get_option( 'blogname', '' ), ENT_QUOTES );
$display_name = wp_strip_all_tags( $user->display_name ?: $user->user_login );
$email_subject = sprintf( __( '[%s] Your verification code', 'robotstxt-2fa' ), $blog_name ?: __( 'WordPress', 'robotstxt-2fa' ) );
$message_lines = array(
/* translators: %s: user display name. */
sprintf( __( 'Hi %s,', 'robotstxt-2fa' ), $display_name ),
'',
/* translators: %s: site name. */
sprintf( __( 'Use the following verification code to finish signing in to %s:', 'robotstxt-2fa' ), $blog_name ?: __( 'your site', 'robotstxt-2fa' ) ),
'',
$code,
'',
__( 'The code expires in 10 minutes.', 'robotstxt-2fa' ),
'',
__( 'If you did not request this code you can ignore this email.', 'robotstxt-2fa' ),
);
$message = implode( "\n", $message_lines );
$headers = array( 'Content-Type: text/plain; charset=UTF-8' );
if ( ! wp_mail( $user->user_email, $email_subject, $message, $headers ) ) {
return false;
}
$this->user_settings_repository->store_email_challenge( $user->ID, $code, $expires );
return true;
}
/**
* Verify a submitted code against the stored challenge.
*
* @param \WP_User $user User attempting to authenticate.
* @param string $code Submitted verification code.
*
* @return bool
*/
public function verify_code( \WP_User $user, string $code ): bool {
$challenge = $this->user_settings_repository->get_email_challenge( $user->ID );
$code = preg_replace( '/[^0-9]/', '', $code );
if ( empty( $challenge['hash'] ) ) {
return false;
}
if ( $challenge['expires'] <= time() ) {
return false;
}
if ( '' === $code ) {
return false;
}
return wp_check_password( $code, $challenge['hash'] );
}
/**
* Generate a random numeric verification code.
*
* @return string
*/
private function generate_code(): string {
$max_value = ( 10 ** self::CODE_LENGTH ) - 1;
$code = (string) wp_rand( 0, (int) $max_value );
return str_pad( $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,337 @@
<?php
/**
* Manage authenticator app secrets and QR provisioning.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\User;
use WP_User;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles OTP secret storage, verification, and QR generation.
*/
class OTP_Manager {
/**
* User meta key that stores the OTP secret.
*
* @var string
*/
private const META_KEY = 'robotstxt_2fa_otp_secret';
/**
* Number of digits generated per OTP code.
*/
private const CODE_DIGITS = 6;
/**
* Retrieve the number of digits expected for OTP codes.
*
* @return int
*/
public function get_code_length(): int {
return self::CODE_DIGITS;
}
/**
* Number of seconds between OTP code changes.
*/
private const TIME_STEP = 30;
/**
* Default length (in bytes) of generated secrets.
*/
private const SECRET_LENGTH = 20;
/**
* Retrieve the stored OTP secret for a user.
*
* @param int $user_id User identifier.
*
* @return string
*/
public function get_secret( int $user_id ): string {
$secret = get_user_meta( $user_id, self::META_KEY, true );
if ( ! is_string( $secret ) ) {
return '';
}
$secret = strtoupper( preg_replace( '/[^A-Z2-7]/', '', $secret ) ?? '' );
return $secret;
}
/**
* Ensure the user has an OTP secret, generating one if necessary.
*
* @param WP_User $user User account that needs an OTP secret.
*
* @return string Generated or existing secret.
*/
public function ensure_secret( WP_User $user ): string {
$secret = $this->get_secret( $user->ID );
if ( '' !== $secret ) {
return $secret;
}
$secret = $this->generate_secret();
update_user_meta( $user->ID, self::META_KEY, $secret );
return $secret;
}
/**
* Remove the stored OTP secret for a user.
*
* @param int $user_id User identifier.
*
* @return void
*/
public function delete_secret( int $user_id ): void {
delete_user_meta( $user_id, self::META_KEY );
}
/**
* Verify an OTP code against the stored secret.
*
* @param WP_User $user User attempting to authenticate.
* @param string $code Submitted verification code.
*
* @return bool
*/
public function verify_code( WP_User $user, string $code ): bool {
$secret = $this->get_secret( $user->ID );
if ( '' === $secret ) {
return false;
}
$code = preg_replace( '/[^0-9]/', '', $code );
if ( null === $code || strlen( $code ) !== self::CODE_DIGITS ) {
return false;
}
$binary_secret = $this->base32_decode( $secret );
if ( '' === $binary_secret ) {
return false;
}
$current_step = (int) floor( time() / self::TIME_STEP );
for ( $offset = -1; $offset <= 1; $offset++ ) {
$otp = $this->generate_otp_for_counter( $binary_secret, $current_step + $offset );
if ( hash_equals( $otp, $code ) ) {
return true;
}
}
return false;
}
/**
* Retrieve the provisioning URI used by authenticator apps.
*
* @param WP_User $user User who owns the OTP secret.
*
* @return string
*/
public function get_provisioning_uri( WP_User $user ): string {
$secret = $this->get_secret( $user->ID );
if ( '' === $secret ) {
return '';
}
$site_name = wp_specialchars_decode( get_option( 'blogname', '' ), ENT_QUOTES );
$site_name = '' !== $site_name ? $site_name : __( 'WordPress Site', 'robotstxt-2fa' );
$label = '' !== $user->user_email ? $user->user_email : $user->user_login;
$encoded_label = rawurlencode( sanitize_text_field( $label ) );
$encoded_site = rawurlencode( $site_name );
$encoded_secret = rawurlencode( $secret );
$parameters = array(
'issuer' => $encoded_site,
'period' => (string) self::TIME_STEP,
);
$query = array();
foreach ( $parameters as $key => $value ) {
$query[] = rawurlencode( $key ) . '=' . $value;
}
return sprintf(
'otpauth://totp/%1$s:%2$s?secret=%3$s&%4$s',
$encoded_site,
$encoded_label,
$encoded_secret,
implode( '&', $query )
);
}
/**
* Generate a QR code and return it as a data URI.
*
* @param WP_User $user User who owns the OTP secret.
*
* @return string Data URI containing an SVG image or an empty string when unavailable.
*/
public function get_qr_code_data_uri( WP_User $user ): string {
$uri = $this->get_provisioning_uri( $user );
if ( '' === $uri ) {
return '';
}
$svg = $this->render_qr_svg( $uri );
if ( '' === $svg ) {
return '';
}
/*
* Encode the SVG payload so it can be embedded safely as a data URI in the admin form.
*/
return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- The QR SVG is encoded to embed it safely as a data URI.
}
/**
* Generate a new secret encoded in Base32.
*
* @return string
*/
private function generate_secret(): string {
$bytes = random_bytes( self::SECRET_LENGTH );
return $this->base32_encode( $bytes );
}
/**
* Render a QR code in SVG format using an available library.
*
* @param string $data Data encoded inside the QR code.
*
* @return string SVG markup or empty string on failure.
*/
private function render_qr_svg( string $data ): string {
if ( ! class_exists( '\BaconQrCode\Writer' ) || ! class_exists( '\BaconQrCode\Renderer\ImageRenderer' ) ) {
return '';
}
try {
$renderer = new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle( 256 ),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$writer = new \BaconQrCode\Writer( $renderer );
return $writer->writeString( $data );
} catch ( \Throwable $exception ) {
do_action( 'robotstxt_2fa_qr_generation_failed', $exception, $data );
}
return '';
}
/**
* Encode binary data using the Base32 alphabet.
*
* @param string $data Binary string to encode.
*
* @return string
*/
private function base32_encode( string $data ): string {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
for ( $i = 0, $length = strlen( $data ); $i < $length; $i++ ) {
$bits .= str_pad( decbin( ord( $data[ $i ] ) ), 8, '0', STR_PAD_LEFT );
}
$chunks = str_split( $bits, 5 );
$encoded = '';
foreach ( $chunks as $chunk ) {
if ( strlen( $chunk ) < 5 ) {
$chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
}
$encoded .= $alphabet[ bindec( $chunk ) ];
}
return $encoded;
}
/**
* Decode a Base32-encoded string.
*
* @param string $encoded Encoded secret.
*
* @return string
*/
private function base32_decode( string $encoded ): string {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$encoded = strtoupper( $encoded );
$encoded = preg_replace( '/[^A-Z2-7]/', '', $encoded );
if ( null === $encoded || '' === $encoded ) {
return '';
}
$bits = '';
for ( $i = 0, $length = strlen( $encoded ); $i < $length; $i++ ) {
$position = strpos( $alphabet, $encoded[ $i ] );
if ( false === $position ) {
return '';
}
$bits .= str_pad( decbin( $position ), 5, '0', STR_PAD_LEFT );
}
$binary = '';
$chunks = str_split( $bits, 8 );
foreach ( $chunks as $chunk ) {
if ( strlen( $chunk ) < 8 ) {
continue;
}
$binary .= chr( bindec( $chunk ) );
}
return $binary;
}
/**
* Generate an OTP value for a specific counter.
*
* @param string $secret Binary secret key.
* @param int $counter Time-based counter value.
*
* @return string
*/
private function generate_otp_for_counter( string $secret, int $counter ): string {
$counter = max( 0, $counter );
$binary_counter = pack( 'N*', 0 ) . pack( 'N*', $counter );
$hash = hash_hmac( 'sha1', $binary_counter, $secret, true );
$offset = ord( substr( $hash, -1 ) ) & 0x0F;
$segment = substr( $hash, $offset, 4 );
$value = unpack( 'N', $segment )[1] & 0x7FFFFFFF;
$code = $value % ( 10 ** self::CODE_DIGITS );
return str_pad( (string) $code, self::CODE_DIGITS, '0', STR_PAD_LEFT );
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,454 @@
<?php
/**
* Manage generation and validation of recovery codes.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\User;
use WP_User;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles creation, storage, and consumption of recovery codes.
*/
class Recovery_Codes {
/**
* User meta key storing recovery code hashes.
*
* @var string
*/
private const META_KEY = 'robotstxt_2fa_recovery_codes';
/**
* Number of codes generated per batch.
*/
private const CODES_PER_BATCH = 10;
/**
* Number of digits per recovery code.
*/
private const CODE_LENGTH = 8;
/**
* Admin-post action slug used to request new recovery codes.
*/
public const ACTION_GENERATE = 'robotstxt_2fa_generate_recovery_codes';
/**
* Nonce action securing recovery code generation.
*/
public const NONCE_ACTION = 'robotstxt_2fa_generate_recovery_codes';
/**
* Nonce field key embedded in recovery code forms.
*/
public const NONCE_FIELD = 'robotstxt_2fa_recovery_nonce';
/**
* Register hooks required by the recovery code workflow.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'wp_ajax_' . self::ACTION_GENERATE, array( $this, 'handle_ajax_generate_request' ) );
add_action( 'wp_ajax_nopriv_' . self::ACTION_GENERATE, array( $this, 'reject_unauthenticated_requests' ) );
}
/**
* Block unauthenticated requests attempting to generate recovery codes.
*
* @return void
*/
public function reject_unauthenticated_requests(): void {
$message = esc_html__( 'You must be logged in to manage recovery codes.', 'robotstxt-2fa' );
if ( wp_doing_ajax() ) {
wp_send_json_error(
array(
'message' => $message,
),
403
);
}
wp_die( $message, '', array( 'response' => 403 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $message is escaped via esc_html__().
}
/**
* Handle AJAX requests to generate recovery codes without reloading the page.
*
* @return void
*/
public function handle_ajax_generate_request(): void {
check_ajax_referer( self::NONCE_ACTION, self::NONCE_FIELD );
$user_id = isset( $_POST['user_id'] ) ? absint( wp_unslash( $_POST['user_id'] ) ) : 0;
if ( $user_id <= 0 ) {
wp_send_json_error(
array(
'message' => esc_html__( 'The requested user account could not be found.', 'robotstxt-2fa' ),
),
400
);
}
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error(
array(
'message' => esc_html__( 'You are not allowed to modify recovery codes for this account.', 'robotstxt-2fa' ),
),
403
);
}
$user = get_user_by( 'ID', $user_id );
if ( ! $user instanceof WP_User ) {
wp_send_json_error(
array(
'message' => esc_html__( 'The requested user account could not be found.', 'robotstxt-2fa' ),
),
400
);
}
try {
$result = $this->regenerate_codes_for_user( $user );
} catch ( \Throwable $exception ) {
do_action( 'robotstxt_2fa_recovery_generation_failed', $exception );
wp_send_json_error(
array(
'message' => esc_html__( 'We could not generate recovery codes. Please try again.', 'robotstxt-2fa' ),
),
500
);
}
wp_send_json_success(
array(
'codes' => $result['codes'],
'unused_count' => $result['unused_count'],
'last_generated' => $result['generated_at'],
'last_generated_formatted' => $this->format_generated_timestamp( $result['generated_at'] ),
)
);
}
/**
* Generate and persist a new batch of recovery codes for a user.
*
* @param WP_User $user User whose recovery codes are being regenerated.
*
* @throws \Exception If secure randomness cannot be generated.
*
* @return array<string, mixed>
*/
public function regenerate_codes_for_user( WP_User $user ): array {
$codes = $this->generate_codes();
$this->store_codes( $user->ID, $codes );
return array(
'codes' => $codes,
'generated_at' => $this->get_last_generated_timestamp( $user->ID ),
'unused_count' => $this->count_unused_codes( $user->ID ),
);
}
/**
* Retrieve the number of digits expected for each recovery code.
*
* @return int
*/
public function get_code_length(): int {
return self::CODE_LENGTH;
}
/**
* Retrieve the number of recovery codes generated per batch.
*
* @return int
*/
public function get_codes_per_batch(): int {
return self::CODES_PER_BATCH;
}
/**
* Count the amount of unused recovery codes available for the user.
*
* @param int $user_id User identifier.
*
* @return int
*/
public function count_unused_codes( int $user_id ): int {
$record = $this->get_stored_codes( $user_id );
if ( empty( $record['codes'] ) ) {
return 0;
}
$unused = 0;
foreach ( $record['codes'] as $entry ) {
if ( empty( $entry['used_at'] ) ) {
++$unused;
}
}
return $unused;
}
/**
* Determine whether the user currently has any unused recovery codes.
*
* @param int $user_id User identifier.
*
* @return bool
*/
public function has_active_codes( int $user_id ): bool {
return $this->count_unused_codes( $user_id ) > 0;
}
/**
* Consume a recovery code if it matches one of the stored hashes.
*
* @param WP_User $user User attempting to validate a recovery code.
* @param string $code Submitted plain-text code.
*
* @return bool
*/
public function consume_code( WP_User $user, string $code ): bool {
$normalized_code = $this->normalize_code( $code );
if ( '' === $normalized_code ) {
return false;
}
$record = $this->get_stored_codes( $user->ID );
if ( empty( $record['codes'] ) ) {
return false;
}
$updated = false;
foreach ( $record['codes'] as $index => $entry ) {
if ( ! empty( $entry['used_at'] ) || empty( $entry['hash'] ) ) {
continue;
}
if ( wp_check_password( $normalized_code, $entry['hash'] ) ) {
$record['codes'][ $index ]['used_at'] = time();
$updated = true;
break;
}
}
if ( $updated ) {
update_user_meta( $user->ID, self::META_KEY, $record );
return true;
}
return false;
}
/**
* Fetch the timestamp of the last generation batch.
*
* @param int $user_id User identifier.
*
* @return int
*/
public function get_last_generated_timestamp( int $user_id ): int {
$record = $this->get_stored_codes( $user_id );
return (int) ( $record['generated_at'] ?? 0 );
}
/**
* Format the provided timestamp using the site's date and time preferences.
*
* @param int $timestamp Unix timestamp representing the generation time.
*
* @return string
*/
private function format_generated_timestamp( int $timestamp ): string {
if ( $timestamp <= 0 ) {
return '';
}
$date_format = get_option( 'date_format' );
$time_format = get_option( 'time_format' );
$format = trim( (string) $date_format . ' ' . (string) $time_format );
return '' !== $format ? wp_date( $format, $timestamp ) : wp_date( 'c', $timestamp );
}
/**
* Generate a batch of unique recovery codes.
*
* @throws \Exception If randomness cannot be generated.
*
* @return array<int, string>
*/
private function generate_codes(): array {
$codes = array();
$attempts = 0;
$target_count = self::CODES_PER_BATCH;
$max_attempts = $target_count * 3;
$unique_count = 0;
while ( $unique_count < $target_count && $attempts < $max_attempts ) {
++$attempts;
$code = $this->generate_single_code();
if ( in_array( $code, $codes, true ) ) {
continue;
}
$codes[] = $code;
++$unique_count;
}
return $codes;
}
/**
* Create a single recovery code.
*
* @throws \Exception If secure randomness cannot be generated.
*
* @return string
*/
private function generate_single_code(): string {
$max_value = ( 10 ** self::CODE_LENGTH ) - 1;
$number = random_int( 0, $max_value );
return str_pad( (string) $number, self::CODE_LENGTH, '0', STR_PAD_LEFT );
}
/**
* Normalize user-submitted recovery codes.
*
* @param string $code Submitted code.
*
* @return string
*/
private function normalize_code( string $code ): string {
$code = preg_replace( '/[^0-9]/', '', $code );
if ( null === $code ) {
return '';
}
$code = substr( $code, 0, self::CODE_LENGTH );
if ( strlen( $code ) !== self::CODE_LENGTH ) {
return '';
}
return $code;
}
/**
* Store recovery codes and metadata for a user.
*
* @param int $user_id User identifier.
* @param array<int, string> $codes Plain-text recovery codes.
*
* @return void
*/
private function store_codes( int $user_id, array $codes ): void {
$stored_codes = array();
foreach ( $codes as $code ) {
$stored_codes[] = array(
'hash' => wp_hash_password( $this->normalize_code( $code ) ),
'used_at' => 0,
);
}
$record = array(
'generated_at' => time(),
'codes' => $stored_codes,
);
update_user_meta( $user_id, self::META_KEY, $record );
}
/**
* Retrieve and sanitize stored recovery code metadata.
*
* @param int $user_id User identifier.
*
* @return array<string, mixed>
*/
private function get_stored_codes( int $user_id ): array {
$record = get_user_meta( $user_id, self::META_KEY, true );
if ( ! is_array( $record ) ) {
$record = array();
}
$record['generated_at'] = isset( $record['generated_at'] ) ? (int) $record['generated_at'] : 0;
$record['codes'] = isset( $record['codes'] ) && is_array( $record['codes'] ) ? $record['codes'] : array();
foreach ( $record['codes'] as $index => $entry ) {
if ( ! is_array( $entry ) ) {
$record['codes'][ $index ] = array(
'hash' => '',
'used_at' => 0,
);
continue;
}
$record['codes'][ $index ]['hash'] = isset( $entry['hash'] ) && is_string( $entry['hash'] ) ? $entry['hash'] : '';
$record['codes'][ $index ]['used_at'] = isset( $entry['used_at'] ) ? (int) $entry['used_at'] : 0;
}
return $record;
}
/**
* Verify whether the provided recovery code matches a stored hash without consuming it.
*
* @param WP_User $user User attempting to validate a recovery code.
* @param string $code Submitted plain-text code.
*
* @return bool
*/
public function verify_code_without_consuming( WP_User $user, string $code ): bool {
$normalized_code = $this->normalize_code( $code );
if ( '' === $normalized_code ) {
return false;
}
$record = $this->get_stored_codes( $user->ID );
if ( empty( $record['codes'] ) ) {
return false;
}
foreach ( $record['codes'] as $entry ) {
if ( empty( $entry['hash'] ) ) {
continue;
}
if ( wp_check_password( $normalized_code, $entry['hash'] ) ) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,130 @@
<?php
/**
* Resolve two-factor configuration from global settings.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\User;
use Robotstxt\TwoFA\Admin\Settings_Page;
use Robotstxt\TwoFA\Frequency_Options;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Exposes helpers to read plugin-level two-factor configuration.
*/
class Two_Factor_Config {
/**
* Retrieve global two-factor settings with multisite support.
*
* @return array<string, mixed>
*/
public function get_settings(): array {
$settings = array();
if ( is_multisite() ) {
$network_settings = get_site_option( Settings_Page::OPTION_NAME, array() );
if ( is_array( $network_settings ) && ! empty( $network_settings ) ) {
$settings = $network_settings;
}
}
if ( empty( $settings ) ) {
$site_settings = get_option( Settings_Page::OPTION_NAME, array() );
if ( is_array( $site_settings ) ) {
$settings = $site_settings;
}
}
return wp_parse_args(
$settings,
array(
'force_roles' => array(),
'default_method' => 'email',
'frequency' => Frequency_Options::FREQUENCY_SESSION,
'force_frequency' => false,
)
);
}
/**
* Retrieve the list of roles that must enable two-factor authentication.
*
* @return array<int, string>
*/
public function get_forced_roles(): array {
$settings = $this->get_settings();
if ( empty( $settings['force_roles'] ) || ! is_array( $settings['force_roles'] ) ) {
return array();
}
return array_values(
array_filter(
array_map( 'sanitize_key', $settings['force_roles'] )
)
);
}
/**
* Determine if two-factor authentication is enforced for the provided user.
*
* @param \WP_User $user User object currently being authenticated.
*
* @return bool
*/
public function is_two_factor_forced_for_user( \WP_User $user ): bool {
$forced_roles = $this->get_forced_roles();
if ( empty( $forced_roles ) ) {
return false;
}
foreach ( $user->roles as $role ) {
if ( in_array( $role, $forced_roles, true ) ) {
return true;
}
}
return false;
}
/**
* Retrieve the default two-factor authentication method.
*
* @return string
*/
public function get_default_method(): string {
$settings = $this->get_settings();
return sanitize_key( $settings['default_method'] ?? 'email' );
}
/**
* Retrieve the globally configured verification frequency.
*
* @return string
*/
public function get_frequency(): string {
$settings = $this->get_settings();
return Frequency_Options::sanitize( (string) ( $settings['frequency'] ?? Frequency_Options::FREQUENCY_SESSION ) );
}
/**
* Determine whether the global frequency must be enforced for all users.
*
* @return bool
*/
public function is_frequency_forced(): bool {
$settings = $this->get_settings();
return ! empty( $settings['force_frequency'] );
}
}

View file

@ -0,0 +1,259 @@
<?php
/**
* Manage persistence of user two-factor settings.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\User;
use Robotstxt\TwoFA\Frequency_Options;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Provides CRUD helpers for user 2FA preferences and challenges.
*/
class User_Settings_Repository {
/**
* User meta key storing 2FA settings.
*
* @var string
*/
private const USER_SETTINGS_META = 'robotstxt_2fa_user_settings';
/**
* User meta key storing email challenges.
*
* @var string
*/
private const EMAIL_CHALLENGE_META = 'robotstxt_2fa_email_challenge';
/**
* User meta key storing last successful verifications per context.
*
* @var string
*/
private const LAST_VERIFICATIONS_META = 'robotstxt_2fa_last_verifications';
/**
* Maximum number of device contexts stored per verification method.
*/
private const CONTEXT_LIMIT = 20;
/**
* Retrieve stored 2FA settings for a user.
*
* @param int $user_id User identifier.
*
* @return array<string, mixed>
*/
public function get_user_settings( int $user_id ): array {
$raw_settings = get_user_meta( $user_id, self::USER_SETTINGS_META, true );
if ( ! is_array( $raw_settings ) ) {
$raw_settings = array();
}
$defaults = array(
'enabled' => false,
'methods' => array(),
'frequency' => Frequency_Options::FREQUENCY_SESSION,
);
$settings = wp_parse_args( $raw_settings, $defaults );
$settings['enabled'] = (bool) $settings['enabled'];
$settings['methods'] = array_values(
array_unique(
array_filter(
array_map( 'sanitize_key', (array) $settings['methods'] )
)
)
);
$settings['frequency'] = Frequency_Options::sanitize( (string) $settings['frequency'] );
return $settings;
}
/**
* Persist 2FA settings for a user.
*
* @param int $user_id User identifier.
* @param array $settings Settings to store.
*
* @return void
*/
public function save_user_settings( int $user_id, array $settings ): void {
$prepared = array(
'enabled' => ! empty( $settings['enabled'] ),
'methods' => array_values(
array_unique(
array_filter(
array_map( 'sanitize_key', $settings['methods'] ?? array() )
)
)
),
'frequency' => Frequency_Options::sanitize( (string) ( $settings['frequency'] ?? Frequency_Options::FREQUENCY_SESSION ) ),
);
update_user_meta( $user_id, self::USER_SETTINGS_META, $prepared );
if ( empty( $prepared['enabled'] ) ) {
$this->delete_email_challenge( $user_id );
$this->delete_last_verifications( $user_id );
}
}
/**
* Store email challenge details for a user.
*
* @param int $user_id User identifier.
* @param string $code Plain-text verification code.
* @param int $expires Expiration timestamp.
*
* @return void
*/
public function store_email_challenge( int $user_id, string $code, int $expires ): void {
$record = array(
'hash' => wp_hash_password( $code ),
'expires' => $expires,
'sent_at' => time(),
);
update_user_meta( $user_id, self::EMAIL_CHALLENGE_META, $record );
}
/**
* Retrieve email challenge details for a user.
*
* @param int $user_id User identifier.
*
* @return array<string, int|string>
*/
public function get_email_challenge( int $user_id ): array {
$record = get_user_meta( $user_id, self::EMAIL_CHALLENGE_META, true );
if ( ! is_array( $record ) ) {
$record = array();
}
$record = wp_parse_args(
$record,
array(
'hash' => '',
'expires' => 0,
'sent_at' => 0,
)
);
$record['hash'] = is_string( $record['hash'] ) ? $record['hash'] : '';
$record['expires'] = (int) $record['expires'];
$record['sent_at'] = (int) $record['sent_at'];
return $record;
}
/**
* Remove stored email challenge details.
*
* @param int $user_id User identifier.
*
* @return void
*/
public function delete_email_challenge( int $user_id ): void {
delete_user_meta( $user_id, self::EMAIL_CHALLENGE_META );
}
/**
* Record a successful verification timestamp for a method and device context.
*
* @param int $user_id User identifier.
* @param string $method Verification method identifier.
* @param string $context_key Normalized device context key.
* @param int $timestamp Verification timestamp.
*
* @return void
*/
public function record_last_verification( int $user_id, string $method, string $context_key, int $timestamp ): void {
$method = sanitize_key( $method );
$context_key = sanitize_text_field( $context_key );
$timestamp = max( 0, $timestamp );
if ( '' === $method || '' === $context_key || 0 === $timestamp ) {
return;
}
$record = get_user_meta( $user_id, self::LAST_VERIFICATIONS_META, true );
if ( ! is_array( $record ) ) {
$record = array();
}
if ( ! isset( $record[ $method ] ) || ! is_array( $record[ $method ] ) ) {
$record[ $method ] = array();
}
$record[ $method ][ $context_key ] = array(
'verified_at' => $timestamp,
);
if ( count( $record[ $method ] ) > self::CONTEXT_LIMIT ) {
uasort(
$record[ $method ],
static function ( array $a, array $b ): int {
return ( $a['verified_at'] ?? 0 ) <=> ( $b['verified_at'] ?? 0 );
}
);
$record[ $method ] = array_slice( $record[ $method ], -1 * self::CONTEXT_LIMIT, null, true );
}
update_user_meta( $user_id, self::LAST_VERIFICATIONS_META, $record );
}
/**
* Retrieve the last verification timestamp for a method and context.
*
* @param int $user_id User identifier.
* @param string $method Verification method identifier.
* @param string $context_key Normalized device context key.
*
* @return int
*/
public function get_last_verification_timestamp( int $user_id, string $method, string $context_key ): int {
$method = sanitize_key( $method );
$context_key = sanitize_text_field( $context_key );
if ( '' === $method || '' === $context_key ) {
return 0;
}
$record = get_user_meta( $user_id, self::LAST_VERIFICATIONS_META, true );
if ( ! is_array( $record ) || empty( $record[ $method ] ) || ! is_array( $record[ $method ] ) ) {
return 0;
}
if ( empty( $record[ $method ][ $context_key ]['verified_at'] ) ) {
return 0;
}
return (int) $record[ $method ][ $context_key ]['verified_at'];
}
/**
* Remove stored verification timestamps.
*
* @param int $user_id User identifier.
*
* @return void
*/
public function delete_last_verifications( int $user_id ): void {
delete_user_meta( $user_id, self::LAST_VERIFICATIONS_META );
}
}