From f7fa19ed2fe423c4674d529067983dff33b4eb6e Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Sun, 18 Oct 2026 16:55:05 +0000 Subject: [PATCH] feat(mfa): two-step verification account panel and routes - Mfa\RestApi: GET /users/{id}/mfa (owner or edit_user), POST /mfa/enroll and /mfa/confirm, DELETE /mfa/factors/{id} with a current code from the factor; wp_rest cookie auth; per-user rate limits; filters workos_mfa_can_manage and workos_mfa_can_manage_user - Account panel (React, wp-element): profile.php section and the [workos:mfa] shortcode - The profile-scoped /auth/mfa/factors|totp/enroll|factor/delete routes go through FactorService (confirm step, code to delete) --- CHANGELOG.md | 5 + docs/mfa.md | 47 ++++ src/WorkOS/Mfa/Assets.php | 96 +++++++ src/WorkOS/Mfa/Controller.php | 13 +- src/WorkOS/Mfa/RestApi.php | 312 ++++++++++++++++++++++ src/WorkOS/Mfa/Shortcode.php | 68 +++++ src/WorkOS/Mfa/UserProfilePanel.php | 49 ++++ src/WorkOS/REST/Auth/Mfa.php | 105 +++----- src/js/mfa-settings/index.tsx | 390 ++++++++++++++++++++++++++++ src/js/mfa-settings/styles.css | 50 ++++ tests/wpunit/AuthKitRestMfaTest.php | 33 ++- tests/wpunit/MfaAccountRestTest.php | 283 ++++++++++++++++++++ webpack.config.js | 1 + 13 files changed, 1372 insertions(+), 80 deletions(-) create mode 100644 src/WorkOS/Mfa/Assets.php create mode 100644 src/WorkOS/Mfa/RestApi.php create mode 100644 src/WorkOS/Mfa/Shortcode.php create mode 100644 src/WorkOS/Mfa/UserProfilePanel.php create mode 100644 src/js/mfa-settings/index.tsx create mode 100644 src/js/mfa-settings/styles.css create mode 100644 tests/wpunit/MfaAccountRestTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c88809..4df527a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,14 @@ - **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 at sign-in, decided by the plugin** — in `plugin` mode every AuthKit sign-in (password, magic code, OAuth, the hosted-AuthKit callback) asks the MFA policy before it sets a session: a user with a factor answers a code, a user who must use MFA and has none enrolls an authenticator app on the spot (QR code and setup key), and a profile that can't satisfy the user's MFA refuses with `403 workos_mfa_blocked`. The WorkOS response waits server-side (`WorkOS\Mfa\PendingLogin`: bound to the profile, single use, 5 wrong codes, 10 minutes) and the browser only holds an `mfa_token`. New `POST /wp-json/workos/v1/auth/mfa/enroll`; `/challenge` and `/verify` accept `mfa_token`. WorkOS's own `mfa_enrollment` now enrolls in the same step instead of being refused. A profile with `enforce: always` asks a user without a factor to enroll instead of refusing them. Hosted-AuthKit sign-ins finish the step on `/workos/login/{profile}/?workos_mfa=…`. +- **Two-step verification account panel** — a "Two-step verification" section on the profile screen and a `[workos:mfa]` shortcode let users turn MFA on (QR code and setup key, confirmed with the first code) and remove an authenticator app (with a current code from it). New routes `GET /wp-json/workos/v1/users/{id}/mfa`, `POST …/mfa/enroll`, `POST …/mfa/confirm` and `DELETE …/mfa/factors/{factor_id}`, cookie-authenticated with the `wp_rest` nonce. Filters `workos_mfa_can_manage` and `workos_mfa_can_manage_user`. - **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`, `workos_mfa_totp_issuer`, `workos_mfa_block_message`, `workos_mfa_pending_ttl` and `workos_mfa_allow_password_fallback`; actions `workos_mfa_enrolled`, `workos_mfa_factor_removed`, `workos_mfa_reset`, `workos_mfa_required_changed`, `workos_mfa_challenge_succeeded`, `workos_mfa_challenge_failed` and `workos_mfa_blocked`; Activity Log events `mfa.enrolled`, `mfa.removed`, `mfa.reset`, `mfa.required_set`, `mfa.required_cleared`, `mfa.challenge_succeeded`, `mfa.challenge_failed` and `mfa.blocked`. +### Changed + +- **Profile-scoped MFA settings routes use the factor service** — `POST /auth/mfa/totp/enroll` leaves the factor unconfirmed until `POST /users/{id}/mfa/confirm`, `POST /auth/mfa/factor/delete` now needs a current `code` from the factor, and an unlinked account gets `409 workos_mfa_user_not_linked` instead of `400 workos_authkit_no_workos_user`. Nothing in the plugin called these routes, and against WorkOS they couldn't have worked before (see Fixed). + ### Security - **Password-only sign-ins can't skip MFA** — in `plugin` mode, `wp-login.php` with `?fallback=1`, `wp_signon()`, headless mode and the login button refuse (`403 workos_mfa_step_unsupported`) a user who has a factor or must use MFA, since they can't ask for a code. Application passwords are exempt. Filter `workos_mfa_allow_password_fallback`. diff --git a/docs/mfa.md b/docs/mfa.md index e5e43c2..fd1f6cd 100644 --- a/docs/mfa.md +++ b/docs/mfa.md @@ -18,6 +18,7 @@ WorkOS users. | What a sign-in needs | `WorkOS\Mfa\Policy::decide()`, filter `workos_mfa_decision`, helper `workos_mfa_decision()` | Code | | Sign-in step | `POST /auth/mfa/enroll`, `/auth/mfa/challenge`, `/auth/mfa/verify` and the React shell | Visitors | | Password-only sign-ins | Refused for users with MFA (`workos_mfa_allow_password_fallback`) | Site | +| Account panel | Profile screen, `[workos:mfa]` shortcode, `/users/{id}/mfa…` routes | Users | | Factors | `WorkOS\Mfa\FactorService`, helpers `workos_mfa_*` | Code | | Activity log | `mfa.*` events | Admin | @@ -203,6 +204,48 @@ add_filter( 'workos_mfa_allow_password_fallback', fn( $allow, $user_id ) => user In `workos` mode a WordPress password never reaches WorkOS, so turn the password fallback off. +## Account panel + +Users turn two-step verification on and off themselves: + +- **Profile screen:** a "Two-step verification" section on `profile.php` for + linked users. +- **`[workos:mfa]`:** the same panel on any page, e.g. a front-end account + screen. It renders nothing for visitors, unlinked accounts, or when + `workos_mfa_can_manage` says no. + +Turning it on shows a QR code and setup key, and the factor only counts once +the first code checks out. Removing a factor needs a current code from it, so +a session someone else got hold of can't quietly turn MFA off. + +### Routes + +Cookie-authenticated with the core `wp_rest` nonce (`X-WP-Nonce`), so any UI +can use them. + +| Route | Who | Body | Returns | +|---|---|---|---| +| `GET /users/{id}/mfa` | The owner, or `edit_user` (`workos_mfa_can_manage_user`) | — | `{ enabled, required, factors }` | +| `POST /users/{id}/mfa/enroll` | The owner (`workos_mfa_can_manage`) | — | `201 { factor_id, challenge_id, qr_code, secret, otpauth_uri }` | +| `POST /users/{id}/mfa/confirm` | The owner | `factor_id`, `code`, `challenge_id` | Status | +| `DELETE /users/{id}/mfa/factors/{factor_id}` | The owner | `code` | Status | + +Rate limits per user: `mfa_enroll_user`, `mfa_confirm_user`, `mfa_delete_user`. +Errors add `workos_mfa_not_logged_in` (401) and `workos_mfa_forbidden` (403). + +The older profile-scoped routes (`GET /auth/mfa/factors`, +`POST /auth/mfa/totp/enroll`, `POST /auth/mfa/factor/delete`) go through the +same service: enrollment needs the confirm step, and delete needs `code`. + +```js +// Third-party UI: is MFA on for the current user? +const res = await fetch( `/wp-json/workos/v1/users/${ userId }/mfa`, { + headers: { 'X-WP-Nonce': wpApiSettings.nonce }, + credentials: 'same-origin', +} ); +const { enabled, required } = await res.json(); +``` + ## Hooks **Filters** @@ -216,6 +259,8 @@ password fallback off. | `workos_mfa_block_message` | `string $message, int $user_id, Profile $profile` | Generic message | | `workos_mfa_pending_ttl` | `int $ttl` | 600 seconds | | `workos_mfa_allow_password_fallback` | `bool $allow, int $user_id` | `false` | +| `workos_mfa_can_manage` | `bool $can, int $user_id` | `true` for the account owner | +| `workos_mfa_can_manage_user` | `bool $can, int $user_id` | `edit_user` on the target | **Actions** @@ -264,4 +309,6 @@ password fallback off. | Sign-in routes | `src/WorkOS/REST/Auth/Mfa.php` | | Password-only guard | `src/WorkOS/Mfa/PasswordGuard.php` | | Enroll and challenge steps | `src/js/authkit/flows.tsx` (`MfaEnroll`, `MfaChallenge`) | +| Account routes | `src/WorkOS/Mfa/RestApi.php` | +| Account panel | `src/js/mfa-settings/index.tsx`, `src/WorkOS/Mfa/{Assets,Shortcode,UserProfilePanel}.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/Mfa/Assets.php b/src/WorkOS/Mfa/Assets.php new file mode 100644 index 0000000..2cddfde --- /dev/null +++ b/src/WorkOS/Mfa/Assets.php @@ -0,0 +1,96 @@ +getDir() . 'build/mfa-settings.asset.php'; + $asset = file_exists( $asset_file ) + ? require $asset_file + : [ + 'dependencies' => [ 'wp-element', 'wp-i18n' ], + 'version' => workos()->getVersion(), + ]; + $version = (string) ( $asset['version'] ?? workos()->getVersion() ); + + wp_register_script( + self::HANDLE, + workos()->getUrl() . 'build/mfa-settings.js', + (array) ( $asset['dependencies'] ?? [] ), + $version, + true + ); + + wp_set_script_translations( self::HANDLE, 'integration-workos' ); + + wp_localize_script( + self::HANDLE, + 'workosMfa', + [ + 'restUrl' => esc_url_raw( rest_url( RestApi::NAMESPACE . '/users/' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ] + ); + + wp_register_style( self::HANDLE, workos()->getUrl() . 'build/mfa-settings.css', [], $version ); + } + + /** + * Enqueue on the signed-in user's own profile screen. + * + * @param string $hook_suffix Admin page hook. + * + * @return void + */ + public function maybe_enqueue_admin( string $hook_suffix ): void { + if ( 'profile.php' !== $hook_suffix || ! User::is_sso() ) { + return; + } + + self::enqueue(); + } + + /** + * Enqueue the panel script and style. + * + * @return void + */ + public static function enqueue(): void { + wp_enqueue_script( self::HANDLE ); + wp_enqueue_style( self::HANDLE ); + } +} diff --git a/src/WorkOS/Mfa/Controller.php b/src/WorkOS/Mfa/Controller.php index 2840443..c21e56c 100644 --- a/src/WorkOS/Mfa/Controller.php +++ b/src/WorkOS/Mfa/Controller.php @@ -10,8 +10,9 @@ use WorkOS\Contracts\Controller as BaseController; /** - * Binds the MFA settings, policy and factor service, and the guard that - * keeps password-only sign-ins from skipping MFA. + * Binds the MFA settings, policy and factor service, the guard that keeps + * password-only sign-ins from skipping MFA, and the account panel (REST, + * assets, `[workos:mfa]`, profile screen). * * Sign-in enforcement lives in `Auth\AuthKit\LoginCompleter`, and the * routes in `REST\Auth\Mfa`; both resolve these from the container. @@ -28,8 +29,16 @@ protected function doRegister(): void { $this->container->singleton( Policy::class ); $this->container->singleton( FactorService::class ); $this->container->singleton( PasswordGuard::class ); + $this->container->singleton( RestApi::class ); + $this->container->singleton( Assets::class ); + $this->container->singleton( Shortcode::class ); + $this->container->singleton( UserProfilePanel::class ); $this->container->get( PasswordGuard::class )->register(); + $this->container->get( RestApi::class )->register(); + $this->container->get( Assets::class )->register(); + $this->container->get( Shortcode::class )->register(); + $this->container->get( UserProfilePanel::class )->register(); } /** diff --git a/src/WorkOS/Mfa/RestApi.php b/src/WorkOS/Mfa/RestApi.php new file mode 100644 index 0000000..9796a5e --- /dev/null +++ b/src/WorkOS/Mfa/RestApi.php @@ -0,0 +1,312 @@ +factors = $factors; + $this->rate_limiter = $rate_limiter; + } + + /** + * Register hooks. + * + * @return void + */ + public function register(): void { + add_action( 'rest_api_init', [ $this, 'register_routes' ] ); + } + + /** + * Register routes. + * + * @return void + */ + public function register_routes(): void { + $id = [ 'id' => [ 'sanitize_callback' => 'absint' ] ]; + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/mfa', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'status' ], + 'permission_callback' => [ $this, 'can_view' ], + 'args' => $id, + ] + ); + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/mfa/enroll', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'enroll' ], + 'permission_callback' => [ $this, 'can_manage_own' ], + 'args' => $id, + ] + ); + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/mfa/confirm', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'confirm' ], + 'permission_callback' => [ $this, 'can_manage_own' ], + 'args' => $id + [ + 'factor_id' => [ 'sanitize_callback' => 'sanitize_text_field' ], + 'challenge_id' => [ 'sanitize_callback' => 'sanitize_text_field' ], + 'code' => [ 'sanitize_callback' => 'sanitize_text_field' ], + ], + ] + ); + + register_rest_route( + self::NAMESPACE, + '/users/(?P\d+)/mfa/factors/(?P[A-Za-z0-9_]+)', + [ + 'methods' => 'DELETE', + 'callback' => [ $this, 'remove' ], + 'permission_callback' => [ $this, 'can_manage_own' ], + 'args' => $id + [ + 'code' => [ 'sanitize_callback' => 'sanitize_text_field' ], + ], + ] + ); + } + + /** + * The account owner, or someone who may edit the user. + * + * @param WP_REST_Request $request REST request. + * + * @return true|WP_Error + */ + public function can_view( WP_REST_Request $request ) { + $user_id = (int) $request->get_param( 'id' ); + + if ( $user_id > 0 && get_current_user_id() === $user_id ) { + return $this->can_manage_own( $request ); + } + + return self::can_manage_user( $user_id ); + } + + /** + * Only the account owner manages their own factors. + * + * @param WP_REST_Request $request REST request. + * + * @return true|WP_Error + */ + public function can_manage_own( WP_REST_Request $request ) { + $user_id = (int) $request->get_param( 'id' ); + + if ( get_current_user_id() <= 0 ) { + return $this->error( 'workos_mfa_not_logged_in', __( 'You must be signed in to manage two-step verification.', 'integration-workos' ), 401 ); + } + + /** + * Filters whether a user may manage their own MFA factors. + * + * @param bool $can Default: true for the account owner. + * @param int $user_id WP user ID. + */ + $can = get_current_user_id() === $user_id && (bool) apply_filters( 'workos_mfa_can_manage', true, $user_id ); + + return $can ? true : $this->error( 'workos_mfa_forbidden', __( 'You can’t manage two-step verification for this account.', 'integration-workos' ), 403 ); + } + + /** + * Whether the current user may administer another user's MFA. + * + * @param int $user_id Target WP user ID. + * + * @return true|WP_Error + */ + public static function can_manage_user( int $user_id ) { + $can = $user_id > 0 && current_user_can( 'edit_user', $user_id ); + + /** + * Filters whether the current user may administer a user's MFA + * (read the status, reset factors, set the require flag). + * + * @param bool $can Default: `edit_user` on the target. + * @param int $user_id Target WP user ID. + */ + $can = (bool) apply_filters( 'workos_mfa_can_manage_user', $can, $user_id ); + + return $can ? true : new WP_Error( + 'workos_mfa_forbidden', + __( 'You can’t manage two-step verification for this account.', 'integration-workos' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + + /** + * GET /users/{id}/mfa + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function status( WP_REST_Request $request ) { + $status = $this->factors->status( (int) $request->get_param( 'id' ) ); + + return is_wp_error( $status ) ? $status : new WP_REST_Response( $status, 200 ); + } + + /** + * POST /users/{id}/mfa/enroll + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function enroll( WP_REST_Request $request ) { + $user_id = (int) $request->get_param( 'id' ); + + $limited = $this->rate_limiter->attempt( 'mfa_enroll_user', (string) $user_id, TieredRateLimiter::REQUEST_SUBJECT ); + if ( is_wp_error( $limited ) ) { + return $limited; + } + + $result = $this->factors->start_enrollment( $user_id ); + + return is_wp_error( $result ) ? $result : new WP_REST_Response( $result, 201 ); + } + + /** + * POST /users/{id}/mfa/confirm + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function confirm( WP_REST_Request $request ) { + $user_id = (int) $request->get_param( 'id' ); + + $limited = $this->rate_limiter->attempt( 'mfa_confirm_user', (string) $user_id, TieredRateLimiter::REQUEST_SUBJECT ); + if ( is_wp_error( $limited ) ) { + return $limited; + } + + $result = $this->factors->confirm_enrollment( + $user_id, + (string) $request->get_param( 'factor_id' ), + (string) $request->get_param( 'code' ), + (string) $request->get_param( 'challenge_id' ) + ); + + return is_wp_error( $result ) ? $result : new WP_REST_Response( $result, 200 ); + } + + /** + * DELETE /users/{id}/mfa/factors/{factor_id} + * + * Needs a current code from the factor being removed, so a session + * someone else got hold of can't quietly turn MFA off. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response|WP_Error + */ + public function remove( WP_REST_Request $request ) { + $user_id = (int) $request->get_param( 'id' ); + $factor_id = (string) $request->get_param( 'factor_id' ); + + $limited = $this->rate_limiter->attempt( 'mfa_delete_user', (string) $user_id, TieredRateLimiter::REQUEST_SUBJECT ); + if ( is_wp_error( $limited ) ) { + return $limited; + } + + $factors = $this->factors->list_factors( $user_id ); + if ( is_wp_error( $factors ) ) { + return $factors; + } + if ( ! in_array( $factor_id, array_column( $factors, 'id' ), true ) ) { + return $this->error( 'workos_mfa_factor_not_found', __( 'Factor not found.', 'integration-workos' ), 404 ); + } + + $valid = $this->factors->verify_code( $factor_id, (string) $request->get_param( 'code' ) ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + $result = $this->factors->delete_factor( $user_id, $factor_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $status = $this->factors->status( $user_id ); + + return new WP_REST_Response( is_wp_error( $status ) ? [ 'deleted' => true ] : $status, 200 ); + } + + /** + * Build an error. + * + * @param string $code Error code. + * @param string $message Message. + * @param int $status HTTP status. + * + * @return WP_Error + */ + private function error( string $code, string $message, int $status ): WP_Error { + return new WP_Error( $code, $message, [ 'status' => $status ] ); + } +} diff --git a/src/WorkOS/Mfa/Shortcode.php b/src/WorkOS/Mfa/Shortcode.php new file mode 100644 index 0000000..20ef8c0 --- /dev/null +++ b/src/WorkOS/Mfa/Shortcode.php @@ -0,0 +1,68 @@ +', $user_id ); + } +} diff --git a/src/WorkOS/Mfa/UserProfilePanel.php b/src/WorkOS/Mfa/UserProfilePanel.php new file mode 100644 index 0000000..55ec9c4 --- /dev/null +++ b/src/WorkOS/Mfa/UserProfilePanel.php @@ -0,0 +1,49 @@ +ID ) ) { + return; + } + + /** This filter is documented in src/WorkOS/Mfa/RestApi.php */ + if ( ! apply_filters( 'workos_mfa_can_manage', true, $user->ID ) ) { + return; + } + + printf( '

%s

', esc_html__( 'Two-step verification', 'integration-workos' ) ); + echo Shortcode::mount( $user->ID ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from an int. + } +} diff --git a/src/WorkOS/REST/Auth/Mfa.php b/src/WorkOS/REST/Auth/Mfa.php index 3229706..e92cee5 100644 --- a/src/WorkOS/REST/Auth/Mfa.php +++ b/src/WorkOS/REST/Auth/Mfa.php @@ -594,31 +594,11 @@ private function factor_not_found(): WP_Error { * @return WP_REST_Response|WP_Error */ public function list_factors( WP_REST_Request $request ) { - $workos_user_id = (string) get_user_meta( get_current_user_id(), '_workos_user_id', true ); - if ( '' === $workos_user_id ) { - return new WP_Error( - 'workos_authkit_no_workos_user', - __( 'Your account is not linked to a WorkOS user.', 'integration-workos' ), - [ 'status' => 400 ] - ); - } - - $result = workos()->api()->list_auth_factors( $workos_user_id ); - if ( is_wp_error( $result ) ) { - return $result; + $factors = App::container()->get( FactorService::class )->list_factors( get_current_user_id() ); + if ( is_wp_error( $factors ) ) { + return $factors; } - $factors = array_map( - static function ( array $factor ): array { - return [ - 'id' => (string) ( $factor['id'] ?? '' ), - 'type' => (string) ( $factor['type'] ?? '' ), - 'created' => (string) ( $factor['created_at'] ?? '' ), - ]; - }, - (array) ( $result['data'] ?? $result ) - ); - return new WP_REST_Response( [ 'factors' => $factors ], 200 ); } @@ -648,40 +628,20 @@ public function enroll_totp( WP_REST_Request $request ) { ); } - $user = wp_get_current_user(); - $workos_user_id = (string) get_user_meta( $user->ID, '_workos_user_id', true ); - if ( '' === $workos_user_id ) { - return new WP_Error( - 'workos_authkit_no_workos_user', - __( 'Your account is not linked to a WorkOS user.', 'integration-workos' ), - [ 'status' => 400 ] - ); + $user_id = get_current_user_id(); + $rate_ok = $this->rate_limit( [ [ 'mfa_enroll_user', (string) $user_id, TieredRateLimiter::REQUEST_SUBJECT ] ] ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; } - $issuer = get_bloginfo( 'name' ); - $label = $user->user_email; - - $result = workos()->api()->enroll_totp_factor( $workos_user_id, $issuer, $label ); + // Same path as the account panel: the factor stays unconfirmed until + // `POST /users/{id}/mfa/confirm` checks its first code. + $result = App::container()->get( FactorService::class )->start_enrollment( $user_id ); if ( is_wp_error( $result ) ) { return $result; } - // WorkOS nests the new factor under `authentication_factor` and adds - // a ready challenge for it under `authentication_challenge`. - $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 = $factor['totp'] ?? []; - - return new WP_REST_Response( - [ - 'factor_id' => (string) ( $factor['id'] ?? '' ), - 'challenge_id' => (string) ( $challenge['id'] ?? '' ), - 'qr_code' => (string) ( $totp['qr_code'] ?? '' ), - 'secret' => (string) ( $totp['secret'] ?? '' ), - 'otpauth_uri' => (string) ( $totp['uri'] ?? '' ), - ], - 201 - ); + return new WP_REST_Response( $result, 201 ); } /** @@ -745,7 +705,8 @@ public function enroll_sms( WP_REST_Request $request ) { /** * POST /auth/mfa/factor/delete * - * Removes an MFA factor the *current* user owns. The ownership check + * Removes an MFA factor the *current* user owns, given a current `code` + * from it. The ownership check * is mandatory: because the plugin authenticates to WorkOS with a * tenant-wide API key, WorkOS cannot distinguish which user is * requesting the delete. Without this check, any authenticated WP @@ -776,38 +737,32 @@ public function delete_factor( WP_REST_Request $request ) { ); } - $workos_user_id = (string) get_user_meta( get_current_user_id(), '_workos_user_id', true ); - if ( '' === $workos_user_id ) { - return new WP_Error( - 'workos_authkit_no_workos_user', - __( 'Your account is not linked to a WorkOS user.', 'integration-workos' ), - [ 'status' => 400 ] - ); + $user_id = get_current_user_id(); + $rate_ok = $this->rate_limit( [ [ 'mfa_delete_user', (string) $user_id, TieredRateLimiter::REQUEST_SUBJECT ] ] ); + if ( is_wp_error( $rate_ok ) ) { + return $rate_ok; } - $factors = workos()->api()->list_auth_factors( $workos_user_id ); + $service = App::container()->get( FactorService::class ); + $factors = $service->list_factors( $user_id ); if ( is_wp_error( $factors ) ) { return $factors; } - $owned_ids = array_map( - static function ( array $factor ): string { - return (string) ( $factor['id'] ?? '' ); - }, - (array) ( $factors['data'] ?? $factors ) - ); + // Return 404 rather than 403 so we don't leak the existence of + // factor IDs that belong to other users. + if ( ! in_array( $factor_id, array_column( $factors, 'id' ), true ) ) { + return $this->factor_not_found(); + } - if ( ! in_array( $factor_id, $owned_ids, true ) ) { - // Return 404 rather than 403 so we don't leak the existence of - // factor IDs that belong to other users. - return new WP_Error( - 'workos_authkit_factor_not_found', - __( 'Factor not found.', 'integration-workos' ), - [ 'status' => 404 ] - ); + // A current code from the factor, so a borrowed session can't turn + // MFA off. + $valid = $service->verify_code( $factor_id, (string) $request->get_param( 'code' ) ); + if ( is_wp_error( $valid ) ) { + return $valid; } - $result = workos()->api()->delete_auth_factor( $factor_id ); + $result = $service->delete_factor( $user_id, $factor_id ); if ( is_wp_error( $result ) ) { return $result; } diff --git a/src/js/mfa-settings/index.tsx b/src/js/mfa-settings/index.tsx new file mode 100644 index 0000000..cb989c3 --- /dev/null +++ b/src/js/mfa-settings/index.tsx @@ -0,0 +1,390 @@ +/** + * Two-step verification account panel. + * + * Mounts on every `.workos-mfa-panel` (the user's profile screen and the + * `[workos:mfa]` shortcode) and talks to `/workos/v1/users/{id}/mfa…` with + * the core `wp_rest` nonce. Turning MFA on scans a QR code and confirms the + * first code; removing a factor needs a current code from it. + */ + +import { createRoot, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import type { FormEvent } from 'react'; +import './styles.css'; + +interface MfaConfig { + /** Base REST URL — routes are `${restUrl}{id}/mfa…`. */ + restUrl: string; + /** WP REST nonce (`wp_rest`). */ + nonce: string; +} + +interface Factor { + id: string; + type: string; + created: string; +} + +interface Status { + enabled: boolean; + required: boolean; + factors: Factor[]; +} + +interface Enrollment { + factor_id: string; + challenge_id: string; + qr_code: string; + secret: string; +} + +interface ApiError { + code?: string; + message?: string; +} + +declare global { + interface Window { + workosMfa?: MfaConfig; + } +} + +async function api< T >( + userId: number, + path: string, + method = 'GET', + body?: Record< string, string > +): Promise< { ok: boolean; data: T | ApiError } > { + const config = window.workosMfa as MfaConfig; + const response = await fetch( `${ config.restUrl }${ userId }/mfa${ path }`, { + method, + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': config.nonce, + }, + body: body ? JSON.stringify( body ) : undefined, + } ); + const data = ( await response.json().catch( () => ( {} ) ) ) as T | ApiError; + return { ok: response.ok, data }; +} + +function message( data: unknown ): string { + return ( + ( data as ApiError )?.message || + __( 'Something went wrong. Please try again.', 'integration-workos' ) + ); +} + +function CodeField( { + id, + value, + onChange, +}: { + id: string; + value: string; + onChange: ( value: string ) => void; +} ) { + return ( +

+ +
+ onChange( event.target.value ) } + required + /> +

+ ); +} + +function Panel( { userId }: { userId: number } ) { + const [ status, setStatus ] = useState< Status | null >( null ); + const [ enrollment, setEnrollment ] = useState< Enrollment | null >( null ); + const [ removing, setRemoving ] = useState< string >( '' ); + const [ code, setCode ] = useState( '' ); + const [ busy, setBusy ] = useState( false ); + const [ error, setError ] = useState( '' ); + const [ notice, setNotice ] = useState( '' ); + + useEffect( () => { + ( async () => { + const { ok, data } = await api< Status >( userId, '' ); + if ( ok ) { + setStatus( data as Status ); + } else { + setError( message( data ) ); + } + } )(); + }, [ userId ] ); + + const reset = ( next: Status, done: string ) => { + setStatus( next ); + setEnrollment( null ); + setRemoving( '' ); + setCode( '' ); + setNotice( done ); + }; + + const start = async () => { + setBusy( true ); + setError( '' ); + setNotice( '' ); + const { ok, data } = await api< Enrollment >( userId, '/enroll', 'POST' ); + setBusy( false ); + if ( ! ok ) { + setError( message( data ) ); + return; + } + setEnrollment( data as Enrollment ); + }; + + const confirm = async ( event: FormEvent ) => { + event.preventDefault(); + if ( ! enrollment ) { + return; + } + setBusy( true ); + setError( '' ); + const { ok, data } = await api< Status >( userId, '/confirm', 'POST', { + factor_id: enrollment.factor_id, + challenge_id: enrollment.challenge_id, + code, + } ); + setBusy( false ); + if ( ! ok ) { + setError( message( data ) ); + return; + } + reset( + data as Status, + __( 'Two-step verification is on.', 'integration-workos' ) + ); + }; + + const remove = async ( event: FormEvent ) => { + event.preventDefault(); + setBusy( true ); + setError( '' ); + const { ok, data } = await api< Status >( + userId, + `/factors/${ encodeURIComponent( removing ) }`, + 'DELETE', + { code } + ); + setBusy( false ); + if ( ! ok ) { + setError( message( data ) ); + return; + } + reset( + data as Status, + __( 'The authenticator app was removed.', 'integration-workos' ) + ); + }; + + if ( ! status ) { + return error ? ( +
+

{ error }

+
+ ) : ( +

+ { __( 'Loading…', 'integration-workos' ) } +

+ ); + } + + return ( +
+ { notice && ( +
+

{ notice }

+
+ ) } + { error && ( +
+

{ error }

+
+ ) } + +

+ { status.enabled + ? __( + 'On. Signing in asks for a code from your authenticator app.', + 'integration-workos' + ) + : __( + 'Off. Add an authenticator app to protect your account with a second step at sign-in.', + 'integration-workos' + ) } +

+ { status.required && ( +

+ { __( + 'Your administrator requires two-step verification for this account.', + 'integration-workos' + ) } +

+ ) } + + { status.factors.length > 0 && ( +
    + { status.factors.map( ( factor ) => ( +
  • + + { __( 'Authenticator app', 'integration-workos' ) } + { factor.created && ( + + { ' · ' } + { new Date( factor.created ).toLocaleDateString() } + + ) } + + { removing !== factor.id && ( + + ) } +
  • + ) ) } +
+ ) } + + { removing && ( +
+

+ { status.required && status.factors.length === 1 + ? __( + 'This is your only authenticator app. Because two-step verification is required, you’ll set up a new one the next time you sign in.', + 'integration-workos' + ) + : __( + 'Enter a code from this app to confirm it’s you.', + 'integration-workos' + ) } +

+ +

+ { ' ' } + +

+ + ) } + + { ! enrollment && ! removing && ! status.enabled && ( +

+ +

+ ) } + + { enrollment && ( +
+

+ { __( + 'Scan this QR code with an authenticator app (1Password, Google Authenticator, Authy…), then enter the 6-digit code it shows.', + 'integration-workos' + ) } +

+ { enrollment.qr_code && ( + { + ) } +

+ { __( 'Can’t scan? Enter this setup key:', 'integration-workos' ) }{ ' ' } + { enrollment.secret } +

+ +

+ { ' ' } + +

+ + ) } +
+ ); +} + +function mount(): void { + if ( ! window.workosMfa ) { + return; + } + document + .querySelectorAll< HTMLElement >( '.workos-mfa-panel' ) + .forEach( ( root ) => { + const userId = Number( root.dataset.userId || 0 ); + if ( userId > 0 ) { + createRoot( root ).render( ); + } + } ); +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', mount ); +} else { + mount(); +} diff --git a/src/js/mfa-settings/styles.css b/src/js/mfa-settings/styles.css new file mode 100644 index 0000000..8b03903 --- /dev/null +++ b/src/js/mfa-settings/styles.css @@ -0,0 +1,50 @@ +/* + * Two-step verification account panel. Uses core admin classes (button, + * notice, description) so it fits profile.php; these rules only lay out + * the pieces those classes don't cover, and stay readable in themes. + */ +.workos-mfa { + max-width: 36rem; +} + +.workos-mfa-factors { + margin: 0 0 1em; + padding: 0; + list-style: none; +} + +.workos-mfa-factors li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1em; + padding: 0.5em 0; + border-bottom: 1px solid #dcdcde; +} + +.workos-mfa-step { + margin: 0 0 1em; + padding: 1em 1.25em; + border: 1px solid #dcdcde; + border-radius: 4px; + background: #fff; +} + +.workos-mfa-qr { + display: block; + margin: 0 0 0.75em; + padding: 8px; + background: #fff; + border: 1px solid #dcdcde; + border-radius: 4px; +} + +.workos-mfa-secret { + overflow-wrap: anywhere; + user-select: all; +} + +.workos-mfa-code { + max-width: 12em; + letter-spacing: 0.1em; +} diff --git a/tests/wpunit/AuthKitRestMfaTest.php b/tests/wpunit/AuthKitRestMfaTest.php index 11fe976..021efe4 100644 --- a/tests/wpunit/AuthKitRestMfaTest.php +++ b/tests/wpunit/AuthKitRestMfaTest.php @@ -465,12 +465,15 @@ public function test_delete_factor_removes_owned_factor(): void { ], ] ); + // A current code from the factor proves it's the owner. + $this->queue_response( '/auth/factors/factor_x/challenge', [ 'id' => 'challenge_del' ] ); + $this->queue_response( '/auth/challenges/challenge_del/verify', [ 'valid' => true ] ); $this->queue_response( '/auth/factors/factor_x', [] ); $response = $this->dispatch_with_nonce( 'POST', '/workos/v1/auth/mfa/factor/delete', - [ 'profile' => 'mfa-on', 'factor_id' => 'factor_x' ], + [ 'profile' => 'mfa-on', 'factor_id' => 'factor_x', 'code' => '123456' ], 'mfa-on' ); @@ -484,6 +487,30 @@ public function test_delete_factor_removes_owned_factor(): void { $this->assertNotEmpty( $delete_calls ); } + public function test_delete_factor_requires_a_code(): void { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, '_workos_user_id', 'user_del' ); + wp_set_current_user( $user_id ); + + $this->queue_response( + '/user_management/users/user_del/auth_factors', + [ 'data' => [ [ 'id' => 'factor_x', 'type' => 'totp' ] ] ] + ); + + $response = $this->dispatch_with_nonce( + 'POST', + '/workos/v1/auth/mfa/factor/delete', + [ 'profile' => 'mfa-on', 'factor_id' => 'factor_x' ], + 'mfa-on' + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'workos_mfa_invalid_code', $response->get_data()['code'] ); + $this->assertEmpty( + array_filter( $this->captured, static fn( array $c ) => 'DELETE' === $c['method'] ) + ); + } + public function test_delete_factor_rejects_other_users_factor(): void { $user_id = self::factory()->user->create(); update_user_meta( $user_id, '_workos_user_id', 'user_attacker' ); @@ -528,7 +555,7 @@ public function test_delete_factor_rejects_user_without_workos_link(): void { 'mfa-on' ); - $this->assertSame( 400, $response->get_status() ); - $this->assertSame( 'workos_authkit_no_workos_user', $response->get_data()['code'] ); + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'workos_mfa_user_not_linked', $response->get_data()['code'] ); } } diff --git a/tests/wpunit/MfaAccountRestTest.php b/tests/wpunit/MfaAccountRestTest.php new file mode 100644 index 0000000..244c037 --- /dev/null +++ b/tests/wpunit/MfaAccountRestTest.php @@ -0,0 +1,283 @@ + + */ + private array $responses = []; + + 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(); + + $api = new RestApi( new FactorService(), new TieredRateLimiter( new DatabaseRateLimiter() ) ); + add_action( 'rest_api_init', [ $api, 'register_routes' ] ); + do_action( 'rest_api_init', rest_get_server() ); + + add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 ); + + $this->user_id = self::factory()->user->create( [ 'user_email' => 'owner@example.com' ] ); + update_user_meta( $this->user_id, User::META_WORKOS_ID, 'user_owner' ); + + $this->captured = []; + wp_set_current_user( $this->user_id ); + } + + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 ); + remove_all_filters( 'workos_mfa_can_manage' ); + remove_all_filters( 'workos_mfa_can_manage_user' ); + $this->captured = []; + $this->responses = []; + $this->deleted = []; + + 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, + ]; + + if ( 'DELETE' === $method && preg_match( '#/auth/factors/([^/]+)$#', $url, $m ) ) { + $this->deleted[] = $m[1]; + } + + foreach ( $this->responses as $key => $resp ) { + [ $want_method, $needle ] = explode( ' ', $key, 2 ); + if ( $want_method === $method && str_contains( $url, $needle ) ) { + if ( 'GET' === $method && str_ends_with( $needle, '/auth_factors' ) ) { + $body = json_decode( $resp['body'], true ); + $body['data'] = array_values( array_filter( $body['data'], fn( $f ) => ! in_array( $f['id'], $this->deleted, true ) ) ); + $resp['body'] = wp_json_encode( $body ); + } + return $resp; + } + } + + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => '{}', + ]; + } + + // ------------------------------- Permissions ------------------------------- + + public function test_visitor_gets_401(): void { + wp_set_current_user( 0 ); + + $this->assertSame( 401, $this->request( 'POST', '/enroll' )->get_status() ); + } + + public function test_owner_reads_status(): void { + $this->factors( [ [ 'id' => 'auth_factor_1', 'type' => 'totp' ] ] ); + + $response = $this->request( 'GET', '' ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['enabled'] ); + $this->assertSame( 'auth_factor_1', $response->get_data()['factors'][0]['id'] ); + } + + public function test_other_user_cannot_read_status_but_an_admin_can(): void { + $this->factors( [] ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + $this->assertContains( $this->request( 'GET', '' )->get_status(), [ 401, 403 ] ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $this->assertSame( 200, $this->request( 'GET', '' )->get_status() ); + } + + public function test_admin_cannot_enroll_someone_else(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $this->captured = []; + + $response = $this->request( 'POST', '/enroll' ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertEmpty( $this->captured ); + } + + public function test_can_manage_filter_hides_self_service(): void { + add_filter( 'workos_mfa_can_manage', '__return_false' ); + + $this->assertSame( 403, $this->request( 'POST', '/enroll' )->get_status() ); + } + + // ------------------------------- Enrollment ------------------------------- + + public function test_enroll_then_confirm_turns_mfa_on(): void { + $this->queue( + 'POST /user_management/users/user_owner/auth_factors', + [ + 'authentication_factor' => [ + 'id' => 'auth_factor_new', + 'type' => 'totp', + 'totp' => [ + 'qr_code' => 'data:image/png;base64,AAA', + 'secret' => 'SECRET', + 'uri' => 'otpauth://totp/x', + ], + ], + 'authentication_challenge' => [ 'id' => 'auth_challenge_new' ], + ], + 201 + ); + + $enroll = $this->request( 'POST', '/enroll' ); + $this->assertSame( 201, $enroll->get_status() ); + $this->assertSame( 'SECRET', $enroll->get_data()['secret'] ); + + $this->queue( 'POST /auth/challenges/auth_challenge_new/verify', [ 'valid' => true ] ); + $this->factors( [ [ 'id' => 'auth_factor_new', 'type' => 'totp' ] ] ); + + $confirm = $this->request( + 'POST', + '/confirm', + [ + 'factor_id' => 'auth_factor_new', + 'challenge_id' => 'auth_challenge_new', + 'code' => '123456', + ] + ); + + $this->assertSame( 200, $confirm->get_status() ); + $this->assertTrue( $confirm->get_data()['enabled'] ); + $this->assertSame( '1', get_user_meta( $this->user_id, User::META_MFA_ENABLED, true ) ); + } + + // --------------------------------- Remove --------------------------------- + + public function test_remove_needs_a_code_from_the_factor(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + $this->factors( [ [ 'id' => 'auth_factor_1', 'type' => 'totp' ] ] ); + + $response = $this->request( 'DELETE', '/factors/auth_factor_1' ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'workos_mfa_invalid_code', $response->get_data()['code'] ); + $this->assertEmpty( array_filter( $this->captured, static fn( $c ) => 'DELETE' === $c['method'] ) ); + } + + public function test_remove_with_code_turns_mfa_off(): void { + update_user_meta( $this->user_id, User::META_MFA_ENABLED, '1' ); + $this->factors( [ [ 'id' => 'auth_factor_1', 'type' => 'totp' ] ] ); + $this->queue( 'POST /auth/factors/auth_factor_1/challenge', [ 'id' => 'auth_challenge_rm' ] ); + $this->queue( 'POST /auth/challenges/auth_challenge_rm/verify', [ 'valid' => true ] ); + + $response = $this->request( 'DELETE', '/factors/auth_factor_1', [ 'code' => '123456' ] ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( array_filter( $this->captured, static fn( $c ) => 'DELETE' === $c['method'] && str_contains( $c['url'], '/auth/factors/auth_factor_1' ) ) ); + $this->assertSame( '', (string) get_user_meta( $this->user_id, User::META_MFA_ENABLED, true ) ); + } + + public function test_remove_someone_elses_factor_is_404(): void { + $this->factors( [ [ 'id' => 'auth_factor_mine', 'type' => 'totp' ] ] ); + + $response = $this->request( 'DELETE', '/factors/auth_factor_theirs', [ 'code' => '123456' ] ); + + $this->assertSame( 404, $response->get_status() ); + } + + // -------------------------------- Shortcode -------------------------------- + + public function test_shortcode_mounts_for_linked_user_only(): void { + $this->assertStringContainsString( 'data-user-id="' . $this->user_id . '"', ( new Shortcode() )->render() ); + + wp_set_current_user( self::factory()->user->create() ); + $this->assertSame( '', ( new Shortcode() )->render() ); + + wp_set_current_user( 0 ); + $this->assertSame( '', ( new Shortcode() )->render() ); + } + + // -------------------------------- Fixtures -------------------------------- + + private function factors( array $factors ): void { + $this->queue( 'GET /user_management/users/user_owner/auth_factors', [ 'data' => $factors ] ); + } + + private function queue( string $key, array $body, int $code = 200 ): void { + $this->responses[ $key ] = [ + 'response' => [ 'code' => $code, 'message' => 'OK' ], + 'body' => wp_json_encode( $body ), + ]; + } + + private function request( string $method, string $path, array $body = [] ): WP_REST_Response { + $request = new WP_REST_Request( $method, '/workos/v1/users/' . $this->user_id . '/mfa' . $path ); + $request->set_header( 'Content-Type', 'application/json' ); + if ( $body ) { + $request->set_body( wp_json_encode( $body ) ); + } + + return rest_get_server()->dispatch( $request ); + } +} diff --git a/webpack.config.js b/webpack.config.js index f3925ac..298c322 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -18,5 +18,6 @@ module.exports = { 'admin-password-reset': path.resolve( __dirname, 'src/js/admin-password-reset/index.ts' ), 'admin-change-email': path.resolve( __dirname, 'src/js/admin-change-email/index.ts' ), 'change-email-confirm': path.resolve( __dirname, 'src/js/change-email-confirm/index.ts' ), + 'mfa-settings': path.resolve( __dirname, 'src/js/mfa-settings/index.tsx' ), }, };