62 | To ensure the highest level of security for your account, please remember to keep
63 | your authentication methods up-to-date, and consult{ ' ' }
64 |
65 | our documentation
66 | { ' ' }
67 | if you need help or have any questions.
68 |
69 |
70 | We recommend configuring multiple authentication methods and generating backup codes
71 | to guarantee you always have access to your account.
72 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WPorg Two-Factor
2 |
3 | WordPress.org-specific customizations for the Two Factor plugin
4 |
5 | ## Setup
6 |
7 | 1. Set up a local WP Multisite.
8 | 1. Add this code to your `wp-config.php`:
9 | ```php
10 | define( 'WP_ENVIRONMENT_TYPE', 'local' );
11 |
12 | // Mimic w.org for testing wporg-two-factor
13 | global $supes, $super_admins;
14 | $supes = array(
15 | 'your_username'
16 | );
17 | $super_admins = array_merge( $supes );
18 |
19 | function is_special_user( $user_id ) {
20 | $user = get_userdata( $user_id );
21 | return in_array( $user->user_login, $GLOBALS['supes'], true );
22 | }
23 | ```
24 | 1. Add this code to your `wp-content/mu-plugins/0-sandbox.php`:
25 | ```php
26 | require_once WPMU_PLUGIN_DIR. '/wporg-mu-plugins/mu-plugins/loader.php';
27 |
28 | // Enable dummy provider for convenience when testing locally.
29 | add_filter( 'two_factor_providers', function( $providers ) {
30 | $providers['Two_Factor_Dummy'] = TWO_FACTOR_DIR . 'providers/class-two-factor-dummy.php';
31 |
32 | return $providers;
33 | }, 100 ); // Must run _after_ wporg-two-factor.
34 |
35 | // Mimics `mu-plugins/main-network/site-support.php`.
36 | function add_rewrite_rules() {
37 | // e.g., https://wordpress.org/support/users/foo/edit/account/
38 | add_rewrite_rule(
39 | bbp_get_user_slug() . '/([^/]+)/' . bbp_get_edit_slug() . '/account/?$',
40 | 'index.php?' . bbp_get_user_rewrite_id() . '=$matches[1]&' . 'edit_account=1',
41 | 'top'
42 | );
43 | }
44 | add_action( 'init', __NAMESPACE__ . '\add_rewrite_rules' );
45 | ```
46 | 1. Install, build, and activate the `wporg-support` theme.
47 | 1. Install `two-factor-provider-webauthn`, `bbPress` and `Gutenberg`. You might need to clone & build `trunk` branch of `Gutenberg` if we happen to be using any new features.
48 | 1. `git clone` https://github.com/WordPress/two-factor/ into `wp-content/plugins` and follow their setup instructions.
49 | 1. `git clone` this repo into `wp-content/plugins`
50 | 1. `cd wporg-two-factor && composer install`
51 | 1. `yarn && yarn workspaces run build`
52 | 1. Setup environment tools `yarn setup:tools`
53 | 1. Start the environment: `yarn wp-env start`
54 | 1. Network-activate all of the plugins.
55 | 1. If you want to make JS changes, then `yarn workspaces run start`
56 | 1. Open `wp-admin/options-general.php?page=bbpress` and uncheck `Prefix all forum content with the Forum Root slug (Recommended)`, then save.
57 | 1. Visit https://example.org/users/{username}/edit/account/ to view the custom settings UI. If you get a `404` error, visit `wp-admin/options-permalinks.php` and then try again.
58 |
59 | ## Testing
60 |
61 | Front-end unit tests can be run in `/settings` using the `npm run test:unit` or `npm run test:unit:watch` commands.
62 |
63 | Back-end unit tests can be run in `/` using the `composer run test` or `composer run test:watch` commands. `composer run coverage` will generate a coverage report.
64 |
65 | ## Security
66 |
67 | Please privately report any potential security issues to the [WordPress HackerOne](https://hackerone.com/wordpress) program.
68 |
--------------------------------------------------------------------------------
/stats.php:
--------------------------------------------------------------------------------
1 | get_key() ) );
33 | }
34 |
35 | /**
36 | * Watch for changes to user meta.
37 | *
38 | * Currently this only records stats for:
39 | * - Enabled 2FA after being nagged about it on login
40 | * - Generated backup codes after being nagged about it on login
41 | */
42 | function action_update_user_meta( $meta_id, $user_id, $meta_key, $new_meta_value ) {
43 | $relevant_meta_keys = [
44 | '_two_factor_backup_codes',
45 | '_two_factor_enabled_providers'
46 | ];
47 | if ( ! in_array( $meta_key, $relevant_meta_keys, true ) ) {
48 | return;
49 | }
50 |
51 | $nag_user_meta = ( '_two_factor_backup_codes' === $meta_key ) ? 'last_2fa_backup_codes_nag_time' : 'last_2fa_nag';
52 | $last_nagged = get_user_meta( $user_id, $nag_user_meta, true );
53 |
54 | // The user was last nagged more than an hour ago, the stats here aren't relevant.
55 | if ( $last_nagged && $last_nagged < time() - HOUR_IN_SECONDS ) {
56 | return;
57 | }
58 |
59 | $old_meta_key = $meta_key;
60 | $old_meta_value = get_user_meta( $user_id, $meta_key, true );
61 | $callback = new stdClass; // Placeholder for the callback, such that it can be used in the callback.
62 | $callback = static function( $meta_id, $user_id, $meta_key, $new_meta_value ) use( $old_meta_value, $old_meta_key, &$callback ) {
63 | if ( $old_meta_key != $meta_key ) {
64 | return;
65 | }
66 | remove_action( 'updated_user_meta', $callback ); // Remove self.
67 |
68 | // The user has set new backup codes.
69 | if ( '_two_factor_backup_codes' === $meta_key ) {
70 | bump_stats_extra( 'wporg-two-factor', 'Set Backup Codes after nag' );
71 | }
72 |
73 | // The user has enabled or disabled providers.
74 | if ( '_two_factor_enabled_providers' === $meta_key ) {
75 | $old_providers = $old_meta_value ?? [];
76 | $new_providers = $new_meta_value ?? [];
77 | $enabled_providers = array_diff( $new_providers, $old_providers );
78 |
79 | foreach ( $enabled_providers as $provider_key ) {
80 | bump_stats_extra( 'wporg-two-factor', 'Set ' . provider_name_from_key( $provider_key ) . ' after nag' );
81 | }
82 | }
83 | };
84 |
85 | add_action( 'updated_user_meta', $callback, 10, 4 );
86 | }
--------------------------------------------------------------------------------
/revalidation/README.md:
--------------------------------------------------------------------------------
1 | # Revalidation
2 |
3 | WordPressdotorg\Two_Factor\Revalidation provides several methods that may be used to trigger a 2FA revalidation process.
4 |
5 | ## get_status()
6 |
7 | Returns the details about the current 2FA session.
8 |
9 | - last_validated: The UTC time that the user last completed a 2FA prompt.
10 | - expires_at: When the users 2FA "sudo mode" revalidation period is up* (see note below). After this time, the user should be prompted for a 2FA revalidation.
11 | - expires_save: After expires_at, 2FA save-actions may still occur, due to the save grace period.
12 | - needs_revalidate: Whether the user should be prompted to revalidate their 2FA now.
13 | - can_save: Whether a save operation should occur that requires 2FA validation.
14 |
15 | Note: The Javascript implementation does not use the same `expires_at`, instead it makes use of `expires_save` and ensures that any action that needs a 2FA session will prompt 1 minute before the `expires_save` timeframe.
16 |
17 | ## auth_redirect( $redirect_to )
18 |
19 | Allows for a save method to require 2FA status, if the request isn't 2FA'd, it'll redirect through a 2FA revalidation prompt, before coming back to your page.
20 |
21 | This should not be used on POST requests, as the payload will be lost, either use `get_status()` or return an error.
22 |
23 | ## get_url( $redirect_to )
24 |
25 | Returns a revalidate_2fa link, which will redirect to the specified `$redirect_to`.
26 |
27 | ## get_js_url( $redirect_to )
28 |
29 | **This is probably the function you should call.**
30 |
31 | Returns `get_url( $redirect_to )` but also calls `enqueue_assets()` to enqueue a JS revalidation modal that will trigger client-side to provide a better user-experience.
32 |
33 | ## Attributes
34 | Two Data attributes are also able to trigger 2FA revalidation modals IF `get_js_url()` has been used or `enqueue_assets()` has been called.
35 |
36 | ### data-2fa-required
37 | If this attribute is present, it'll trigger the 2FA modal on click, and throw the click event after completion.
38 |
39 | ### data-2fa-message
40 | If this attribute is present, it'll be shown in the 2FA dialogue in place of the default text.
41 |
42 | ## Example of use.
43 |
44 | ```php
45 | use function WordPressdotorg\Two_Factor\Revalidation\{
46 | get_status as get_revalidation_status,
47 | get_url as get_revalidation_url,
48 | get_js_url as get_revalidation_js_url
49 | };
50 |
51 | # This is an example of a 'redirect through a 2FA revalidation screen' request. 2FA revalidation is always required.
52 | echo '
67 | WordPress.org uses Subversion (SVN) for version control, providing each hosted
68 | plugin and theme with a repository that the author can commit to. For information on
69 | using SVN, please see the{ ' ' }
70 |
71 | WordPress.org Plugin Developer Handbook
72 |
73 | .
74 |
75 |
76 |
77 | For security, your WordPress.org account password should not be used to commit to
78 | SVN, use a separate SVN password, which you can generate here.
79 |
}
150 |
151 |
152 |
153 | );
154 |
155 | let classes = 'wporg-2fa__status-card wporg-2fa__status-card-' + screen;
156 |
157 | if ( disabled ) {
158 | classes += ' is-disabled';
159 | }
160 |
161 | return (
162 |
163 | { disabled ? cardContent : }
164 |
165 | );
166 | }
167 |
168 | /**
169 | * Render the icon for the given status
170 | *
171 | * @param props
172 | * @param props.status
173 | */
174 | function StatusIcon( { status } ) {
175 | let icon;
176 |
177 | if ( 'boolean' === typeof status ) {
178 | status = status ? 'enabled' : 'disabled';
179 | }
180 |
181 | switch ( status ) {
182 | case 'ok':
183 | case 'enabled':
184 | icon = check;
185 | break;
186 |
187 | case 'pending':
188 | icon = warning;
189 | break;
190 |
191 | case 'info':
192 | case 'error':
193 | case 'disabled':
194 | default:
195 | icon = cancelCircleFilled;
196 | }
197 |
198 | return ;
199 | }
200 |
--------------------------------------------------------------------------------
/revalidation/index.php:
--------------------------------------------------------------------------------
1 | $last_validated,
45 | 'expires_at' => $expires_at,
46 | 'expires_save' => $expires_save,
47 | 'needs_revalidate' => ( ! $last_validated || $expires_at < time() ),
48 | 'can_save' => ( $expires_save > time() ),
49 | ];
50 | }
51 |
52 | /**
53 | * Perform a redirect to the revalidation URL if the user needs to revalidate.
54 | *
55 | * @param string $redirect_to The URL to redirect to after revalidating.
56 | * @return void
57 | */
58 | function auth_redirect( $redirect_to = '' ) {
59 | $status = get_status();
60 |
61 | if ( ! $status ) {
62 | wp_die( 'Two-Factor Authentication Required.', 401 );
63 | }
64 |
65 | if ( ! $status['needs_revalidate'] ) {
66 | return;
67 | }
68 |
69 | // If the user is not validated, redirect to the revalidation URL.
70 | wp_safe_redirect( get_url( $redirect_to ) );
71 | exit;
72 | }
73 |
74 | /**
75 | * Get the URL for revalidating 2FA, with a redirect parameter.
76 | *
77 | * @param string $redirect_to The URL to redirect to after revalidating.
78 | * @return string
79 | */
80 | function get_url( $redirect_to = '' ) {
81 | $url = Two_Factor_Core::get_user_two_factor_revalidate_url();
82 | if ( ! empty( $redirect_to ) ) {
83 | $url = add_query_arg( 'redirect_to', urlencode( $redirect_to ), $url );
84 | }
85 |
86 | return $url;
87 | }
88 |
89 | /**
90 | * Get the URL for revalidating 2FA via JavaScript.
91 | *
92 | * The calling code can listening for a 'reValidationComplete' event, or
93 | * simply have the user continue to $redirect_to.
94 | *
95 | * @param string $redirect_to The URL to redirect to after revalidating.
96 | * @return string
97 | */
98 | function get_js_url( $redirect_to = '' ) {
99 | // Enqueue the JS to to handle the revalidate action.
100 | enqueue_assets();
101 |
102 | return get_url( $redirect_to );
103 | }
104 |
105 | /**
106 | * Output the JavaScript & CSS for the revalidate modal.
107 | *
108 | * This is output to the footer of the page, and listens for clicks on revalidate links.
109 | * When a revalidate link is clicked, a modal dialog is opened with an iframe to the revalidate 2FA session.
110 | * When the revalidation is complete, the dialog is closed and the calling code is notified via a 'reValidationComplete' event.
111 | */
112 | function enqueue_assets() {
113 | wp_enqueue_style( 'wporg-2fa-revalidation', plugins_url( 'style.css', __FILE__ ), [], filemtime( __DIR__ . '/style.css' ) );
114 | wp_enqueue_script( 'wporg-2fa-revalidation', plugins_url( 'script.js', __FILE__ ), [], filemtime( __DIR__ . '/script.js' ), true );
115 |
116 | wp_localize_script( 'wporg-2fa-revalidation', 'wporgTwoFactorRevalidation', [
117 | 'cookieName' => COOKIE_NAME,
118 | 'l10n' => [
119 | 'title' => __( 'Two-Factor Authentication', 'wporg' ),
120 | 'message' => __( 'Please verify your Two-Factor Authentication to continue.', 'wporg' ),
121 | ],
122 | 'url' => get_url(),
123 | ] );
124 | }
125 |
126 | add_action( 'two_factor_user_authenticated', __NAMESPACE__ . '\set_cookie' );
127 | add_action( 'two_factor_user_revalidated', __NAMESPACE__ . '\set_cookie' );
128 | function set_cookie() {
129 | if ( ! apply_filters( 'send_auth_cookies', true, 0, 0, 0, '', '' ) ) {
130 | return;
131 | }
132 |
133 | $expires_at = get_status()['expires_save'] ?? time();
134 |
135 | /*
136 | * Set a cookie to let JS know when the validation expires.
137 | *
138 | * The value is "wporg_2fa_status=TIMESTAMP", where TIMESTAMP is when the validation will expire.
139 | * The cookie will expire a minute before the server would cease to accept the save action.
140 | */
141 | setcookie(
142 | COOKIE_NAME,
143 | $expires_at,
144 | $expires_at - MINUTE_IN_SECONDS, // The cookie will cease to exist to JS at this time.
145 | COOKIEPATH,
146 | COOKIE_DOMAIN,
147 | is_ssl(),
148 | false // NOT HTTP only, this needs to be JS accessible.
149 | );
150 | }
151 |
152 | add_action( 'clear_auth_cookie', __NAMESPACE__ . '\clear_cookie' );
153 | function clear_cookie() {
154 | if ( ! apply_filters( 'send_auth_cookies', true, 0, 0, 0, '', '' ) ) {
155 | return;
156 | }
157 |
158 | setcookie( COOKIE_NAME, '', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false );
159 | }
160 |
--------------------------------------------------------------------------------
/settings/src/components/webauthn/webauthn.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { Button, Flex, Notice, Spinner, Modal } from '@wordpress/components';
5 | import { useCallback, useContext, useState } from '@wordpress/element';
6 | import { Icon, cancelCircleFilled } from '@wordpress/icons';
7 | import apiFetch from '@wordpress/api-fetch';
8 |
9 | /**
10 | * Internal dependencies
11 | */
12 | import { GlobalContext } from '../../script';
13 | import { refreshRecord } from '../../utilities/common';
14 | import ListKeys from './list-keys';
15 | import RegisterKey from './register-key';
16 |
17 | /**
18 | * Render the WebAuthn setting.
19 | *
20 | * @param {Object} props
21 | * @param {Function} props.onKeyAdd
22 | */
23 | export default function WebAuthn( { onKeyAdd = () => {} } ) {
24 | const {
25 | user: { userRecord, webAuthnEnabled },
26 | setGlobalNotice,
27 | } = useContext( GlobalContext );
28 | const {
29 | record: { id: userId, '2fa_webauthn_keys': keys },
30 | } = userRecord;
31 | const [ flow, setFlow ] = useState( 'manage' );
32 | const [ statusError, setStatusError ] = useState( '' );
33 | const [ statusWaiting, setStatusWaiting ] = useState( false );
34 | const [ confirmingDisable, setConfirmingDisable ] = useState( false );
35 |
36 | /**
37 | * Clear any notices then move to the desired step in the flow
38 | */
39 | const updateFlow = useCallback(
40 | ( nextFlow ) => {
41 | setGlobalNotice( '' );
42 | setStatusError( '' );
43 | setFlow( nextFlow );
44 | },
45 | [ setGlobalNotice ]
46 | );
47 |
48 | /**
49 | * Display the confirmation modal for disabling the WebAuthn provider.
50 | */
51 | const showConfirmDisableModal = useCallback( () => {
52 | setConfirmingDisable( true );
53 | }, [] );
54 |
55 | /**
56 | * Remove the confirmation modal for disabling the WebAuthn provider.
57 | */
58 | const hideConfirmDisableModal = useCallback( () => {
59 | setConfirmingDisable( false );
60 | }, [] );
61 |
62 | /**
63 | * Toggle enablement of the WebAuthn provider.
64 | */
65 | const toggleProvider = useCallback( async () => {
66 | const newStatus = webAuthnEnabled ? 'disable' : 'enable';
67 |
68 | try {
69 | setGlobalNotice( '' );
70 | setStatusError( '' );
71 | setStatusWaiting( true );
72 |
73 | await apiFetch( {
74 | path: '/wporg-two-factor/1.0/provider-status',
75 | method: 'POST',
76 | data: {
77 | user_id: userId,
78 | provider: 'TwoFactor_Provider_WebAuthn',
79 | status: newStatus,
80 | },
81 | } );
82 |
83 | await refreshRecord( userRecord );
84 | setGlobalNotice( `Successfully ${ newStatus }d Security Keys.` );
85 | } catch ( error ) {
86 | setStatusError( error?.message || error?.responseJSON?.data || error );
87 | } finally {
88 | hideConfirmDisableModal();
89 | setStatusWaiting( false );
90 | }
91 | }, [ webAuthnEnabled, userId, userRecord, setGlobalNotice, hideConfirmDisableModal ] );
92 |
93 | /**
94 | * Handle post-registration processing.
95 | */
96 | const onRegisterSuccess = useCallback( async () => {
97 | if ( ! webAuthnEnabled ) {
98 | await toggleProvider();
99 | }
100 |
101 | updateFlow( 'manage' );
102 | onKeyAdd();
103 | }, [ webAuthnEnabled, toggleProvider, updateFlow, onKeyAdd ] );
104 |
105 | if ( 'register' === flow ) {
106 | return (
107 |
108 | updateFlow( 'manage' ) }
111 | />
112 |
113 | );
114 | }
115 |
116 | return (
117 | <>
118 |
119 | A security key is a physical or software-based device that adds an extra layer of
120 | authentication and protection to online accounts. It generates unique codes or
121 | cryptographic signatures to verify the user's identity, offering stronger
122 | security than passwords alone.
123 |