diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc88a3..8c1f7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- **MFA core** — a site-level MFA mode (`mfa_mode` per environment: `plugin`, the default, decides who is challenged per user, role and Login Profile; `workos` defers to WorkOS's environment-wide MFA; `off`), an admin require flag per user, and required roles (`mfa_required_roles`). `WorkOS\Mfa\Policy` turns the profile's `mfa.enforce`, the user's factor and the requirement into `none`, `challenge`, `enroll` or `block`. `WorkOS\Mfa\FactorService` is the one path for listing, enrolling (confirmed with a first code before it counts), removing and resetting factors, and keeps the mirrored `_workos_mfa_enabled` flag in step with WorkOS. See [`docs/mfa.md`](docs/mfa.md). +- **MFA helpers** — `workos_mfa_get_status()`, `workos_mfa_is_enabled()`, `workos_mfa_is_required()`, `workos_mfa_set_required()`, `workos_mfa_list_factors()`, `workos_mfa_reset()` and `workos_mfa_decision()`. +- **MFA hooks and events** — filters `workos_mfa_mode`, `workos_mfa_required_for_user`, `workos_mfa_decision` and `workos_mfa_totp_issuer`; actions `workos_mfa_enrolled`, `workos_mfa_factor_removed`, `workos_mfa_reset` and `workos_mfa_required_changed`; Activity Log events `mfa.enrolled`, `mfa.removed`, `mfa.reset`, `mfa.required_set` and `mfa.required_cleared`. + ### Security - **WordPress password could skip WorkOS MFA** — on the headless `wp-login.php` path, a WorkOS `mfa_challenge` or `mfa_enrollment` answer was treated like a wrong password, and WordPress's own password check then signed the user in without a second factor. That path now refuses with `403 workos_mfa_step_unsupported`, past WordPress's password handlers too. The login button's form gives the same message instead of WorkOS's raw error, and the AuthKit password route no longer re-syncs the password to WorkOS when only the second factor is missing. diff --git a/README.md b/README.md index e28ba64..acd88f6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Enterprise identity management for WordPress powered by [WorkOS](https://workos. - **Admin-triggered password reset** — send a WorkOS reset email on behalf of any linked user via `POST /wp-json/workos/v1/admin/users/{id}/password-reset` (gated by `edit_user($id)` so the same route covers self-service). Surfaced as a row action under the **WorkOS column** on `wp-admin/users.php`, a button on the user-edit screen, a per-row button on the WorkOS Users admin page, and a `[workos:password-reset]` shortcode. Successful sends are audited as `password_reset.admin_sent` in the activity log; emails point at the in-site React shell (`/workos/login/{slug}?token=…&redirect_to=…`) and the new password is mirrored to the linked WP user so `?fallback=1` / `wp_authenticate` / REST app passwords stay in sync. See [`docs/password-reset.md`](docs/password-reset.md). - **Change email** — WorkOS-verified, conflict-guarded email-change flow. Self-service via `[workos:change-email]` shortcode confirms a WP-side hashed token sent to the new address (with a cancel-link notice to the old address) before committing; an admin acting on *another* account commits immediately (gated by `edit_users`, no verification round-trip). Admin surfaces are a row action under the **WorkOS column** on `wp-admin/users.php`, a panel on the user-edit screen, and a "Change email" action on the **WorkOS → Users** admin page. A configurable conflict policy (`block`, `allow_orphan`, `merge_request`) keeps the new email from silently overwriting another local WP user. Commits to WorkOS first (via `update_user`) then mirrors into WP, with a 60-second in-progress transient that short-circuits the webhook fan-back. See [`docs/change-email.md`](docs/change-email.md). - **Skeleton placeholders** on every AuthKit surface (wp-login.php takeover, `/workos/login/{profile}`, shortcode) — pre-hydration markup from PHP plus a React `FlowSkeleton` during bootstrap, mirroring the real card heights so swap-in is a flicker not a jump. -- **MFA** — TOTP, SMS, WebAuthn/passkey with full enrollment + challenge UI; profile-level `mfa.enforce` (`never` / `if_required` / `always`) and factor allowlist +- **MFA** — TOTP factors held by WorkOS. The plugin decides who must use a second factor (per user, role and Login Profile `mfa.enforce`), or defers to WorkOS's environment-wide MFA. PHP helpers, hooks and activity-log events for every step. See [`docs/mfa.md`](docs/mfa.md) - **Profile routing rules** — ordered `redirect_to` glob / `referrer_host` / `user_role` matchers pick the right profile per request - **WorkOS Radar** anti-fraud integration — browser SDK supplies an action token the plugin forwards on every server-side auth call - **No WorkOS PHP SDK** — every WorkOS call is `wp_remote_request` from PHP, so the API key never leaves the server @@ -234,16 +234,14 @@ fallback. ### MFA policy -Profile-level MFA enforcement is applied by `Auth\AuthKit\LoginCompleter`: +A profile's `mfa.enforce` (`never` / `if_required` / `always`) and +`mfa.factors` feed the MFA policy, together with the user's own state (has a +factor, must use MFA) and the site's MFA mode (`plugin`, `workos` or `off`). +The full decision table, the helpers and the hooks are in +[`docs/mfa.md`](docs/mfa.md). -- `enforce: never` — single-step login is always accepted -- `enforce: if_required` (default) — MFA step surfaces when WorkOS returns - a pending factor; otherwise single-step is accepted -- `enforce: always` — single-step success is rejected with - `workos_authkit_mfa_required`; the user must enroll a factor first - -Factor types WorkOS returns in a pending-factor response are checked against -the profile's `mfa.factors` allowlist; disallowed types are rejected. +Factor types WorkOS returns with an MFA challenge are checked against the +profile's `mfa.factors` allowlist; disallowed types are rejected. ### WorkOS Radar @@ -321,6 +319,10 @@ workos_get_user_id( $user_id = 0 ); // string — WorkOS user id workos_get_access_token( $user_id = 0 ); // string — current access token ``` +MFA has its own set (`workos_mfa_get_status()`, `workos_mfa_is_enabled()`, +`workos_mfa_is_required()`, `workos_mfa_set_required()`, `workos_mfa_list_factors()`, +`workos_mfa_reset()`, `workos_mfa_decision()`); see [`docs/mfa.md`](docs/mfa.md#php-helpers). + ### Distinguishing "linked" vs "currently signed in" - **`is_sso()`** remains true after the user logs out. Use it when you @@ -371,6 +373,8 @@ before authorizing a sensitive action), verify via ## Hooks Reference +MFA hooks (`workos_mfa_*`) are listed in [`docs/mfa.md`](docs/mfa.md#hooks). + ### Filters #### `workos_redirect_urls` diff --git a/docs/mfa.md b/docs/mfa.md new file mode 100644 index 0000000..4da1e35 --- /dev/null +++ b/docs/mfa.md @@ -0,0 +1,201 @@ +# Multi-factor authentication (MFA) + +WorkOS holds every factor and its secret. WordPress mirrors two flags per +user and decides, per site, who has to use a second factor. Everything the +plugin does with MFA goes through one PHP service, so REST, the PHP helpers, +WP-CLI and the admin screens behave the same and fire the same hooks. + +Only TOTP (authenticator apps) is supported. WorkOS's built-in MFA is TOTP +only, and its SMS factors belong to a separate API that isn't tied to +WorkOS users. + +## At a glance + +| Surface | Where | Who | +|---|---|---| +| Mode | `mfa_mode` option per environment, filter `workos_mfa_mode` | Site | +| Who must use MFA | Require flag per user, `mfa_required_roles` option, filter `workos_mfa_required_for_user` | Admin, code | +| What a sign-in needs | `WorkOS\Mfa\Policy::decide()`, filter `workos_mfa_decision`, helper `workos_mfa_decision()` | Code | +| Factors | `WorkOS\Mfa\FactorService`, helpers `workos_mfa_*` | Code | +| Activity log | `mfa.*` events | Admin | + +## Modes + +WorkOS's own MFA is one switch per environment (**Authentication → +Multi-factor auth → Required**). Once it's on, every non-SSO user must +enroll at their next sign-in. That suits a staff-only site, not a customer +portal where only some accounts should use MFA. So the plugin has three +modes: + +| Mode | Who decides | Use it when | +|---|---|---| +| `plugin` (default) | The plugin: per user, role and Login Profile, using the table below | WorkOS MFA is off and only some users need MFA | +| `workos` | WorkOS, for every user in the environment | WorkOS MFA is set to Required | +| `off` | Nobody in WordPress | You don't want the plugin to challenge anyone | + +In every mode, a step WorkOS asks for (`mfa_challenge`, `mfa_enrollment`) +still runs. WordPress can't skip a factor WorkOS requires. + +```php +// Lock the mode in code, e.g. per environment. +add_filter( 'workos_mfa_mode', fn() => 'workos' ); +``` + +## Who must use MFA + +`Policy::is_required()` is true when any of these is: + +1. The user's require flag (`_workos_mfa_required`), set by an admin or + `workos_mfa_set_required()`. +2. The user has a role listed in the `mfa_required_roles` option. +3. The `workos_mfa_required_for_user` filter returns true. + +```php +// Everyone who can edit others' posts must use MFA. +add_filter( 'workos_mfa_required_for_user', function ( bool $required, int $user_id ) { + return $required || user_can( $user_id, 'edit_others_posts' ); +}, 10, 2 ); +``` + +## What a sign-in needs + +After the first factor succeeds, `Policy::decide( $user_id, $profile )` +returns one of four answers. Only `plugin` mode decides anything; the other +modes always answer `none`. + +| Profile `mfa.enforce` | Has a factor | Must use MFA | Decision | +|---|---|---|---| +| `never`, or TOTP not in `mfa.factors` | yes | any | `block` | +| `never`, or TOTP not in `mfa.factors` | no | yes | `block` | +| `never`, or TOTP not in `mfa.factors` | no | no | `none` | +| `if_required` | yes | any | `challenge` | +| `if_required` | no | yes | `enroll` | +| `if_required` | no | no | `none` | +| `always` | yes | any | `challenge` | +| `always` | no | any | `enroll` | + +A profile that turns MFA off never lets a user with MFA sign in without it. +The sign-in is blocked instead, so the conflict is visible. + +The `workos_mfa_decision` filter gets the last word: + +```php +// Skip the challenge for requests from the office network. +add_filter( 'workos_mfa_decision', function ( string $decision, int $user_id, $profile, array $context ) { + if ( 'challenge' === $decision && office_ip() ) { + return 'none'; + } + return $decision; +}, 10, 4 ); +``` + +## Factors + +`WorkOS\Mfa\FactorService` is the only code that touches a user's factors. +None of its methods check capabilities, so callers do. + +| Method | Does | +|---|---| +| `status( $user_id )` | `{ enabled, required, factors }`, reconciled with WorkOS | +| `list_factors( $user_id )` | Confirmed factors from WorkOS. Also fixes the mirrored flag when factors were added or removed outside WordPress | +| `start_enrollment( $user_id )` | Creates a TOTP factor and returns `{ factor_id, challenge_id, qr_code, secret, otpauth_uri }`. The factor stays unconfirmed, and doesn't count, until confirmed | +| `confirm_enrollment( $user_id, $factor_id, $code, $challenge_id = '' )` | Checks the first code and turns MFA on | +| `verify_code( $factor_id, $code, $challenge_id = '' )` | Checks a code; no hooks | +| `delete_factor( $user_id, $factor_id )` | Removes one of the user's factors (404 for anyone else's) | +| `reset( $user_id )` | Removes every factor, for a lost authenticator | +| `set_required( $user_id, $required )` | Sets or clears the require flag | + +An unconfirmed factor is deleted from WorkOS when the user starts over, or +after a day. + +### PHP helpers + +```php +workos_mfa_get_status( $user_id = 0 ); // array|WP_Error — { enabled, required, factors } +workos_mfa_is_enabled( $user_id = 0 ); // bool — mirrored flag, no WorkOS call +workos_mfa_is_required( $user_id = 0 ); // bool — flag, role or filter +workos_mfa_set_required( $user_id, $required ); // bool|WP_Error — whether it changed +workos_mfa_list_factors( $user_id = 0 ); // array|WP_Error +workos_mfa_reset( $user_id ); // int|WP_Error — factors removed +workos_mfa_decision( $user_id = 0, $profile ); // 'none'|'challenge'|'enroll'|'block' +``` + +`$user_id = 0` means the current user. Like the service, the helpers don't +check capabilities. + +### Errors + +| Code | Status | Cause | +|---|---|---| +| `workos_mfa_user_not_linked` | 409 | The account has no WorkOS user | +| `workos_mfa_factor_not_found` | 404 | The factor isn't the user's, or isn't the one being enrolled | +| `workos_mfa_invalid_code` | 400 | Wrong, expired or malformed code | +| `workos_mfa_invalid_user` | 404 | `workos_mfa_set_required()` got an unknown user | +| `workos_mfa_invalid_response` | 502 | WorkOS answered an enrollment without a factor | + +## Sign-in errors from WorkOS + +When WorkOS requires MFA, `authenticate` answers with an error, not a +success: + +- `mfa_challenge`: the sign-in form shows the code step + (`{ mfa_required, pending_authentication_token, factors }`). The profile's + factor allowlist applies. +- `mfa_enrollment`: refused with `403 workos_authkit_mfa_enrollment_required` + for now. + +Sign-in paths that can't collect a code (headless `wp-login.php`, the login +button's form) refuse with `403 workos_mfa_step_unsupported`. They never fall +back to the WordPress password, since WorkOS already accepted the password and +only the second factor is missing. + +## Hooks + +**Filters** + +| Filter | Arguments | Default | +|---|---|---| +| `workos_mfa_mode` | `string $mode` | `mfa_mode` option, else `plugin` | +| `workos_mfa_required_for_user` | `bool $required, int $user_id, ?Profile $profile` | Flag or role | +| `workos_mfa_decision` | `string $decision, int $user_id, Profile $profile, array $context` | Policy table | +| `workos_mfa_totp_issuer` | `string $issuer, int $user_id` | Site name | + +**Actions** + +| Action | Arguments | When | +|---|---|---| +| `workos_mfa_enrolled` | `int $user_id, array $factor` | A new factor is confirmed | +| `workos_mfa_factor_removed` | `int $user_id, string $factor_id` | One factor is removed | +| `workos_mfa_reset` | `int $user_id, int $removed` | Every factor is removed | +| `workos_mfa_required_changed` | `int $user_id, bool $required` | The require flag changes | + +## Activity log + +| Event | Metadata | +|---|---| +| `mfa.enrolled` | `factor_id`, `factor_type` | +| `mfa.removed` | `factor_id` | +| `mfa.reset` | `removed`, `actor_id` | +| `mfa.required_set`, `mfa.required_cleared` | `actor_id` | + +## Don't do this + +- Don't store TOTP secrets or QR codes in WordPress. WorkOS holds them; the + plugin returns them once, at enrollment. +- Don't write `_workos_mfa_enabled` yourself. It mirrors WorkOS, and + `list_factors()` overwrites it. +- Don't let a profile with `enforce: never` stand in for "MFA off for this + user". It blocks users who have MFA. Use `workos_mfa_decision` for + exceptions. + +## Internal references + +| Concept | Source | +|---|---| +| Modes and options | `src/WorkOS/Mfa/Settings.php` | +| Decision table | `src/WorkOS/Mfa/Policy.php` | +| Factors, flags, hooks, events | `src/WorkOS/Mfa/FactorService.php` | +| Helpers | `src/includes/functions-helpers.php` | +| WorkOS MFA errors | `src/WorkOS/Auth/AuthKit/MfaStep.php` | +| Sign-in completion | `src/WorkOS/Auth/AuthKit/LoginCompleter.php` | +| WorkOS calls | `src/WorkOS/Api/Client.php` (`list_auth_factors`, `enroll_totp_factor`, `challenge_auth_factor`, `verify_auth_challenge`, `delete_auth_factor`) | diff --git a/src/WorkOS/ActivityLog/AdminPage.php b/src/WorkOS/ActivityLog/AdminPage.php index 5a812ad..3f9bf33 100644 --- a/src/WorkOS/ActivityLog/AdminPage.php +++ b/src/WorkOS/ActivityLog/AdminPage.php @@ -91,7 +91,7 @@ public function render_page(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $cleared = ! empty( SuperGlobals::get_get_var( 'cleared' ) ); - $event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused' ]; + $event_types = [ 'login', 'logout', 'login_failed', 'login_denied', 'user_suspended', 'onboarding_sync', 'bypass_activated', 'link_refused', 'mfa.enrolled', 'mfa.removed', 'mfa.reset', 'mfa.required_set', 'mfa.required_cleared', 'mfa.challenge_succeeded', 'mfa.challenge_failed', 'mfa.blocked' ]; ?>
diff --git a/src/WorkOS/Controller.php b/src/WorkOS/Controller.php index d46772b..dff0722 100644 --- a/src/WorkOS/Controller.php +++ b/src/WorkOS/Controller.php @@ -16,6 +16,7 @@ use WorkOS\Auth\Controller as AuthController; use WorkOS\Auth\PasswordResetAdmin\Controller as PasswordResetAdminController; use WorkOS\Auth\ChangeEmail\Controller as ChangeEmailController; +use WorkOS\Mfa\Controller as MfaController; use WorkOS\REST\Controller as RESTController; use WorkOS\Webhook\Controller as WebhookController; use WorkOS\Sync\Controller as SyncController; @@ -42,6 +43,7 @@ protected function doRegister(): void { $this->container->register( HttpController::class ); $this->container->register( EmailController::class ); $this->container->register( RateLimitController::class ); + $this->container->register( MfaController::class ); $this->container->register( AdminController::class ); $this->container->register( AuthKitController::class ); diff --git a/src/WorkOS/Mfa/Controller.php b/src/WorkOS/Mfa/Controller.php new file mode 100644 index 0000000..b4ed82f --- /dev/null +++ b/src/WorkOS/Mfa/Controller.php @@ -0,0 +1,38 @@ +container->singleton( Settings::class ); + $this->container->singleton( Policy::class ); + $this->container->singleton( FactorService::class ); + } + + /** + * Unregister. + * + * @return void + */ + protected function doUnregister(): void { + } +} diff --git a/src/WorkOS/Mfa/FactorService.php b/src/WorkOS/Mfa/FactorService.php new file mode 100644 index 0000000..b7cc46f --- /dev/null +++ b/src/WorkOS/Mfa/FactorService.php @@ -0,0 +1,517 @@ +list_factors( $user_id ); + if ( is_wp_error( $factors ) ) { + return $factors; + } + + return [ + 'enabled' => $this->is_enabled( $user_id ), + 'required' => $this->is_required_flag( $user_id ), + 'factors' => $factors, + ]; + } + + /** + * Confirmed factors, read from WorkOS. + * + * Also keeps `_workos_mfa_enabled` in step with WorkOS, since factors + * can be added or removed outside WordPress, and drops a stale + * unconfirmed factor. + * + * @param int $user_id WP user ID. + * + * @return array|WP_Error List of `{ id, type, created }`. + */ + public function list_factors( int $user_id ) { + $workos_id = $this->workos_id( $user_id ); + if ( is_wp_error( $workos_id ) ) { + return $workos_id; + } + + $result = workos()->api()->list_auth_factors( $workos_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $pending = $this->pending_factor( $user_id ); + if ( $pending && ( time() - $pending['created'] ) > self::PENDING_FACTOR_TTL ) { + workos()->api()->delete_auth_factor( $pending['factor_id'] ); + delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR ); + $pending = null; + } + + $factors = []; + foreach ( (array) ( $result['data'] ?? $result ) as $factor ) { + if ( ! is_array( $factor ) || empty( $factor['id'] ) ) { + continue; + } + if ( $pending && $pending['factor_id'] === $factor['id'] ) { + continue; + } + $factors[] = [ + 'id' => (string) $factor['id'], + 'type' => (string) ( $factor['type'] ?? '' ), + 'created' => (string) ( $factor['created_at'] ?? '' ), + ]; + } + + $this->sync_enabled( $user_id, ! empty( $factors ) ); + + return $factors; + } + + /** + * Start a TOTP enrollment. + * + * Creates the factor in WorkOS and remembers it as unconfirmed. Any + * earlier unconfirmed factor is deleted first, so restarting doesn't + * pile up factors the user never finished. + * + * @param int $user_id WP user ID. + * + * @return array|WP_Error `{ factor_id, challenge_id, qr_code, secret, otpauth_uri }`. + */ + public function start_enrollment( int $user_id ) { + $workos_id = $this->workos_id( $user_id ); + if ( is_wp_error( $workos_id ) ) { + return $workos_id; + } + + $pending = $this->pending_factor( $user_id ); + if ( $pending ) { + workos()->api()->delete_auth_factor( $pending['factor_id'] ); + delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR ); + } + + $user = get_userdata( $user_id ); + + /** + * Filters the issuer an authenticator app shows for the factor. + * + * @param string $issuer Issuer. Default: the site name. + * @param int $user_id WP user ID. + */ + $issuer = (string) apply_filters( 'workos_mfa_totp_issuer', (string) get_bloginfo( 'name' ), $user_id ); + + $result = workos()->api()->enroll_totp_factor( $workos_id, $issuer, $user ? (string) $user->user_email : '' ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $factor = isset( $result['authentication_factor'] ) && is_array( $result['authentication_factor'] ) ? $result['authentication_factor'] : $result; + $challenge = isset( $result['authentication_challenge'] ) && is_array( $result['authentication_challenge'] ) ? $result['authentication_challenge'] : []; + $totp = isset( $factor['totp'] ) && is_array( $factor['totp'] ) ? $factor['totp'] : []; + $factor_id = (string) ( $factor['id'] ?? '' ); + + if ( '' === $factor_id ) { + return new WP_Error( + 'workos_mfa_invalid_response', + __( 'Unexpected response from the authentication provider.', 'integration-workos' ), + [ 'status' => 502 ] + ); + } + + update_user_meta( + $user_id, + User::META_MFA_PENDING_FACTOR, + [ + 'factor_id' => $factor_id, + 'created' => time(), + ] + ); + + return [ + 'factor_id' => $factor_id, + 'challenge_id' => (string) ( $challenge['id'] ?? '' ), + 'qr_code' => (string) ( $totp['qr_code'] ?? '' ), + 'secret' => (string) ( $totp['secret'] ?? '' ), + 'otpauth_uri' => (string) ( $totp['uri'] ?? '' ), + ]; + } + + /** + * Confirm an enrollment with the first code from the authenticator. + * + * @param int $user_id WP user ID. + * @param string $factor_id Factor from start_enrollment(). + * @param string $code Code from the authenticator app. + * @param string $challenge_id Challenge from start_enrollment(); a new one is created when empty. + * + * @return array|WP_Error Status, as status() returns it. + */ + public function confirm_enrollment( int $user_id, string $factor_id, string $code, string $challenge_id = '' ) { + $pending = $this->pending_factor( $user_id ); + if ( ! $pending || $pending['factor_id'] !== $factor_id ) { + return $this->factor_not_found(); + } + + $valid = $this->verify_code( $factor_id, $code, $challenge_id ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR ); + $this->sync_enabled( $user_id, true ); + + EventLogger::log( + 'mfa.enrolled', + [ + 'user_id' => $user_id, + 'metadata' => [ + 'factor_id' => $factor_id, + 'factor_type' => 'totp', + ], + ] + ); + + /** + * Fires after a user confirms a new MFA factor. + * + * @param int $user_id WP user ID. + * @param array $factor `{ id, type }`. + */ + do_action( + 'workos_mfa_enrolled', + $user_id, + [ + 'id' => $factor_id, + 'type' => 'totp', + ] + ); + + return $this->status( $user_id ); + } + + /** + * Check a code against one of the user's factors. + * + * Used to confirm an enrollment and, in plugin mode, to answer the + * sign-in challenge. Doesn't fire hooks: callers know which of the two + * it was. + * + * @param string $factor_id Factor ID. + * @param string $code Code from the authenticator app. + * @param string $challenge_id Existing challenge; a new one is created when empty. + * + * @return true|WP_Error + */ + public function verify_code( string $factor_id, string $code, string $challenge_id = '' ) { + $code = preg_replace( '/\D/', '', $code ); + if ( 6 !== strlen( (string) $code ) ) { + return $this->invalid_code(); + } + + if ( '' === $challenge_id ) { + $challenge = workos()->api()->challenge_auth_factor( $factor_id ); + if ( is_wp_error( $challenge ) ) { + return $challenge; + } + $challenge_id = (string) ( $challenge['id'] ?? ( $challenge['authentication_challenge']['id'] ?? '' ) ); + } + + $result = workos()->api()->verify_auth_challenge( $challenge_id, (string) $code ); + if ( is_wp_error( $result ) ) { + // A wrong code may come back as an error rather than `valid: false`. + $status = (int) ( $result->get_error_data()['status'] ?? 0 ); + return ( $status >= 400 && $status < 500 ) ? $this->invalid_code() : $result; + } + + return empty( $result['valid'] ) ? $this->invalid_code() : true; + } + + /** + * Remove one of the user's factors. + * + * @param int $user_id WP user ID. + * @param string $factor_id Factor ID; must belong to the user. + * + * @return true|WP_Error + */ + public function delete_factor( int $user_id, string $factor_id ) { + $factors = $this->list_factors( $user_id ); + if ( is_wp_error( $factors ) ) { + return $factors; + } + + // The plugin calls WorkOS with a tenant-wide key, so WorkOS can't + // tell whose factor this is. 404, not 403, so factor IDs that + // belong to other users don't leak. + if ( ! in_array( $factor_id, array_column( $factors, 'id' ), true ) ) { + return $this->factor_not_found(); + } + + $result = workos()->api()->delete_auth_factor( $factor_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $this->sync_enabled( $user_id, count( $factors ) > 1 ); + + EventLogger::log( + 'mfa.removed', + [ + 'user_id' => $user_id, + 'metadata' => [ 'factor_id' => $factor_id ], + ] + ); + + /** + * Fires after one of a user's MFA factors is removed. + * + * @param int $user_id WP user ID. + * @param string $factor_id Factor ID. + */ + do_action( 'workos_mfa_factor_removed', $user_id, $factor_id ); + + return true; + } + + /** + * Remove every factor the user has, confirmed or not. + * + * For a user who lost their authenticator. With the require flag set + * they enroll again at their next sign-in. + * + * @param int $user_id WP user ID. + * + * @return int|WP_Error Number of factors removed. + */ + public function reset( int $user_id ) { + $workos_id = $this->workos_id( $user_id ); + if ( is_wp_error( $workos_id ) ) { + return $workos_id; + } + + $result = workos()->api()->list_auth_factors( $workos_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $removed = 0; + foreach ( (array) ( $result['data'] ?? $result ) as $factor ) { + if ( ! is_array( $factor ) || empty( $factor['id'] ) ) { + continue; + } + $deleted = workos()->api()->delete_auth_factor( (string) $factor['id'] ); + if ( is_wp_error( $deleted ) ) { + return $deleted; + } + ++$removed; + } + + delete_user_meta( $user_id, User::META_MFA_PENDING_FACTOR ); + $this->sync_enabled( $user_id, false ); + + EventLogger::log( + 'mfa.reset', + [ + 'user_id' => $user_id, + 'metadata' => [ + 'removed' => $removed, + 'actor_id' => get_current_user_id(), + ], + ] + ); + + /** + * Fires after every MFA factor of a user is removed. + * + * @param int $user_id WP user ID. + * @param int $removed Number of factors removed. + */ + do_action( 'workos_mfa_reset', $user_id, $removed ); + + return $removed; + } + + /** + * Set or clear the admin require flag. + * + * @param int $user_id WP user ID. + * @param bool $required Whether the user must use MFA. + * + * @return bool Whether the flag changed. + */ + public function set_required( int $user_id, bool $required ): bool { + if ( $this->is_required_flag( $user_id ) === $required ) { + return false; + } + + if ( $required ) { + update_user_meta( $user_id, User::META_MFA_REQUIRED, '1' ); + } else { + delete_user_meta( $user_id, User::META_MFA_REQUIRED ); + } + + EventLogger::log( + $required ? 'mfa.required_set' : 'mfa.required_cleared', + [ + 'user_id' => $user_id, + 'metadata' => [ 'actor_id' => get_current_user_id() ], + ] + ); + + /** + * Fires after the admin require flag on a user changes. + * + * @param int $user_id WP user ID. + * @param bool $required New value. + */ + do_action( 'workos_mfa_required_changed', $user_id, $required ); + + return true; + } + + /** + * Whether the user has a confirmed factor (mirrored, no API call). + * + * @param int $user_id WP user ID. + * + * @return bool + */ + public function is_enabled( int $user_id ): bool { + return '1' === (string) get_user_meta( $user_id, User::META_MFA_ENABLED, true ); + } + + /** + * Whether an admin set the require flag (roles and filters aside). + * + * @param int $user_id WP user ID. + * + * @return bool + */ + public function is_required_flag( int $user_id ): bool { + return '1' === (string) get_user_meta( $user_id, User::META_MFA_REQUIRED, true ); + } + + /** + * The unconfirmed factor, if any. + * + * @param int $user_id WP user ID. + * + * @return array{factor_id: string, created: int}|null + */ + public function pending_factor( int $user_id ): ?array { + $pending = get_user_meta( $user_id, User::META_MFA_PENDING_FACTOR, true ); + if ( ! is_array( $pending ) || empty( $pending['factor_id'] ) ) { + return null; + } + + return [ + 'factor_id' => (string) $pending['factor_id'], + 'created' => (int) ( $pending['created'] ?? 0 ), + ]; + } + + /** + * Linked WorkOS user ID, or an error. + * + * @param int $user_id WP user ID. + * + * @return string|WP_Error + */ + private function workos_id( int $user_id ) { + $workos_id = $user_id > 0 ? User::get_workos_id( $user_id ) : ''; + if ( '' === $workos_id ) { + return new WP_Error( + 'workos_mfa_user_not_linked', + __( 'This account is not linked to a WorkOS user.', 'integration-workos' ), + [ 'status' => 409 ] + ); + } + + return $workos_id; + } + + /** + * Mirror the enabled state. + * + * @param int $user_id WP user ID. + * @param bool $enabled Whether the user has a confirmed factor. + * + * @return void + */ + private function sync_enabled( int $user_id, bool $enabled ): void { + if ( $enabled ) { + update_user_meta( $user_id, User::META_MFA_ENABLED, '1' ); + } else { + delete_user_meta( $user_id, User::META_MFA_ENABLED ); + } + } + + /** + * Error for a factor the user doesn't own. + * + * @return WP_Error + */ + private function factor_not_found(): WP_Error { + return new WP_Error( + 'workos_mfa_factor_not_found', + __( 'Factor not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + /** + * Error for a wrong or malformed code. + * + * @return WP_Error + */ + private function invalid_code(): WP_Error { + return new WP_Error( + 'workos_mfa_invalid_code', + __( 'That code didn’t work. Check your authenticator app and try again.', 'integration-workos' ), + [ 'status' => 400 ] + ); + } +} diff --git a/src/WorkOS/Mfa/Policy.php b/src/WorkOS/Mfa/Policy.php new file mode 100644 index 0000000..4ce37e8 --- /dev/null +++ b/src/WorkOS/Mfa/Policy.php @@ -0,0 +1,189 @@ +settings = $settings; + } + + /** + * What this sign-in needs. + * + * @param int $user_id WP user ID. + * @param Profile $profile Login Profile the sign-in came through. + * + * @return string One of the decision constants. + */ + public function decide( int $user_id, Profile $profile ): string { + $enrolled = $this->is_enrolled( $user_id ); + $required = $this->is_required( $user_id, $profile ); + $decision = $this->table( $profile, $enrolled, $required ); + + /** + * Filters what a sign-in needs after the first factor succeeded. + * + * Runs in `plugin` mode only. Return `none`, `challenge`, `enroll` + * or `block`; anything else is ignored. + * + * @param string $decision Decision from the policy table. + * @param int $user_id WP user ID. + * @param Profile $profile Login Profile the sign-in came through. + * @param array $context `{ mode, enrolled, required }`. + */ + $filtered = (string) apply_filters( + 'workos_mfa_decision', + $decision, + $user_id, + $profile, + [ + 'mode' => $this->settings->mode(), + 'enrolled' => $enrolled, + 'required' => $required, + ] + ); + + return in_array( $filtered, [ self::NONE, self::CHALLENGE, self::ENROLL, self::BLOCK ], true ) ? $filtered : $decision; + } + + /** + * Whether the user must use MFA. + * + * True when an admin set the flag, the user has a required role, or + * the `workos_mfa_required_for_user` filter says so. Independent of + * the mode, so admin screens can show the requirement before the site + * switches to `plugin` mode. + * + * @param int $user_id WP user ID. + * @param Profile|null $profile Login Profile, when known. + * + * @return bool + */ + public function is_required( int $user_id, ?Profile $profile = null ): bool { + $required = '1' === (string) get_user_meta( $user_id, User::META_MFA_REQUIRED, true ); + + if ( ! $required ) { + $roles = $this->settings->required_roles(); + $user = $roles ? get_userdata( $user_id ) : false; + if ( $user && array_intersect( $roles, (array) $user->roles ) ) { + $required = true; + } + } + + /** + * Filters whether a user must use MFA. + * + * @param bool $required Whether the user must use MFA. + * @param int $user_id WP user ID. + * @param Profile|null $profile Login Profile, when known. + */ + return (bool) apply_filters( 'workos_mfa_required_for_user', $required, $user_id, $profile ); + } + + /** + * Whether the user has a confirmed factor (mirrored from WorkOS). + * + * @param int $user_id WP user ID. + * + * @return bool + */ + public function is_enrolled( int $user_id ): bool { + return '1' === (string) get_user_meta( $user_id, User::META_MFA_ENABLED, true ); + } + + /** + * The decision table, before the filter. + * + * @param Profile $profile Login Profile. + * @param bool $enrolled Whether the user has a confirmed factor. + * @param bool $required Whether the user must use MFA. + * + * @return string + */ + private function table( Profile $profile, bool $enrolled, bool $required ): string { + if ( Settings::MODE_PLUGIN !== $this->settings->mode() ) { + return self::NONE; + } + + $enforce = (string) ( $profile->get_mfa()['enforce'] ?? Profile::MFA_ENFORCE_IF_REQUIRED ); + + if ( Profile::MFA_ENFORCE_NEVER === $enforce || ! $profile->allows_factor( Profile::FACTOR_TOTP ) ) { + return ( $enrolled || $required ) ? self::BLOCK : self::NONE; + } + + if ( $enrolled ) { + return self::CHALLENGE; + } + + if ( Profile::MFA_ENFORCE_ALWAYS === $enforce || $required ) { + return self::ENROLL; + } + + return self::NONE; + } +} diff --git a/src/WorkOS/Mfa/Settings.php b/src/WorkOS/Mfa/Settings.php new file mode 100644 index 0000000..d22342f --- /dev/null +++ b/src/WorkOS/Mfa/Settings.php @@ -0,0 +1,93 @@ +option( self::OPTION_MODE, self::MODE_PLUGIN ); + + /** + * Filters who decides that a sign-in needs a second factor. + * + * `plugin` (default): the plugin decides per user, role and Login + * Profile. `workos`: the WorkOS environment requires MFA for + * everyone and the plugin only runs the steps WorkOS asks for. + * `off`: the plugin challenges nobody. + * + * @param string $mode Active mode. + */ + $mode = (string) apply_filters( 'workos_mfa_mode', $mode ); + + return in_array( $mode, self::modes(), true ) ? $mode : self::MODE_PLUGIN; + } + + /** + * Roles whose members must use MFA (plugin mode). + * + * @return string[] + */ + public function required_roles(): array { + $roles = workos()->option( self::OPTION_REQUIRED_ROLES, [] ); + + return is_array( $roles ) ? array_values( array_filter( array_map( 'strval', $roles ) ) ) : []; + } +} diff --git a/src/WorkOS/User.php b/src/WorkOS/User.php index fe6ddda..6ce209e 100644 --- a/src/WorkOS/User.php +++ b/src/WorkOS/User.php @@ -84,6 +84,15 @@ final class User { /** User meta key — flag indicating this WP user's first login via WorkOS. */ public const META_FIRST_LOGIN = '_workos_first_login'; + /** User meta key — '1' once the user has a confirmed MFA factor in WorkOS (mirrored; WorkOS holds the factor). */ + public const META_MFA_ENABLED = '_workos_mfa_enabled'; + + /** User meta key — '1' when an admin requires this user to use MFA. */ + public const META_MFA_REQUIRED = '_workos_mfa_required'; + + /** User meta key — factor enrolled but not yet confirmed with a code: `{ factor_id, created }`. */ + public const META_MFA_PENDING_FACTOR = '_workos_mfa_pending_factor'; + /** * Whether the user is linked to a WorkOS identity. * diff --git a/src/includes/functions-helpers.php b/src/includes/functions-helpers.php index b914a5d..3de7052 100644 --- a/src/includes/functions-helpers.php +++ b/src/includes/functions-helpers.php @@ -102,3 +102,157 @@ function workos_get_user_id( int $user_id = 0 ): string { function workos_get_access_token( int $user_id = 0 ): string { return User::get_access_token( $user_id ); } + +/** + * Resolve an MFA helper's user argument: 0 or omitted means the current user. + * + * @internal Used by the `workos_mfa_*` helpers below. + * + * @param int $user_id WP user ID. + * + * @return int + */ +function workos_mfa_resolve_user_id( int $user_id ): int { + return $user_id > 0 ? $user_id : get_current_user_id(); +} + +/** + * A user's MFA status, reconciled with WorkOS. + * + * Calls WorkOS. Doesn't check capabilities: callers do. + * + * ```php + * $status = workos_mfa_get_status(); + * if ( ! is_wp_error( $status ) && ! $status['enabled'] ) { + * // Nudge the user to turn on MFA. + * } + * ``` + * + * Errors: `workos_mfa_user_not_linked` (409), `workos_api_error`. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return array|\WP_Error `{ enabled: bool, required: bool, factors: array }`. + */ +function workos_mfa_get_status( int $user_id = 0 ) { + return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->status( workos_mfa_resolve_user_id( $user_id ) ); +} + +/** + * Whether a user has a confirmed MFA factor. + * + * Reads the mirrored flag; no WorkOS call. {@see workos_mfa_get_status()} + * reconciles it with WorkOS. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return bool + */ +function workos_mfa_is_enabled( int $user_id = 0 ): bool { + return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->is_enabled( workos_mfa_resolve_user_id( $user_id ) ); +} + +/** + * Whether a user must use MFA. + * + * True when an admin set the require flag, the user has a role listed in + * the MFA settings, or the `workos_mfa_required_for_user` filter says so. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return bool + */ +function workos_mfa_is_required( int $user_id = 0 ): bool { + $user_id = workos_mfa_resolve_user_id( $user_id ); + + return $user_id > 0 && \WorkOS\App::container()->get( \WorkOS\Mfa\Policy::class )->is_required( $user_id ); +} + +/** + * Set or clear the admin flag that requires MFA for a user. + * + * Logs `mfa.required_set` / `mfa.required_cleared` and fires + * `workos_mfa_required_changed` when the value changes. Doesn't check + * capabilities: callers do. + * + * ```php + * // Require MFA for everyone who can publish. + * add_action( 'set_user_role', function ( $user_id, $role ) { + * workos_mfa_set_required( $user_id, in_array( $role, [ 'editor', 'administrator' ], true ) ); + * }, 10, 2 ); + * ``` + * + * Errors: `workos_mfa_invalid_user` (404). + * + * @param int $user_id WP user ID. + * @param bool $required Whether the user must use MFA. + * + * @return bool|\WP_Error Whether the flag changed. + */ +function workos_mfa_set_required( int $user_id, bool $required ) { + if ( $user_id <= 0 || ! get_userdata( $user_id ) ) { + return new \WP_Error( + 'workos_mfa_invalid_user', + __( 'User not found.', 'integration-workos' ), + [ 'status' => 404 ] + ); + } + + return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->set_required( $user_id, $required ); +} + +/** + * A user's confirmed MFA factors, read from WorkOS. + * + * Errors: `workos_mfa_user_not_linked` (409), `workos_api_error`. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * + * @return array|\WP_Error List of `{ id, type, created }`. + */ +function workos_mfa_list_factors( int $user_id = 0 ) { + return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->list_factors( workos_mfa_resolve_user_id( $user_id ) ); +} + +/** + * Remove every MFA factor a user has. + * + * For a user who lost their authenticator. Logs `mfa.reset` and fires + * `workos_mfa_reset`. Doesn't check capabilities: callers do. + * + * Errors: `workos_mfa_user_not_linked` (409), `workos_api_error`. + * + * @param int $user_id WP user ID. + * + * @return int|\WP_Error Number of factors removed. + */ +function workos_mfa_reset( int $user_id ) { + return \WorkOS\App::container()->get( \WorkOS\Mfa\FactorService::class )->reset( $user_id ); +} + +/** + * What a sign-in by this user would need after the first factor. + * + * `none`, `challenge`, `enroll` or `block`. Useful to warn a user before + * they sign in through a profile that would block them. Only `plugin` + * mode decides anything; other modes always return `none`. + * + * @param int $user_id WP user ID; 0 or omitted targets the current user. + * @param \WorkOS\Auth\AuthKit\Profile|string $profile Login Profile or its slug; default profile when omitted. + * + * @return string + */ +function workos_mfa_decision( int $user_id = 0, $profile = '' ): string { + $user_id = workos_mfa_resolve_user_id( $user_id ); + + if ( ! $profile instanceof \WorkOS\Auth\AuthKit\Profile ) { + $repository = \WorkOS\App::container()->get( \WorkOS\Auth\AuthKit\ProfileRepository::class ); + $profile = $repository->find_by_slug( '' !== $profile ? (string) $profile : \WorkOS\Auth\AuthKit\Profile::DEFAULT_SLUG ); + } + + if ( $user_id <= 0 || ! $profile ) { + return \WorkOS\Mfa\Policy::NONE; + } + + return \WorkOS\App::container()->get( \WorkOS\Mfa\Policy::class )->decide( $user_id, $profile ); +} diff --git a/tests/wpunit/MfaFactorServiceTest.php b/tests/wpunit/MfaFactorServiceTest.php new file mode 100644 index 0000000..49b3c5b --- /dev/null +++ b/tests/wpunit/MfaFactorServiceTest.php @@ -0,0 +1,430 @@ + + */ + private array $responses = []; + + /** + * Hooks fired during the test. + * + * @var array + */ + private array $fired = []; + + public function setUp(): void { + parent::setUp(); + + \WorkOS\Config::set_active_environment( 'production' ); + update_option( + 'workos_production', + [ + 'api_key' => 'sk_test_fake', + 'client_id' => 'client_fake', + 'environment_id' => 'environment_test', + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + + $this->service = new FactorService(); + $this->user_id = self::factory()->user->create( [ 'user_email' => 'mfa@example.com' ] ); + update_user_meta( $this->user_id, User::META_WORKOS_ID, 'user_mfa' ); + + // Creating the user pushes it to WorkOS; start the capture clean. + $this->captured = []; + + foreach ( [ 'workos_mfa_enrolled', 'workos_mfa_factor_removed', 'workos_mfa_reset', 'workos_mfa_required_changed' ] as $hook ) { + add_action( + $hook, + function ( ...$args ) use ( $hook ) { + $this->fired[] = [ $hook, $args ]; + }, + 10, + 2 + ); + } + } + + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + foreach ( [ 'workos_mfa_enrolled', 'workos_mfa_factor_removed', 'workos_mfa_reset', 'workos_mfa_required_changed', 'workos_mfa_totp_issuer' ] as $hook ) { + remove_all_filters( $hook ); + } + $this->captured = []; + $this->responses = []; + $this->fired = []; + + wp_set_current_user( 0 ); + delete_option( 'workos_production' ); + \WorkOS\Config::set_active_environment( 'staging' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + public function intercept_http( $preempt, array $args, string $url ) { + if ( ! str_contains( $url, 'api.workos.com' ) ) { + return $preempt; + } + + $method = $args['method'] ?? 'GET'; + $this->captured[] = [ + 'method' => $method, + 'url' => $url, + 'body' => $args['body'] ?? '', + ]; + + foreach ( $this->responses as $key => $resp ) { + [ $want_method, $needle ] = explode( ' ', $key, 2 ); + if ( $want_method === $method && str_contains( $url, $needle ) ) { + return $resp; + } + } + + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => '{}', + ]; + } + + // ------------------------------- Enrollment ------------------------------- + + public function test_start_enrollment_creates_factor_and_remembers_it_unconfirmed(): void { + $this->queue( 'POST /user_management/users/user_mfa/auth_factors', $this->enroll_response( 'auth_factor_new' ), 201 ); + + $result = $this->service->start_enrollment( $this->user_id ); + + $this->assertIsArray( $result ); + $this->assertSame( 'auth_factor_new', $result['factor_id'] ); + $this->assertSame( 'auth_challenge_new', $result['challenge_id'] ); + $this->assertSame( 'SECRET', $result['secret'] ); + $this->assertSame( 'auth_factor_new', $this->service->pending_factor( $this->user_id )['factor_id'] ); + $this->assertFalse( $this->service->is_enabled( $this->user_id ) ); + + $body = json_decode( $this->captured[0]['body'], true ); + $this->assertSame( 'totp', $body['type'] ); + $this->assertSame( 'mfa@example.com', $body['totp_user'] ); + } + + public function test_start_enrollment_uses_issuer_filter(): void { + add_filter( 'workos_mfa_totp_issuer', static fn() => 'Nexcess Portal' ); + $this->queue( 'POST /user_management/users/user_mfa/auth_factors', $this->enroll_response( 'auth_factor_new' ), 201 ); + + $this->service->start_enrollment( $this->user_id ); + + $this->assertSame( 'Nexcess Portal', json_decode( $this->captured[0]['body'], true )['totp_issuer'] ); + } + + public function test_restarting_enrollment_deletes_the_unfinished_factor(): void { + update_user_meta( + $this->user_id, + User::META_MFA_PENDING_FACTOR, + [ + 'factor_id' => 'auth_factor_old', + 'created' => time(), + ] + ); + $this->queue( 'POST /user_management/users/user_mfa/auth_factors', $this->enroll_response( 'auth_factor_new' ), 201 ); + + $this->service->start_enrollment( $this->user_id ); + + $this->assertSame( 'DELETE', $this->captured[0]['method'] ); + $this->assertStringContainsString( '/auth/factors/auth_factor_old', $this->captured[0]['url'] ); + $this->assertSame( 'auth_factor_new', $this->service->pending_factor( $this->user_id )['factor_id'] ); + } + + public function test_unlinked_user_gets_409(): void { + $unlinked = self::factory()->user->create(); + $this->captured = []; + + $result = $this->service->start_enrollment( $unlinked ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_mfa_user_not_linked', $result->get_error_code() ); + $this->assertSame( 409, $result->get_error_data()['status'] ); + $this->assertEmpty( $this->captured ); + } + + public function test_confirm_with_valid_code_enables_mfa_and_fires_hook(): void { + $this->set_pending( 'auth_factor_new' ); + $this->queue( 'POST /auth/challenges/auth_challenge_new/verify', [ 'challenge' => [ 'id' => 'auth_challenge_new' ], 'valid' => true ] ); + $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_new', 'type' => 'totp' ] ] ] ); + + $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '123 456', 'auth_challenge_new' ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['enabled'] ); + $this->assertSame( 'auth_factor_new', $result['factors'][0]['id'] ); + $this->assertNull( $this->service->pending_factor( $this->user_id ) ); + $this->assertSame( 'workos_mfa_enrolled', $this->fired[0][0] ); + $this->assertSame( $this->user_id, $this->fired[0][1][0] ); + $this->assertSame( '123456', json_decode( $this->captured[0]['body'], true )['code'] ); + } + + public function test_confirm_creates_challenge_when_none_given(): void { + $this->set_pending( 'auth_factor_new' ); + $this->queue( 'POST /auth/factors/auth_factor_new/challenge', [ 'id' => 'auth_challenge_fresh' ] ); + $this->queue( 'POST /auth/challenges/auth_challenge_fresh/verify', [ 'valid' => true ] ); + $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_new', 'type' => 'totp' ] ] ] ); + + $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '123456' ); + + $this->assertStringContainsString( '/auth/factors/auth_factor_new/challenge', $this->captured[0]['url'] ); + $this->assertStringContainsString( '/auth/challenges/auth_challenge_fresh/verify', $this->captured[1]['url'] ); + $this->assertTrue( $this->service->is_enabled( $this->user_id ) ); + } + + /** + * @return array + */ + public function wrong_code_responses(): array { + return [ + 'valid false' => [ [ 'valid' => false ], 200 ], + '4xx error' => [ [ 'code' => 'authentication_challenge_failed' ], 400 ], + ]; + } + + /** + * @dataProvider wrong_code_responses + */ + public function test_confirm_with_wrong_code_keeps_mfa_off( array $body, int $status ): void { + $this->set_pending( 'auth_factor_new' ); + $this->queue( 'POST /auth/challenges/auth_challenge_new/verify', $body, $status ); + + $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '000000', 'auth_challenge_new' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_mfa_invalid_code', $result->get_error_code() ); + $this->assertFalse( $this->service->is_enabled( $this->user_id ) ); + $this->assertSame( 'auth_factor_new', $this->service->pending_factor( $this->user_id )['factor_id'] ); + $this->assertEmpty( $this->fired ); + } + + public function test_confirm_rejects_malformed_code_without_calling_workos(): void { + $this->set_pending( 'auth_factor_new' ); + + $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_new', '12ab', 'auth_challenge_new' ); + + $this->assertSame( 'workos_mfa_invalid_code', $result->get_error_code() ); + $this->assertEmpty( $this->captured ); + } + + public function test_confirm_rejects_factor_that_is_not_pending(): void { + $this->set_pending( 'auth_factor_new' ); + + $result = $this->service->confirm_enrollment( $this->user_id, 'auth_factor_someone_else', '123456', 'auth_challenge_new' ); + + $this->assertSame( 'workos_mfa_factor_not_found', $result->get_error_code() ); + $this->assertEmpty( $this->captured ); + } + + // --------------------------------- Listing --------------------------------- + + public function test_list_hides_pending_factor_and_reconciles_enabled_flag(): void { + $this->set_pending( 'auth_factor_pending' ); + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + $this->queue( + 'GET /user_management/users/user_mfa/auth_factors', + [ 'data' => [ [ 'id' => 'auth_factor_pending', 'type' => 'totp' ] ] ] + ); + + $factors = $this->service->list_factors( $this->user_id ); + + $this->assertSame( [], $factors ); + $this->assertFalse( $this->service->is_enabled( $this->user_id ), 'Only a pending factor in WorkOS: MFA is off.' ); + } + + public function test_list_turns_flag_on_for_factor_added_outside_wordpress(): void { + $this->queue( + 'GET /user_management/users/user_mfa/auth_factors', + [ 'data' => [ [ 'id' => 'auth_factor_1', 'type' => 'totp', 'created_at' => '2026-10-18T00:00:00Z' ] ] ] + ); + + $factors = $this->service->list_factors( $this->user_id ); + + $this->assertSame( [ [ 'id' => 'auth_factor_1', 'type' => 'totp', 'created' => '2026-10-18T00:00:00Z' ] ], $factors ); + $this->assertTrue( $this->service->is_enabled( $this->user_id ) ); + } + + public function test_list_deletes_stale_pending_factor(): void { + update_user_meta( + $this->user_id, + User::META_MFA_PENDING_FACTOR, + [ + 'factor_id' => 'auth_factor_stale', + 'created' => time() - FactorService::PENDING_FACTOR_TTL - 60, + ] + ); + $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_stale', 'type' => 'totp' ] ] ] ); + + $this->service->list_factors( $this->user_id ); + + $deletes = array_filter( $this->captured, static fn( $c ) => 'DELETE' === $c['method'] ); + $this->assertCount( 1, $deletes ); + $this->assertNull( $this->service->pending_factor( $this->user_id ) ); + } + + // ----------------------------- Delete and reset ----------------------------- + + public function test_delete_owned_factor_turns_mfa_off_when_last(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_1', 'type' => 'totp' ] ] ] ); + + $result = $this->service->delete_factor( $this->user_id, 'auth_factor_1' ); + + $this->assertTrue( $result ); + $this->assertFalse( $this->service->is_enabled( $this->user_id ) ); + $this->assertSame( [ 'workos_mfa_factor_removed', [ $this->user_id, 'auth_factor_1' ] ], $this->fired[0] ); + } + + public function test_delete_other_users_factor_is_404_and_deletes_nothing(): void { + $this->queue( 'GET /user_management/users/user_mfa/auth_factors', [ 'data' => [ [ 'id' => 'auth_factor_mine', 'type' => 'totp' ] ] ] ); + + $result = $this->service->delete_factor( $this->user_id, 'auth_factor_theirs' ); + + $this->assertSame( 'workos_mfa_factor_not_found', $result->get_error_code() ); + $this->assertEmpty( array_filter( $this->captured, static fn( $c ) => 'DELETE' === $c['method'] ) ); + } + + public function test_reset_deletes_every_factor(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + $this->set_pending( 'auth_factor_pending' ); + $this->queue( + 'GET /user_management/users/user_mfa/auth_factors', + [ 'data' => [ [ 'id' => 'auth_factor_1' ], [ 'id' => 'auth_factor_pending' ] ] ] + ); + + $removed = $this->service->reset( $this->user_id ); + + $this->assertSame( 2, $removed ); + $this->assertFalse( $this->service->is_enabled( $this->user_id ) ); + $this->assertNull( $this->service->pending_factor( $this->user_id ) ); + $this->assertSame( [ 'workos_mfa_reset', [ $this->user_id, 2 ] ], $this->fired[0] ); + } + + // ------------------------------ Require flag ------------------------------ + + public function test_set_required_fires_only_on_change(): void { + $this->assertTrue( $this->service->set_required( $this->user_id, true ) ); + $this->assertFalse( $this->service->set_required( $this->user_id, true ) ); + $this->assertTrue( $this->service->is_required_flag( $this->user_id ) ); + $this->assertTrue( $this->service->set_required( $this->user_id, false ) ); + + $this->assertSame( + [ + [ 'workos_mfa_required_changed', [ $this->user_id, true ] ], + [ 'workos_mfa_required_changed', [ $this->user_id, false ] ], + ], + $this->fired + ); + } + + // --------------------------------- Helpers --------------------------------- + + public function test_helpers_default_to_current_user(): void { + wp_set_current_user( $this->user_id ); + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + update_user_meta( $this->user_id, User::META_MFA_REQUIRED, '1' ); + + $this->assertTrue( workos_mfa_is_enabled() ); + $this->assertTrue( workos_mfa_is_required() ); + } + + public function test_set_required_helper_rejects_unknown_user(): void { + $result = workos_mfa_set_required( 999999, true ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'workos_mfa_invalid_user', $result->get_error_code() ); + } + + public function test_set_required_helper_sets_flag(): void { + $this->assertTrue( workos_mfa_set_required( $this->user_id, true ) ); + $this->assertTrue( workos_mfa_is_required( $this->user_id ) ); + } + + public function test_decision_helper_returns_none_for_logged_out_visitor(): void { + $this->assertSame( 'none', workos_mfa_decision() ); + } + + // -------------------------------- Fixtures -------------------------------- + + private function set_pending( string $factor_id ): void { + update_user_meta( + $this->user_id, + User::META_MFA_PENDING_FACTOR, + [ + 'factor_id' => $factor_id, + 'created' => time(), + ] + ); + } + + private function enroll_response( string $factor_id ): array { + return [ + 'authentication_factor' => [ + 'object' => 'authentication_factor', + 'id' => $factor_id, + 'type' => 'totp', + 'totp' => [ + 'qr_code' => 'data:image/png;base64,AAA', + 'secret' => 'SECRET', + 'uri' => 'otpauth://totp/Site:mfa@example.com?secret=SECRET', + ], + ], + 'authentication_challenge' => [ + 'object' => 'authentication_challenge', + 'id' => 'auth_challenge_new', + ], + ]; + } + + private function queue( string $key, array $body, int $code = 200 ): void { + $this->responses[ $key ] = [ + 'response' => [ 'code' => $code, 'message' => 'OK' ], + 'body' => wp_json_encode( $body ), + ]; + } +} diff --git a/tests/wpunit/MfaPolicyTest.php b/tests/wpunit/MfaPolicyTest.php new file mode 100644 index 0000000..950c08d --- /dev/null +++ b/tests/wpunit/MfaPolicyTest.php @@ -0,0 +1,203 @@ + Settings::MODE_PLUGIN ] ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $this->policy = new Policy( new Settings() ); + $this->user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + } + + public function tearDown(): void { + remove_all_filters( 'workos_mfa_mode' ); + remove_all_filters( 'workos_mfa_decision' ); + remove_all_filters( 'workos_mfa_required_for_user' ); + delete_option( 'workos_production' ); + \WorkOS\Config::set_active_environment( 'staging' ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + parent::tearDown(); + } + + /** + * @return array + */ + public function table_provider(): array { + return [ + 'never, enrolled' => [ Profile::MFA_ENFORCE_NEVER, true, false, Policy::BLOCK ], + 'never, required' => [ Profile::MFA_ENFORCE_NEVER, false, true, Policy::BLOCK ], + 'never, neither' => [ Profile::MFA_ENFORCE_NEVER, false, false, Policy::NONE ], + 'if_required, enrolled' => [ Profile::MFA_ENFORCE_IF_REQUIRED, true, false, Policy::CHALLENGE ], + 'if_required, enrolled+required' => [ Profile::MFA_ENFORCE_IF_REQUIRED, true, true, Policy::CHALLENGE ], + 'if_required, required' => [ Profile::MFA_ENFORCE_IF_REQUIRED, false, true, Policy::ENROLL ], + 'if_required, neither' => [ Profile::MFA_ENFORCE_IF_REQUIRED, false, false, Policy::NONE ], + 'always, enrolled' => [ Profile::MFA_ENFORCE_ALWAYS, true, false, Policy::CHALLENGE ], + 'always, not enrolled' => [ Profile::MFA_ENFORCE_ALWAYS, false, false, Policy::ENROLL ], + 'always, required not enrolled' => [ Profile::MFA_ENFORCE_ALWAYS, false, true, Policy::ENROLL ], + ]; + } + + /** + * @dataProvider table_provider + */ + public function test_decision_table( string $enforce, bool $enrolled, bool $required, string $expected ): void { + if ( $enrolled ) { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + } + if ( $required ) { + update_user_meta( $this->user_id, User::META_MFA_REQUIRED, '1' ); + } + + $this->assertSame( $expected, $this->policy->decide( $this->user_id, $this->profile( $enforce ) ) ); + } + + public function test_profile_without_totp_acts_like_never(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + + $profile = $this->profile( Profile::MFA_ENFORCE_ALWAYS, [ Profile::FACTOR_SMS ] ); + + $this->assertSame( Policy::BLOCK, $this->policy->decide( $this->user_id, $profile ) ); + } + + /** + * @return array + */ + public function non_plugin_modes(): array { + return [ + 'workos' => [ Settings::MODE_WORKOS ], + 'off' => [ Settings::MODE_OFF ], + ]; + } + + /** + * @dataProvider non_plugin_modes + */ + public function test_only_plugin_mode_decides( string $mode ): void { + update_option( 'workos_production', [ Settings::OPTION_MODE => $mode ] ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + + $this->assertSame( Policy::NONE, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_ALWAYS ) ) ); + } + + public function test_mode_filter_overrides_option_and_rejects_unknown_values(): void { + add_filter( 'workos_mfa_mode', static fn() => Settings::MODE_OFF ); + $this->assertSame( Settings::MODE_OFF, ( new Settings() )->mode() ); + + remove_all_filters( 'workos_mfa_mode' ); + add_filter( 'workos_mfa_mode', static fn() => 'sometimes' ); + $this->assertSame( Settings::MODE_PLUGIN, ( new Settings() )->mode() ); + } + + public function test_required_role_requires_mfa(): void { + update_option( + 'workos_production', + [ + Settings::OPTION_MODE => Settings::MODE_PLUGIN, + Settings::OPTION_REQUIRED_ROLES => [ 'editor' ], + ] + ); + \WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset(); + + $this->assertFalse( $this->policy->is_required( $this->user_id ) ); + + ( new \WP_User( $this->user_id ) )->set_role( 'editor' ); + + $this->assertTrue( $this->policy->is_required( $this->user_id ) ); + $this->assertSame( Policy::ENROLL, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) ); + } + + public function test_required_filter_requires_mfa(): void { + $user_id = $this->user_id; + add_filter( + 'workos_mfa_required_for_user', + static fn( bool $required, int $id ) => $required || $id === $user_id, + 10, + 2 + ); + + $this->assertSame( Policy::ENROLL, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) ); + } + + public function test_decision_filter_wins_and_ignores_unknown_values(): void { + add_filter( 'workos_mfa_decision', static fn() => Policy::CHALLENGE ); + $this->assertSame( Policy::CHALLENGE, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) ); + + remove_all_filters( 'workos_mfa_decision' ); + add_filter( 'workos_mfa_decision', static fn() => 'maybe' ); + $this->assertSame( Policy::NONE, $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ) ); + } + + public function test_decision_filter_receives_context(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + $seen = null; + add_filter( + 'workos_mfa_decision', + static function ( $decision, $user_id, $profile, $context ) use ( &$seen ) { + $seen = $context; + return $decision; + }, + 10, + 4 + ); + + $this->policy->decide( $this->user_id, $this->profile( Profile::MFA_ENFORCE_IF_REQUIRED ) ); + + $this->assertSame( + [ + 'mode' => Settings::MODE_PLUGIN, + 'enrolled' => true, + 'required' => false, + ], + $seen + ); + } + + private function profile( string $enforce, array $factors = [ Profile::FACTOR_TOTP ] ): Profile { + return Profile::from_array( + [ + 'slug' => 'members', + 'title' => 'Members', + 'mfa' => [ + 'enforce' => $enforce, + 'factors' => $factors, + ], + ] + ); + } +}