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 '
'; } @@ -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 ); + ?> + +