diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8806e58b6..8b727df20 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,16 +15,16 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' node-version-file: '.nvmrc' - name: Setup PHP and Composer - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: '5.6' tools: composer:v2 @@ -36,7 +36,7 @@ jobs: run: npm run build - name: WordPress.org asset and readme update - uses: 10up/action-wordpress-plugin-asset-update@stable + uses: 10up/action-wordpress-plugin-asset-update@2480306f6f693672726d08b5917ea114cb2825f7 # v2.2.0 if: github.ref_name == 'trunk' env: # Note: this action doesn't support BUILD_DIR so it pushes the raw readme.txt @@ -46,7 +46,7 @@ jobs: - name: WordPress.org deploy id: deploy - uses: 10up/action-wordpress-plugin-deploy@stable + uses: 10up/action-wordpress-plugin-asset-update@2480306f6f693672726d08b5917ea114cb2825f7 # v2.2.0 if: startsWith( github.ref, 'refs/tags/' ) with: generate-zip: true @@ -57,7 +57,7 @@ jobs: - name: Upload release asset - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 if: startsWith( github.ref, 'refs/tags/' ) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/props-bot.yml b/.github/workflows/props-bot.yml index 671f39928..0adf5eb2e 100644 --- a/.github/workflows/props-bot.yml +++ b/.github/workflows/props-bot.yml @@ -71,7 +71,7 @@ jobs: steps: - name: Gather a list of contributors - uses: WordPress/props-bot-action@trunk + uses: WordPress/props-bot-action@992186595bc18334988a431c317237c48b7711a5 # v1.0.0 with: format: 'git' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63d91e020..1602a8901 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' node-version-file: '.nvmrc' @@ -37,15 +37,15 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup PHP and Composer - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: '8.3' - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' node-version-file: '.nvmrc' @@ -94,15 +94,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: '8.3' - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' node-version-file: '.nvmrc' @@ -111,7 +111,7 @@ jobs: run: npm install - name: Start the Docker testing environment - uses: nick-fields/retry@v3 + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 with: timeout_minutes: 10 max_attempts: 3 @@ -135,15 +135,15 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: '8.3' - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' node-version-file: '.nvmrc' diff --git a/.wordpress-org/blueprints/blueprint.json b/.wordpress-org/blueprints/blueprint.json index 59aa3c072..de6d8cab7 100644 --- a/.wordpress-org/blueprints/blueprint.json +++ b/.wordpress-org/blueprints/blueprint.json @@ -1,6 +1,6 @@ { "$schema": "https://playground.wordpress.net/blueprint-schema.json", - "landingPage": "/wp-admin/profile.php#application-passwords-section", + "landingPage": "/wp-admin/profile.php#two-factor-options", "preferredVersions": { "php": "7.4", "wp": "latest" diff --git a/.wordpress-org/screenshot-1.png b/.wordpress-org/screenshot-1.png index 031d6804f..6d3f5774e 100644 Binary files a/.wordpress-org/screenshot-1.png and b/.wordpress-org/screenshot-1.png differ diff --git a/.wordpress-org/screenshot-2.png b/.wordpress-org/screenshot-2.png index 4cc885b3a..fea2c9aa5 100644 Binary files a/.wordpress-org/screenshot-2.png and b/.wordpress-org/screenshot-2.png differ diff --git a/.wordpress-org/screenshot-3.png b/.wordpress-org/screenshot-3.png index e7404018a..92ce4e1d2 100644 Binary files a/.wordpress-org/screenshot-3.png and b/.wordpress-org/screenshot-3.png differ diff --git a/.wordpress-org/screenshot-4.png b/.wordpress-org/screenshot-4.png index 52424bece..8d5421774 100644 Binary files a/.wordpress-org/screenshot-4.png and b/.wordpress-org/screenshot-4.png differ diff --git a/.wordpress-org/screenshot-5.png b/.wordpress-org/screenshot-5.png deleted file mode 100644 index 211e2d9dc..000000000 Binary files a/.wordpress-org/screenshot-5.png and /dev/null differ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4b2cc1de3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,109 @@ +# AI Instructions + +Two-Factor is a WordPress plugin, potentially eventually merging into WordPress Core, that provides Multi-Factor Authentication for WordPress interactive logins. It is network-enabled and can be activated across a WordPress multisite network. + +## Development Environment + +Requires Docker. Uses `@wordpress/env` to run a local WordPress install in containers. + +```bash +npm install +npm run build +npm run env start +``` + +For code coverage support: `npm run env start -- --xdebug=coverage` + +`npm test` and `npm run composer` are wrappers that execute commands inside the `tests-cli` wp-env container at the plugin path. Tests must be run through these wrappers, not directly with `phpunit`. + +## Commands + +### Testing + +@TESTS.md + +### Linting & Static Analysis + +```bash +npm run lint # all linters (PHP, CSS, JS) +npm run lint:php # PHPCS with WordPress + VIP-Go standards +npm run lint:phpstan # PHPStan static analysis (level 0) +npm run lint:css # wp-scripts lint-style +npm run lint:js # wp-scripts lint-js +npm run format # auto-fix PHPCS and JS/CSS issues +``` + +### Build + +```bash +npm run build +``` + +The Grunt build copies all distributable files to `dist/` (respecting `.distignore`) and copies `node_modules/qrcode-generator/qrcode.js` into `dist/includes/`. The `qrcode-generator` package is a **runtime JS dependency** — it is not present in `includes/` in the source tree and must be built before the plugin is usable in a browser context. Always run `npm run build` after a fresh checkout. + +## Architecture + +The plugin follows a provider pattern. `Two_Factor_Core` owns the login interception and orchestration; individual providers handle their own credential prompts and validation. + +### Core Files + +- **`two-factor.php`** — Entry point. Defines `TWO_FACTOR_DIR` and `TWO_FACTOR_VERSION`, loads all core files, instantiates `Two_Factor_Compat`, and calls `Two_Factor_Core::add_hooks()`. +- **`class-two-factor-core.php`** — Central class. Owns the login flow, user meta, nonce management, rate limiting, session tracking, REST API endpoints, and the user profile settings UI. +- **`class-two-factor-compat.php`** — Compatibility shims for third-party plugins (currently: Jetpack SSO). New integrations go here; the goal is to avoid any plugin-specific logic outside this file. +- **`providers/class-two-factor-provider.php`** — Abstract base class all providers extend. Defines the required interface: `get_label()`, `is_available_for_user()`, `authentication_page()`, `validate_authentication()`, and optional hooks for REST routes, settings UI, and uninstall cleanup. +- **`providers/`** — Concrete providers: `class-two-factor-totp.php`, `class-two-factor-email.php`, `class-two-factor-backup-codes.php`, `class-two-factor-dummy.php`. +- **`includes/`** — Custom `login_header()` and `login_footer()` template functions that replace the WordPress core versions with additional filter hooks. Excluded from PHPCS because they intentionally deviate from core function signatures. Do not modify files in includes/ directly. They are intentionally kept close to WordPress core function signatures to ease future merging into Core. Any functional changes should go through the filter hooks they expose instead. +- **`tests/`** — PHPUnit tests. See [TESTS.md](TESTS.md). + +### Login Flow + +1. User submits username/password. +2. `Two_Factor_Core::filter_authenticate()` runs at priority **31** on the `authenticate` filter (one above WP core's 30). If 2FA is required, it intercepts the `WP_User` object to prevent WP from issuing auth cookies. +3. `Two_Factor_Core::wp_login()` runs at priority `PHP_INT_MAX` on `wp_login`, renders the 2FA prompt, and exits. +4. On 2FA form submission, `login_form_validate_2fa` action handles validation and issues the final auth cookie only if the second factor passes. + +Auth cookies set during the password phase are tracked via `collect_auth_cookie_tokens` and invalidated before the 2FA step. + +### Provider Registration + +Providers are registered via the `two_factor_providers` filter, which receives and returns an array of the form: + +```php +array( 'Class_Name' => '/absolute/path/to/class-file.php' ) +``` + +The key (class name) is what gets stored in user meta. A per-provider `two_factor_provider_classname_{$provider_key}` filter allows swapping a provider's implementing class without changing its key. Use `two_factor_providers_for_user` to control which providers are available to a specific user. + +**The `Two_Factor_Dummy` provider is only available when `WP_DEBUG` is `true`.** It is removed at runtime by `enable_dummy_method_for_debug()` in all other environments. If a dummy provider isn't appearing, check `WP_DEBUG`. + +### Provider Self-Registration Pattern + +Each concrete provider registers its own hooks in its constructor: + +- REST routes → `rest_api_init` +- Assets → `admin_enqueue_scripts`, `wp_enqueue_scripts` +- User profile UI section → `two_factor_user_options_{ClassName}` action + +New providers should follow this pattern rather than registering hooks from outside the class. + +### Key User Meta (constants on `Two_Factor_Core`) + +| Constant | Meta Key | Purpose | +|---|---|---| +| `PROVIDER_USER_META_KEY` | `_two_factor_provider` | Active provider class name | +| `ENABLED_PROVIDERS_USER_META_KEY` | `_two_factor_enabled_providers` | Array of enabled provider class names | +| `USER_META_NONCE_KEY` | `_two_factor_nonce` | Login nonce | +| `USER_RATE_LIMIT_KEY` | `_two_factor_last_login_failure` | Rate limiting timestamp | +| `USER_FAILED_LOGIN_ATTEMPTS_KEY` | `_two_factor_failed_login_attempts` | Failed attempt count | +| `USER_PASSWORD_WAS_RESET_KEY` | `_two_factor_password_was_reset` | Flags compromised-password reset | + +### REST API + +Namespace: `two-factor/1.0` (constant `Two_Factor_Core::REST_NAMESPACE`). Each provider that exposes REST endpoints registers its own routes in `register_rest_routes()` called from its constructor. + +## Code Standards + +- PHP 7.2+ compatibility required; enforced by PHPCompatibilityWP. +- Follows WordPress coding standards (WPCS) and WordPress-VIP-Go rules. +- `includes/` is excluded from PHPCS — those files intentionally override core functions. +- The codebase does not fully pass all PHPCS checks (known issue [#437](https://github.com/WordPress/two-factor/issues/437)). Do not treat existing violations as license to introduce new ones. diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ea82798..1b99d7332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,62 @@ All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/), and will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - TBD +## [0.15.0] - 2026-02-13 + +### Breaking Changes + +- Trigger two-factor flow only when expected by @kasparsd in [#660](https://github.com/WordPress/two-factor/pull/660) and [#793](https://github.com/WordPress/two-factor/pull/793). + +### New Features + +- Include user IP address and contextual warning in two-factor code emails by @todeveni in [#728](https://github.com/WordPress/two-factor/pull/728) +- Consistent user experience for TOTP setup by @kasparsd in [#792](https://github.com/WordPress/two-factor/pull/792) +- Optimize email text for TOTP by @masteradhoc in [#789](https://github.com/WordPress/two-factor/pull/789) +- Add "Settings" action link to plugin list for quick access to profile by @hardikRathi in [#740](https://github.com/WordPress/two-factor/pull/740) +- Additional form hooks by @eric-michel in [#742](https://github.com/WordPress/two-factor/pull/742) +- Full RFC6238 Compatibility by @ericmann in [#656](https://github.com/WordPress/two-factor/pull/656) + +### Documentation + +- `@since` docs by @masteradhoc in [#781](https://github.com/WordPress/two-factor/pull/781) +- Update user and admin docs, prepare for more screenshots by @jeffpaul in [#701](https://github.com/WordPress/two-factor/pull/701) +- Add changelog & credits, update release notes by @jeffpaul in [#696](https://github.com/WordPress/two-factor/pull/696) +- Clear readme.txt by @masteradhoc in [#785](https://github.com/WordPress/two-factor/pull/785) +- Add date and time information above TOTP setup instructions by @masteradhoc in [#772](https://github.com/WordPress/two-factor/pull/772) +- Clarify TOTP setup instructions by @masteradhoc in [#763](https://github.com/WordPress/two-factor/pull/763) +- Update RELEASING.md by @jeffpaul in [#787](https://github.com/WordPress/two-factor/pull/787) + +### Development Updates + +- Pause deploys to SVN trunk for merges to `master` by @kasparsd in [#738](https://github.com/WordPress/two-factor/pull/738) +- Fix CI checks for PHP compatability by @kasparsd in [#739](https://github.com/WordPress/two-factor/pull/739) +- Fix Playground refs by @kasparsd in [#744](https://github.com/WordPress/two-factor/pull/744) +- Persist existing translations when introducing new helper text in emails by @kasparsd in [#745](https://github.com/WordPress/two-factor/pull/745) +- Fix `missing_direct_file_access_protection` by @masteradhoc in [#760](https://github.com/WordPress/two-factor/pull/760) +- Fix `mismatched_plugin_name` by @masteradhoc in [#754](https://github.com/WordPress/two-factor/pull/754) +- Introduce Props Bot workflow by @jeffpaul in [#749](https://github.com/WordPress/two-factor/pull/749) +- Plugin Check: Fix Missing $domain parameter by @masteradhoc in [#753](https://github.com/WordPress/two-factor/pull/753) +- Tests: Update to supported WP version 6.8 by @masteradhoc in [#770](https://github.com/WordPress/two-factor/pull/770) +- Fix PHP 8.5 deprecated message by @masteradhoc in [#762](https://github.com/WordPress/two-factor/pull/762) +- Exclude 7.2 and 7.3 checks against trunk by @masteradhoc in [#769](https://github.com/WordPress/two-factor/pull/769) +- Fix Plugin Check errors: `MissingTranslatorsComment` & `MissingSingularPlaceholder` by @masteradhoc in [#758](https://github.com/WordPress/two-factor/pull/758) +- Add PHP 8.5 tests for latest and trunk version of WP by @masteradhoc in [#771](https://github.com/WordPress/two-factor/pull/771) +- Add `phpcs:ignore` for falsepositives by @masteradhoc in [#777](https://github.com/WordPress/two-factor/pull/777) +- Fix(totp): `otpauth` link in QR code URL by @sjinks in [#784](https://github.com/WordPress/two-factor/pull/784) +- Update deploy.yml by @masteradhoc in [#773](https://github.com/WordPress/two-factor/pull/773) +- Update required WordPress Version by @masteradhoc in [#765](https://github.com/WordPress/two-factor/pull/765) +- Fix: ensure execution stops after redirects by @sjinks in [#786](https://github.com/WordPress/two-factor/pull/786) +- Fix `WordPress.Security.EscapeOutput.OutputNotEscaped` errors by @masteradhoc in [#776](https://github.com/WordPress/two-factor/pull/776) + +### Dependency Updates + +- Bump qs and express by @dependabot[bot] in [#746](https://github.com/WordPress/two-factor/pull/746) +- Bump lodash from 4.17.21 to 4.17.23 by @dependabot[bot] in [#750](https://github.com/WordPress/two-factor/pull/750) +- Bump lodash-es from 4.17.21 to 4.17.23 by @dependabot[bot] in [#748](https://github.com/WordPress/two-factor/pull/748) +- Bump phpunit/phpunit from 8.5.44 to 8.5.52 by @dependabot[bot] in [#755](https://github.com/WordPress/two-factor/pull/755) +- Bump symfony/process from 5.4.47 to 5.4.51 by @dependabot[bot] in [#756](https://github.com/WordPress/two-factor/pull/756) +- Bump qs and body-parser by @dependabot[bot] in [#782](https://github.com/WordPress/two-factor/pull/782) +- Bump webpack from 5.101.3 to 5.105.0 by @dependabot[bot] in [#780](https://github.com/WordPress/two-factor/pull/780) ## [0.14.2] - 2025-12-11 ### New Features @@ -220,9 +275,9 @@ All notable changes to this project will be documented in this file, per [the Ke ## [0.2.0] - 2018-10-16 - Add developer tools for deploying to WP.org manually. -[Unreleased]: https://github.com/WordPress/two-factor/compare/master...develop -[0.14.0]: https://github.com/WordPress/two-factor/compare/0.14.1...0.14.2 -[0.14.0]: https://github.com/WordPress/two-factor/compare/0.14.0...0.14.1 +[0.15.0]: https://github.com/WordPress/two-factor/compare/0.14.1...0.15.0 +[0.14.2]: https://github.com/WordPress/two-factor/compare/0.14.1...0.14.2 +[0.14.1]: https://github.com/WordPress/two-factor/compare/0.14.0...0.14.1 [0.14.0]: https://github.com/WordPress/two-factor/compare/0.13.0...0.14.0 [0.13.0]: https://github.com/WordPress/two-factor/compare/0.12.0...0.13.0 [0.12.0]: https://github.com/WordPress/two-factor/compare/0.11.0...0.12.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CREDITS.md b/CREDITS.md index fa3febb87..317130110 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -13,7 +13,7 @@ The following individuals are responsible for curating the list of issues, respo Thank you to all the people who have already contributed to this repository via bug reports, code, design, ideas, project management, translation, testing, etc. -[George Stephanis (@georgestephanis)](https://github.com/georgestephanis), [Kaspars Dambis (@kasparsd)](https://github.com/kasparsd), [Dion Hulse (@dd32)](https://github.com/dd32), [853 (@Sonic853)](https://github.com/Sonic853), [Aaron Campbell (@aaroncampbell)](https://github.com/aaroncampbell), [Alexandru Apostol (@aapost0l)](https://github.com/aapost0l), [Ali Husnain (@alihusnainarshad)](https://github.com/alihusnainarshad), [Anton Vanyukov (@av3nger)](https://github.com/av3nger), [Arslan Kalwar (@akkspros)](https://github.com/akkspros), [Axel Simon (@axelsimon)](https://github.com/axelsimon), [Birgit Pauli-Haack (@bph)](https://github.com/bph), [Brooke. (@BrookeDot)](https://github.com/BrookeDot), [Calvin Alkan (@calvinalkan)](https://github.com/calvinalkan), [Carlos Faria (@cfaria)](https://github.com/cfaria), [Christian Chung (@christianc1)](https://github.com/christianc1), [Clayton Collie (@claytoncollie)](https://github.com/claytoncollie), [Connor Jennings (@cojennin)](https://github.com/cojennin), [Daisuke Takahashi (@shield-9)](https://github.com/shield-9), [Derek Herman (@valendesigns)](https://github.com/valendesigns), [fossyatra (@netweb)](https://github.com/netweb), [Jeffrey Paul (@jeffpaul)](https://github.com/jeffpaul), [John Blackbourn (@johnbillion)](https://github.com/johnbillion), [John James Jacoby (@JJJ)](https://github.com/JJJ), [Josh Betz (@joshbetz)](https://github.com/joshbetz), [Kurt Zenisek (@KZeni)](https://github.com/KZeni), [Ian Dunn (@iandunn)](https://github.com/iandunn), [Mario Hoyos (@squaredpx)](https://github.com/squaredpx), [Mathesh (@Mati02K)](https://github.com/Mati02K), [Mehul Gohil (@mehul0810)](https://github.com/mehul0810), [Nauris Pūķis (@pyronaur)](https://github.com/pyronaur), [Neil Batchelor (@nbwpuk)](https://github.com/nbwpuk), [Ole Melhus (@omelhus)](https://github.com/omelhus), [Pascal Birchler (@swissspidy)](https://github.com/swissspidy), [Paul Kevan (@pkevan)](https://github.com/pkevan), [Paul Schreiber (@paulschreiber)](https://github.com/paulschreiber), [r-a-y (@r-a-y)](https://github.com/r-a-y), [Sergey Jinks (@sjinks)](https://github.com/sjinks), [Scott Grant (@scotchfield)](https://github.com/scotchfield), [Shai Sapphire (@shay1383)](https://github.com/shay1383), [Spenser Hale (@spenserhale)](https://github.com/spenserhale), [Stefan Momm (@stefanmomm)](https://github.com/stefanmomm), [Steve Grunwell (@stevegrunwell)](https://github.com/stevegrunwell), [Steven Word (@stevenkword)](https://github.com/stevenkword), [Thrijith Thankachan (@thrijith)](https://github.com/thrijith), [Tomasz Dziuda (@dziudek)](https://github.com/dziudek), [Toni Viemerö (@todeveni)](https://github.com/todeveni), [Viktor Szépe (@szepeviktor)](https://github.com/szepeviktor), [joost de keijzer (@joostdekeijzer)](https://github.com/joostdekeijzer), [Timothy Jacobs (@TimothyBJacobs)](https://github.com/TimothyBJacobs), [Alex Seifert (@eiskalteschatten)](https://github.com/eiskalteschatten), [Brian Alexander (@ironprogrammer)](https://github.com/ironprogrammer), [fb656720 (@fb656720)](https://github.com/fb656720), [S.Lakshmi Vignesh (@slvignesh05)](https://github.com/slvignesh05), [Sudar Muthu (@sudar)](https://github.com/sudar), [Gediminas (@gedeminas)](https://github.com/gedeminas), [Augusto Bennemann (@gutobenn)](https://github.com/gutobenn), [Iqbal Hossain (@iqbal-web)](https://github.com/iqbal-web). +[George Stephanis (@georgestephanis)](https://github.com/georgestephanis), [Kaspars Dambis (@kasparsd)](https://github.com/kasparsd), [Dion Hulse (@dd32)](https://github.com/dd32), [853 (@Sonic853)](https://github.com/Sonic853), [Aaron Campbell (@aaroncampbell)](https://github.com/aaroncampbell), [Alexandru Apostol (@aapost0l)](https://github.com/aapost0l), [Ali Husnain (@alihusnainarshad)](https://github.com/alihusnainarshad), [Anton Vanyukov (@av3nger)](https://github.com/av3nger), [Arslan Kalwar (@akkspros)](https://github.com/akkspros), [Axel Simon (@axelsimon)](https://github.com/axelsimon), [Birgit Pauli-Haack (@bph)](https://github.com/bph), [Brooke. (@BrookeDot)](https://github.com/BrookeDot), [Calvin Alkan (@calvinalkan)](https://github.com/calvinalkan), [Carlos Faria (@cfaria)](https://github.com/cfaria), [Christian Chung (@christianc1)](https://github.com/christianc1), [Clayton Collie (@claytoncollie)](https://github.com/claytoncollie), [Connor Jennings (@cojennin)](https://github.com/cojennin), [Daisuke Takahashi (@shield-9)](https://github.com/shield-9), [Derek Herman (@valendesigns)](https://github.com/valendesigns), [fossyatra (@netweb)](https://github.com/netweb), [Jeffrey Paul (@jeffpaul)](https://github.com/jeffpaul), [John Blackbourn (@johnbillion)](https://github.com/johnbillion), [John James Jacoby (@JJJ)](https://github.com/JJJ), [Josh Betz (@joshbetz)](https://github.com/joshbetz), [Kurt Zenisek (@KZeni)](https://github.com/KZeni), [Ian Dunn (@iandunn)](https://github.com/iandunn), [Mario Hoyos (@squaredpx)](https://github.com/squaredpx), [Mathesh (@Mati02K)](https://github.com/Mati02K), [Mehul Gohil (@mehul0810)](https://github.com/mehul0810), [Nauris Pūķis (@pyronaur)](https://github.com/pyronaur), [Neil Batchelor (@nbwpuk)](https://github.com/nbwpuk), [Ole Melhus (@omelhus)](https://github.com/omelhus), [Pascal Birchler (@swissspidy)](https://github.com/swissspidy), [Paul Kevan (@pkevan)](https://github.com/pkevan), [Paul Schreiber (@paulschreiber)](https://github.com/paulschreiber), [r-a-y (@r-a-y)](https://github.com/r-a-y), [Sergey Jinks (@sjinks)](https://github.com/sjinks), [Scott Grant (@scotchfield)](https://github.com/scotchfield), [Shai Sapphire (@shay1383)](https://github.com/shay1383), [Spenser Hale (@spenserhale)](https://github.com/spenserhale), [Stefan Momm (@stefanmomm)](https://github.com/stefanmomm), [Steve Grunwell (@stevegrunwell)](https://github.com/stevegrunwell), [Steven Word (@stevenkword)](https://github.com/stevenkword), [Thrijith Thankachan (@thrijith)](https://github.com/thrijith), [Tomasz Dziuda (@dziudek)](https://github.com/dziudek), [Toni Viemerö (@todeveni)](https://github.com/todeveni), [Viktor Szépe (@szepeviktor)](https://github.com/szepeviktor), [joost de keijzer (@joostdekeijzer)](https://github.com/joostdekeijzer), [Timothy Jacobs (@TimothyBJacobs)](https://github.com/TimothyBJacobs), [Alex Seifert (@eiskalteschatten)](https://github.com/eiskalteschatten), [Brian Alexander (@ironprogrammer)](https://github.com/ironprogrammer), [fb656720 (@fb656720)](https://github.com/fb656720), [S.Lakshmi Vignesh (@slvignesh05)](https://github.com/slvignesh05), [Sudar Muthu (@sudar)](https://github.com/sudar), [Gediminas (@gedeminas)](https://github.com/gedeminas), [Augusto Bennemann (@gutobenn)](https://github.com/gutobenn), [Iqbal Hossain (@iqbal-web)](https://github.com/iqbal-web), [Eric Michel (@eric-michel)](https://github.com/eric-michel), [Eric Mann (@ericmann)](https://github.com/ericmann), [Hardik Kumar (@hardikRathi)](https://github.com/hardikRathi), [Brian Haas (@masteradhoc)](https://github.com/masteradhoc). ## Libraries diff --git a/RELEASING.md b/RELEASING.md index 3e920a1d9..8ed81733f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,8 +7,8 @@ The following content can be copied and pasted into a release issue or PR to hel - [ ] Branch: Starting from `master`, create a branch named `release/X.Y.Z` for the release-related changes. - [ ] Version bump: Bump the version number in `readme.txt` and `two-factor.php` if it does not already reflect the version being released. Update both the plugin "Version:" header value and the plugin `TWO_FACTOR_VERSION` constant in `two-factor.php`. -- [ ] Changelog: Add/update the changelog in `CHANGELOG.md`. The changelog can be generated from a `compare` URL like [0.8.0...HEAD](https://github.com/WordPress/two-factor/compare/0.8.0...HEAD). Ensure the version number is added to the footer links at the bottom showing the compare from the prior version (e.g., https://github.com/WordPress/two-factor/compare/0.12.0...0.13.0). Trim the changelog entry in `readme.txt` to the least recent between a year about and the prior major release. -- [ ] Props: update `CREDITS.md` file with any new contributors, confirm maintainers are accurate. +- [ ] Changelog: Add/update the changelog in `CHANGELOG.md`. The changelog can be generated from a `compare` URL like [0.8.0...HEAD](https://github.com/WordPress/two-factor/compare/0.8.0...HEAD). Ensure the version number is added to the footer links at the bottom showing the compare from the prior version (e.g., https://github.com/WordPress/two-factor/compare/0.12.0...0.13.0). Trim the changelog entry in `readme.txt` to the least recent between a year ago and the prior major release. +- [ ] Props: update `CREDITS.md` file with any new contributors, confirm maintainers are accurate. With the CHANGELOG.md updates completed in the step previous, open those PRs merged in the release, open the merge commits from them, and then assuming the Props Bot recommendations for Co-Authored By are included in the merge commits those GitHub usernames and the person making the merge commit can be added (where missing) to the CREDITS.md file. - [ ] New files: Check to be sure any new files/paths that are unnecessary in the production version are included in [.distignore](https://github.com/WordPress/two-factor/blob/master/.distignore). - [ ] Readme updates: Make any other readme changes as necessary. `readme.md` is geared toward GitHub and `readme.txt` contains WordPress.org-specific content. The two are slightly different. - [ ] Create Release PR: Push any local changes in `release/X.Y.Z` to origin, create a release PR, and request review to ensure all CI checks pass and ensure master branch changes are limited to merges only. diff --git a/TESTS.md b/TESTS.md new file mode 100644 index 000000000..d0460f50b --- /dev/null +++ b/TESTS.md @@ -0,0 +1,160 @@ +# Tests + +The test suite uses PHPUnit and runs inside the Docker-based `@wordpress/env` environment against a live WordPress install. The `npm run composer` script is a wrapper that executes `composer` inside the `tests-cli` container at the plugin path. + +## Running Tests + +```bash +# Full test suite +npm test + +# Watch mode (re-runs on file changes, no coverage) +npm run test:watch + +# Full test suite with coverage (requires xdebug-enabled env) +npm run env start -- --xdebug=coverage +npm test +``` + +Coverage reports are written to `tests/logs/clover.xml` and `tests/logs/html/`. Open `tests/logs/html/index.html` in a browser to view the HTML report. + +### Filtering + +Pass PHPUnit arguments through the `composer` wrapper: + +```bash +# Run a single test class +npm run composer -- test -- --filter Tests_Two_Factor_Core + +# Run a single test method +npm run composer -- test -- --filter test_create_login_nonce + +# Run by @group annotation +npm run composer -- test -- --group totp +npm run composer -- test -- --group email +npm run composer -- test -- --group backup-codes +npm run composer -- test -- --group providers +npm run composer -- test -- --group core + +# Run a single file +npm run composer -- test -- tests/providers/class-two-factor-totp.php +``` + +## Test Files + +### Plugin Bootstrap — `tests/two-factor.php` + +**Class:** `Tests_Two_Factor` +Smoke tests that the plugin loaded correctly: the `TWO_FACTOR_DIR` constant is defined and the core classes exist. + +### Core — `tests/class-two-factor-core.php` + +**Class:** `Tests_Two_Factor_Core` · **Group:** `core` +The largest test file. Covers the full authentication lifecycle managed by `Two_Factor_Core`: + +- Hook registration (`add_hooks`) +- Provider registration and retrieval (`get_providers`, `get_enabled_providers_for_user`, `get_available_providers_for_user`, `get_primary_provider_for_user`) +- Login interception (`filter_authenticate`, `show_two_factor_login`, `process_provider`) +- Login nonce creation, verification, and deletion +- Rate limiting (`get_user_time_delay`, `is_user_rate_limited`) +- Session management: two-factor factored vs. non-factored sessions, session destruction on 2FA enable/disable, revalidation +- Password reset flow (compromise detection, email notifications, reset notices) +- REST API permission callbacks (`rest_api_can_edit_user`) +- User settings actions (`trigger_user_settings_action`, `current_user_can_update_two_factor_options`) +- Uninstall cleanup +- Filter hooks (`two_factor_providers`, `two_factor_primary_provider_for_user`, `two_factor_user_api_login_enable`) + +### Provider Base Class — `tests/providers/class-two-factor-provider.php` + +**Class:** `Tests_Two_Factor_Provider` · **Group:** `providers` +Tests the abstract `Two_Factor_Provider` base class: + +- Singleton pattern (`get_instance`) +- Code generation (`get_code`) and request sanitization (`sanitize_code_from_request`) +- `get_key` returning the class name +- `is_supported_for_user` (globally registered vs. not) +- Default implementations of `get_alternative_provider_label`, `pre_process_authentication`, `uninstall_user_meta_keys`, `uninstall_options` + +### TOTP Provider — `tests/providers/class-two-factor-totp.php` + +**Class:** `Tests_Two_Factor_Totp` · **Groups:** `providers`, `totp` +Tests `Two_Factor_Totp`: + +- Base32 encode/decode (including invalid input exception) +- QR code URL generation +- TOTP key storage and retrieval per user +- Auth code validation (current tick, spaces stripped, invalid chars rejected) +- `validate_code_for_user` replay protection +- Algorithm variants: SHA1, SHA256, SHA512 (code generation and authentication) +- Secret padding (`pad_secret`) + +### TOTP REST API — `tests/providers/class-two-factor-totp-rest-api.php` + +**Class:** `Tests_Two_Factor_Totp_REST_API` · **Groups:** `providers`, `totp` +Extends `WP_Test_REST_TestCase`. Tests the TOTP REST endpoints: + +- Setting a TOTP key with a valid/invalid/missing auth code +- Updating an existing TOTP key +- Deleting own secret +- Admin deleting another user's secret +- Non-admin cannot delete another user's secret + +### Email Provider — `tests/providers/class-two-factor-email.php` + +**Class:** `Tests_Two_Factor_Email` · **Groups:** `providers`, `email` +Tests `Two_Factor_Email`: + +- Token generation and validation (same user, different user, deleted token) +- Email delivery (`generate_and_email_token`) +- Authentication page rendering (no user, no token, existing token) +- `validate_authentication` (valid, missing input, spaces stripped) +- Token TTL and expiry +- Token generation time tracking +- Custom token length filter +- `pre_process_authentication` (resend vs. no resend) +- User options UI output +- Uninstall meta key cleanup + +### Backup Codes Provider — `tests/providers/class-two-factor-backup-codes.php` + +**Class:** `Tests_Two_Factor_Backup_Codes` · **Groups:** `providers`, `backup-codes` +Tests `Two_Factor_Backup_Codes`: + +- Code generation and validation +- Replay prevention (code invalidated after use) +- Cross-user isolation (code invalid for different user) +- `is_available_for_user` (no codes vs. codes generated) +- User options UI output +- Code deletion +- `two_factor_backup_codes_count` filter for customizing code length + +### Backup Codes REST API — `tests/providers/class-two-factor-backup-codes-rest-api.php` + +**Class:** `Tests_Two_Factor_Backup_Codes_REST_API` · **Groups:** `providers`, `backup-codes` +Extends `WP_Test_REST_TestCase`. Tests the backup codes REST endpoints: + +- Generate codes and validate the downloadable file contents +- User cannot generate codes for a different user +- Admin can generate codes for other users + +### Dummy Provider — `tests/providers/class-two-factor-dummy.php` + +**Class:** `Tests_Two_Factor_Dummy` · **Groups:** `providers`, `dummy` +Tests the `Two_Factor_Dummy` provider (always passes authentication — used as a test fixture): + +- `get_instance`, `get_label`, `authentication_page`, `validate_authentication`, `is_available_for_user` + +### Dummy Secure Provider — `tests/providers/class-two-factor-dummy-secure.php` + +**Class:** `Tests_Two_Factor_Dummy_Secure` · **Groups:** `providers`, `dummy` +Tests `Two_Factor_Dummy_Secure` (a fixture that always _fails_ authentication, used to test the provider class name filter): + +- `get_key` override returns `Two_Factor_Dummy` +- Authentication page rendering +- `validate_authentication` always returns false +- `two_factor_provider_classname` filter + +## Test Helpers + +- **`tests/bootstrap.php`** — Locates the WordPress test library (via `WP_TESTS_DIR` env var, relative path, or `/tmp/wordpress-tests-lib`), loads the plugin via `muplugins_loaded`, then boots the WP test environment. +- **`tests/class-secure-dummy.php`** — Defines `Two_Factor_Dummy_Secure`, a test-only provider class that spoofs the key of `Two_Factor_Dummy` but always fails `validate_authentication`. Used by `Tests_Two_Factor_Dummy_Secure` and some core tests. diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 0f8643345..267e969a2 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -1,6 +1,6 @@ init(); } + /** + * Register login page scripts. + * + * @since 0.10.0 + * + * @codeCoverageIgnore + */ + public static function login_enqueue_scripts() { + $environment_prefix = file_exists( TWO_FACTOR_DIR . '/dist' ) ? '/dist' : ''; + + wp_register_script( + 'two-factor-login', + plugins_url( $environment_prefix . '/providers/js/two-factor-login.js', __FILE__ ), + array(), + TWO_FACTOR_VERSION, + true + ); + + wp_register_script( + 'two-factor-login-authcode', + plugins_url( $environment_prefix . '/providers/js/two-factor-login-authcode.js', __FILE__ ), + array(), + TWO_FACTOR_VERSION, + true + ); + } + /** * Delete all plugin data on uninstall. - * + * * @since 0.10.0 * * @return void @@ -205,7 +241,7 @@ public static function uninstall() { /** * Get the registered providers of which some might not be enabled. - * + * * @since 0.11.0 * * @return array List of provider keys and paths to class files. @@ -214,7 +250,6 @@ private static function get_default_providers() { return 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', - 'Two_Factor_FIDO_U2F' => TWO_FACTOR_DIR . 'providers/class-two-factor-fido-u2f.php', 'Two_Factor_Backup_Codes' => TWO_FACTOR_DIR . 'providers/class-two-factor-backup-codes.php', 'Two_Factor_Dummy' => TWO_FACTOR_DIR . 'providers/class-two-factor-dummy.php', ); @@ -222,7 +257,7 @@ private static function get_default_providers() { /** * Get the classnames for specific providers. - * + * * @since 0.10.0 * * @param array $providers List of paths to provider class files indexed by class names. @@ -240,6 +275,8 @@ private static function get_providers_classes( $providers ) { /** * Filters the classname for a provider. The dynamic portion of the filter is the defined providers key. * + * @since 0.9.0 + * * @param string $class The PHP Classname of the provider. * @param string $path The provided provider path to be included. */ @@ -267,7 +304,7 @@ private static function get_providers_classes( $providers ) { * * @since 0.2.0 * - * @return array + * @return Two_Factor_Provider[] */ public static function get_providers() { $providers = self::get_default_providers(); @@ -278,23 +315,13 @@ public static function get_providers() { * This lets third-parties either remove providers (such as Email), or * add their own providers (such as text message or Clef). * + * @since 0.1-dev + * * @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 = self::get_providers_classes( $providers ); @@ -312,14 +339,15 @@ public static function get_providers() { /** * Get providers available for user which may not be enabled or configured. - * + * * @since 0.13.0 * * @see Two_Factor_Core::get_enabled_providers_for_user() * @see Two_Factor_Core::get_available_providers_for_user() * * @param WP_User|int|null $user User ID. - * @return array List of provider instances indexed by provider key. + * + * @return Two_Factor_Provider[] List of provider instances indexed by provider key. */ public static function get_supported_providers_for_user( $user = null ) { $user = self::fetch_user( $user ); @@ -336,7 +364,7 @@ public static function get_supported_providers_for_user( $user = null ) { /** * Enable the dummy method only during debugging. - * + * * @since 0.5.2 * * @param array $methods List of enabled methods. @@ -352,29 +380,71 @@ public static function enable_dummy_method_for_debug( $methods ) { } /** - * Add "Settings" link to the plugin action links on the Plugins screen. + * Add Plugin and User Settings link to the plugin action links on the Plugins screen. * * @since 0.14.3 * * @param string[] $links An array of plugin action links. - * @return string[] Modified array with the Settings link added. + * @return string[] Modified array with the User Settings link added. */ public static function add_settings_action_link( $links ) { - $settings_url = admin_url( 'profile.php#application-passwords-section' ); - $settings_link = sprintf( + $plugin_settings_url = admin_url( 'options-general.php?page=two-factor-settings' ); + $plugin_settings_link = sprintf( '%s', - esc_url( $settings_url ), - esc_html__( 'Settings', 'two-factor' ) + esc_url( $plugin_settings_url ), + esc_html__( 'Plugin Settings', 'two-factor' ) ); - array_unshift( $links, $settings_link ); + $user_settings_url = admin_url( 'profile.php#application-passwords-section' ); + $user_settings_link = sprintf( + '%s', + esc_url( $user_settings_url ), + esc_html__( 'User Settings', 'two-factor' ) + ); + + // Show plugin settings first, then user settings. + array_unshift( $links, $user_settings_link ); + + if ( current_user_can( 'manage_options' ) ) { + array_unshift( $links, $plugin_settings_link ); + } return $links; } + /** + * Register an error associated with the current request. + * + * @param WP_Error $error Error instance. + + * @return void + */ + private static function add_error( WP_Error $error ) { + self::$profile_errors[ $error->get_error_code() ] = $error; + } + + /** + * Attach Two-Factor profile errors to WordPress core profile update errors. + * + * @since NEXT + * + * @param WP_Error $errors WP_Error object passed by core. + * + * @return void + */ + public static function action_user_profile_update_errors( WP_Error $errors ) { + foreach ( self::$profile_errors as $profile_error ) { + foreach ( $profile_error->get_error_codes() as $code ) { + foreach ( $profile_error->get_error_messages( $code ) as $message ) { + $errors->add( $code, $message ); + } + } + } + } + /** * Check if the debug mode is enabled. - * + * * @since 0.5.2 * * @return boolean @@ -388,7 +458,7 @@ protected static function is_wp_debug() { * * Fetch this from the plugin core after we introduce proper dependency injection * and get away from the singletons at the provider level (should be handled by core). - * + * * @since 0.5.2 * * @param integer $user_id User ID. @@ -410,7 +480,7 @@ protected static function get_user_settings_page_url( $user_id ) { /** * Get the URL for resetting the secret token. - * + * * @since 0.5.2 * * @param integer $user_id User ID. @@ -433,7 +503,7 @@ public static function get_user_update_action_url( $user_id, $action ) { /** * Get the two-factor revalidate URL. - * + * * @since 0.9.0 * * @param bool $interim If the URL should load the interim login iframe modal. @@ -452,7 +522,7 @@ public static function get_user_two_factor_revalidate_url( $interim = false ) { /** * Check if a user action is valid. - * + * * @since 0.5.2 * * @param integer $user_id User ID. @@ -475,7 +545,7 @@ public static function is_valid_user_action( $user_id, $action ) { /** * Get the ID of the user being edited. - * + * * @since 0.5.2 * * @return integer @@ -496,7 +566,7 @@ public static function current_user_being_edited() { /** * Trigger our custom update action if a valid * action request is detected and passes the nonce check. - * + * * @since 0.5.2 * * @return void @@ -520,7 +590,7 @@ public static function trigger_user_settings_action() { /** * Keep track of all the authentication cookies that need to be * invalidated before the second factor authentication. - * + * * @since 0.5.1 * * @param string $cookie Cookie string. @@ -561,14 +631,15 @@ public static function fetch_user( $user = null ) { /** * Get two-factor providers that are enabled for the specified (or current) user * but might not be configured, yet. - * + * * @since 0.2.0 * * @see Two_Factor_Core::get_supported_providers_for_user() * @see Two_Factor_Core::get_available_providers_for_user() * * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. - * @return array + * + * @return string[] List of keys of enabled providers for the user. */ public static function get_enabled_providers_for_user( $user = null ) { $user = self::fetch_user( $user ); @@ -586,6 +657,8 @@ public static function get_enabled_providers_for_user( $user = null ) { /** * Filter the enabled two-factor authentication providers for this user. * + * @since 0.5.2 + * * @param array $enabled_providers The enabled providers. * @param int $user_id The user ID. */ @@ -595,14 +668,14 @@ public static function get_enabled_providers_for_user( $user = null ) { /** * Get all two-factor providers that are both enabled and configured * for the specified (or current) user. - * + * * @since 0.2.0 * * @see Two_Factor_Core::get_supported_providers_for_user() * @see Two_Factor_Core::get_enabled_providers_for_user() * * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. - * @return array List of provider instances. + * @return Two_Factor_Provider[]|WP_Error List of provider instances, or a WP_Error if all configured providers are unavailable. */ public static function get_available_providers_for_user( $user = null ) { $user = self::fetch_user( $user ); @@ -613,6 +686,31 @@ public static function get_available_providers_for_user( $user = null ) { $providers = self::get_supported_providers_for_user( $user ); // Returns full objects. $enabled_providers = self::get_enabled_providers_for_user( $user ); // Returns just the keys. $configured_providers = array(); + $user_providers_raw = get_user_meta( $user->ID, self::ENABLED_PROVIDERS_USER_META_KEY, true ); + + /** + * If the user had enabled providers, but none of them exist currently, + * if emailed codes is available force it to be on, so that deprecated + * or removed providers don't result in the two-factor requirement being + * removed and 'failing open'. + * + * Possible enhancement: add a filter to change the fallback method? + */ + if ( empty( $enabled_providers ) && $user_providers_raw ) { + if ( isset( $providers['Two_Factor_Email'] ) && $providers['Two_Factor_Email']->is_available_for_user( $user ) ) { + // Force Emailed codes to 'on'. + $enabled_providers[] = 'Two_Factor_Email'; + } else { + return new WP_Error( + 'no_available_2fa_methods', + __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), + array( + 'user_providers_raw' => $user_providers_raw, + 'available_providers' => array_keys( $providers ), + ) + ); + } + } foreach ( $providers as $provider_key => $provider ) { if ( in_array( $provider_key, $enabled_providers, true ) && $provider->is_available_for_user( $user ) ) { @@ -625,7 +723,7 @@ public static function get_available_providers_for_user( $user = null ) { /** * Fetch the provider for the request based on the user preferences. - * + * * @since 0.9.0 * * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. @@ -653,7 +751,7 @@ public static function get_provider_for_user( $user = null, $preferred_provider if ( is_string( $preferred_provider ) ) { $providers = self::get_available_providers_for_user( $user ); - if ( isset( $providers[ $preferred_provider ] ) ) { + if ( ! is_wp_error( $providers ) && isset( $providers[ $preferred_provider ] ) ) { return $providers[ $preferred_provider ]; } } @@ -664,7 +762,7 @@ public static function get_provider_for_user( $user = null, $preferred_provider /** * Get the name of the primary provider selected by the user * and enabled for the user. - * + * * @since 0.12.0 * * @param WP_User|int $user User ID or instance. @@ -675,6 +773,10 @@ private static function get_primary_provider_key_selected_for_user( $user ) { $primary_provider = get_user_meta( $user->ID, self::PROVIDER_USER_META_KEY, true ); $available_providers = self::get_available_providers_for_user( $user ); + if ( is_wp_error( $available_providers ) ) { + return null; + } + if ( ! empty( $primary_provider ) && ! empty( $available_providers[ $primary_provider ] ) ) { return $primary_provider; } @@ -702,6 +804,9 @@ public static function get_primary_provider_for_user( $user = null ) { // If there's only one available provider, force that to be the primary. if ( empty( $available_providers ) ) { return null; + } elseif ( is_wp_error( $available_providers ) ) { + // If it returned an error, the configured methods don't exist, and it couldn't swap in a replacement. + wp_die( $available_providers ); } elseif ( 1 === count( $available_providers ) ) { $provider = key( $available_providers ); } else { @@ -716,6 +821,8 @@ public static function get_primary_provider_for_user( $user = null ) { /** * Filter the two-factor authentication provider used for this user. * + * @since 0.2.0 + * * @param string $provider The provider currently being used. * @param int $user_id The user ID. */ @@ -746,6 +853,8 @@ public static function is_user_using_two_factor( $user = null ) { * * @since 0.2.0 * + * @see https://developer.wordpress.org/reference/hooks/wp_login/ + * * @param string $user_login Username. * @param WP_User $user WP_User object of the logged-in user. */ @@ -770,7 +879,7 @@ public static function wp_login( $user_login, $user ) { * Is there a better way of finding the current session token without * having access to the authentication cookies which are just being set * on the first password-based authentication request. - * + * * @since 0.5.1 * * @param \WP_User $user User object. @@ -786,22 +895,25 @@ public static function destroy_current_session_for_user( $user ) { } /** - * Trigget the two-factor workflow only for valid login attempts - * with username present. Prevent authentication during API requests - * unless explicitly enabled for the user (disabled by default). - * + * Disable WP core login cookies for users that require second factor. Disable + * authenticated API requests unless explicitly enabled for the user (disabled by default). + * * @since 0.4.0 * * @param WP_User|WP_Error $user Valid WP_User only if the previous filters * have verified and confirmed the * authentication credentials. - * @param string $username The username. - * @param string $password The password. * * @return WP_User|WP_Error */ - public static function filter_authenticate( $user, $username, $password ) { - if ( strlen( $username ) && $user instanceof WP_User && self::is_user_using_two_factor( $user->ID ) ) { + public static function filter_authenticate( $user ) { + if ( $user instanceof WP_User && self::is_user_using_two_factor( $user->ID ) ) { + /** + * Prevent WP core from sending login cookies during `wp_set_auth_cookie()` and + * let two-factor do it after validating the second factor. + */ + add_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); + // Disable authentication requests for API requests for users with two-factor enabled. if ( self::is_api_request() && ! self::is_user_api_login_enabled( $user->ID ) ) { return new WP_Error( @@ -809,12 +921,6 @@ public static function filter_authenticate( $user, $username, $password ) { __( 'Error: API login for user disabled.', 'two-factor' ) ); } - - // Disable core auth cookies because we must send them manually once the 2nd factor has been verified. - add_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); - - // Trigger the two-factor flow only for login attempts. - add_action( 'wp_login', array( __CLASS__, 'wp_login' ), PHP_INT_MAX, 2 ); } return $user; @@ -824,7 +930,7 @@ public static function filter_authenticate( $user, $username, $password ) { * If the user can login via API requests such as XML-RPC and REST. * * Only logins with application passwords are permitted by default. - * + * * @since 0.4.0 * * @param integer $user_id User ID. @@ -836,6 +942,8 @@ public static function is_user_api_login_enabled( $user_id ) { * Allow or prevent logins without two-factor during * API requests such as XML-RPC and REST. * + * @since 0.4.0 + * * @param boolean $enabled Whether the user can login via API requests. * @param integer $user_id User ID. */ @@ -848,7 +956,7 @@ public static function is_user_api_login_enabled( $user_id ) { /** * Is the current request an XML-RPC or REST request. - * + * * @since 0.4.0 * * @return boolean @@ -889,7 +997,7 @@ public static function show_two_factor_login( $user ) { /** * Displays a message informing the user that their account has had failed login attempts. - * + * * @since 0.8.0 * * @param WP_User $user WP_User object of the logged-in user. @@ -901,15 +1009,17 @@ public static function maybe_show_last_login_failure_notice( $user ) { if ( $last_failed_two_factor_login ) { echo '
'; printf( - /* translators: 1: number of failed login attempts, 2: time since last failed attempt */ - _n( - 'WARNING: Your account has attempted to login %1$s time without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', - 'WARNING: Your account has attempted to login %1$s times without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', - $failed_login_count, - 'two-factor' + esc_html( + /* translators: 1: number of failed login attempts, 2: time since last failed attempt */ + _n( + 'WARNING: Your account has attempted to login %1$s time without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', + 'WARNING: Your account has attempted to login %1$s times without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', + $failed_login_count, + 'two-factor' + ) ), - number_format_i18n( $failed_login_count ), - human_time_diff( $last_failed_two_factor_login, time() ) + esc_html( number_format_i18n( $failed_login_count ) ), + esc_html( human_time_diff( $last_failed_two_factor_login, time() ) ) ); echo '
'; } @@ -920,7 +1030,7 @@ public static function maybe_show_last_login_failure_notice( $user ) { * * 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. - * + * * @since 0.8.0 * * @param WP_Error $errors Error object. @@ -965,7 +1075,7 @@ public static function maybe_show_reset_password_notice( $errors ) { /** * Clear the password reset notice after the user resets their password. - * + * * @since 0.8.0 * * @param WP_User $user User object. @@ -989,16 +1099,21 @@ public static function clear_password_reset_notice( $user ) { 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' ) ); + wp_die( esc_html__( 'Two-factor provider not available for this user.', 'two-factor' ) ); } $provider_key = $provider->get_key(); $available_providers = self::get_available_providers_for_user( $user ); + if ( is_wp_error( $available_providers ) ) { + wp_die( $available_providers ); + } $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. require_once TWO_FACTOR_DIR . 'includes/function.login-header.php'; @@ -1007,6 +1122,8 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg // Disable the language switcher. add_filter( 'login_display_language_dropdown', '__return_false' ); + wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', __FILE__ ), array(), TWO_FACTOR_VERSION ); + login_header(); if ( ! empty( $error_msg ) ) { @@ -1031,37 +1148,57 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg $action, - 'wp-auth-id' => $user->ID, - 'wp-auth-nonce' => $login_nonce, - ); - if ( $rememberme ) { - $backup_link_args['rememberme'] = $rememberme; - } - if ( $redirect_to ) { - $backup_link_args['redirect_to'] = $redirect_to; - } - if ( $interim_login ) { - $backup_link_args['interim-login'] = 1; + $links = array(); + + if ( $backup_providers ) { + $backup_link_args = array( + 'action' => $action, + 'wp-auth-id' => $user->ID, + 'wp-auth-nonce' => $login_nonce, + ); + if ( $rememberme ) { + $backup_link_args['rememberme'] = $rememberme; + } + if ( $redirect_to ) { + $backup_link_args['redirect_to'] = $redirect_to; + } + if ( $interim_login ) { + $backup_link_args['interim-login'] = 1; + } + + foreach ( $backup_providers as $backup_provider_key => $backup_provider ) { + $backup_link_args['provider'] = $backup_provider_key; + $links[] = array( + 'url' => self::login_url( $backup_link_args ), + 'label' => $backup_provider->get_alternative_provider_label(), + ); + } } - ?> + + /** + * Filters the links displayed on the two-factor login form. + * + * Plugins can use this filter to modify or add links to the two-factor authentication + * login form, allowing users to select backup methods for authentication or provide documentation links. + * + * @since 0.16.0 + * + * @param array $links An array of links displayed on the two-factor login form, each with `url` and `label` keys. + */ + $links = apply_filters( 'two_factor_login_backup_links', $links ); + ?> + +

@@ -1093,41 +1230,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg opacity: 0.5; } - + ID, $nonce ) ) { wp_safe_redirect( home_url() ); - return; + exit; } $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( __( 'Cheatin’ uh?', 'two-factor' ) ); + wp_die( esc_html__( 'Two-factor provider not available for this user.', 'two-factor' ) ); } // Run the provider processing. @@ -1535,6 +1654,14 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider = wp_set_auth_cookie( $user->ID, $rememberme ); + /** + * Fires after a user has been authenticated via two-factor. + * + * @since 0.5.2 + * + * @param WP_User $user The authenticated user. + * @param Two_Factor_Provider $provider The two-factor provider used for authentication. + */ do_action( 'two_factor_user_authenticated', $user, $provider ); remove_filter( 'attach_session_information', $session_information_callback ); @@ -1547,6 +1674,10 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider = $customize_login = isset( $_REQUEST['customize-login'] ); if ( $customize_login ) { wp_enqueue_script( 'customize-base' ); + wp_add_inline_script( + 'customize-base', + 'setTimeout( function(){ new wp.customize.Messenger({ url: ' . wp_json_encode( esc_url( wp_customize_url() ) ) . ', channel: \'login\' }).send(\'login\') }, 1000 );' + ); } $message = '

' . __( 'You have logged in successfully.', 'two-factor' ) . '

'; $interim_login = 'success'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited @@ -1557,9 +1688,6 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider = /** This action is documented in wp-login.php */ do_action( 'login_footer' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress action. ?> - - - ID ) ) { wp_safe_redirect( home_url() ); - return; + exit; } $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( __( 'Cheatin’ uh?', 'two-factor' ) ); + wp_die( esc_html__( 'Two-factor provider not available for this user.', 'two-factor' ) ); } // Run the provider processing. @@ -1642,6 +1771,14 @@ public static function _login_form_revalidate_2fa( $nonce = '', $provider = '', ) ); + /** + * Fires after a user has been revalidated via two-factor. + * + * @since 0.8.0 + * + * @param WP_User $user The revalidated user. + * @param Two_Factor_Provider $provider The two-factor provider used for revalidation. + */ do_action( 'two_factor_user_revalidated', $user, $provider ); // Must be global because that's how login_header() uses it. @@ -1665,12 +1802,12 @@ public static function _login_form_revalidate_2fa( $nonce = '', $provider = '', $redirect_to = apply_filters( 'login_redirect', $redirect_to, $redirect_to, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. wp_safe_redirect( $redirect_to ); - return; + exit; } /** * Process the 2FA provider authentication. - * + * * @since 0.9.0 * * @param object $provider The Two Factor Provider. @@ -1682,7 +1819,7 @@ public static function process_provider( $provider, $user, $is_post_request ) { if ( ! $provider ) { return new WP_Error( 'two_factor_provider_missing', - __( 'Cheatin’ uh?', 'two-factor' ) + esc_html__( 'Two-factor provider not available for this user.', 'two-factor' ) ); } @@ -1752,11 +1889,13 @@ public static function should_reset_password( $user_id ) { * that the password has been compromised and an attacker is trying to brute force the 2nd * factor. * - * ⚠️ `get_user_time_delay()` mitigates brute force attempts, but many 2nd factors -- + * `get_user_time_delay()` mitigates brute force attempts, but many 2nd factors -- * like TOTP and backup codes -- are very weak on their own, so it's not safe to give * attackers unlimited attempts. Setting this to a very large number is strongly * discouraged. * + * @since 0.8.0 + * * @param int $limit The number of attempts before the password is reset. */ $failed_attempt_limit = apply_filters( 'two_factor_failed_attempt_limit', 30 ); @@ -1790,7 +1929,7 @@ public static function reset_compromised_password( $user ) { /** * Notify the user and admin that a password was reset for being compromised. - * + * * @since 0.8.0 * * @param WP_User $user The user whose password should be reset. @@ -1802,6 +1941,8 @@ public static function send_password_reset_emails( $user ) { * Filters whether or not to email the site admin when a user's password has been * compromised and reset. * + * @since 0.8.0 + * * @param bool $reset `true` to notify the admin, `false` to not notify them. */ $notify_admin = apply_filters( 'two_factor_notify_admin_user_password_reset', true ); @@ -1814,7 +1955,7 @@ public static function send_password_reset_emails( $user ) { /** * Notify the user that their password has been compromised and reset. - * + * * @since 0.8.0 * * @param WP_User $user The user to notify. @@ -1823,7 +1964,8 @@ public static function send_password_reset_emails( $user ) { */ public static function notify_user_password_reset( $user ) { $user_message = sprintf( - 'Hello %1$s, an unusually high number of failed login attempts have been detected on your account at %2$s. + /* translators: 1: username, 2: site URL, 3: URL to password best-practices article, 4: URL to reset password */ + __( 'Hello %1$s, an unusually high number of failed login attempts have been detected on your account at %2$s. These attempts successfully entered your password, and were only blocked because they failed to enter your second authentication factor. Despite not being able to access your account, this behavior indicates that the attackers have compromised your password. The most common reasons for this are that your password was easy to guess, or was reused on another site which has been compromised. @@ -1831,7 +1973,7 @@ public static function notify_user_password_reset( $user ) { To pick a new password, please visit %4$s - This is an automated notification. If you would like to speak to a site administrator, please contact them directly.', + This is an automated notification. If you would like to speak to a site administrator, please contact them directly.', 'two-factor' ), esc_html( $user->user_login ), home_url(), 'https://wordpress.org/documentation/article/password-best-practices/', @@ -1839,12 +1981,12 @@ public static function notify_user_password_reset( $user ) { ); $user_message = str_replace( "\t", '', $user_message ); - return wp_mail( $user->user_email, 'Your password was compromised and has been reset', $user_message ); + return wp_mail( $user->user_email, __( 'Your password was compromised and has been reset', 'two-factor' ), $user_message ); } /** * Notify the admin that a user's password was compromised and reset. - * + * * @since 0.8.0 * * @param WP_User $user The user whose password was reset. @@ -1853,10 +1995,15 @@ public static function notify_user_password_reset( $user ) { */ public static function notify_admin_user_password_reset( $user ) { $admin_email = get_option( 'admin_email' ); - $subject = sprintf( 'Compromised password for %s has been reset', esc_html( $user->user_login ) ); + $subject = sprintf( + /* translators: %s: username */ + __( 'Compromised password for %s has been reset', 'two-factor' ), + esc_html( $user->user_login ) + ); $message = sprintf( - 'Hello, this is a notice from the Two Factor plugin to inform you that an unusually high number of failed login attempts have been detected on the %1$s account (ID %2$d). + /* translators: 1: username, 2: user ID, 3: URL to developer docs */ + __( 'Hello, this is a notice from the Two Factor plugin to inform you that an unusually high number of failed login attempts have been detected on the %1$s account (ID %2$d). Those attempts successfully entered the user\'s password, and were only blocked because they entered invalid second authentication factors. @@ -1864,7 +2011,7 @@ public static function notify_admin_user_password_reset( $user ) { If you do not wish to receive these notifications, you can disable them with the `two_factor_notify_admin_user_password_reset` filter. See %3$s for more information. - Thank you', + Thank you', 'two-factor' ), esc_html( $user->user_login ), $user->ID, 'https://developer.wordpress.org/plugins/hooks/' @@ -1876,7 +2023,7 @@ public static function notify_admin_user_password_reset( $user ) { /** * Show the password reset error when on the login screen. - * + * * @since 0.8.0 */ public static function show_password_reset_error() { @@ -1896,7 +2043,7 @@ public static function show_password_reset_error() { /** * Filter the columns on the Users admin screen. - * + * * @since 0.2.0 * * @param array $columns Available columns. @@ -1909,7 +2056,7 @@ public static function filter_manage_users_columns( array $columns ) { /** * Output the 2FA column data on the Users screen. - * + * * @since 0.2.0 * * @param string $output The column output. @@ -1941,13 +2088,12 @@ public static function manage_users_custom_column( $output, $column_name, $user_ * @param WP_User $user WP_User object of the logged-in user. */ public static function user_two_factor_options( $user ) { - $notices = array(); - $providers = self::get_supported_providers_for_user( $user ); wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', __FILE__ ), array(), TWO_FACTOR_VERSION ); - $enabled_providers = array_keys( self::get_available_providers_for_user( $user ) ); + $available_providers_result = self::get_available_providers_for_user( $user ); + $enabled_providers = is_wp_error( $available_providers_result ) ? array() : array_keys( $available_providers_result ); // This is specific to the current session, not the displayed user. $show_2fa_options = self::current_user_can_update_two_factor_options(); @@ -1959,31 +2105,61 @@ public static function user_two_factor_options( $user ) { self::get_user_two_factor_revalidate_url() ); - $notices['warning two-factor-warning-revalidate-session'] = sprintf( - esc_html__( 'To update your Two-Factor options, you must first revalidate your session.', 'two-factor' ) . - ' ' . esc_html__( 'Revalidate now', 'two-factor' ) . '', - esc_url( $url ) + self::add_error( + new WP_Error( + 'two_factor_revalidate_session', + sprintf( + __( 'To update your Two-Factor options, you must first revalidate your session.', 'two-factor' ) . + ' ' . esc_html__( 'Revalidate now', 'two-factor' ) . '', + esc_url( $url ) + ), + array( + 'type' => 'warning', + ) + ) ); } if ( empty( $providers ) ) { - $notices['notice two-factor-notice-no-providers-supported'] = esc_html__( 'No providers are available for your account.', 'two-factor' ); + self::add_error( + new WP_Error( + 'two_factor_no_providers_supported', + __( 'No providers are available for your account.', 'two-factor' ), + array( + 'type' => 'notice', + ) + ) + ); } // Suggest enabling a backup method if only one method is enabled and there are more available. if ( count( $providers ) > 1 && 1 === count( $enabled_providers ) ) { - $notices['warning two-factor-warning-suggest-backup'] = esc_html__( 'To prevent being locked out of your account, consider enabling a backup method like Recovery Codes in case you lose access to your primary authentication method.', 'two-factor' ); + self::add_error( + new WP_Error( + 'two_factor_suggest_backup', + __( 'To prevent being locked out of your account, consider enabling a backup method like Recovery Codes in case you lose access to your primary authentication method.', 'two-factor' ), + array( + 'type' => 'warning', + ) + ) + ); } + + $generic_errors = array_filter( + self::$profile_errors, + static function ( WP_Error $error ) { + $error_data = $error->get_error_data(); + return empty( $error_data['provider'] ); // Where the associated provider is not set. + } + ); + ?>

- $notice ) : ?> -
-

-
- +
> + has_errors() ) { + $error_type = $error->get_error_data()['type'] ?? null; + + wp_admin_notice( + implode( '

', $error->get_error_messages() ), + array( + 'type' => is_string( $error_type ) ? $error_type : 'error', + 'additional_classes' => array( 'inline' ), + ) + ); + } + } + } + /** * Render the user settings. - * + * * @since 0.13.0 * * @param WP_User $user User instance. @@ -2039,7 +2238,7 @@ private static function get_recommended_providers( $user ) { */ private static function render_user_providers_form( $user, $providers ) { $primary_provider_key = self::get_primary_provider_key_selected_for_user( $user ); - $enabled_providers = self::get_enabled_providers_for_user( $user ); + $available_providers = self::get_available_providers_for_user( $user ); $recommended_provider_keys = self::get_recommended_providers( $user ); // Move the recommended providers first. @@ -2066,8 +2265,9 @@ private static function render_user_providers_form( $user, $providers ) { get_label() ); ?> +

-

- - -

- -

- - -
- - - - - -
- - -
-

-
- - -

- - items = $security_keys; - $u2f_list_table->prepare_items(); - $u2f_list_table->display(); - $u2f_list_table->inline_edit(); - ?> -
- doRegister( get_user_meta( $user_id, self::REGISTER_DATA_USER_META_KEY, true ), $response ); - $reg->new = true; - - Two_Factor_FIDO_U2F::add_security_key( $user_id, $reg ); - } catch ( Exception $e ) { - return; - } - - delete_user_meta( $user_id, self::REGISTER_DATA_USER_META_KEY ); - - wp_safe_redirect( - add_query_arg( - array( - 'new_app_pass' => 1, - ), - wp_get_referer() - ) . '#security-keys-section' - ); - exit; - } - } - - /** - * Catch the delete security key request. - * - * This executes during the `load-profile.php` & `load-user-edit.php` actions. - * - * @since 0.1-dev - * - * @access public - * @static - */ - public static function catch_delete_security_key() { - $user_id = Two_Factor_Core::current_user_being_edited(); - - if ( ! empty( $user_id ) && ! empty( $_REQUEST['delete_security_key'] ) ) { - $slug = $_REQUEST['delete_security_key']; - - check_admin_referer( "delete_security_key-{$slug}", '_nonce_delete_security_key' ); - - Two_Factor_FIDO_U2F::delete_security_key( $user_id, $slug ); - - wp_safe_redirect( remove_query_arg( 'new_app_pass', wp_get_referer() ) . '#security-keys-section' ); - exit; - } - } - - /** - * Generate a link to rename a specified security key. - * - * @since 0.1-dev - * - * @access public - * @static - * - * @param array $item The current item. - * @return string - */ - public static function rename_link( $item ) { - return sprintf( '%s', esc_html__( 'Rename', 'two-factor' ) ); - } - - /** - * Generate a link to delete a specified security key. - * - * @since 0.1-dev - * - * @access public - * @static - * - * @param array $item The current item. - * @return string - */ - public static function delete_link( $item ) { - $delete_link = add_query_arg( 'delete_security_key', $item->keyHandle ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $delete_link = wp_nonce_url( $delete_link, "delete_security_key-{$item->keyHandle}", '_nonce_delete_security_key' ); - return sprintf( '%2$s', esc_url( $delete_link ), esc_html__( 'Delete', 'two-factor' ) ); - } - - /** - * Ajax handler for quick edit saving for a security key. - * - * @since 0.1-dev - * - * @access public - * @static - */ - public static function wp_ajax_inline_save() { - check_ajax_referer( 'keyinlineeditnonce', '_inline_edit' ); - - require_once TWO_FACTOR_DIR . 'providers/class-two-factor-fido-u2f-admin-list-table.php'; - $wp_list_table = new Two_Factor_FIDO_U2F_Admin_List_Table(); - - if ( ! isset( $_POST['keyHandle'] ) ) { - wp_die(); - } - - $user_id = Two_Factor_Core::current_user_being_edited(); - $security_keys = Two_Factor_FIDO_U2F::get_security_keys( $user_id ); - if ( ! $security_keys ) { - wp_die(); - } - - foreach ( $security_keys as &$key ) { - if ( $key->keyHandle === $_POST['keyHandle'] ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - break; - } - } - - $key->name = $_POST['name']; - - $updated = Two_Factor_FIDO_U2F::update_security_key( $user_id, $key ); - if ( ! $updated ) { - wp_die( esc_html__( 'Item not updated.', 'two-factor' ) ); - } - $wp_list_table->single_row( $key ); - wp_die(); - } -} diff --git a/providers/class-two-factor-fido-u2f.php b/providers/class-two-factor-fido-u2f.php deleted file mode 100644 index c6b6d0473..000000000 --- a/providers/class-two-factor-fido-u2f.php +++ /dev/null @@ -1,407 +0,0 @@ - -

- ID ); - $data = self::$u2f->getAuthenticateData( $keys ); - update_user_meta( $user->ID, self::AUTH_DATA_USER_META_KEY, $data ); - } catch ( Exception $e ) { - ?> -

- $data, - ) - ); - - wp_enqueue_script( 'fido-u2f-login' ); - - ?> - -

- - - - ID, self::AUTH_DATA_USER_META_KEY, true ); - - $response = json_decode( stripslashes( $_REQUEST['u2f_response'] ) ); - - $keys = self::get_security_keys( $user->ID ); - - try { - $reg = self::$u2f->doAuthenticate( $requests, $keys, $response ); - - $reg->last_used = time(); - - self::update_security_key( $user->ID, $reg ); - - return true; - } catch ( Exception $e ) { - return false; - } - } - - /** - * Whether this Two Factor provider is configured and available for the user specified. - * - * @since 0.1-dev - * - * @param WP_User $user WP_User object of the logged-in user. - * @return boolean - */ - public function is_available_for_user( $user ) { - return (bool) self::get_security_keys( $user->ID ); - } - - /** - * Inserts markup at the end of the user profile field for this provider. - * - * @since 0.1-dev - * - * @param WP_User $user WP_User object of the logged-in user. - */ - public function user_options( $user ) { - ?> -

- -

- keyHandle ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - || ! property_exists( $register, 'publicKey' ) || empty( $register->publicKey ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - || ! property_exists( $register, 'certificate' ) || empty( $register->certificate ) - || ! property_exists( $register, 'counter' ) || ( -1 > $register->counter ) - ) { - return false; - } - - $register = array( - 'keyHandle' => $register->keyHandle, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - 'publicKey' => $register->publicKey, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - 'certificate' => $register->certificate, - 'counter' => $register->counter, - ); - - $register['name'] = __( 'New Security Key', 'two-factor' ); - $register['added'] = time(); - $register['last_used'] = $register['added']; - - return add_user_meta( $user_id, self::REGISTERED_KEY_USER_META_KEY, $register ); - } - - /** - * Retrieve registered security keys for a user. - * - * @since 0.1-dev - * - * @param int $user_id User ID. - * @return array|bool Array of keys on success, false on failure. - */ - public static function get_security_keys( $user_id ) { - if ( ! is_numeric( $user_id ) ) { - return false; - } - - $keys = get_user_meta( $user_id, self::REGISTERED_KEY_USER_META_KEY ); - if ( $keys ) { - foreach ( $keys as &$key ) { - $key = (object) $key; - } - unset( $key ); - } - - return $keys; - } - - /** - * Update registered security key. - * - * Use the $prev_value parameter to differentiate between meta fields with the - * same key and user ID. - * - * If the meta field for the user does not exist, it will be added. - * - * @since 0.1-dev - * - * @param int $user_id User ID. - * @param object $data The data of registered security key. - * @return int|bool Meta ID if the key didn't exist, true on successful update, false on failure. - */ - public static function update_security_key( $user_id, $data ) { - if ( ! is_numeric( $user_id ) ) { - return false; - } - - if ( - ! is_object( $data ) - || ! property_exists( $data, 'keyHandle' ) || empty( $data->keyHandle ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - || ! property_exists( $data, 'publicKey' ) || empty( $data->publicKey ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - || ! property_exists( $data, 'certificate' ) || empty( $data->certificate ) - || ! property_exists( $data, 'counter' ) || ( -1 > $data->counter ) - ) { - return false; - } - - $keys = self::get_security_keys( $user_id ); - if ( $keys ) { - foreach ( $keys as $key ) { - if ( $key->keyHandle === $data->keyHandle ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - return update_user_meta( $user_id, self::REGISTERED_KEY_USER_META_KEY, (array) $data, (array) $key ); - } - } - } - - return self::add_security_key( $user_id, $data ); - } - - /** - * Remove registered security key matching criteria from a user. - * - * @since 0.1-dev - * - * @param int $user_id User ID. - * @param string $keyHandle Optional. Key handle. - * @return bool True on success, false on failure. - */ - public static function delete_security_key( $user_id, $keyHandle = null ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase - global $wpdb; - - if ( ! is_numeric( $user_id ) ) { - return false; - } - - $user_id = absint( $user_id ); - if ( ! $user_id ) { - return false; - } - - $keyHandle = wp_unslash( $keyHandle ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase - $keyHandle = maybe_serialize( $keyHandle ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase - - $query = $wpdb->prepare( "SELECT umeta_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND user_id = %d", self::REGISTERED_KEY_USER_META_KEY, $user_id ); - - if ( $keyHandle ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase - $key_handle_lookup = sprintf( ':"%s";s:', $keyHandle ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase - - $query .= $wpdb->prepare( - ' AND meta_value LIKE %s', - '%' . $wpdb->esc_like( $key_handle_lookup ) . '%' - ); - } - - $meta_ids = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - if ( ! count( $meta_ids ) ) { - return false; - } - - foreach ( $meta_ids as $meta_id ) { - delete_metadata_by_mid( 'user', $meta_id ); - } - - return true; - } - - /** - * Return user meta keys to delete during plugin uninstall. - * - * @return array - */ - public static function uninstall_user_meta_keys() { - return array( - self::REGISTERED_KEY_USER_META_KEY, - self::AUTH_DATA_USER_META_KEY, - '_two_factor_fido_u2f_register_request', // From Two_Factor_FIDO_U2F_Admin which is not loaded during uninstall. - ); - } -} diff --git a/providers/class-two-factor-provider.php b/providers/class-two-factor-provider.php index 001194f27..275cbae77 100644 --- a/providers/class-two-factor-provider.php +++ b/providers/class-two-factor-provider.php @@ -37,7 +37,6 @@ public static function get_instance() { * @since 0.1-dev */ protected function __construct() { - return $this; } /** @@ -68,6 +67,8 @@ public function get_alternative_provider_label() { * Prints the name of the provider. * * @since 0.1-dev + * + * @codeCoverageIgnore */ public function print_label() { echo esc_html( $this->get_label() ); @@ -98,6 +99,8 @@ abstract public function authentication_page( $user ); * Return `true` to prevent the authentication and render the * authentication page. * + * @since 0.2.0 + * * @param WP_User $user WP_User object of the logged-in user. * @return boolean */ @@ -118,6 +121,8 @@ abstract public function validate_authentication( $user ); /** * Whether this Two Factor provider is configured and available for the user specified. * + * @since 0.7.0 + * * @param WP_User $user WP_User object of the logged-in user. * @return boolean */ @@ -126,6 +131,8 @@ abstract public function is_available_for_user( $user ); /** * If this provider should be available for the user. * + * @since 0.13.0 + * * @param WP_User|int $user WP_User object, user ID or null to resolve the current user. * * @return bool @@ -159,6 +166,8 @@ public static function get_code( $length = 8, $chars = '1234567890' ) { /** * Sanitizes a numeric code to be used as an auth code. * + * @since 0.8.0 + * * @param string $field The _REQUEST field to check for the code. * @param int $length The valid expected length of the field. * @return false|string Auth code on success, false if the field is not set or not expected length. @@ -182,6 +191,8 @@ public static function sanitize_code_from_request( $field, $length = 0 ) { /** * Return the user meta keys that need to be deletated on plugin uninstall. * + * @since 0.10.0 + * * @return array */ public static function uninstall_user_meta_keys() { @@ -191,6 +202,8 @@ public static function uninstall_user_meta_keys() { /** * Return the option keys that need to be deleted on plugin uninstall. * + * @since 0.10.0 + * * Note: this method doesn't have access to the instantiated provider object. * * @return array diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 13a3312e8..e34dee6c3 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -7,6 +7,8 @@ /** * Class Two_Factor_Totp + * + * @since 0.2.0 */ class Two_Factor_Totp extends Two_Factor_Provider { @@ -40,6 +42,8 @@ class Two_Factor_Totp extends Two_Factor_Provider { /** * Class constructor. Sets up hooks, etc. * + * @since 0.2.0 + * * @codeCoverageIgnore */ protected function __construct() { @@ -61,6 +65,8 @@ protected function __construct() { /** * Override time() in the current object for testing. * + * @since 0.15.0 + * * @return int */ private static function time() { @@ -70,6 +76,8 @@ private static function time() { /** * Set up the internal state of time() invocations for deterministic generation. * + * @since 0.15.0 + * * @param int $now Timestamp to use when overriding time(). */ public static function set_time( $now ) { @@ -79,6 +87,8 @@ public static function set_time( $now ) { /** * Register the rest-api endpoints required for this provider. * + * @since 0.8.0 + * * @codeCoverageIgnore */ public function register_rest_routes() { @@ -133,6 +143,8 @@ public function register_rest_routes() { /** * Returns the name of the provider. + * + * @since 0.2.0 */ public function get_label() { return _x( 'Authenticator App', 'Provider Label', 'two-factor' ); @@ -150,6 +162,8 @@ public function get_alternative_provider_label() { /** * Enqueue scripts * + * @since 0.8.0 + * * @codeCoverageIgnore * @param string $hook_suffix Hook suffix. */ @@ -163,24 +177,42 @@ public function enqueue_assets( $hook_suffix ) { TWO_FACTOR_VERSION, true ); + + wp_register_script( + 'two-factor-totp-qrcode', + plugins_url( 'js/totp-admin-qrcode.js', __FILE__ ), + array( 'two-factor-qr-code-generator' ), + TWO_FACTOR_VERSION, + true + ); + + wp_register_script( + 'two-factor-totp-admin', + plugins_url( 'js/totp-admin.js', __FILE__ ), + array( 'jquery', 'wp-api-request', 'two-factor-qr-code-generator' ), + TWO_FACTOR_VERSION, + true + ); } /** * Rest API endpoint for handling deactivation of TOTP. * + * @since 0.8.0 + * * @param WP_REST_Request $request The Rest Request object. - * @return array Success array. + * @return WP_Error|array Array of data on success, WP_Error on error. */ public function rest_delete_totp( $request ) { $user_id = $request['user_id']; $user = get_user_by( 'id', $user_id ); - $this->delete_user_totp_key( $user_id ); - if ( ! Two_Factor_Core::disable_provider_for_user( $user_id, 'Two_Factor_Totp' ) ) { return new WP_Error( 'db_error', __( 'Unable to disable TOTP provider for this user.', 'two-factor' ), array( 'status' => 500 ) ); } + $this->delete_user_totp_key( $user_id ); + ob_start(); $this->user_two_factor_options( $user ); $html = ob_get_clean(); @@ -194,6 +226,8 @@ public function rest_delete_totp( $request ) { /** * REST API endpoint for setting up TOTP. * + * @since 0.8.0 + * * @param WP_REST_Request $request The Rest Request object. * @return WP_Error|array Array of data on success, WP_Error on error. */ @@ -233,6 +267,8 @@ public function rest_setup_totp( $request ) { /** * Generates a URL that can be used to create a QR code. * + * @since 0.8.0 + * * @param WP_User $user The user to generate a URL for. * @param string $secret_key The secret key. * @@ -242,21 +278,27 @@ public static function generate_qr_code_url( $user, $secret_key ) { $issuer = get_bloginfo( 'name', 'display' ); /** - * Filter the Issuer for the TOTP. + * Filters the Issuer for the TOTP. * * Must follow the TOTP format for a "issuer". Do not URL Encode. * + * @since 0.8.0 + * * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format#issuer + * * @param string $issuer The issuer for TOTP. */ $issuer = apply_filters( 'two_factor_totp_issuer', $issuer ); /** - * Filter the Label for the TOTP. + * Filters the Label for the TOTP. * * Must follow the TOTP format for a "label". Do not URL Encode. * + * @since 0.4.7 + * * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format#label + * * @param string $totp_title The label for the TOTP. * @param WP_User $user The User object. * @param string $issuer The issuer of the TOTP. This should be the prefix of the result. @@ -272,11 +314,14 @@ public static function generate_qr_code_url( $user, $secret_key ) { ); /** - * Filter the TOTP generated URL. + * Filters the TOTP generated URL. * * Must follow the TOTP format. Do not URL Encode. * + * @since 0.8.0 + * * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * * @param string $totp_url The TOTP URL. * @param WP_User $user The user object. */ @@ -289,6 +334,8 @@ public static function generate_qr_code_url( $user, $secret_key ) { /** * Display TOTP options on the user settings page. * + * @since 0.2.0 + * * @param WP_User $user The current user being edited. * @return void * @@ -301,9 +348,16 @@ public function user_two_factor_options( $user ) { $key = $this->get_user_totp_key( $user->ID ); - wp_enqueue_script( 'two-factor-qr-code-generator' ); - wp_enqueue_script( 'wp-api-request' ); - wp_enqueue_script( 'jquery' ); + wp_localize_script( + 'two-factor-totp-admin', + 'twoFactorTotpAdmin', + array( + 'restPath' => Two_Factor_Core::REST_NAMESPACE . '/totp', + 'userId' => $user->ID, + 'qrCodeAriaLabel' => __( 'Authenticator App QR Code', 'two-factor' ), + ) + ); + wp_enqueue_script( 'two-factor-totp-admin' ); ?>
@@ -311,123 +365,75 @@ public function user_two_factor_options( $user ) { if ( empty( $key ) ) : $key = $this->generate_key(); $totp_url = $this->generate_qr_code_url( $user, $key ); - ?>

- -

-

- - - - -

- - - - -

- -

-
-

- -

-

- - - +

- - - +
    +
  1. + +
  2. +
  3. + +

    + + + + +

    +

    + +

    +

    + +

    +
  4. +
  5. +

    +

    + + + +

    +

    + %2$s (%3$s)', + esc_attr( wp_date( 'c' ) ), + esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ), + esc_html( wp_timezone_string() ) + ) + ); + ?> +

    +
  6. +
+ $totp_url, + 'qrCodeLabel' => __( 'Authenticator App QR Code', 'two-factor' ), + ) + ); + wp_enqueue_script( 'two-factor-totp-qrcode' ); + ?>

@@ -436,24 +442,6 @@ public function user_two_factor_options( $user ) { -

@@ -463,6 +451,8 @@ public function user_two_factor_options( $user ) { /** * Get the TOTP secret key for a user. * + * @since 0.2.0 + * * @param int $user_id User ID. * * @return string @@ -474,6 +464,8 @@ public function get_user_totp_key( $user_id ) { /** * Set the TOTP secret key for a user. * + * @since 0.2.0 + * * @param int $user_id User ID. * @param string $key TOTP secret key. * @@ -486,6 +478,8 @@ public function set_user_totp_key( $user_id, $key ) { /** * Delete the TOTP secret key for a user. * + * @since 0.2.0 + * * @param int $user_id User ID. * * @return boolean If the key was deleted successfully. @@ -498,6 +492,8 @@ public function delete_user_totp_key( $user_id ) { /** * Check if the TOTP secret key has a proper format. * + * @since 0.2.0 + * * @param string $key TOTP secret key. * * @return boolean @@ -515,6 +511,8 @@ public function is_valid_key( $key ) { /** * Validates authentication. * + * @since 0.2.0 + * * @param WP_User $user WP_User object of the logged-in user. * * @return bool Whether the user gave a valid code @@ -531,6 +529,8 @@ public function validate_authentication( $user ) { /** * Validates an authentication code for a given user, preventing re-use and older TOTP keys. * + * @since 0.8.0 + * * @param WP_User $user WP_User object of the logged-in user. * @param int $code The TOTP token to validate. * @@ -562,6 +562,8 @@ public function validate_code_for_user( $user, $code ) { /** * Checks if a given code is valid for a given key, allowing for a certain amount of time drift. * + * @since 0.15.0 + * * @param string $key The share secret key to use. * @param string $authcode The code to test. * @param string $hash The hash used to calculate the code. @@ -576,6 +578,8 @@ public static function is_valid_authcode( $key, $authcode, $hash = self::DEFAULT /** * Checks if a given code is valid for a given key, allowing for a certain amount of time drift. * + * @since 0.15.0 + * * @param string $key The share secret key to use. * @param string $authcode The code to test. * @param string $hash The hash used to calculate the code. @@ -588,28 +592,40 @@ public static function get_authcode_valid_ticktime( $key, $authcode, $hash = sel * Filter the maximum ticks to allow when checking valid codes. * * Ticks are the allowed offset from the correct time in 30 second increments, - * so the default of 4 allows codes that are two minutes to either side of server time + * so the default of 4 allows codes that are two minutes to either side of server time. * + * @since 0.2.0 * @deprecated 0.7.0 Use {@see 'two_factor_totp_time_step_allowance'} instead. + * * @param int $max_ticks Max ticks of time correction to allow. Default 4. */ $max_ticks = apply_filters_deprecated( 'two-factor-totp-time-step-allowance', array( self::DEFAULT_TIME_STEP_ALLOWANCE ), '0.7.0', 'two_factor_totp_time_step_allowance' ); + /** + * Filters the maximum ticks to allow when checking valid codes. + * + * Ticks are the allowed offset from the correct time in 30 second increments, + * so the default of 4 allows codes that are two minutes to either side of server time. + * + * @since 0.7.0 + * + * @param int $max_ticks Max ticks of time correction to allow. Default 4. + */ $max_ticks = apply_filters( 'two_factor_totp_time_step_allowance', self::DEFAULT_TIME_STEP_ALLOWANCE ); // Array of all ticks to allow, sorted using absolute value to test closest match first. $ticks = range( - $max_ticks, $max_ticks ); usort( $ticks, array( __CLASS__, 'abssort' ) ); - $time = floor( self::time() / $time_step ); + $time = (int) floor( self::time() / $time_step ); $digits = strlen( $authcode ); foreach ( $ticks as $offset ) { - $log_time = $time + $offset; + $log_time = (int) ( $time + $offset ); if ( hash_equals( self::calc_totp( $key, $log_time, $digits, $hash, $time_step ), $authcode ) ) { // Return the tick timestamp. - return $log_time * self::DEFAULT_TIME_STEP_SEC; + return (int) ( $log_time * self::DEFAULT_TIME_STEP_SEC ); } } @@ -619,6 +635,8 @@ public static function get_authcode_valid_ticktime( $key, $authcode, $hash = sel /** * Generates key * + * @since 0.2.0 + * * @param int $bitsize Nume of bits to use for key. * * @return string $bitsize long string composed of available base32 chars. @@ -631,32 +649,24 @@ public static function generate_key( $bitsize = self::DEFAULT_KEY_BIT_SIZE ) { } /** - * Pack stuff + * Pack stuff. We're currently only using this to pack integers, however the generic `pack` method can handle mixed. * - * @param string $value The value to be packed. + * @since 0.2.0 + * + * @param int $value The value to be packed. * * @return string Binary packed string. */ - public static function pack64( $value ) { - // 64bit mode (PHP_INT_SIZE == 8). - if ( PHP_INT_SIZE >= 8 ) { - // If we're on PHP 5.6.3+ we can use the new 64bit pack functionality. - if ( version_compare( PHP_VERSION, '5.6.3', '>=' ) && PHP_INT_SIZE >= 8 ) { - return pack( 'J', $value ); // phpcs:ignore PHPCompatibility.ParameterValues.NewPackFormat.NewFormatFound - } - $highmap = 0xffffffff << 32; - $higher = ( $value & $highmap ) >> 32; - } else { - /* - * 32bit PHP can't shift 32 bits like that, so we have to assume 0 for the higher - * and not pack anything beyond it's limits. - */ - $higher = 0; + public static function pack64( int $value ): string { + // Native 64-bit support (modern PHP on 64-bit builds). + if ( 8 === PHP_INT_SIZE ) { + return pack( 'J', $value ); } - - $lowmap = 0xffffffff; - $lower = $value & $lowmap; - + + // 32-bit PHP fallback + $higher = ( $value >> 32 ) & 0xFFFFFFFF; + $lower = $value & 0xFFFFFFFF; + return pack( 'NN', $higher, $lower ); } @@ -664,6 +674,8 @@ public static function pack64( $value ) { * Pad a short secret with bytes from the same until it's the correct length * for hashing. * + * @since 0.15.0 + * * @param string $secret Secret key to pad. * @param int $length Byte length of the desired padded secret. * @@ -687,12 +699,14 @@ protected static function pad_secret( $secret, $length ) { /** * Calculate a valid code given the shared secret key * + * @since 0.2.0 + * * @param string $key The shared secret key to use for calculating code. * @param mixed $step_count The time step used to calculate the code, which is the floor of time() divided by step size. * @param int $digits The number of digits in the returned code. * @param string $hash The hash used to calculate the code. * @param int $time_step The size of the time step. - * + * * @throws InvalidArgumentException If the hash type is invalid. * * @return string The totp code @@ -712,7 +726,7 @@ public static function calc_totp( $key, $step_count = false, $digits = self::DEF break; default: throw new InvalidArgumentException( 'Invalid hash type specified!' ); - } + } if ( false === $step_count ) { $step_count = floor( self::time() / $time_step ); @@ -737,6 +751,8 @@ public static function calc_totp( $key, $step_count = false, $digits = self::DEF /** * Whether this Two Factor provider is configured and available for the user specified. * + * @since 0.2.0 + * * @param WP_User $user WP_User object of the logged-in user. * * @return boolean @@ -751,6 +767,8 @@ public function is_available_for_user( $user ) { /** * Prints the form that prompts the user to authenticate. * + * @since 0.2.0 + * * @param WP_User $user WP_User object of the logged-in user. * * @codeCoverageIgnore @@ -758,32 +776,36 @@ public function is_available_for_user( $user ) { public function authentication_page( $user ) { require_once ABSPATH . '/wp-admin/includes/template.php'; ?> - +

- +

- +

- - + ' ).val( csvCodes ).css( { position: 'absolute', left: '-9999px' } ); + $( 'body' ).append( $temp ); + $temp[0].select(); + document.execCommand( 'copy' ); + $temp.remove(); + } ); + + $( '.button-two-factor-backup-codes-generate' ).click( function() { + wp.apiRequest( { + method: 'POST', + path: twoFactorBackupCodes.restPath, + data: { + user_id: parseInt( twoFactorBackupCodes.userId, 10 ) + } + } ).then( function( response ) { + var $codesList = $( '.two-factor-backup-codes-unused-codes' ), + i; + + $( '.two-factor-backup-codes-wrapper' ).show(); + $codesList.html( '' ); + $codesList.css( { 'column-count': 2, 'column-gap': '80px', 'max-width': '420px' } ); + $( '.two-factor-backup-codes-wrapper' ).data( 'codesCsv', response.codes.join( ',' ) ); + + // Append the codes. + for ( i = 0; i < response.codes.length; i++ ) { + $codesList.append( '
  • ' + response.codes[ i ] + '
  • ' ); + } + + // Update counter. + $( '.two-factor-backup-codes-count' ).html( response.i18n.count ); + $( '#two-factor-backup-codes-download-link' ).attr( 'href', response.download_link ); + } ); + } ); +}( jQuery ) ); diff --git a/providers/js/email-admin.js b/providers/js/email-admin.js new file mode 100644 index 000000000..5aee3e069 --- /dev/null +++ b/providers/js/email-admin.js @@ -0,0 +1,56 @@ +/* global twoFactorEmailAdmin, wp, jQuery */ +( function( $ ) { + $( '#two-factor-email-send-code' ).on( 'click', function( e ) { + var $btn = $( this ); + + e.preventDefault(); + $btn.prop( 'disabled', true ); + + wp.apiRequest( { + method: 'POST', + path: twoFactorEmailAdmin.restPath, + data: { + user_id: parseInt( twoFactorEmailAdmin.userId, 10 ) + } + } ).done( function() { + $btn.hide(); + $( '#two-factor-email-verification-form' ).slideDown(); + $( '#two-factor-email-code-input' ).focus(); + } ).fail( function( response ) { + var msg = ( response.responseJSON && response.responseJSON.message ) ? response.responseJSON.message : 'Error sending email'; + + // eslint-disable-next-line no-alert + alert( msg ); + $btn.prop( 'disabled', false ); + } ); + } ); + + $( '#two-factor-email-verify-code' ).on( 'click', function( e ) { + var $btn = $( this ), + code = $( '#two-factor-email-code-input' ).val(); + + e.preventDefault(); + $btn.prop( 'disabled', true ); + + wp.apiRequest( { + method: 'POST', + path: twoFactorEmailAdmin.restPath, + data: { + user_id: parseInt( twoFactorEmailAdmin.userId, 10 ), + code: code, + enable_provider: true + } + } ).done( function( response ) { + var $newContent = $( response.html ); + + $( '#two-factor-email-options' ).replaceWith( $newContent ); + $( '#enabled-Two_Factor_Email' ).prop( 'checked', true ); + } ).fail( function( response ) { + var msg = ( response.responseJSON && response.responseJSON.message ) ? response.responseJSON.message : 'Error verifying code'; + + // eslint-disable-next-line no-alert + alert( msg ); + $btn.prop( 'disabled', false ); + } ); + } ); +}( jQuery ) ); diff --git a/providers/js/fido-u2f-admin-inline-edit.js b/providers/js/fido-u2f-admin-inline-edit.js deleted file mode 100644 index b7b0123e4..000000000 --- a/providers/js/fido-u2f-admin-inline-edit.js +++ /dev/null @@ -1,150 +0,0 @@ -/* global window, document, jQuery, inlineEditL10n, ajaxurl */ -var inlineEditKey; - -( function( $ ) { - inlineEditKey = { - - init: function() { - var t = this, - row = $( '#security-keys-section #inline-edit' ); - - t.what = '#key-'; - - $( '#security-keys-section #the-list' ).on( 'click', 'a.editinline', function() { - inlineEditKey.edit( this ); - return false; - } ); - - // Prepare the edit row. - row.keyup( function( event ) { - if ( 27 === event.which ) { - return inlineEditKey.revert(); - } - } ); - - $( 'a.cancel', row ).click( function() { - return inlineEditKey.revert(); - } ); - - $( 'a.save', row ).click( function() { - return inlineEditKey.save( this ); - } ); - - $( 'input, select', row ).keydown( function( event ) { - if ( 13 === event.which ) { - return inlineEditKey.save( this ); - } - } ); - }, - - toggle: function( el ) { - var t = this; - - if ( 'none' === $( t.what + t.getId( el ) ).css( 'display' ) ) { - t.revert(); - } else { - t.edit( el ); - } - }, - - edit: function( id ) { - var editRow, rowData, val, - t = this; - t.revert(); - - if ( 'object' === typeof id ) { - id = t.getId( id ); - } - - editRow = $( '#inline-edit' ).clone( true ); - rowData = $( '#inline_' + id ); - - $( 'td', editRow ).attr( 'colspan', $( 'th:visible, td:visible', '#security-keys-section .widefat thead' ).length ); - - $( t.what + id ).hide().after( editRow ).after( '' ); - - val = $( '.name', rowData ); - val.find( 'img' ).replaceWith( function() { - return this.alt; - } ); - val = val.text(); - $( ':input[name="name"]', editRow ).val( val ); - - $( editRow ).attr( 'id', 'edit-' + id ).addClass( 'inline-editor' ).show(); - $( '.ptitle', editRow ).eq( 0 ).focus(); - - return false; - }, - - save: function( id ) { - var params, fields; - - if ( 'object' === typeof id ) { - id = this.getId( id ); - } - - $( '#security-keys-section table.widefat .spinner' ).addClass( 'is-active' ); - - params = { - action: 'inline-save-key', - keyHandle: id, - user_id: window.u2fL10n.user_id - }; - - fields = $( '#edit-' + id ).find( ':input' ).serialize(); - params = fields + '&' + $.param( params ); - - // Make ajax request. - $.post( ajaxurl, params, - function( r ) { - var row, newID; - $( '#security-keys-section table.widefat .spinner' ).removeClass( 'is-active' ); - - if ( r ) { - if ( -1 !== r.indexOf( '' )[0].submit.call( $( '#your-profile' )[0] ); - } ); - } ); -}( jQuery ) ); diff --git a/providers/js/fido-u2f-login.js b/providers/js/fido-u2f-login.js deleted file mode 100644 index 28295307f..000000000 --- a/providers/js/fido-u2f-login.js +++ /dev/null @@ -1,16 +0,0 @@ -/* global window, u2f, u2fL10n, jQuery */ -( function( $ ) { - if ( ! window.u2fL10n ) { - window.console.error( 'u2fL10n is not defined' ); - return; - } - - u2f.sign( u2fL10n.request[0].appId, u2fL10n.request[0].challenge, u2fL10n.request, function( data ) { - if ( data.errorCode ) { - window.console.error( 'Registration Failed', data.errorCode ); - } else { - $( '#u2f_response' ).val( JSON.stringify( data ) ); - $( '#loginform' ).submit(); - } - } ); -}( jQuery ) ); diff --git a/providers/js/totp-admin-qrcode.js b/providers/js/totp-admin-qrcode.js new file mode 100644 index 000000000..bb2cf4eff --- /dev/null +++ b/providers/js/totp-admin-qrcode.js @@ -0,0 +1,35 @@ +/* global twoFactorTotpQrcode, qrcode, document, window */ +( function() { + var qrGenerator = function() { + /* + * 0 = Automatically select the version, to avoid going over the limit of URL + * length. + * L = Least amount of error correction, because it's not needed when scanning + * on a monitor, and it lowers the image size. + */ + var qr = qrcode( 0, 'L' ), + svg, + title; + + qr.addData( twoFactorTotpQrcode.totpUrl ); + qr.make(); + + document.querySelector( '#two-factor-qr-code a' ).innerHTML = qr.createSvgTag( 5 ); + + // For accessibility, markup the SVG with a title and role. + svg = document.querySelector( '#two-factor-qr-code a svg' ); + title = document.createElement( 'title' ); + + svg.setAttribute( 'role', 'img' ); + svg.setAttribute( 'aria-label', twoFactorTotpQrcode.qrCodeLabel ); + title.innerText = twoFactorTotpQrcode.qrCodeLabel; + svg.appendChild( title ); + }; + + // Run now if the document is loaded, otherwise on DOMContentLoaded. + if ( document.readyState === 'complete' ) { + qrGenerator(); + } else { + window.addEventListener( 'DOMContentLoaded', qrGenerator ); + } +}() ); diff --git a/providers/js/totp-admin.js b/providers/js/totp-admin.js new file mode 100644 index 000000000..4d59e800a --- /dev/null +++ b/providers/js/totp-admin.js @@ -0,0 +1,95 @@ +/* global twoFactorTotpAdmin, qrcode, wp, document, jQuery */ +( function( $ ) { + var generateQrCode = function( totpUrl ) { + var $qrLink = $( '#two-factor-qr-code a' ), + qr, + svg, + title; + + if ( ! $qrLink.length || typeof qrcode === 'undefined' ) { + return; + } + + qr = qrcode( 0, 'L' ); + + qr.addData( totpUrl ); + qr.make(); + $qrLink.html( qr.createSvgTag( 5 ) ); + + svg = $qrLink.find( 'svg' )[ 0 ]; + if ( svg ) { + var ariaLabel = ( typeof twoFactorTotpAdmin !== 'undefined' && twoFactorTotpAdmin && twoFactorTotpAdmin.qrCodeAriaLabel ) ? twoFactorTotpAdmin.qrCodeAriaLabel : 'Authenticator App QR Code'; + title = document.createElement( 'title' ); + svg.setAttribute( 'role', 'img' ); + svg.setAttribute( 'aria-label', ariaLabel ); + title.innerText = ariaLabel; + svg.appendChild( title ); + } + }; + + var checkbox = document.getElementById( 'enabled-Two_Factor_Totp' ); + + // Focus the auth code input when the checkbox is clicked. + if ( checkbox ) { + checkbox.addEventListener( 'click', function( e ) { + if ( e.target.checked ) { + document.getElementById( 'two-factor-totp-authcode' ).focus(); + } + } ); + } + + $( '.totp-submit' ).click( function( e ) { + var key = $( '#two-factor-totp-key' ).val(), + code = $( '#two-factor-totp-authcode' ).val(); + + e.preventDefault(); + + wp.apiRequest( { + method: 'POST', + path: twoFactorTotpAdmin.restPath, + data: { + user_id: parseInt( twoFactorTotpAdmin.userId, 10 ), + key: key, + code: code, + enable_provider: true + } + } ).fail( function( response, status ) { + var errorMessage = ( response && response.responseJSON && response.responseJSON.message ) || ( response && response.statusText ) || status || '', + $error = $( '#totp-setup-error' ); + + if ( ! $error.length ) { + $error = $( '

    ' ).insertAfter( $( '.totp-submit' ) ); + } + + $error.find( 'p' ).text( errorMessage ); + + $( '#enabled-Two_Factor_Totp' ).prop( 'checked', false ).trigger( 'change' ); + $( '#two-factor-totp-authcode' ).val( '' ); + } ).then( function( response ) { + $( '#enabled-Two_Factor_Totp' ).prop( 'checked', true ).trigger( 'change' ); + $( '#two-factor-totp-options' ).html( response.html ); + } ); + } ); + + $( '.button.reset-totp-key' ).click( function( e ) { + e.preventDefault(); + + wp.apiRequest( { + method: 'DELETE', + path: twoFactorTotpAdmin.restPath, + data: { + user_id: parseInt( twoFactorTotpAdmin.userId, 10 ) + } + } ).then( function( response ) { + var totpUrl; + + $( '#enabled-Two_Factor_Totp' ).prop( 'checked', false ); + $( '#two-factor-totp-options' ).html( response.html ); + + totpUrl = $( '#two-factor-qr-code a' ).attr( 'href' ); + if ( totpUrl ) { + generateQrCode( totpUrl ); + } + } ); + } ); +}( jQuery ) ); diff --git a/providers/js/two-factor-login-authcode.js b/providers/js/two-factor-login-authcode.js new file mode 100644 index 000000000..f7e6e7aae --- /dev/null +++ b/providers/js/two-factor-login-authcode.js @@ -0,0 +1,38 @@ +/* global document */ +( function() { + // Enforce numeric-only input for numeric inputmode elements. + var form = document.querySelector( '#loginform' ), + inputEl = document.querySelector( 'input.authcode[inputmode="numeric"]' ), + expectedLength = ( inputEl && inputEl.dataset ) ? inputEl.dataset.digits : 0, + spaceInserted = false; + + if ( inputEl ) { + inputEl.addEventListener( + 'input', + function() { + var value = this.value.replace( /[^0-9 ]/g, '' ).replace( /^\s+/, '' ), + submitControl; + + if ( ! spaceInserted && expectedLength && value.length === Math.floor( expectedLength / 2 ) ) { + value += ' '; + spaceInserted = true; + } else if ( spaceInserted && ! this.value ) { + spaceInserted = false; + } + + this.value = value; + + // Auto-submit if it's the expected length. + if ( expectedLength && value.replace( / /g, '' ).length === parseInt( expectedLength, 10 ) ) { + if ( form && typeof form.requestSubmit === 'function' ) { + form.requestSubmit(); + submitControl = form.querySelector( '[type="submit"]' ); + if ( submitControl ) { + submitControl.disabled = true; + } + } + } + } + ); + } +}() ); diff --git a/providers/js/two-factor-login.js b/providers/js/two-factor-login.js new file mode 100644 index 000000000..6526581fc --- /dev/null +++ b/providers/js/two-factor-login.js @@ -0,0 +1,11 @@ +/* global document, setTimeout */ +( function() { + setTimeout( function() { + var d; + try { + d = document.getElementById( 'authcode' ); + d.value = ''; + d.focus(); + } catch ( e ) {} + }, 200 ); +}() ); diff --git a/readme.md b/readme.md index 83dc826e8..ba89985dd 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,18 @@ # Two-Factor +![Two-Factor](https://github.com/WordPress/two-factor/blob/master/.wordpress-org/banner-1544x500.png) -[![Test](https://github.com/WordPress/two-factor/actions/workflows/test.yml/badge.svg)](https://github.com/WordPress/two-factor/actions/workflows/test.yml) [![Deploy](https://github.com/WordPress/two-factor/actions/workflows/deploy.yml/badge.svg)](https://github.com/WordPress/two-factor/actions/workflows/deploy.yml) [![WordPress Playground Demo](https://img.shields.io/wordpress/plugin/v/two-factor?logo=wordpress&logoColor=FFFFFF&label=Playground%20Demo&labelColor=3858E9&color=3858E9)](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/WordPress/two-factor/master/.wordpress-org/blueprints/blueprint.json) +![Required PHP Version](https://img.shields.io/wordpress/plugin/required-php/two-factor?label=Requires%20PHP) ![Required WordPress Version](https://img.shields.io/wordpress/plugin/wp-version/two-factor?label=Requires%20WordPress) ![WordPress Tested Up To](https://img.shields.io/wordpress/plugin/tested/two-factor?label=WordPress) [![GPL-2.0-or-later License](https://img.shields.io/github/license/WordPress/two-factor.svg)](https://github.com/WordPress/two-factor/blob/trunk/LICENSE.md?label=License) + +![WordPress.org Rating](https://img.shields.io/wordpress/plugin/rating/two-factor?label=WP.org%20Rating) ![WordPress Plugin Downloads](https://img.shields.io/wordpress/plugin/dt/two-factor?label=WP.org%20Downloads) ![WordPress Plugin Active Installs](https://img.shields.io/wordpress/plugin/installs/two-factor?label=WP.org%20Active%20Installs) [![WordPress Playground Demo](https://img.shields.io/wordpress/plugin/v/two-factor?logo=wordpress&logoColor=FFFFFF&label=Live%20Demo&labelColor=3858E9&color=3858E9)](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/WordPress/two-factor/master/.wordpress-org/blueprints/blueprint.json) + +[![Test](https://github.com/WordPress/two-factor/actions/workflows/test.yml/badge.svg)](https://github.com/WordPress/two-factor/actions/workflows/test.yml) [![Deploy](https://github.com/WordPress/two-factor/actions/workflows/deploy.yml/badge.svg)](https://github.com/WordPress/two-factor/actions/workflows/deploy.yml) + +> Two-Factor plugin for WordPress. [View on WordPress.org →](https://wordpress.org/plugins/two-factor/) + +## Description + +The Two-Factor plugin adds an extra layer of security to your WordPress login by requiring users to provide a second form of authentication in addition to their password. This helps protect against unauthorized access even if passwords are compromised. -Two-Factor plugin for WordPress. [View on WordPress.org →](https://wordpress.org/plugins/two-factor/) ## Usage diff --git a/readme.txt b/readme.txt index 3f88feca9..18d0cd42b 100644 --- a/readme.txt +++ b/readme.txt @@ -2,11 +2,11 @@ Contributors: georgestephanis, valendesigns, stevenkword, extendwings, sgrant, aaroncampbell, johnbillion, stevegrunwell, netweb, kasparsd, alihusnainarshad, passoniate Tags: 2fa, mfa, totp, authentication, security Tested up to: 6.9 -Stable tag: 0.14.2 +Stable tag: 0.15.0 License: GPL-2.0-or-later License URI: https://spdx.org/licenses/GPL-2.0-or-later.html -Enable Two-Factor Authentication (2FA) using time-based one-time passwords (TOTP), Universal 2nd Factor (U2F), email, and backup verification codes. +Enable Two-Factor Authentication (2FA) using time-based one-time passwords (TOTP), email, and backup verification codes. == Description == @@ -14,7 +14,7 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by ## Setup Instructions -**Important**: Each user must individually configure their two-factor authentication settings. There are no site-wide settings for this plugin. +**Important**: Each user must individually configure their two-factor authentication settings. ### For Individual Users @@ -23,7 +23,6 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by 3. **Choose your methods**: Enable one or more authentication providers (noting a site admin may have hidden one or more so what is available could vary): - **Authenticator App (TOTP)** - Use apps like Google Authenticator, Authy, or 1Password - **Email Codes** - Receive one-time codes via email - - **FIDO U2F Security Keys** - Use physical security keys (requires HTTPS) - **Backup Codes** - Generate one-time backup codes for emergencies - **Dummy Method** - For testing purposes only (requires WP_DEBUG) 4. **Configure each method**: Follow the setup instructions for each enabled provider @@ -32,7 +31,7 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by ### For Site Administrators -- **No global settings**: This plugin operates on a per-user basis only. For more, see [GH#249](https://github.com/WordPress/two-factor/issues/249). +- **Plugin settings**: The plugin provides a settings page under "Settings → Two-Factor" to configure which providers should be disabled site-wide. - **User management**: Administrators can configure 2FA for other users by editing their profiles - **Security recommendations**: Encourage users to enable backup methods to prevent account lockouts @@ -57,11 +56,7 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by - **Best for**: Users who prefer email-based authentication ### FIDO U2F Security Keys -- **Security**: High - Hardware-based authentication -- **Setup**: Register physical security keys (USB, NFC, or Bluetooth) -- **Requirements**: HTTPS connection required, compatible browser needed -- **Browser Support**: Chrome, Firefox, Edge (varies by key type) -- **Best for**: Users with security keys who want maximum security +- Deprecated and removed due to loss of browser support. ### Dummy Method - **Security**: None - Always succeeds @@ -72,11 +67,9 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by ## Important Notes ### HTTPS Requirement -- FIDO U2F Security Keys require an HTTPS connection to function -- Other methods work on both HTTP and HTTPS sites +- All methods work on both HTTP and HTTPS sites ### Browser Compatibility -- FIDO U2F requires a compatible browser and may not work on all devices - TOTP and email methods work on all devices and browsers ### Account Recovery @@ -108,6 +101,7 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_before_authentication_prompt` action which receives the provider object and fires prior to the prompt shown on the authentication input form. - `two_factor_after_authentication_prompt` action which receives the provider object and fires after the prompt shown on the authentication input form. - `two_factor_after_authentication_input`action which receives the provider object and fires after the input shown on the authentication input form (if form contains no input, action fires immediately after `two_factor_after_authentication_prompt`). +- `two_factor_login_backup_links` filters the backup links displayed on the two-factor login form. == Frequently Asked Questions == @@ -125,19 +119,15 @@ The plugin contributors and WordPress community take security bugs seriously. We To report a security issue, please visit the [WordPress HackerOne](https://hackerone.com/wordpress) program. -= Why doesn't this plugin have site-wide settings? = - -This plugin is designed to work on a per-user basis, allowing each user to choose their preferred authentication methods. This approach provides maximum flexibility and security. Site administrators can still configure 2FA for other users by editing their profiles. For more information, see [issue #437](https://github.com/WordPress/two-factor/issues/437). - = What if I lose access to all my authentication methods? = If you have backup codes enabled, you can use one of those to regain access. If you don't have backup codes or have used them all, you'll need to contact your site administrator to reset your account. This is why it's important to always enable backup codes and keep them in a secure location. = Can I use this plugin with WebAuthn? = -The plugin currently supports FIDO U2F, which is the predecessor to WebAuthn. For full WebAuthn support, you may want to look into additional plugins that extend this functionality. The current U2F implementation requires HTTPS and has browser compatibility limitations. +The plugin previously supported FIDO U2F, which was a predecessor to WebAuthn. There is an open issue to add WebAuthn support here: https://github.com/WordPress/two-factor/pull/427 -= Is there a recommended way to use passkeys or hardware security keys with Two-Factor? = += Is there a recommended way to use passkeys or hardware security keys with Two-Factor? = Yes. For passkeys and hardware security keys, you can install the Two-Factor Provider: WebAuthn plugin: https://wordpress.org/plugins/two-factor-provider-webauthn/ . It integrates directly with Two-Factor and adds WebAuthn-based authentication as an additional two-factor option for users. @@ -145,13 +135,55 @@ Yes. For passkeys and hardware security keys, you can install the Two-Factor Pro == Screenshots == 1. Two-factor options under User Profile - Shows the main configuration area where users can enable different authentication methods. -2. U2F Security Keys section under User Profile - Displays the security key management interface for registering and managing FIDO U2F devices. -3. Email Code Authentication during WordPress Login - Shows the email verification screen that appears during login. -4. Authenticator App (TOTP) setup with QR code - Demonstrates the QR code generation and manual key entry for TOTP setup. -5. Backup codes generation and management - Shows the backup codes interface for generating and managing emergency access codes. +2. Email Code Authentication during WordPress Login - Shows the email verification screen that appears during login. +3. Authenticator App (TOTP) setup with QR code - Demonstrates the QR code generation and manual key entry for TOTP setup. +4. Backup codes generation and management - Shows the backup codes interface for generating and managing emergency access codes. == Changelog == += 0.15.0 - 2026-02-13 = + +* **Breaking Changes:** Trigger two-factor flow only when expected by @kasparsd in [#660](https://github.com/WordPress/two-factor/pull/660) and [#793](https://github.com/WordPress/two-factor/pull/793). +* **New Features:** Include user IP address and contextual warning in two-factor code emails by @todeveni in [#728](https://github.com/WordPress/two-factor/pull/728) +* **New Features:** Optimize email text for TOTP by @masteradhoc in [#789](https://github.com/WordPress/two-factor/pull/789) +* **New Features:** Add "Settings" action link to plugin list for quick access to profile by @hardikRathi in [#740](https://github.com/WordPress/two-factor/pull/740) +* **New Features:** Additional form hooks by @eric-michel in [#742](https://github.com/WordPress/two-factor/pull/742) +* **New Features:** Full RFC6238 Compatibility by @ericmann in [#656](https://github.com/WordPress/two-factor/pull/656) +* **New Features:** Consistent user experience for TOTP setup by @kasparsd in [#792](https://github.com/WordPress/two-factor/pull/792) +* **Documentation:** `@since` docs by @masteradhoc in [#781](https://github.com/WordPress/two-factor/pull/781) +* **Documentation:** Update user and admin docs, prepare for more screenshots by @jeffpaul in [#701](https://github.com/WordPress/two-factor/pull/701) +* **Documentation:** Add changelog & credits, update release notes by @jeffpaul in [#696](https://github.com/WordPress/two-factor/pull/696) +* **Documentation:** Clear readme.txt by @masteradhoc in [#785](https://github.com/WordPress/two-factor/pull/785) +* **Documentation:** Add date and time information above TOTP setup instructions by @masteradhoc in [#772](https://github.com/WordPress/two-factor/pull/772) +* **Documentation:** Clarify TOTP setup instructions by @masteradhoc in [#763](https://github.com/WordPress/two-factor/pull/763) +* **Documentation:** Update RELEASING.md by @jeffpaul in [#787](https://github.com/WordPress/two-factor/pull/787) +* **Development Updates:** Pause deploys to SVN trunk for merges to `master` by @kasparsd in [#738](https://github.com/WordPress/two-factor/pull/738) +* **Development Updates:** Fix CI checks for PHP compatability by @kasparsd in [#739](https://github.com/WordPress/two-factor/pull/739) +* **Development Updates:** Fix Playground refs by @kasparsd in [#744](https://github.com/WordPress/two-factor/pull/744) +* **Development Updates:** Persist existing translations when introducing new helper text in emails by @kasparsd in [#745](https://github.com/WordPress/two-factor/pull/745) +* **Development Updates:** Fix `missing_direct_file_access_protection` by @masteradhoc in [#760](https://github.com/WordPress/two-factor/pull/760) +* **Development Updates:** Fix `mismatched_plugin_name` by @masteradhoc in [#754](https://github.com/WordPress/two-factor/pull/754) +* **Development Updates:** Introduce Props Bot workflow by @jeffpaul in [#749](https://github.com/WordPress/two-factor/pull/749) +* **Development Updates:** Plugin Check: Fix Missing $domain parameter by @masteradhoc in [#753](https://github.com/WordPress/two-factor/pull/753) +* **Development Updates:** Tests: Update to supported WP version 6.8 by @masteradhoc in [#770](https://github.com/WordPress/two-factor/pull/770) +* **Development Updates:** Fix PHP 8.5 deprecated message by @masteradhoc in [#762](https://github.com/WordPress/two-factor/pull/762) +* **Development Updates:** Exclude 7.2 and 7.3 checks against trunk by @masteradhoc in [#769](https://github.com/WordPress/two-factor/pull/769) +* **Development Updates:** Fix Plugin Check errors: `MissingTranslatorsComment` & `MissingSingularPlaceholder` by @masteradhoc in [#758](https://github.com/WordPress/two-factor/pull/758) +* **Development Updates:** Add PHP 8.5 tests for latest and trunk version of WP by @masteradhoc in [#771](https://github.com/WordPress/two-factor/pull/771) +* **Development Updates:** Add `phpcs:ignore` for falsepositives by @masteradhoc in [#777](https://github.com/WordPress/two-factor/pull/777) +* **Development Updates:** Fix(totp): `otpauth` link in QR code URL by @sjinks in [#784](https://github.com/WordPress/two-factor/pull/784) +* **Development Updates:** Update deploy.yml by @masteradhoc in [#773](https://github.com/WordPress/two-factor/pull/773) +* **Development Updates:** Update required WordPress Version by @masteradhoc in [#765](https://github.com/WordPress/two-factor/pull/765) +* **Development Updates:** Fix: ensure execution stops after redirects by @sjinks in [#786](https://github.com/WordPress/two-factor/pull/786) +* **Development Updates:** Fix `WordPress.Security.EscapeOutput.OutputNotEscaped` errors by @masteradhoc in [#776](https://github.com/WordPress/two-factor/pull/776) +* **Dependency Updates:** Bump qs and express by @dependabot[bot] in [#746](https://github.com/WordPress/two-factor/pull/746) +* **Dependency Updates:** Bump lodash from 4.17.21 to 4.17.23 by @dependabot[bot] in [#750](https://github.com/WordPress/two-factor/pull/750) +* **Dependency Updates:** Bump lodash-es from 4.17.21 to 4.17.23 by @dependabot[bot] in [#748](https://github.com/WordPress/two-factor/pull/748) +* **Dependency Updates:** Bump phpunit/phpunit from 8.5.44 to 8.5.52 by @dependabot[bot] in [#755](https://github.com/WordPress/two-factor/pull/755) +* **Dependency Updates:** Bump symfony/process from 5.4.47 to 5.4.51 by @dependabot[bot] in [#756](https://github.com/WordPress/two-factor/pull/756) +* **Dependency Updates:** Bump qs and body-parser by @dependabot[bot] in [#782](https://github.com/WordPress/two-factor/pull/782) +* **Dependency Updates:** Bump webpack from 5.101.3 to 5.105.0 by @dependabot[bot] in [#780](https://github.com/WordPress/two-factor/pull/780) + = 0.14.2 - 2025-12-11 = * **New Features:** Add filter for rest_api_can_edit_user_and_update_two_factor_options by @gutobenn in [#689](https://github.com/WordPress/two-factor/pull/689) @@ -197,3 +229,4 @@ Bumps WordPress minimum supported version to 6.3 and PHP minimum to 7.2. = 0.9.0 = Users are now asked to re-authenticate with their two-factor before making changes to their two-factor settings. This associates each login session with the two-factor login meta data for improved handling of that session. + diff --git a/settings/class-two-factor-settings.php b/settings/class-two-factor-settings.php new file mode 100644 index 000000000..cb7b7132d --- /dev/null +++ b/settings/class-two-factor-settings.php @@ -0,0 +1,98 @@ +

    ' . esc_html__( 'Settings saved.', 'two-factor' ) . '

    '; + } + + // Build provider list for display using public core API. + $provider_instances = array(); + if ( class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'get_providers' ) ) { + $provider_instances = Two_Factor_Core::get_providers(); + if ( ! is_array( $provider_instances ) ) { + $provider_instances = array(); + } + } + + // Default to all providers enabled when the option has never been saved. + $all_provider_keys = array_keys( $provider_instances ); + $saved_enabled = get_option( 'two_factor_enabled_providers', $all_provider_keys ); + + echo '
    '; + echo '

    ' . esc_html__( 'Two-Factor Settings', 'two-factor' ) . '

    '; + echo '

    ' . esc_html__( 'Enabled Providers', 'two-factor' ) . '

    '; + echo '

    ' . esc_html__( 'Choose which Two-Factor providers are available on this site. All providers are enabled by default.', 'two-factor' ) . '

    '; + echo '
    '; + wp_nonce_field( 'two_factor_save_settings', 'two_factor_settings_nonce' ); + + echo '
    ' . esc_html__( 'Providers', 'two-factor' ) . ''; + echo ''; + + if ( empty( $provider_instances ) ) { + echo ''; + } else { + // Render a compact stacked list of provider checkboxes below the title/description. + echo ''; + echo ''; + echo ''; + } + + echo '
    ' . esc_html__( 'No providers found.', 'two-factor' ) . '
    '; + foreach ( $provider_instances as $provider_key => $instance ) { + $label = method_exists( $instance, 'get_label' ) ? $instance->get_label() : $provider_key; + + echo '

    '; + } + + echo '
    '; + echo '
    '; + + submit_button( __( 'Save Settings', 'two-factor' ), 'primary', 'two_factor_settings_submit' ); + echo '
    '; + + echo '
    '; + } + +} diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 3aa36478b..46d5c18d7 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -5,6 +5,11 @@ * @package Two_Factor */ +/** + * Exception thrown when wp_redirect fires, to prevent exit() from terminating the test process. + */ +class Two_Factor_Redirect_Exception extends RuntimeException {} + /** * Class Test_ClassTwoFactorCore * @@ -49,6 +54,10 @@ public function tearDown(): void { parent::tearDown(); unset( $_COOKIE[ AUTH_COOKIE ], $_COOKIE[ LOGGED_IN_COOKIE ] ); + + // Remove the plugin's send_auth_cookies block that filter_authenticate installs, + // so it does not leak into subsequent tests that expect cookies to be settable. + remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); } /** @@ -122,6 +131,34 @@ public function clean_dummy_user() { unset( $_POST[ $key ] ); } + /** + * Run a callable that may call wp_safe_redirect() + exit. + * + * Intercepts the redirect via the wp_redirect filter to throw a + * Two_Factor_Redirect_Exception, preventing exit() from terminating the + * test process. Also captures and discards any output produced. + * + * @param callable $callback Code that may trigger a redirect. + * @return string|null The intercepted redirect URL, or null if no redirect occurred. + */ + private function do_redirect_callable( $callback ) { + $intercepted_url = null; + $redirect_filter = function ( $location ) { + throw new Two_Factor_Redirect_Exception( $location ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + }; + add_filter( 'wp_redirect', $redirect_filter, PHP_INT_MAX ); + ob_start(); + try { + $callback(); + } catch ( Two_Factor_Redirect_Exception $e ) { + $intercepted_url = $e->getMessage(); + } finally { + ob_end_clean(); + remove_filter( 'wp_redirect', $redirect_filter, PHP_INT_MAX ); + } + return $intercepted_url; + } + /** * Verify adding hooks. * @@ -301,6 +338,55 @@ public function test_get_available_providers_for_user_logged_in() { wp_set_current_user( $old_user_id ); } + /** + * Verify that if a user has a non-existent provider set, that it + * swaps to email instead, rather than treating the user as having + * no methods enabled. + */ + public function test_deprecated_provider_for_user() { + $user = $this->get_dummy_user(); + + // Set the dummy user with a non-existent provider. + update_user_meta( + $user->ID, + Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, + array( + 'Two_Factor_Deprecated', + ) + ); + + // Mark the user as verified for Two_Factor_Email. + update_user_meta( $user->ID, Two_Factor_Email::VERIFIED_META_KEY, $user->user_email ); + + // This should fail back to `Two_Factor_Email` then. + $this->assertEquals( + array( + 'Two_Factor_Email', + ), + array_keys( Two_Factor_Core::get_available_providers_for_user( $user ) ) + ); + + // Set the dummy user with a non-existent provider and a valid one. + update_user_meta( + $user->ID, + Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, + array( + 'Two_Factor_Deprecated', + 'Two_Factor_Dummy', + ) + ); + + // This time it should just strip out the invalid one, and not inject a new one. + $this->assertEquals( + array( + 'Two_Factor_Dummy', + ), + array_keys( Two_Factor_Core::get_available_providers_for_user( $user ) ) + ); + + $this->clean_dummy_user(); + } + /** * Verify primary provider for not-logged-in user. * @@ -400,38 +486,45 @@ public function test_filter_authenticate() { $this->assertFalse( Two_Factor_Core::is_api_request(), 'Is not an API request by default' ); - $this->assertInstanceOf( - 'WP_User', - Two_Factor_Core::filter_authenticate( $user_default, '', '' ), - 'Existing non-2FA user session should not trigger 2FA' - ); + // The WP test framework registers __return_false on send_auth_cookies at priority 10 to + // prevent real cookies from being set during tests. Check for the plugin's specific + // __return_false callback at PHP_INT_MAX (see Two_Factor_Core::filter_authenticate). + $has_plugin_cookie_block = function () { + global $wp_filter; + + if ( + ! isset( $wp_filter['send_auth_cookies'] ) || + ! $wp_filter['send_auth_cookies'] instanceof WP_Hook || + empty( $wp_filter['send_auth_cookies']->callbacks[ PHP_INT_MAX ] ) + ) { + return false; + } - $this->assertInstanceOf( - 'WP_User', - Two_Factor_Core::filter_authenticate( $user_default, 'username', '' ), - 'Existing non-2FA user login attempts should not trigger 2FA' - ); + foreach ( $wp_filter['send_auth_cookies']->callbacks[ PHP_INT_MAX ] as $cb ) { + if ( '__return_false' === $cb['function'] ) { + return true; + } + } + return false; + }; - $this->assertInstanceOf( - 'WP_User', - Two_Factor_Core::filter_authenticate( $user_2fa_enabled, '', '' ), - 'Existing 2FA user sessions should not trigger 2FA' + $this->assertFalse( + $has_plugin_cookie_block(), + 'Auth cookie block not registered before the `authenticate` filter has run.' ); + Two_Factor_Core::filter_authenticate( $user_default ); + $this->assertFalse( - has_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ) ), - 'Requests with existing user sessions should not trigger the two-factor flow' + $has_plugin_cookie_block(), + 'User login without 2fa should not block auth cookies.' ); - $this->assertInstanceOf( - 'WP_User', - Two_Factor_Core::filter_authenticate( $user_2fa_enabled, 'user-name', 'password' ), - 'Existing 2FA user session with username present should forward the user' - ); + Two_Factor_Core::filter_authenticate( $user_2fa_enabled ); - $this->assertNotFalse( - has_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ) ), - 'Existing 2FA user session with username present should trigger two-factor flow' + $this->assertTrue( + $has_plugin_cookie_block(), + 'User login with 2fa should block auth cookies.' ); } @@ -440,33 +533,32 @@ public function test_filter_authenticate() { * * @covers Two_Factor_Core::filter_authenticate * @covers Two_Factor_Core::is_api_request + * @runInSeparateProcess + * @preserveGlobalState disabled */ public function test_filter_authenticate_api() { $user_default = new WP_User( self::factory()->user->create() ); $user_2fa_enabled = $this->get_dummy_user(); // User with a dummy two-factor method enabled. // TODO: Get Two_Factor_Core away from static methods to allow mocking this. - define( 'XMLRPC_REQUEST', true ); + // Guard against re-definition if the constant is already set in this process. + if ( ! defined( 'XMLRPC_REQUEST' ) ) { + define( 'XMLRPC_REQUEST', true ); + } $this->assertTrue( Two_Factor_Core::is_api_request(), 'Can detect an API request' ); $this->assertInstanceOf( - 'WP_User', - Two_Factor_Core::filter_authenticate( $user_default, 'username', 'password' ), + WP_User::class, + Two_Factor_Core::filter_authenticate( $user_default ), 'Non-2FA user should be able to authenticate during API requests' ); $this->assertInstanceOf( - 'WP_Error', - Two_Factor_Core::filter_authenticate( $user_2fa_enabled, 'username', 'password' ), + WP_Error::class, + Two_Factor_Core::filter_authenticate( $user_2fa_enabled ), '2FA user should not be able to authenticate during API requests' ); - - $this->assertInstanceOf( - 'WP_User', - Two_Factor_Core::filter_authenticate( $user_2fa_enabled, '', null ), - 'Existing user session without a username should not trigger 2FA' - ); } /** @@ -817,8 +909,8 @@ public function test_reset_compromised_password() { $this->assertNotSame( $old_hash, $user->user_pass ); $this->assertSame( '1', get_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ) ); $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY, true ) ); - $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY ) ); - $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, false ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, false ) ); } /** @@ -1106,9 +1198,12 @@ public function test_is_current_user_session_two_factor_with_two_factor() { $this->assertNotFalse( $login_nonce ); // Process it. - ob_start(); - Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $redirect_url = $this->do_redirect_callable( + function () use ( $user, $login_nonce ) { + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $redirect_url, 'Expected a redirect after successful 2FA validation.' ); $this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] ); $this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] ); @@ -1156,9 +1251,12 @@ public function test_revalidation_sets_time() { $this->assertNotFalse( $login_nonce ); // Process it. - ob_start(); - Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $redirect_url = $this->do_redirect_callable( + function () use ( $user, $login_nonce ) { + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $redirect_url, 'Expected a redirect after successful 2FA validation.' ); $this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] ); $this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] ); @@ -1199,10 +1297,14 @@ public function test_revalidation_sets_time() { $this->assertLessThan( time(), Two_Factor_Core::is_current_user_session_two_factor() ); // Simulate clicking it with an incorrect nonce. - $bad_nonce = '__BAD_NONCE__'; - ob_start(); - Two_Factor_Core::_login_form_revalidate_2fa( $bad_nonce, 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $bad_nonce = '__BAD_NONCE__'; + $bad_redirect_url = $this->do_redirect_callable( + function () use ( $bad_nonce ) { + Two_Factor_Core::_login_form_revalidate_2fa( $bad_nonce, 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $bad_redirect_url, 'Expected a redirect after bad-nonce revalidation attempt.' ); + $this->assertEquals( home_url(), $bad_redirect_url, 'Bad-nonce revalidation should redirect to home.' ); // Check it's still expired. $this->assertLessThan( time(), Two_Factor_Core::is_current_user_session_two_factor() ); @@ -1210,9 +1312,12 @@ public function test_revalidation_sets_time() { // Simulate clicking it. $login_nonce = wp_create_nonce( 'two_factor_revalidate_' . $user->ID ); - ob_start(); - Two_Factor_Core::_login_form_revalidate_2fa( $login_nonce, 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $good_redirect_url = $this->do_redirect_callable( + function () use ( $login_nonce ) { + Two_Factor_Core::_login_form_revalidate_2fa( $login_nonce, 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $good_redirect_url, 'Expected a redirect after successful revalidation.' ); // Validate that the session is flagged as 2FA, and set to now-ish. $current_session_two_factor = Two_Factor_Core::is_current_user_session_two_factor(); @@ -1303,7 +1408,7 @@ public function test_session_getter_setter() { array( 'test-key' => true, 'test-key-two' => true, - ) + ) ); // Retrieve the session again, and verify it's updated. @@ -1316,7 +1421,7 @@ public function test_session_getter_setter() { Two_Factor_Core::update_current_user_session( array( 'test-key' => null, - ) + ) ); // Check the key is no longer there. @@ -1343,7 +1448,7 @@ public function test_get_provider_for_user() { array( 'two-factor-provider' => 'Two_Factor_Dummy', 'two-factor-login' => time(), - ) + ) ); $dummy = Two_Factor_Dummy::get_instance(); @@ -1397,7 +1502,7 @@ public function test_get_provider_for_user() { Two_Factor_Core::update_current_user_session( array( 'two-factor-provider' => $email->get_key(), - ) + ) ); // Validate it's now the default for the current session. @@ -1443,9 +1548,12 @@ public function test_filter_session_information() { $this->assertNotFalse( $login_nonce ); // Process it. - ob_start(); - Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); - ob_end_clean(); + $redirect_url = $this->do_redirect_callable( + function () use ( $user, $login_nonce ) { + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Dummy', '', true ); + } + ); + $this->assertNotNull( $redirect_url, 'Expected a redirect after successful 2FA validation.' ); $this->assertNotEmpty( $_COOKIE[ AUTH_COOKIE ] ); $this->assertNotEmpty( $_COOKIE[ LOGGED_IN_COOKIE ] ); @@ -1458,7 +1566,7 @@ public function test_filter_session_information() { 'two-factor-test-key1' => 'test-value', 'two-factor-test-key2' => 'test-value', 'tests-key' => 'test-value', - ) + ) ); $session = Two_Factor_Core::get_current_user_session(); @@ -1527,7 +1635,7 @@ public function test_other_sessions_destroyed_when_enabling_2fa() { 'set_logged_in_cookie', function ( $logged_in_cookie ) { $_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie; - } + } ); $user_authenticated = wp_signon( @@ -1563,6 +1671,9 @@ function ( $logged_in_cookie ) { $session_manager->create( time() + DAY_IN_SECONDS ); $this->assertCount( 2, $session_manager->get_all(), 'Failed to create another session' ); + // Set the email provider as verified so it can be enabled. + update_user_meta( $user->ID, Two_Factor_Email::VERIFIED_META_KEY, true ); + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy', 'Two_Factor_Email' => 'Two_Factor_Email', @@ -1784,4 +1895,674 @@ function ( $providers ) { remove_all_filters( 'two_factor_providers' ); } + + /** + * Test delete_login_nonce removes the nonce. + * + * @covers Two_Factor_Core::delete_login_nonce + */ + public function test_delete_login_nonce() { + $user_id = self::factory()->user->create(); + $nonce = Two_Factor_Core::create_login_nonce( $user_id ); + + $this->assertNotEmpty( $nonce, 'Login nonce was created' ); + $this->assertNotEmpty( get_user_meta( $user_id, Two_Factor_Core::USER_META_NONCE_KEY, true ), 'Nonce meta exists' ); + + $result = Two_Factor_Core::delete_login_nonce( $user_id ); + + $this->assertTrue( $result, 'Nonce was deleted successfully' ); + $this->assertEmpty( get_user_meta( $user_id, Two_Factor_Core::USER_META_NONCE_KEY, true ), 'Nonce meta was removed' ); + } + + /** + * Test get_user_update_action_url generates correct URL. + * + * @covers Two_Factor_Core::get_user_update_action_url + */ + public function test_get_user_update_action_url() { + $user_id = self::factory()->user->create(); + $action = 'test_action'; + + $url = Two_Factor_Core::get_user_update_action_url( $user_id, $action ); + + $this->assertStringContainsString( 'two_factor_action=test_action', $url, 'URL contains action parameter' ); + $this->assertStringContainsString( '_two_factor_action_nonce=', $url, 'URL contains nonce parameter' ); + } + + /** + * Test get_user_two_factor_revalidate_url generates correct URL. + * + * @covers Two_Factor_Core::get_user_two_factor_revalidate_url + */ + public function test_get_user_two_factor_revalidate_url() { + $url = Two_Factor_Core::get_user_two_factor_revalidate_url(); + + $this->assertStringContainsString( 'action=revalidate_2fa', $url, 'URL contains revalidate action' ); + $this->assertStringNotContainsString( 'interim-login', $url, 'URL does not contain interim login by default' ); + + $url_with_interim = Two_Factor_Core::get_user_two_factor_revalidate_url( true ); + + $this->assertStringContainsString( 'interim-login=1', $url_with_interim, 'URL contains interim login when requested' ); + } + + /** + * Test is_valid_user_action validates actions correctly. + * + * @covers Two_Factor_Core::is_valid_user_action + */ + public function test_is_valid_user_action() { + $user_id = self::factory()->user->create(); + $action = 'test_action'; + + // Test without nonce. + $this->assertFalse( Two_Factor_Core::is_valid_user_action( $user_id, $action ), 'Action is invalid without nonce' ); + + // Test with invalid nonce. + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] = 'invalid_nonce'; + $this->assertFalse( Two_Factor_Core::is_valid_user_action( $user_id, $action ), 'Action is invalid with wrong nonce' ); + + // Test with valid nonce. + $nonce = wp_create_nonce( sprintf( '%d-%s', $user_id, $action ) ); + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] = $nonce; + $this->assertNotFalse( Two_Factor_Core::is_valid_user_action( $user_id, $action ), 'Action is valid with correct nonce' ); + + // Test with missing user_id. + $this->assertFalse( Two_Factor_Core::is_valid_user_action( 0, $action ), 'Action is invalid without user ID' ); + + // Test with missing action. + $this->assertFalse( Two_Factor_Core::is_valid_user_action( $user_id, '' ), 'Action is invalid without action name' ); + + // Cleanup. + unset( $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ); + } + + /** + * Test current_user_being_edited returns correct user ID. + * + * @covers Two_Factor_Core::current_user_being_edited + */ + public function test_current_user_being_edited() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + // Test without user_id in request. + $this->assertEquals( $user_id, Two_Factor_Core::current_user_being_edited(), 'Returns current user ID when no user_id in request' ); + + // Test with user_id in request for current user. + $_REQUEST['user_id'] = $user_id; + $this->assertEquals( $user_id, Two_Factor_Core::current_user_being_edited(), 'Returns user ID from request when editing self' ); + + // Test with admin editing another user. + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + $_REQUEST['user_id'] = $user_id; + + $this->assertEquals( $user_id, Two_Factor_Core::current_user_being_edited(), 'Returns user ID from request when admin edits user' ); + + // Test with non-admin trying to edit another user. + $other_user = self::factory()->user->create(); + wp_set_current_user( $other_user ); + $_REQUEST['user_id'] = $user_id; + + $this->assertEquals( $other_user, Two_Factor_Core::current_user_being_edited(), 'Returns current user ID when user lacks edit permission' ); + + // Cleanup. + unset( $_REQUEST['user_id'] ); + } + + /** + * Test trigger_user_settings_action triggers action hook. + * + * @covers Two_Factor_Core::trigger_user_settings_action + */ + public function test_trigger_user_settings_action() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + $action = 'test_action'; + $action_fired = false; + $received_args = array(); + + // Add a test hook. + $test_callback = function ( $uid, $act ) use ( &$action_fired, &$received_args ) { + $action_fired = true; + $received_args = array( $uid, $act ); + }; + add_action( 'two_factor_user_settings_action', $test_callback, 10, 2 ); + + // Test without valid nonce. + Two_Factor_Core::trigger_user_settings_action(); + $this->assertFalse( $action_fired, 'Action does not fire without valid nonce' ); + + // Set up valid action and nonce. + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_QUERY_VAR ] = $action; + $nonce = wp_create_nonce( sprintf( '%d-%s', $user_id, $action ) ); + $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] = $nonce; + + // Trigger the action. + Two_Factor_Core::trigger_user_settings_action(); + + $this->assertTrue( $action_fired, 'Action fires with valid nonce' ); + $this->assertEquals( $user_id, $received_args[0], 'Action receives correct user ID' ); + $this->assertEquals( $action, $received_args[1], 'Action receives correct action name' ); + + // Cleanup. + remove_action( 'two_factor_user_settings_action', $test_callback, 10 ); + unset( $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_QUERY_VAR ] ); + unset( $_REQUEST[ Two_Factor_Core::USER_SETTINGS_ACTION_NONCE_QUERY_ARG ] ); + } + + /** + * Test get_primary_provider_for_user with multiple providers. + * + * @covers Two_Factor_Core::get_primary_provider_for_user + */ + public function test_get_primary_provider_for_user_with_multiple_providers() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + // Enable TOTP as well. + $totp = Two_Factor_Totp::get_instance(); + $totp->set_user_totp_key( $user->ID, 'test_key' ); + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); + + // Get the initial primary provider. + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertNotNull( $primary, 'Primary provider exists when multiple are enabled' ); + $initial_primary_key = $primary->get_key(); + $this->assertContains( $initial_primary_key, array( 'Two_Factor_Dummy', 'Two_Factor_Totp' ), 'Primary is one of enabled providers' ); + + // Set Dummy as primary explicitly. + update_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Dummy' ); + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Dummy', $primary->get_key(), 'Primary provider can be set to Dummy' ); + + // Set TOTP as primary. + update_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY, 'Two_Factor_Totp' ); + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Totp', $primary->get_key(), 'Primary provider can be changed to TOTP' ); + + // Disable TOTP, should fall back to Dummy. + Two_Factor_Core::disable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Dummy', $primary->get_key(), 'Primary falls back when selected provider is disabled' ); + + $this->clean_dummy_user(); + } + + /** + * Test get_primary_provider_for_user with filter. + * + * @covers Two_Factor_Core::get_primary_provider_for_user + */ + public function test_get_primary_provider_for_user_with_filter() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + // Enable Email provider as well. + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Email' ); + + // Add filter to force Email as primary. + add_filter( + 'two_factor_primary_provider_for_user', + function ( $provider, $user_id ) use ( $user ) { + if ( $user_id === $user->ID ) { + return 'Two_Factor_Email'; + } + return $provider; + }, + 10, + 2 + ); + + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + $this->assertEquals( 'Two_Factor_Email', $primary->get_key(), 'Filter can override primary provider' ); + + remove_all_filters( 'two_factor_primary_provider_for_user' ); + $this->clean_dummy_user(); + } + + /** + * Test show_two_factor_login displays login form. + * + * @covers Two_Factor_Core::show_two_factor_login + */ + public function test_show_two_factor_login() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + // Test with user parameter. + ob_start(); + Two_Factor_Core::show_two_factor_login( $user ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'wp-login.php', $output, 'Output contains login form action' ); + $this->assertStringContainsString( 'validate_2fa_form', $output, 'Output contains two-factor form' ); + $this->assertStringContainsString( 'wp-auth-nonce', $output, 'Output contains auth nonce field' ); + + // Test without user parameter (uses current user). + wp_set_current_user( $user->ID ); + ob_start(); + Two_Factor_Core::show_two_factor_login( null ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'wp-login.php', $output, 'Output contains login form with current user' ); + $this->assertStringContainsString( 'validate_2fa_form', $output, 'Output contains two-factor form for current user' ); + + $this->clean_dummy_user(); + } + + /** + * Test get_supported_providers_for_user. + * + * @covers Two_Factor_Core::get_supported_providers_for_user + */ + public function test_get_supported_providers_for_user() { + $user = self::factory()->user->create_and_get(); + $providers = Two_Factor_Core::get_supported_providers_for_user( $user ); + + $this->assertIsArray( $providers, 'Returns an array of providers' ); + $this->assertArrayHasKey( 'Two_Factor_Email', $providers, 'Email provider is supported by default' ); + $this->assertArrayHasKey( 'Two_Factor_Totp', $providers, 'TOTP provider is supported by default' ); + } + + /** + * Test is_user_using_two_factor with enabled provider. + * + * @covers Two_Factor_Core::is_user_using_two_factor + */ + public function test_is_user_using_two_factor_with_enabled_provider() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ), 'User with enabled provider is using two factor' ); + + wp_set_current_user( $user->ID ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor(), 'Current user with enabled provider is using two factor' ); + + $this->clean_dummy_user(); + } + + /** + * Test user_two_factor_options_update with invalid nonce. + * + * @covers Two_Factor_Core::user_two_factor_options_update + */ + public function test_user_two_factor_options_update_with_invalid_nonce() { + $user = self::factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + // Do NOT set nonce — the function skips the update block entirely when nonce is absent. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ); + + // Should not enable provider without a valid nonce. + Two_Factor_Core::user_two_factor_options_update( $user->ID ); + + $providers = Two_Factor_Core::get_enabled_providers_for_user( $user->ID ); + $this->assertEmpty( $providers, 'Providers not enabled without a valid nonce' ); + + // Cleanup. + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + + /** + * Test collect_auth_cookie_tokens stores tokens correctly. + * + * @covers Two_Factor_Core::collect_auth_cookie_tokens + */ + public function test_collect_auth_cookie_tokens() { + $user_id = self::factory()->user->create( + array( + 'user_login' => 'testuser', + 'user_pass' => 'password123', + ) + ); + + // Reset the private static before the test to ensure a clean baseline, + // but capture the original value so it can be restored afterward. + $reflection = new ReflectionClass( Two_Factor_Core::class ); + $prop = $reflection->getProperty( 'password_auth_tokens' ); + $prop->setAccessible( true ); + $original_tokens = $prop->getValue( null ); + + try { + $prop->setValue( null, array() ); + + // Authenticate user — this fires set_auth_cookie / set_logged_in_cookie, + // which call collect_auth_cookie_tokens() via hook. + $authenticated = wp_signon( + array( + 'user_login' => 'testuser', + 'user_password' => 'password123', + ) + ); + + $this->assertSame( $user_id, $authenticated->ID, 'Correct user authenticated' ); + + // Verify collect_auth_cookie_tokens() actually stored at least one token. + $tokens = $prop->getValue( null ); + $this->assertNotEmpty( $tokens, 'collect_auth_cookie_tokens stored at least one token' ); + + // Cleanup. + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + } finally { + // Restore original static state to avoid leaking into other tests. + $prop->setValue( null, $original_tokens ); + } + } + + /** + * Test get_available_providers_for_user with configured providers. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_with_configured_providers() { + $user = self::factory()->user->create_and_get(); + + // Initially no providers. + $this->assertEmpty( Two_Factor_Core::get_available_providers_for_user( $user->ID ), 'No providers available initially' ); + + // Enable TOTP and configure it. + $totp = Two_Factor_Totp::get_instance(); + $totp->set_user_totp_key( $user->ID, 'test_key' ); + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); + + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + $this->assertCount( 1, $available, 'One provider is available when configured' ); + $this->assertArrayHasKey( 'Two_Factor_Totp', $available, 'TOTP provider is available' ); + + // Enable Dummy (always available). + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Dummy' ); + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + $this->assertCount( 2, $available, 'Two providers are available' ); + } + + /** + * Verify process_provider() returns WP_Error when no provider is given. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_with_null_provider() { + $user = self::factory()->user->create_and_get(); + $result = Two_Factor_Core::process_provider( null, $user, true ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'two_factor_provider_missing', $result->get_error_code() ); + } + + /** + * Verify process_provider() returns false when pre-processing signals a re-send. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_returns_false_when_pre_process_returns_true() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Email::get_instance(); + + // Simulate a "resend code" request – triggers pre_process_authentication() to return true. + $_REQUEST[ Two_Factor_Email::INPUT_NAME_RESEND_CODE ] = '1'; + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + unset( $_REQUEST[ Two_Factor_Email::INPUT_NAME_RESEND_CODE ] ); + + $this->assertFalse( $result ); + } + + /** + * Verify process_provider() returns false on a GET (non-POST) request. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_not_post_request() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + $result = Two_Factor_Core::process_provider( $provider, $user, false ); + + $this->assertFalse( $result ); + } + + /** + * Verify process_provider() returns WP_Error when the user is rate-limited. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_rate_limited() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 1 ); + update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() ); + + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'two_factor_too_fast', $result->get_error_code() ); + } + + /** + * Verify process_provider() returns WP_Error and increments the failed-attempts + * counter when authentication fails. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_invalid_authentication() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Email::get_instance(); + + // No code submitted in POST → validate_authentication() returns false. + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'two_factor_invalid', $result->get_error_code() ); + $this->assertEquals( 1, (int) get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + } + + /** + * Verify process_provider() returns true when authentication succeeds. + * + * @covers Two_Factor_Core::process_provider + */ + public function test_process_provider_successful_authentication() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + $result = Two_Factor_Core::process_provider( $provider, $user, true ); + + $this->assertTrue( $result ); + } + + /** + * Verify _login_form_validate_2fa() renders the login form and creates a new + * nonce when the provider fails authentication. + * + * @covers Two_Factor_Core::_login_form_validate_2fa + */ + public function test_login_form_validate_2fa_provider_failure() { + $user = self::factory()->user->create_and_get(); + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Email' ); + + $login_nonce = Two_Factor_Core::create_login_nonce( $user->ID ); + $this->assertNotFalse( $login_nonce ); + + // POST request but no email code supplied → provider fails. + ob_start(); + Two_Factor_Core::_login_form_validate_2fa( $user, $login_nonce['key'], 'Two_Factor_Email', '', true ); + ob_end_clean(); + + // Authentication did not succeed – no auth cookie should be set. + $this->assertArrayNotHasKey( AUTH_COOKIE, $_COOKIE ); + + // A new login nonce should have been created for the retry form. + $stored_nonce = get_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY, true ); + $this->assertNotEmpty( $stored_nonce ); + } + + /** + * Verify that rest_api_can_edit_user_and_update_two_factor_options() returns + * false when the current user cannot edit the target user. + * + * @covers Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options + */ + public function test_rest_api_can_edit_user_no_capability() { + $subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $other_user = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + + wp_set_current_user( $subscriber->ID ); + + $result = Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $other_user->ID ); + + $this->assertFalse( $result ); + } + + /** + * Verify that rest_api_can_edit_user_and_update_two_factor_options() returns + * WP_Error when revalidation is required. + * + * @covers Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options + */ + public function test_rest_api_can_edit_user_revalidation_required() { + $user = self::factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + wp_set_auth_cookie( $user->ID ); + + // Enable 2FA, but the session carries no two-factor metadata → + // current_user_can_update_two_factor_options( 'save' ) returns false. + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Dummy' ); + + $result = Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $user->ID ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'revalidation_required', $result->get_error_code() ); + } + + /** + * Verify that rest_api_can_edit_user_and_update_two_factor_options() applies + * the two_factor_rest_api_can_edit_user filter when permissions are satisfied. + * + * @covers Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options + */ + public function test_rest_api_can_edit_user_filter_overrides() { + $user = self::factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + wp_set_auth_cookie( $user->ID ); + + // Set up a valid 2FA session so save-context passes. + $manager = WP_Session_Tokens::get_instance( $user->ID ); + $token = wp_get_session_token(); + $session = $manager->get( $token ); + + $session['two-factor-provider'] = 'Two_Factor_Dummy'; + $session['two-factor-login'] = time(); + $manager->update( $token, $session ); + + // Filter should receive (true, $user_id) and its return value used. + $filter_received_user_id = null; + $filter = function ( $can, $user_id ) use ( &$filter_received_user_id ) { + $filter_received_user_id = $user_id; + return false; + }; + add_filter( 'two_factor_rest_api_can_edit_user', $filter, 10, 2 ); + + $result = Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $user->ID ); + + remove_filter( 'two_factor_rest_api_can_edit_user', $filter, 10 ); + + $this->assertFalse( $result, 'Filter return value overrides default' ); + $this->assertSame( $user->ID, $filter_received_user_id, 'Filter receives correct user ID' ); + } + + /** + * Verify filter_manage_users_columns() adds the two-factor column. + * + * @covers Two_Factor_Core::filter_manage_users_columns + */ + public function test_filter_manage_users_columns() { + $columns = array( + 'username' => 'Username', + 'email' => 'Email', + ); + + $result = Two_Factor_Core::filter_manage_users_columns( $columns ); + + $this->assertArrayHasKey( 'two-factor', $result ); + // Existing columns must be preserved. + $this->assertSame( 'Username', $result['username'] ); + } + + /** + * Verify manage_users_custom_column() returns the original output for + * columns other than 'two-factor'. + * + * @covers Two_Factor_Core::manage_users_custom_column + */ + public function test_manage_users_custom_column_wrong_column() { + $result = Two_Factor_Core::manage_users_custom_column( 'original_output', 'username', 1 ); + + $this->assertSame( 'original_output', $result ); + } + + /** + * Verify manage_users_custom_column() returns a "Disabled" indicator for + * users who have not enabled two-factor. + * + * @covers Two_Factor_Core::manage_users_custom_column + */ + public function test_manage_users_custom_column_disabled() { + $user = self::factory()->user->create_and_get(); + + $result = Two_Factor_Core::manage_users_custom_column( '', 'two-factor', $user->ID ); + + $this->assertStringContainsString( 'Disabled', $result ); + } + + /** + * Verify manage_users_custom_column() returns the provider label for users + * who have two-factor enabled. + * + * @covers Two_Factor_Core::manage_users_custom_column + */ + public function test_manage_users_custom_column_enabled() { + $user = $this->get_dummy_user( array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy' ) ); + + $result = Two_Factor_Core::manage_users_custom_column( '', 'two-factor', $user->ID ); + + $this->assertSame( Two_Factor_Dummy::get_instance()->get_label(), $result ); + + $this->clean_dummy_user(); + } + + /** + * Verify wp_login() returns early without side-effects for users who have + * not enabled two-factor authentication. + * + * @covers Two_Factor_Core::wp_login + */ + public function test_wp_login_non_two_factor_user() { + $user = self::factory()->user->create_and_get(); + // No 2FA providers enabled – is_user_using_two_factor() returns false. + + // Should return without touching auth cookies or destroying sessions. + Two_Factor_Core::wp_login( $user->user_login, $user ); + + // No auth cookie set by wp_login for a non-2FA user. + $this->assertArrayNotHasKey( AUTH_COOKIE, $_COOKIE ); + } + + /** + * Verify add_settings_action_link() prepends a settings link. + * + * @covers Two_Factor_Core::add_settings_action_link + */ + public function test_add_settings_action_link() { + $admin_user = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_user ); + + $links = array( 'deactivate' => 'Deactivate' ); + $result = Two_Factor_Core::add_settings_action_link( $links ); + + // Settings link should be first. + $this->assertCount( 3, $result ); + $first = reset( $result ); + $this->assertStringContainsString( 'assertStringContainsString( 'Settings', $first ); + $this->assertStringContainsString( 'options-general.php', $first ); + + wp_set_current_user( 0 ); + } } diff --git a/tests/providers/class-two-factor-backup-codes.php b/tests/providers/class-two-factor-backup-codes.php index f1b62f571..5ba6c9864 100644 --- a/tests/providers/class-two-factor-backup-codes.php +++ b/tests/providers/class-two-factor-backup-codes.php @@ -163,9 +163,12 @@ public function test_user_options() { $this->provider->user_options( $user ); $buffer = ob_get_clean(); - $this->assertStringContainsString( '

    ', $buffer ); + $this->assertStringContainsString( '

    ', $buffer ); $this->assertStringContainsString( '