453 lines
12 KiB
PHP
453 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* WP-CLI commands for the robotstxt-2FA plugin.
|
|
*
|
|
* This file is only loaded when WP_CLI is defined and truthy.
|
|
*
|
|
* @package Robotstxt_2FA
|
|
*/
|
|
|
|
namespace Robotstxt\TwoFA\Cli;
|
|
|
|
use Robotstxt\TwoFA\User\OTP_Manager;
|
|
use Robotstxt\TwoFA\User\Recovery_Codes;
|
|
use Robotstxt\TwoFA\User\Two_Factor_Config;
|
|
use Robotstxt\TwoFA\User\User_Settings_Repository;
|
|
use WP_CLI_Command;
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Manages two-factor authentication from the command line.
|
|
*
|
|
* @since 1.3.0
|
|
*/
|
|
class Cli_Command extends WP_CLI_Command {
|
|
/**
|
|
* User settings repository.
|
|
*
|
|
* @var User_Settings_Repository
|
|
*/
|
|
private User_Settings_Repository $repository;
|
|
|
|
/**
|
|
* Recovery codes manager.
|
|
*
|
|
* @var Recovery_Codes
|
|
*/
|
|
private Recovery_Codes $recovery_codes;
|
|
|
|
/**
|
|
* OTP manager.
|
|
*
|
|
* @var OTP_Manager
|
|
*/
|
|
private OTP_Manager $otp_manager;
|
|
|
|
/**
|
|
* Plugin configuration reader.
|
|
*
|
|
* @var Two_Factor_Config
|
|
*/
|
|
private Two_Factor_Config $config;
|
|
|
|
/**
|
|
* Constructor.
|
|
*/
|
|
public function __construct() {
|
|
$this->repository = new User_Settings_Repository();
|
|
$this->recovery_codes = new Recovery_Codes();
|
|
$this->otp_manager = new OTP_Manager();
|
|
$this->config = new Two_Factor_Config();
|
|
}
|
|
|
|
/**
|
|
* Show the 2FA configuration for a user.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* <user_id>
|
|
* : User ID to inspect.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp 2fa status 42
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param array<int, string> $args Positional arguments.
|
|
* @param array<string, string> $assoc_args Associative arguments.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function status( array $args, array $assoc_args ): void {
|
|
$user = $this->get_user( $args[0] ?? '' );
|
|
|
|
$settings = $this->repository->get_user_settings( $user->ID );
|
|
$required_methods = $this->config->get_required_methods_for_user( $user );
|
|
$unused_codes = $this->recovery_codes->count_unused_codes( $user->ID );
|
|
$has_otp = '' !== $this->otp_manager->get_secret( $user->ID );
|
|
|
|
\WP_CLI::line( 'User: ' . $user->user_login . ' (ID ' . $user->ID . ')' );
|
|
\WP_CLI::line( '2FA enabled: ' . ( $settings['enabled'] ? 'yes' : 'no' ) );
|
|
\WP_CLI::line( 'Methods: ' . ( ! empty( $settings['methods'] ) ? implode( ', ', $settings['methods'] ) : '(none)' ) );
|
|
\WP_CLI::line( 'Frequency: ' . ( '' !== $settings['frequency'] ? $settings['frequency'] : 'session' ) );
|
|
\WP_CLI::line( 'Forced by role: ' . ( ! empty( $required_methods ) ? 'yes (' . implode( ', ', $required_methods ) . ')' : 'no' ) );
|
|
\WP_CLI::line( 'OTP secret: ' . ( $has_otp ? 'set' : 'not set' ) );
|
|
\WP_CLI::line( 'Unused codes: ' . $unused_codes . ' / 10' );
|
|
}
|
|
|
|
/**
|
|
* Enable 2FA for a user and optionally set the active method.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* <user_id>
|
|
* : User ID to enable 2FA for.
|
|
*
|
|
* [--method=<method>]
|
|
* : Verification method to activate. Accepts: email, otp, recovery.
|
|
* Default: email.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp 2fa enable 42
|
|
* wp 2fa enable 42 --method=email
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param array<int, string> $args Positional arguments.
|
|
* @param array<string, string> $assoc_args Associative arguments.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function enable( array $args, array $assoc_args ): void {
|
|
$user = $this->get_user( $args[0] ?? '' );
|
|
$method = isset( $assoc_args['method'] ) ? sanitize_key( $assoc_args['method'] ) : 'email';
|
|
$allowed = array( 'email', 'otp', 'recovery' );
|
|
|
|
if ( ! in_array( $method, $allowed, true ) ) {
|
|
\WP_CLI::error( "Unknown method '{$method}'. Accepted values: email, otp, recovery." );
|
|
return;
|
|
}
|
|
|
|
if ( 'otp' === $method && '' === $this->otp_manager->get_secret( $user->ID ) ) {
|
|
\WP_CLI::error( 'OTP requires a confirmed secret. The user must set up an authenticator app first.' );
|
|
return;
|
|
}
|
|
|
|
if ( 'recovery' === $method && ! $this->recovery_codes->has_active_codes( $user->ID ) ) {
|
|
\WP_CLI::error( 'Recovery codes require a confirmed batch. Use `wp 2fa reset-recovery` to generate one.' );
|
|
return;
|
|
}
|
|
|
|
$settings = $this->repository->get_user_settings( $user->ID );
|
|
$settings['enabled'] = true;
|
|
|
|
if ( ! in_array( $method, $settings['methods'], true ) ) {
|
|
$settings['methods'][] = $method;
|
|
}
|
|
|
|
$this->repository->save_user_settings( $user->ID, $settings );
|
|
|
|
\WP_CLI::success( "2FA enabled for user {$user->ID} ({$user->user_login}) using {$method}." );
|
|
}
|
|
|
|
/**
|
|
* Disable 2FA for a user (preserves stored secrets and codes).
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* <user_id>
|
|
* : User ID to disable 2FA for.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp 2fa disable 42
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param array<int, string> $args Positional arguments.
|
|
* @param array<string, string> $assoc_args Associative arguments.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function disable( array $args, array $assoc_args ): void {
|
|
$user = $this->get_user( $args[0] ?? '' );
|
|
$settings = $this->repository->get_user_settings( $user->ID );
|
|
|
|
$settings['enabled'] = false;
|
|
$this->repository->save_user_settings( $user->ID, $settings );
|
|
|
|
\WP_CLI::success( "2FA disabled for user {$user->ID} ({$user->user_login}). Secrets and codes are preserved." );
|
|
}
|
|
|
|
/**
|
|
* Regenerate recovery codes for a user and display them once.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* <user_id>
|
|
* : User ID to regenerate codes for.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp 2fa reset-recovery 42
|
|
*
|
|
* @subcommand reset-recovery
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param array<int, string> $args Positional arguments.
|
|
* @param array<string, string> $assoc_args Associative arguments.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function reset_recovery( array $args, array $assoc_args ): void {
|
|
$user = $this->get_user( $args[0] ?? '' );
|
|
$result = $this->recovery_codes->regenerate_codes_for_user( $user );
|
|
|
|
\WP_CLI::warning( 'Store these codes securely. They will not be shown again.' );
|
|
\WP_CLI::line( '' );
|
|
\WP_CLI::line( "New recovery codes for user {$user->ID} ({$user->user_login}):" );
|
|
\WP_CLI::line( '' );
|
|
|
|
foreach ( $result['codes'] as $index => $code ) {
|
|
\WP_CLI::line( ' ' . str_pad( (string) ( $index + 1 ), 2, ' ', STR_PAD_LEFT ) . '. ' . $code );
|
|
}
|
|
|
|
\WP_CLI::line( '' );
|
|
}
|
|
|
|
/**
|
|
* List users and their 2FA status.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* [--role=<role>]
|
|
* : Filter by WordPress role.
|
|
*
|
|
* [--without-2fa]
|
|
* : Only show users who do not have 2FA enabled.
|
|
*
|
|
* [--format=<format>]
|
|
* : Output format. Accepts: table, csv, json. Default: table.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp 2fa list
|
|
* wp 2fa list --role=editor --without-2fa
|
|
* wp 2fa list --format=csv
|
|
*
|
|
* @subcommand list
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param array<int, string> $args Positional arguments.
|
|
* @param array<string, string> $assoc_args Associative arguments.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function list_users( array $args, array $assoc_args ): void {
|
|
$query_args = array( 'number' => -1 );
|
|
|
|
if ( isset( $assoc_args['role'] ) && '' !== $assoc_args['role'] ) {
|
|
$query_args['role'] = sanitize_key( $assoc_args['role'] );
|
|
}
|
|
|
|
$users = get_users( $query_args );
|
|
$format = isset( $assoc_args['format'] ) ? sanitize_key( $assoc_args['format'] ) : 'table';
|
|
$rows = array();
|
|
|
|
foreach ( $users as $user ) {
|
|
if ( ! $user instanceof \WP_User ) {
|
|
continue;
|
|
}
|
|
|
|
$settings = $this->repository->get_user_settings( $user->ID );
|
|
$required = $this->config->get_required_methods_for_user( $user );
|
|
$is_enabled = $settings['enabled'];
|
|
$is_forced = ! empty( $required );
|
|
|
|
if ( isset( $assoc_args['without-2fa'] ) && $is_enabled ) {
|
|
continue;
|
|
}
|
|
|
|
if ( $is_enabled ) {
|
|
$status = 'enabled (' . implode( ', ', $settings['methods'] ) . ')';
|
|
} elseif ( $is_forced ) {
|
|
$status = 'disabled (required)';
|
|
} else {
|
|
$status = 'disabled';
|
|
}
|
|
|
|
$rows[] = array(
|
|
'ID' => (string) $user->ID,
|
|
'Login' => $user->user_login,
|
|
'Email' => $user->user_email,
|
|
'2FA' => $status,
|
|
'Frequency' => '' !== $settings['frequency'] ? $settings['frequency'] : 'session',
|
|
);
|
|
}
|
|
|
|
if ( 'json' === $format ) {
|
|
$encoded = wp_json_encode( $rows );
|
|
\WP_CLI::line( is_string( $encoded ) ? $encoded : '[]' );
|
|
return;
|
|
}
|
|
|
|
if ( 'csv' === $format ) {
|
|
$headers = array( 'ID', 'Login', 'Email', '2FA', 'Frequency' );
|
|
\WP_CLI::line( implode( ',', array_map( 'addslashes', $headers ) ) );
|
|
foreach ( $rows as $row ) {
|
|
\WP_CLI::line( implode( ',', array_map( 'addslashes', array_values( $row ) ) ) );
|
|
}
|
|
return;
|
|
}
|
|
|
|
\WP_CLI\Utils\format_items( 'table', $rows, array( 'ID', 'Login', 'Email', '2FA', 'Frequency' ) );
|
|
}
|
|
|
|
/**
|
|
* Enforce 2FA for a specific user or all users of a role.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* [<user_id>]
|
|
* : User ID to enforce. Required when --role is not set.
|
|
*
|
|
* [--role=<role>]
|
|
* : WordPress role slug. When supplied, updates the global role setting
|
|
* to require email verification for that role.
|
|
*
|
|
* [--method=<method>]
|
|
* : Method to require when using --role. Default: email.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp 2fa force-setup 42
|
|
* wp 2fa force-setup --role=administrator
|
|
* wp 2fa force-setup --role=editor --method=otp
|
|
*
|
|
* @subcommand force-setup
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param array<int, string> $args Positional arguments.
|
|
* @param array<string, string> $assoc_args Associative arguments.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function force_setup( array $args, array $assoc_args ): void {
|
|
if ( isset( $assoc_args['role'] ) ) {
|
|
$role = sanitize_key( $assoc_args['role'] );
|
|
$method = isset( $assoc_args['method'] ) ? sanitize_key( $assoc_args['method'] ) : 'email';
|
|
|
|
if ( ! in_array( $method, array( 'email', 'otp', 'recovery' ), true ) ) {
|
|
\WP_CLI::error( "Unknown method '{$method}'." );
|
|
return;
|
|
}
|
|
|
|
$raw_settings = get_option( 'robotstxt_2fa_settings' );
|
|
$settings = is_array( $raw_settings ) ? $raw_settings : array();
|
|
|
|
if ( ! isset( $settings['role_methods'] ) || ! is_array( $settings['role_methods'] ) ) {
|
|
$settings['role_methods'] = array();
|
|
}
|
|
|
|
$settings['role_methods'][ $role ] = array( $method );
|
|
update_option( 'robotstxt_2fa_settings', $settings, false );
|
|
|
|
if ( is_multisite() ) {
|
|
update_site_option( 'robotstxt_2fa_settings', $settings );
|
|
}
|
|
|
|
\WP_CLI::success( "2FA enforced for role '{$role}' ({$method} required)." );
|
|
return;
|
|
}
|
|
|
|
$user = $this->get_user( $args[0] ?? '' );
|
|
$settings = $this->repository->get_user_settings( $user->ID );
|
|
|
|
$settings['enabled'] = true;
|
|
|
|
if ( empty( $settings['methods'] ) ) {
|
|
$settings['methods'] = array( 'email' );
|
|
}
|
|
|
|
$this->repository->save_user_settings( $user->ID, $settings );
|
|
|
|
\WP_CLI::success( "2FA enabled for user {$user->ID} ({$user->user_login})." );
|
|
}
|
|
|
|
/**
|
|
* Grant a temporary bypass that skips the 2FA challenge for a user.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* <user_id>
|
|
* : User ID to grant the bypass for.
|
|
*
|
|
* [--days=<n>]
|
|
* : Number of days the bypass is valid. Maximum 30. Default: 1.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp 2fa bypass 42
|
|
* wp 2fa bypass 42 --days=7
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param array<int, string> $args Positional arguments.
|
|
* @param array<string, string> $assoc_args Associative arguments.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function bypass( array $args, array $assoc_args ): void {
|
|
$user = $this->get_user( $args[0] ?? '' );
|
|
$days = isset( $assoc_args['days'] ) && is_numeric( $assoc_args['days'] )
|
|
? max( 1, min( 30, (int) $assoc_args['days'] ) )
|
|
: 1;
|
|
|
|
$expires = time() + $days * DAY_IN_SECONDS;
|
|
set_transient( 'robotstxt_2fa_bypass_' . $user->ID, $expires, $days * DAY_IN_SECONDS );
|
|
|
|
$until = gmdate( 'Y-m-d H:i:s', $expires ) . ' UTC';
|
|
|
|
\WP_CLI::success( "2FA bypass granted for user {$user->ID} ({$user->user_login}) until {$until}." );
|
|
\WP_CLI::warning( 'Remember to allow the bypass to expire or revoke it once access is restored.' );
|
|
}
|
|
|
|
/**
|
|
* Resolve a user ID or login string to a WP_User object.
|
|
*
|
|
* Halts with a WP-CLI error when the user cannot be found.
|
|
*
|
|
* @since 1.3.0
|
|
*
|
|
* @param string $identifier User ID or login name.
|
|
*
|
|
* @return \WP_User
|
|
*/
|
|
private function get_user( string $identifier ): \WP_User {
|
|
$identifier = sanitize_text_field( $identifier );
|
|
|
|
if ( '' === $identifier ) {
|
|
\WP_CLI::error( 'Please provide a user ID.' );
|
|
exit( 1 ); // Unreachable; satisfies static analysis.
|
|
}
|
|
|
|
$user = is_numeric( $identifier )
|
|
? get_user_by( 'ID', (int) $identifier )
|
|
: get_user_by( 'login', $identifier );
|
|
|
|
if ( ! $user instanceof \WP_User ) {
|
|
\WP_CLI::error( "User '{$identifier}' not found." );
|
|
exit( 1 ); // Unreachable; satisfies static analysis.
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
}
|