diff --git a/.distignore b/.distignore index ffb1e0e4..cd7978e7 100644 --- a/.distignore +++ b/.distignore @@ -14,5 +14,6 @@ /package-lock.json /phpcs* /phpunit* +/phpstan.* /readme.md -/SECURITY.md \ No newline at end of file +/SECURITY.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c20d222e..c940d0db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,9 @@ jobs: - name: Lint PHP Compatibility run: composer lint-compat + - name: PHPStan + run: npm run lint:phpstan + test-php: name: Test PHP ${{ matrix.php }} ${{ matrix.wp != '' && format( ' (WP {0}) ', matrix.wp ) || '' }} runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6430f0ee..d36ae742 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /dist/ /tests/logs/ .phpunit.result.cache +phpstan.neon diff --git a/assets/screenshot-1.png b/assets/screenshot-1.png index 545b45ee..001fb2ae 100644 Binary files a/assets/screenshot-1.png and b/assets/screenshot-1.png differ diff --git a/assets/screenshot-2.png b/assets/screenshot-2.png index b9835800..9fb4f742 100644 Binary files a/assets/screenshot-2.png and b/assets/screenshot-2.png differ diff --git a/assets/screenshot-3.png b/assets/screenshot-3.png index a9810f87..b866bbb0 100644 Binary files a/assets/screenshot-3.png and b/assets/screenshot-3.png differ diff --git a/class-two-factor-compat.php b/class-two-factor-compat.php index 731a2dff..d7b4f46a 100644 --- a/class-two-factor-compat.php +++ b/class-two-factor-compat.php @@ -50,6 +50,6 @@ public function jetpack_rememberme( $rememberme ) { * @return boolean */ public function jetpack_is_sso_active() { - return ( method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'sso' ) ); + return ( class_exists( 'Jetpack' ) && method_exists( 'Jetpack', 'is_module_active' ) && Jetpack::is_module_active( 'sso' ) ); } } diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 51c3cee3..99033a70 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -93,7 +93,7 @@ class Two_Factor_Core { * @since 0.1-dev */ public static function add_hooks( $compat ) { - add_action( 'init', array( __CLASS__, 'get_providers' ) ); + add_action( 'init', array( __CLASS__, 'get_providers' ) ); // @phpstan-ignore return.void add_action( 'wp_login', array( __CLASS__, 'wp_login' ), 10, 2 ); add_filter( 'wp_login_errors', array( __CLASS__, 'maybe_show_reset_password_notice' ) ); add_action( 'after_password_reset', array( __CLASS__, 'clear_password_reset_notice' ) ); @@ -132,13 +132,67 @@ public static function add_hooks( $compat ) { } /** - * For each provider, include it and then instantiate it. + * Delete all plugin data on uninstall. * - * @since 0.1-dev + * @return void + */ + public static function uninstall() { + // Keep this updated as user meta keys are added or removed. + $user_meta_keys = array( + self::PROVIDER_USER_META_KEY, + self::ENABLED_PROVIDERS_USER_META_KEY, + self::USER_META_NONCE_KEY, + self::USER_RATE_LIMIT_KEY, + self::USER_FAILED_LOGIN_ATTEMPTS_KEY, + self::USER_PASSWORD_WAS_RESET_KEY, + ); + + $option_keys = array(); + + foreach ( self::get_providers_classes() as $provider_class ) { + // Merge with provider-specific user meta keys. + if ( method_exists( $provider_class, 'uninstall_user_meta_keys' ) ) { + try { + $user_meta_keys = array_merge( + $user_meta_keys, + call_user_func( array( $provider_class, 'uninstall_user_meta_keys' ) ) + ); + } catch ( Exception $e ) { + // Do nothing. + } + } + + // Merge with provider-specific option keys. + if ( method_exists( $provider_class, 'uninstall_options' ) ) { + try { + $option_keys = array_merge( + $option_keys, + call_user_func( array( $provider_class, 'uninstall_options' ) ) + ); + } catch ( Exception $e ) { + // Do nothing. + } + } + } + + // Delete options first since that is faster. + if ( ! empty( $option_keys ) ) { + foreach ( $option_keys as $option_key ) { + delete_option( $option_key ); + } + } + + foreach ( $user_meta_keys as $meta_key ) { + delete_metadata( 'user', null, $meta_key, null, true ); + } + } + + /** + * Get the registered providers of which some might not be enabled. * - * @return array + * @return array List of provider keys and paths to class files. */ - public static function get_providers() { + public static function get_providers_registered() { $providers = array( 'Two_Factor_Email' => TWO_FACTOR_DIR . 'providers/class-two-factor-email.php', 'Two_Factor_Totp' => TWO_FACTOR_DIR . 'providers/class-two-factor-totp.php', @@ -150,29 +204,29 @@ public static function get_providers() { /** * Filter the supplied providers. * - * This lets third-parties either remove providers (such as Email), or - * add their own providers (such as text message or Clef). - * * @param array $providers A key-value array where the key is the class name, and * the value is the path to the file containing the class. */ - $providers = apply_filters( 'two_factor_providers', $providers ); + $additional_providers = apply_filters( 'two_factor_providers', $providers ); - // FIDO U2F is PHP 5.3+ only. - if ( isset( $providers['Two_Factor_FIDO_U2F'] ) && version_compare( PHP_VERSION, '5.3.0', '<' ) ) { - unset( $providers['Two_Factor_FIDO_U2F'] ); - trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - sprintf( - /* translators: %s: version number */ - __( 'FIDO U2F is not available because you are using PHP %s. (Requires 5.3 or greater)', 'two-factor' ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - PHP_VERSION - ) - ); + // Merge them with the default providers. + if ( ! empty( $additional_providers ) ) { + return array_merge( $providers, $additional_providers ); } - /** - * For each filtered provider, - */ + return $providers; + } + + /** + * Get the classnames for all registered providers. + * + * Note some of these providers might not be enabled. + * + * @return array List of provider keys and classnames. + */ + private static function get_providers_classes() { + $providers = self::get_providers_registered(); + foreach ( $providers as $provider_key => $path ) { require_once $path; @@ -189,9 +243,56 @@ public static function get_providers() { /** * Confirm that it's been successfully included before instantiating. */ - if ( class_exists( $class ) ) { + if ( method_exists( $class, 'get_instance' ) ) { + $providers[ $provider_key ] = $class; + } else { + unset( $providers[ $provider_key ] ); + } + } + + return $providers; + } + + /** + * Get all enabled two-factor providers. + * + * @since 0.1-dev + * + * @return array + */ + public static function get_providers() { + $providers = self::get_providers_registered(); + + /** + * Filter the supplied providers. + * + * This lets third-parties either remove providers (such as Email), or + * add their own providers (such as text message or Clef). + * + * @param array $providers A key-value array where the key is the class name, and + * the value is the path to the file containing the class. + */ + $providers = apply_filters( 'two_factor_providers', $providers ); + + // FIDO U2F is PHP 5.3+ only. + if ( isset( $providers['Two_Factor_FIDO_U2F'] ) && version_compare( PHP_VERSION, '5.3.0', '<' ) ) { + unset( $providers['Two_Factor_FIDO_U2F'] ); + trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + sprintf( + /* translators: %s: version number */ + __( 'FIDO U2F is not available because you are using PHP %s. (Requires 5.3 or greater)', 'two-factor' ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + PHP_VERSION + ) + ); + } + + // Map provider keys to classes so that we can instantiate them. + $providers = array_intersect_key( self::get_providers_classes(), $providers ); + + foreach ( $providers as $provider_key => $provider_class ) { + if ( method_exists( $provider_class, 'get_instance' ) ) { try { - $providers[ $provider_key ] = call_user_func( array( $class, 'get_instance' ) ); + $providers[ $provider_key ] = call_user_func( array( $provider_class, 'get_instance' ) ); } catch ( Exception $e ) { unset( $providers[ $provider_key ] ); } diff --git a/composer.json b/composer.json index fe2389c7..2fe8e169 100644 --- a/composer.json +++ b/composer.json @@ -31,12 +31,14 @@ "phpcompatibility/phpcompatibility-wp": "^2.1", "phpunit/phpunit": "^8.5|^9.6", "spatie/phpunit-watcher": "^1.23", + "szepeviktor/phpstan-wordpress": "^1.3", "wp-coding-standards/wpcs": "^3.1", "yoast/phpunit-polyfills": "^2.0" }, "scripts": { "lint": "phpcs", "lint-compat": "phpcs -p --standard=PHPCompatibilityWP --runtime-set testVersion 7.2- --extensions=php --ignore='tests/,dist/,includes/Yubico/,vendor/,node_modules/' .", + "lint-phpstan": "phpstan analyse --verbose --memory-limit=1G", "test": "vendor/bin/phpunit", "test:watch": [ "Composer\\Config::disableProcessTimeout", diff --git a/composer.lock b/composer.lock index a4046e68..f1ecd991 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a1a44a4d12c7db29c2010274f4d2f36f", + "content-hash": "13824921a7208b7752f2a13ecde2ab08", "packages": [], "packages-dev": [ { @@ -1121,6 +1121,54 @@ }, "time": "2023-11-22T10:21:01+00:00" }, + { + "name": "php-stubs/wordpress-stubs", + "version": "v6.6.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-stubs.git", + "reference": "86e8753e89d59849276dcdd91b9a7dd78bb4abe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/86e8753e89d59849276dcdd91b9a7dd78bb4abe2", + "reference": "86e8753e89d59849276dcdd91b9a7dd78bb4abe2", + "shasum": "" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "nikic/php-parser": "^4.13", + "php": "^7.4 || ^8.0", + "php-stubs/generator": "^0.8.3", + "phpdocumentor/reflection-docblock": "^5.4.1", + "phpstan/phpstan": "^1.10.49", + "phpunit/phpunit": "^9.5", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.0", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wordpress-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-stubs/issues", + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.6.0" + }, + "time": "2024-07-17T08:50:38+00:00" + }, { "name": "phpcompatibility/php-compatibility", "version": "dev-develop", @@ -1519,6 +1567,64 @@ ], "time": "2024-05-20T13:34:27+00:00" }, + { + "name": "phpstan/phpstan", + "version": "1.12.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0fcbf194ab63d8159bb70d9aa3e1350051632009", + "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2024-09-09T08:10:35+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "7.0.17", @@ -4589,6 +4695,69 @@ ], "time": "2024-05-31T14:33:22+00:00" }, + { + "name": "szepeviktor/phpstan-wordpress", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/szepeviktor/phpstan-wordpress.git", + "reference": "7f8cfe992faa96b6a33bbd75c7bace98864161e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/7f8cfe992faa96b6a33bbd75c7bace98864161e7", + "reference": "7f8cfe992faa96b6a33bbd75c7bace98864161e7", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0", + "phpstan/phpstan": "^1.10.31", + "symfony/polyfill-php73": "^1.12.0" + }, + "require-dev": { + "composer/composer": "^2.1.14", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.1", + "phpstan/phpstan-strict-rules": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.0", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "swissspidy/phpstan-no-private": "Detect usage of internal core functions, classes and methods" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "SzepeViktor\\PHPStan\\WordPress\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress extensions for PHPStan", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", + "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v1.3.5" + }, + "time": "2024-06-28T22:27:19+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.3", diff --git a/package.json b/package.json index 7ee5b30a..894e834d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "grunt build", "lint": "npm-run-all lint:*", "lint:php": "composer lint", + "lint:phpstan": "composer lint-phpstan", "lint:css": "wp-scripts lint-style ./user-edit.css ./providers/css/", "lint:js": "wp-scripts lint-js ./Gruntfile.js ./providers/js/", "format": "npm-run-all format:*", diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 00000000..fc02e7c0 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,10 @@ +includes: + - vendor/szepeviktor/phpstan-wordpress/extension.neon +parameters: + level: 0 + paths: + - includes + - providers + - class-two-factor-compat.php + - class-two-factor-core.php + - two-factor.php diff --git a/providers/class-two-factor-backup-codes.php b/providers/class-two-factor-backup-codes.php index 6b017de4..12601d5c 100644 --- a/providers/class-two-factor-backup-codes.php +++ b/providers/class-two-factor-backup-codes.php @@ -399,4 +399,15 @@ public function delete_code( $user, $code_hashed ) { // Update the backup code master list. update_user_meta( $user->ID, self::BACKUP_CODES_META_KEY, $backup_codes ); } + + /** + * Return user meta keys to delete during plugin uninstall. + * + * @return array + */ + public static function uninstall_user_meta_keys() { + return array( + self::BACKUP_CODES_META_KEY, + ); + } } diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index 0ab3bc18..da7a7c60 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -351,4 +351,16 @@ public function user_options( $user ) { assertCount( 1, $admin_session_manager->get_all(), 'No admin sessions are present first' ); } + + /** + * Plugin uninstall removes all user meta. + * + * @covers Two_Factor_Core::uninstall + */ + public function test_uninstall_removes_user_meta() { + $user = self::factory()->user->create_and_get(); + + // Enable a provider for the user. + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); + + $this->assertContains( + 'Two_Factor_Totp', + Two_Factor_Core::get_enabled_providers_for_user( $user->ID ), + 'Sample provider was enabled' + ); + + Two_Factor_Core::uninstall(); + + $this->assertNotContains( + 'Two_Factor_Totp', + Two_Factor_Core::get_enabled_providers_for_user( $user->ID ), + 'Provider was disabled due to uninstall' + ); + } } diff --git a/two-factor.php b/two-factor.php index 20d76a20..537e61ee 100644 --- a/two-factor.php +++ b/two-factor.php @@ -3,7 +3,7 @@ * Two Factor * * @package Two_Factor - * @author Plugin Contributors + * @author WordPress.org Contributors * @copyright 2020 Plugin Contributors * @license GPL-2.0-or-later * @@ -14,7 +14,7 @@ * Version: 0.9.2 * Requires at least: 6.3 * Requires PHP: 7.2 - * Author: Plugin Contributors + * Author: WordPress.org Contributors * Author URI: https://github.com/wordpress/two-factor/graphs/contributors * License: GPL-2.0-or-later * License URI: https://spdx.org/licenses/GPL-2.0-or-later.html @@ -50,3 +50,6 @@ $two_factor_compat = new Two_Factor_Compat(); Two_Factor_Core::add_hooks( $two_factor_compat ); + +// Delete our options and user meta during uninstall. +register_uninstall_hook( __FILE__, array( Two_Factor_Core::class, 'uninstall' ) );