diff --git a/.distignore b/.distignore index 72e50a2e..cd7978e7 100644 --- a/.distignore +++ b/.distignore @@ -1,15 +1,19 @@ -.* +# Directories +/node_modules/ /tests/ /vendor/ -/node_modules/ -/wordpress/ -/Gruntfile.js + +# Files +.* /composer.json /composer.lock +/docker-compose.yml +/Gruntfile.js +/npm-debug.log /package.json /package-lock.json -/npm-debug.log -/docker-compose.yml -/readme.md /phpcs* /phpunit* +/phpstan.* +/readme.md +/SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 00000000..63130134 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,72 @@ +name: "Bug report" +description: "Report a bug with the Two-Factor plugin for WordPress." +labels: "Bug" +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! Please fill in as much of the template below as you can. If this is a security issue, please report it in HackerOne instead: https://hackerone.com/wordpress. + - type: textarea + attributes: + label: Describe the bug + description: Please write a clear and concise description of the bug, including what you expect to happen and what is currently happening. + placeholder: | + Feature '...' is not working properly. I expect '...' to happen, but '...' happens instead. + validations: + required: true + + - type: textarea + attributes: + label: Steps to Reproduce + description: Please write the steps needed to reproduce the bug. + placeholder: | + 1. Go to '...'. + 2. Click on '...'. + 3. Scroll down to '...'. + 4. See error. + validations: + required: true + + - type: textarea + attributes: + label: Screenshots, screen recording, code snippet + description: | + If possible, please upload a screenshot or screen recording which demonstrates the bug. You can use LIEcap to create a GIF screen recording: https://www.cockos.com/licecap/ + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + For small snippets paste it directly here, or you can use GitHub Gist to share multiple code files: https://gist.github.com + Please ensure the shared code can be used by a developer to reproduce the issue—ideally it can be copied into a local development environment or executed in a browser console to help debug the issue + validations: + required: false + + - type: textarea + attributes: + label: Environment information + placeholder: | + - WordPress version and active Theme you are using. + - Browser(s) are you seeing the problem on. + - Device you are using and operating system (e.g. "Desktop with Windows 10", "iPhone with iOS 14", etc.). + validations: + required: false + + - type: dropdown + id: existing + attributes: + label: Please confirm that you have searched existing issues in this repository. + description: You can do this by searching https://github.com/WordPress/two-factor/issues and making sure the bug is not related to another plugin. + multiple: true + options: + - 'Yes' + - 'No' + validations: + required: true + + - type: dropdown + id: plugins + attributes: + label: Please confirm that you have tested with all plugins deactivated except Two-Factor. + multiple: true + options: + - 'Yes' + - 'No' + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/2-enhancement.yml b/.github/ISSUE_TEMPLATE/2-enhancement.yml new file mode 100644 index 00000000..4ad96ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-enhancement.yml @@ -0,0 +1,55 @@ +name: "Enhancement" +description: "Suggest an idea for a feature or enhancement to the Two-Factor plugin for WordPress." +labels: "type:enhancement" +body: + - type: markdown + attributes: + value: | + Thank you for suggesting an idea to make things better. Please fill in as much of the template below as you can. + - type: textarea + attributes: + label: Is your enhancement related to a problem? Please describe. + description: Please describe the problem you are trying to solve. + placeholder: | + "I'm always frustrated when ..." or "It is currently difficult to ...". + validations: + required: true + + - type: textarea + attributes: + label: Proposed Solution + description: | + Please outline the feature or enhancement that you want and how it addresses any problem identified above. + validations: + required: false + + - type: textarea + attributes: + label: Designs + description: | + If applicable, add mockups/screenshots/etc. to help explain your idea. + Tip: You can attach images or videos by clicking this area to highlight it and then dragging files in. + validations: + required: false + + - type: textarea + attributes: + label: Describe alternatives you've considered + description: | + Please describe alternative solutions or features you have considered. + placeholder: | + I have also considered `...describe alternative...`, however I feel that my solution described above is better because of `...reason...`. + validations: + required: false + + - type: dropdown + id: existing + attributes: + label: Please confirm that you have searched existing issues in this repository. + description: You can do this by searching https://github.com/WordPress/two-factor/issues and making sure the bug is not related to another plugin. + multiple: true + options: + - 'Yes' + - 'No' + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..4dcce3ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: General help request + url: https://wordpress.org/support/plugin/two-factor/ + about: For general help requests, create a new topic in the Two-Factor support forum on WordPress.org. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..9ffa6b31 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ + + +## What? + + +## Why? + + +## How? + + +## Testing Instructions + + +## Screenshots or screencast + + +## Changelog Entry + +> Added - New feature. +> Changed - Existing functionality. +> Deprecated - Soon-to-be removed feature. +> Removed - Feature. +> Fixed - Bug fix. +> Security - Vulnerability. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 495203af..7f964c13 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,7 @@ name: Deploy -# Run deploy only on tag and master builds. +# Run deploy only on published releases. on: - push: - branches: - - master release: types: [published] @@ -32,15 +29,25 @@ jobs: - name: Install NPM dependencies run: npm install - - name: Authenticate with WordPress.org SVN - run: svn info --non-interactive --username "${{ secrets.SVN_USERNAME }}" --password "${{ secrets.SVN_PASSWORD }}" https://plugins.svn.wordpress.org/two-factor/ + - name: Build plugin + run: npm run build - - name: Deploy to WordPress.org SVN + - name: WordPress Plugin Deploy + id: deploy + uses: 10up/action-wordpress-plugin-deploy@stable + with: + generate-zip: true + env: + BUILD_DIR: dist + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + + - name: Upload release asset + uses: actions/upload-release-asset@v1 env: - DEPLOY_SVN_USERNAME: ${{ secrets.SVN_USERNAME }} - DEPLOY_TRUNK: ${{ contains( github.ref_name, 'master' ) }} - DEPLOY_TAG: ${{ contains( github.ref_type, 'tag' ) }} - DEPLOY_SKIP_CONFIRMATION: true - # Disable deployments while they are failing for unknown reason. - if: env.DEPLOY_SVN_USERNAME && false - run: npm run deploy + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ${{ steps.deploy.outputs.zip-path }} + asset_name: ${{ github.event.repository.name }}.zip + asset_content_type: application/zip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4ea2a9d..c940d0db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,47 +2,145 @@ name: Test on: [push, pull_request] +# Cancels all previous workflow runs for pull requests that have not completed. +concurrency: + # The concurrency group contains the workflow name and the branch name for pull requests + # or the commit hash for any other events. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + jobs: + lint-js-css: + name: Lint JS & CSS + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + cache: 'npm' + node-version-file: '.nvmrc' + + - name: Install NPM dependencies + run: npm install + + - name: Lint JS + run: npm run lint:js + + - name: Lint CSS + run: npm run lint:css + + lint-php-and-compatibility: + name: Lint PHP & PHP Compatibility checks. + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP and Composer + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + cache: 'npm' + node-version-file: '.nvmrc' + + - name: Install NPM dependencies + run: npm install + + - name: Lint PHP + run: npm run lint:php || true # Ignore for now. + + - 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 + strategy: + matrix: + php: + - '8.3' + - '8.2' + - '8.1' + - '8.0' + - '7.4' + - '7.3' + - '7.2' + wp: + - latest + - trunk + - '6.3' + env: + WP_ENV_PHP_VERSION: ${{ matrix.php }} + WP_ENV_CORE: ${{ matrix.wp == 'trunk' && 'WordPress/WordPress' || format( 'https://wordpress.org/wordpress-{0}.zip', matrix.wp ) }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' - lint: - name: Lint and Test - runs-on: ubuntu-20.04 - steps: - - name: Checkout - uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v4 + with: + cache: 'npm' + node-version-file: '.nvmrc' - - name: Setup Node - uses: actions/setup-node@v3 - with: - cache: 'npm' - node-version-file: '.nvmrc' + - name: Install NPM dependencies + run: npm install - - name: Setup PHP and Composer - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - tools: composer:v2 + - name: Start the Docker testing environment + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + command: npm run env start - - name: Install NPM dependencies - run: npm install + - name: Composer install + run: | + rm composer.lock || true # We need to install fresh. + npm run composer install - - name: Lint JS - run: npm run lint:js + - name: Versions + run: | + npm run env run cli php -- -v + npm run env run cli wp core version - - name: Lint CSS - run: npm run lint:css + - name: Test + run: npm run test - - name: Lint PHP - run: npm run lint:php || true # Ignore for now. + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 - - name: Lint PHP8 - run: composer lint-php8 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' - - name: Start the Docker testing environment - run: npm run env start --xdebug=coverage + - name: Setup Node + uses: actions/setup-node@v4 + with: + cache: 'npm' + node-version-file: '.nvmrc' - - name: Test - run: npm run test + - name: Install NPM dependencies + run: npm install - - name: Build - run: npm run build + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index 7da52a0e..d36ae742 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /vendor/ /dist/ /tests/logs/ -/wordpress/ +.phpunit.result.cache +phpstan.neon diff --git a/.nvmrc b/.nvmrc index b6a7d89c..209e3ef4 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16 +20 diff --git a/.wp-env.json b/.wp-env.json index 0ee66b30..8e06478a 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,5 +1,15 @@ { - "core": "./wordpress", "phpVersion": "7.4", - "plugins": [ "." ] + "plugins": [ "." ], + "env": { + "tests": { + "config": { + "WP_DEBUG": true, + "WP_TESTS_EMAIL": "admin@example.org", + "WP_TESTS_DOMAIN": "example.org", + "WP_SITEURL": "https://example.org", + "WP_HOME": "https://example.org" + } + } + } } diff --git a/Gruntfile.js b/Gruntfile.js index e60363bf..9a58b436 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -11,16 +11,6 @@ module.exports = function( grunt ) { invert: true, } ); - /** - * Check if CLI input appears to indicate a truthy value. - * - * @param {string} input Value to check. - * @return {boolean} If value appears to be truthy. - */ - function isTruthy( input ) { - return ( '1' === input || 'true' === input ); - } - grunt.initConfig( { pkg: grunt.file.readJSON( 'package.json' ), @@ -47,23 +37,6 @@ module.exports = function( grunt ) { ], }, }, - - wp_deploy: { - options: { - plugin_slug: 'two-factor', - build_dir: '<%= dist_dir %>', - assets_dir: 'assets', - }, - wporg: { - options: { - skip_confirmation: isTruthy( process.env.DEPLOY_SKIP_CONFIRMATION ), - svn_user: process.env.DEPLOY_SVN_USERNAME, - deploy_tag: isTruthy( process.env.DEPLOY_TAG ), - deploy_trunk: isTruthy( process.env.DEPLOY_TRUNK ), - assets_dir: ( isTruthy( process.env.DEPLOY_TAG ) || isTruthy( process.env.DEPLOY_TRUNK ) ) ? 'assets' : null, - }, - }, - }, } ); grunt.registerTask( @@ -72,11 +45,4 @@ module.exports = function( grunt ) { 'copy', ] ); - - grunt.registerTask( - 'deploy', [ - 'build', - 'wp_deploy', - ] - ); }; diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8ca74346 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Reporting Security Issues + +The plugin contributors and WordPress community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. + +To report a security issue, please visit the [WordPress HackerOne](https://hackerone.com/wordpress) program. 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 ee15420a..f0204378 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -36,7 +36,28 @@ class Two_Factor_Core { const USER_META_NONCE_KEY = '_two_factor_nonce'; /** - * URL query paramater used for our custom actions. + * The user meta key to store the last failed timestamp. + * + * @type string + */ + const USER_RATE_LIMIT_KEY = '_two_factor_last_login_failure'; + + /** + * The user meta key to store the number of failed login attempts. + * + * @var string + */ + const USER_FAILED_LOGIN_ATTEMPTS_KEY = '_two_factor_failed_login_attempts'; + + /** + * The user meta key to store whether or not the password was reset. + * + * @var string + */ + const USER_PASSWORD_WAS_RESET_KEY = '_two_factor_password_was_reset'; + + /** + * URL query parameter used for our custom actions. * * @var string */ @@ -67,16 +88,18 @@ class Two_Factor_Core { /** * Set up filters and actions. * - * @param object $compat A compaitbility later for plugins. + * @param object $compat A compatibility layer for plugins. * * @since 0.1-dev */ public static function add_hooks( $compat ) { - add_action( 'plugins_loaded', array( __CLASS__, 'load_textdomain' ) ); - 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' ) ); add_action( 'login_form_validate_2fa', array( __CLASS__, 'login_form_validate_2fa' ) ); - add_action( 'login_form_backup_2fa', array( __CLASS__, 'backup_2fa' ) ); + add_action( 'login_form_revalidate_2fa', array( __CLASS__, 'login_form_revalidate_2fa' ) ); + add_action( 'show_user_profile', array( __CLASS__, 'user_two_factor_options' ) ); add_action( 'edit_user_profile', array( __CLASS__, 'user_two_factor_options' ) ); add_action( 'personal_options_update', array( __CLASS__, 'user_two_factor_options_update' ) ); @@ -100,6 +123,8 @@ public static function add_hooks( $compat ) { // Run as late as possible to prevent other plugins from unintentionally bypassing. add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate_block_cookies' ), PHP_INT_MAX ); + add_filter( 'attach_session_information', array( __CLASS__, 'filter_session_information' ), 10, 2 ); + add_action( 'admin_init', array( __CLASS__, 'trigger_user_settings_action' ) ); add_filter( 'two_factor_providers', array( __CLASS__, 'enable_dummy_method_for_debug' ) ); @@ -107,22 +132,67 @@ public static function add_hooks( $compat ) { } /** - * Loads the plugin's text domain. + * Delete all plugin data on uninstall. * - * Sites on WordPress 4.6+ benefit from just-in-time loading of translations. + * @return void */ - public static function load_textdomain() { - load_plugin_textdomain( 'two-factor' ); + 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 ); + } } /** - * For each provider, include it and then instantiate it. + * Get the registered providers of which some might not be enabled. * - * @since 0.1-dev - * - * @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', @@ -131,6 +201,68 @@ public static function get_providers() { 'Two_Factor_Dummy' => TWO_FACTOR_DIR . 'providers/class-two-factor-dummy.php', ); + /** + * Filter the supplied providers. + * + * @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. + */ + $additional_providers = apply_filters( 'two_factor_providers', $providers ); + + // Merge them with the default providers. + if ( ! empty( $additional_providers ) ) { + return array_merge( $providers, $additional_providers ); + } + + 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; + + $class = $provider_key; + + /** + * Filters the classname for a provider. The dynamic portion of the filter is the defined providers key. + * + * @param string $class The PHP Classname of the provider. + * @param string $path The provided provider path to be included. + */ + $class = apply_filters( "two_factor_provider_classname_{$provider_key}", $class, $path ); + + /** + * Confirm that it's been successfully included before instantiating. + */ + 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. * @@ -154,20 +286,15 @@ public static function get_providers() { ); } - /** - * For each filtered provider, - */ - foreach ( $providers as $class => $path ) { - include_once $path; + // Map provider keys to classes so that we can instantiate them. + $providers = array_intersect_key( self::get_providers_classes(), $providers ); - /** - * Confirm that it's been successfully included before instantiating. - */ - if ( class_exists( $class ) ) { + foreach ( $providers as $provider_key => $provider_class ) { + if ( method_exists( $provider_class, 'get_instance' ) ) { try { - $providers[ $class ] = call_user_func( array( $class, 'get_instance' ) ); + $providers[ $provider_key ] = call_user_func( array( $provider_class, 'get_instance' ) ); } catch ( Exception $e ) { - unset( $providers[ $class ] ); + unset( $providers[ $provider_key ] ); } } } @@ -210,17 +337,15 @@ protected static function is_wp_debug() { * @return string */ protected static function get_user_settings_page_url( $user_id ) { - $page = 'user-edit.php'; - if ( defined( 'IS_PROFILE_PAGE' ) && IS_PROFILE_PAGE ) { - $page = 'profile.php'; + return self_admin_url( 'profile.php' ); } return add_query_arg( array( 'user_id' => intval( $user_id ), ), - self_admin_url( $page ) + self_admin_url( 'user-edit.php' ) ); } @@ -245,6 +370,23 @@ public static function get_user_update_action_url( $user_id, $action ) { ); } + /** + * Get the two-factor revalidate URL. + * + * @param bool $interim If the URL should load the interim login iframe modal. + * @return string + */ + public static function get_user_two_factor_revalidate_url( $interim = false ) { + $args = array( + 'action' => 'revalidate_2fa', + ); + if ( $interim ) { + $args['interim-login'] = 1; + } + + return self::login_url( $args ); + } + /** * Check if a user action is valid. * @@ -254,7 +396,11 @@ public static function get_user_update_action_url( $user_id, $action ) { * @return boolean */ public static function is_valid_user_action( $user_id, $action ) { - $request_nonce = filter_input( INPUT_GET, self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG, FILTER_CALLBACK, array( 'options' => 'sanitize_key' ) ); + $request_nonce = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ) : ''; + + if ( ! $user_id || ! $action || ! $request_nonce ) { + return false; + } return wp_verify_nonce( $request_nonce, @@ -287,10 +433,10 @@ public static function current_user_being_edited() { * @return void */ public static function trigger_user_settings_action() { - $action = filter_input( INPUT_GET, self::USER_SETTINGS_ACTION_QUERY_VAR, FILTER_CALLBACK, array( 'options' => 'sanitize_key' ) ); + $action = isset( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) ? wp_unslash( $_REQUEST[ self::USER_SETTINGS_ACTION_QUERY_VAR ] ) : ''; $user_id = self::current_user_being_edited(); - if ( ! empty( $action ) && self::is_valid_user_action( $user_id, $action ) ) { + if ( self::is_valid_user_action( $user_id, $action ) ) { /** * This action is triggered when a valid Two Factor settings * action is detected and it passes the nonce validation. @@ -318,15 +464,39 @@ public static function collect_auth_cookie_tokens( $cookie ) { } } + /** + * Fetch the WP_User object for a provided input. + * + * @since 0.8.0 + * + * @param int|WP_User $user Optional. The WP_User or user ID. Defaults to current user. + * + * @return false|WP_User WP_User on success, false on failure. + */ + public static function fetch_user( $user = null ) { + if ( null === $user ) { + $user = wp_get_current_user(); + } elseif ( ! ( $user instanceof WP_User ) ) { + $user = get_user_by( 'id', $user ); + } + + if ( ! $user || ! $user->exists() ) { + return false; + } + + return $user; + } + /** * Get all Two-Factor Auth providers that are enabled for the specified|current user. * - * @param WP_User $user WP_User object of the logged-in user. + * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. * @return array */ public static function get_enabled_providers_for_user( $user = null ) { - if ( empty( $user ) || ! is_a( $user, 'WP_User' ) ) { - $user = wp_get_current_user(); + $user = self::fetch_user( $user ); + if ( ! $user ) { + return array(); } $providers = self::get_providers(); @@ -348,42 +518,80 @@ public static function get_enabled_providers_for_user( $user = null ) { /** * Get all Two-Factor Auth providers that are both enabled and configured for the specified|current user. * - * @param WP_User $user WP_User object of the logged-in user. + * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. * @return array */ public static function get_available_providers_for_user( $user = null ) { - if ( empty( $user ) || ! is_a( $user, 'WP_User' ) ) { - $user = wp_get_current_user(); + $user = self::fetch_user( $user ); + if ( ! $user ) { + return array(); } $providers = self::get_providers(); $enabled_providers = self::get_enabled_providers_for_user( $user ); $configured_providers = array(); - foreach ( $providers as $classname => $provider ) { - if ( in_array( $classname, $enabled_providers, true ) && $provider->is_available_for_user( $user ) ) { - $configured_providers[ $classname ] = $provider; + foreach ( $providers as $provider_key => $provider ) { + if ( in_array( $provider_key, $enabled_providers, true ) && $provider->is_available_for_user( $user ) ) { + $configured_providers[ $provider_key ] = $provider; } } return $configured_providers; } + /** + * Fetch the provider for the request based on the user preferences. + * + * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. + * @param null|string|object $preferred_provider Optional. The name of the provider, the provider, or empty. + * @return null|object The provider + */ + public static function get_provider_for_user( $user = null, $preferred_provider = null ) { + $user = self::fetch_user( $user ); + if ( ! $user ) { + return null; + } + + // If a specific provider instance is passed, process it just as the key. + if ( $preferred_provider && $preferred_provider instanceof Two_Factor_Provider ) { + $preferred_provider = $preferred_provider->get_key(); + } + + // Default to the currently logged in provider. + if ( ! $preferred_provider && get_current_user_id() === $user->ID ) { + $session = self::get_current_user_session(); + if ( ! empty( $session['two-factor-provider'] ) ) { + $preferred_provider = $session['two-factor-provider']; + } + } + + if ( is_string( $preferred_provider ) ) { + $providers = self::get_available_providers_for_user( $user ); + if ( isset( $providers[ $preferred_provider ] ) ) { + return $providers[ $preferred_provider ]; + } + } + + return self::get_primary_provider_for_user( $user ); + } + /** * Gets the Two-Factor Auth provider for the specified|current user. * * @since 0.1-dev * - * @param int $user_id Optional. User ID. Default is 'null'. + * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. * @return object|null */ - public static function get_primary_provider_for_user( $user_id = null ) { - if ( empty( $user_id ) || ! is_numeric( $user_id ) ) { - $user_id = get_current_user_id(); + public static function get_primary_provider_for_user( $user = null ) { + $user = self::fetch_user( $user ); + if ( ! $user ) { + return null; } $providers = self::get_providers(); - $available_providers = self::get_available_providers_for_user( get_userdata( $user_id ) ); + $available_providers = self::get_available_providers_for_user( $user ); // If there's only one available provider, force that to be the primary. if ( empty( $available_providers ) ) { @@ -391,7 +599,7 @@ public static function get_primary_provider_for_user( $user_id = null ) { } elseif ( 1 === count( $available_providers ) ) { $provider = key( $available_providers ); } else { - $provider = get_user_meta( $user_id, self::PROVIDER_USER_META_KEY, true ); + $provider = get_user_meta( $user->ID, self::PROVIDER_USER_META_KEY, true ); // If the provider specified isn't enabled, just grab the first one that is. if ( ! isset( $available_providers[ $provider ] ) ) { @@ -405,7 +613,7 @@ public static function get_primary_provider_for_user( $user_id = null ) { * @param string $provider The provider currently being used. * @param int $user_id The user ID. */ - $provider = apply_filters( 'two_factor_primary_provider_for_user', $provider, $user_id ); + $provider = apply_filters( 'two_factor_primary_provider_for_user', $provider, $user->ID ); if ( isset( $providers[ $provider ] ) ) { return $providers[ $provider ]; @@ -419,11 +627,11 @@ public static function get_primary_provider_for_user( $user_id = null ) { * * @since 0.1-dev * - * @param int $user_id Optional. User ID. Default is 'null'. + * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. * @return bool */ - public static function is_user_using_two_factor( $user_id = null ) { - $provider = self::get_primary_provider_for_user( $user_id ); + public static function is_user_using_two_factor( $user = null ) { + $provider = self::get_primary_provider_for_user( $user ); return ! empty( $provider ); } @@ -512,11 +720,11 @@ public static function filter_authenticate_block_cookies( $user ) { return $user; } - + /** * If the current user can login via API requests such as XML-RPC and REST. * - * @param integer $user_id User ID. + * @param integer $user_id User ID. * * @return boolean */ @@ -564,40 +772,82 @@ public static function show_two_factor_login( $user ) { } /** - * Display the Backup code 2fa screen. + * Displays a message informing the user that their account has had failed login attempts. * - * @since 0.1-dev + * @param WP_User $user WP_User object of the logged-in user. */ - public static function backup_2fa() { - $wp_auth_id = filter_input( INPUT_GET, 'wp-auth-id', FILTER_SANITIZE_NUMBER_INT ); - $nonce = filter_input( INPUT_GET, 'wp-auth-nonce', FILTER_CALLBACK, array( 'options' => 'sanitize_key' ) ); - $provider = filter_input( INPUT_GET, 'provider', FILTER_CALLBACK, array( 'options' => 'sanitize_text_field' ) ); + public static function maybe_show_last_login_failure_notice( $user ) { + $last_failed_two_factor_login = (int) get_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY, true ); + $failed_login_count = (int) get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ); + + if ( $last_failed_two_factor_login ) { + echo '
'; + } + } - if ( ! $wp_auth_id || ! $nonce || ! $provider ) { - return; + /** + * Show the password reset notice if the user's password was reset. + * + * They were also sent an email notification in `send_password_reset_email()`, but email sent from a typical + * web server is not reliable enough to trust completely. + * + * @param WP_Error $errors + */ + public static function maybe_show_reset_password_notice( $errors ) { + if ( 'incorrect_password' !== $errors->get_error_code() ) { + return $errors; } - $user = get_userdata( $wp_auth_id ); - if ( ! $user ) { - return; + if ( ! isset( $_POST['log'] ) ) { + return $errors; } - if ( true !== self::verify_login_nonce( $user->ID, $nonce ) ) { - wp_safe_redirect( home_url() ); - exit; + $user_name = sanitize_user( wp_unslash( $_POST['log'] ) ); + $attempted_user = get_user_by( 'login', $user_name ); + if ( ! $attempted_user && str_contains( $user_name, '@' ) ) { + $attempted_user = get_user_by( 'email', $user_name ); } - $providers = self::get_available_providers_for_user( $user ); - if ( isset( $providers[ $provider ] ) ) { - $provider = $providers[ $provider ]; - } else { - wp_die( esc_html__( 'Cheatin’ uh?', 'two-factor' ), 403 ); + if ( ! $attempted_user ) { + return $errors; } - $redirect_to = filter_input( INPUT_GET, 'redirect_to', FILTER_SANITIZE_URL ); - self::login_html( $user, $nonce, $redirect_to, '', $provider ); + $password_was_reset = get_user_meta( $attempted_user->ID, self::USER_PASSWORD_WAS_RESET_KEY, true ); - exit; + if ( ! $password_was_reset ) { + return $errors; + } + + $errors->remove( 'incorrect_password' ); + $errors->add( + 'two_factor_password_reset', + sprintf( + __( 'Your password was reset because of too many failed Two Factor attempts. You will need to create a new password to regain access. Please check your email for more information.', 'two-factor' ), + esc_url( add_query_arg( 'action', 'lostpassword', wp_login_url() ) ) + ) + ); + + return $errors; + } + + /** + * Clear the password reset notice after the user resets their password. + * + * @param WP_User $user + */ + public static function clear_password_reset_notice( $user ) { + delete_user_meta( $user->ID, self::USER_PASSWORD_WAS_RESET_KEY ); } /** @@ -611,35 +861,38 @@ public static function backup_2fa() { * @param string $error_msg Optional. Login error message. * @param string|object $provider An override to the provider. */ - public static function login_html( $user, $login_nonce, $redirect_to, $error_msg = '', $provider = null ) { - if ( empty( $provider ) ) { - $provider = self::get_primary_provider_for_user( $user->ID ); - } elseif ( is_string( $provider ) && method_exists( $provider, 'get_instance' ) ) { - $provider = call_user_func( array( $provider, 'get_instance' ) ); + public static function login_html( $user, $login_nonce, $redirect_to, $error_msg = '', $provider = null, $action = 'validate_2fa' ) { + $provider = self::get_provider_for_user( $user, $provider ); + if ( ! $provider ) { + wp_die( __( 'Cheatin’ uh?', 'two-factor' ) ); } - $provider_class = get_class( $provider ); - + $provider_key = $provider->get_key(); $available_providers = self::get_available_providers_for_user( $user ); - $backup_providers = array_diff_key( $available_providers, array( $provider_class => null ) ); + $backup_providers = array_diff_key( $available_providers, array( $provider_key => null ) ); $interim_login = isset( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $rememberme = intval( self::rememberme() ); if ( ! function_exists( 'login_header' ) ) { // We really should migrate login_header() out of `wp-login.php` so it can be called from an includes file. - include_once TWO_FACTOR_DIR . 'includes/function.login-header.php'; + require_once TWO_FACTOR_DIR . 'includes/function.login-header.php'; } + // Disable the language switcher. + add_filter( 'login_display_language_dropdown', '__return_false' ); + login_header(); if ( ! empty( $error_msg ) ) { echo '- - - +
+
-