v0.2.0
This commit is contained in:
commit
f936aeb807
118 changed files with 15710 additions and 0 deletions
415
includes/admin/class-settings-page.php
Normal file
415
includes/admin/class-settings-page.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
110
includes/class-frequency-options.php
Normal file
110
includes/class-frequency-options.php
Normal 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
120
includes/class-plugin.php
Normal 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/' );
|
||||
}
|
||||
}
|
||||
149
includes/login/class-email-code-sender.php
Normal file
149
includes/login/class-email-code-sender.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
1170
includes/login/class-login-form-manager.php
Normal file
1170
includes/login/class-login-form-manager.php
Normal file
File diff suppressed because it is too large
Load diff
337
includes/user/class-otp-manager.php
Normal file
337
includes/user/class-otp-manager.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
1119
includes/user/class-profile-settings.php
Normal file
1119
includes/user/class-profile-settings.php
Normal file
File diff suppressed because it is too large
Load diff
454
includes/user/class-recovery-codes.php
Normal file
454
includes/user/class-recovery-codes.php
Normal 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;
|
||||
}
|
||||
}
|
||||
130
includes/user/class-two-factor-config.php
Normal file
130
includes/user/class-two-factor-config.php
Normal 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'] );
|
||||
}
|
||||
}
|
||||
259
includes/user/class-user-settings-repository.php
Normal file
259
includes/user/class-user-settings-repository.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue