diff --git a/assets/js/force-2fa.js b/assets/js/force-2fa.js new file mode 100644 index 00000000..0b3775d7 --- /dev/null +++ b/assets/js/force-2fa.js @@ -0,0 +1,88 @@ +/* global ajaxurl, jQuery */ + +/** + * Checks that an element has a non-empty `name` and `value` property. + * + * @param {Element} element The element to check + * @return {Boolean} true if the element is an input, false if not + */ +var isValidElement = function( element ) { + return element.name && element.value; +}; + +/** + * Checks if an element’s value can be saved (e.g. not an unselected checkbox). + * + * @param {Element} element The element to check + * @return {Boolean} true if the value should be added, false if not + */ +var isValidValue = function( element ) { + return ( ! [ 'checkbox', 'radio' ].includes( element.type ) || element.checked ); +}; + +/** + * Checks if an input is a checkbox, because checkboxes allow multiple values. + * + * @param {Element} element The element to check + * @return {Boolean} true if the element is a checkbox, false if not + */ +var isCheckbox = function( element ) { + return 'checkbox' === element.type; +}; + +/** + * Retrieves input data from a form and returns it as a JSON object. + * + * @param {HTMLFormControlsCollection} elements the form elements + * @return {Object} form data as an object literal + */ +var formToJSON = function( elements ) { + return [].reduce.call( elements, function( data, element ) { + + // Make sure the element has the required properties and should be added. + if ( ! isValidElement( element ) || ! isValidValue( element ) ) { + return data; + } + + /* + * Some fields allow for more than one value, so we need to check if this + * is one of those fields and, if so, store the values as an array. + */ + if ( isCheckbox( element ) ) { + data[ element.name ] = ( data[ element.name ] || [] ).concat( element.value ); + } else { + data[ element.name ] = element.value; + } + + return data; + }, {} ); +}; + +/** + * A handler function to prevent default submission and run our custom script. + * + * @param {Event} event the submit event triggered by the user + */ +var handleFormSubmit = function( event ) { + + // Get form data. + var formData = formToJSON( event.target.elements ); + + event.preventDefault(); + + formData.action = 'two_factor_force_form_submit'; + + // Submit data to WordPress. + jQuery.post( + ajaxurl, + formData, + function () { + window.location.reload(); + } + ); +}; + +window.addEventListener( 'load', function() { + var form = document.querySelector( '#force_2fa_form' ); + form.addEventListener( 'submit', handleFormSubmit ); +} ); diff --git a/class.two-factor-core.php b/class.two-factor-core.php index 8ecc2cdd..b851ca57 100644 --- a/class.two-factor-core.php +++ b/class.two-factor-core.php @@ -44,9 +44,11 @@ public static function add_hooks() { add_action( 'edit_user_profile', array( __CLASS__, 'user_two_factor_options' ) ); add_action( 'personal_options_update', array( __CLASS__, 'user_two_factor_options_update' ) ); add_action( 'edit_user_profile_update', array( __CLASS__, 'user_two_factor_options_update' ) ); + add_action( 'two_factor_ajax_options_update', array( __CLASS__, 'user_two_factor_options_update' ) ); add_filter( 'manage_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) ); add_filter( 'wpmu_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) ); add_filter( 'manage_users_custom_column', array( __CLASS__, 'manage_users_custom_column' ), 10, 3 ); + add_action( 'init', array( __CLASS__, 'register_scripts' ) ); } /** @@ -58,6 +60,16 @@ public static function load_textdomain() { load_plugin_textdomain( 'two-factor' ); } + /** + * Register scripts. + */ + public static function register_scripts() { + wp_register_style( + 'user-edit-2fa', + plugins_url( 'user-edit.css', __FILE__ ) + ); + } + /** * For each provider, include it and then instantiate it. * @@ -612,7 +624,7 @@ public static function manage_users_custom_column( $output, $column_name, $user_ * @param WP_User $user WP_User object of the logged-in user. */ public static function user_two_factor_options( $user ) { - wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', __FILE__ ) ); + wp_enqueue_style( 'user-edit-2fa' ); $enabled_providers = array_keys( self::get_available_providers_for_user( $user ) ); $primary_provider = self::get_primary_provider_for_user( $user->ID ); @@ -629,7 +641,7 @@ public static function user_two_factor_options( $user ) {
+ |
diff --git a/class.two-factor-force.php b/class.two-factor-force.php
new file mode 100644
index 00000000..88f59ed3
--- /dev/null
+++ b/class.two-factor-force.php
@@ -0,0 +1,496 @@
+ID ) ) {
+ return $redirect_to;
+ };
+
+ // Append redirect_to URL.
+ return add_query_arg(
+ [
+ 'force_2fa_screen' => 1,
+ 'redirect_to' => rawurlencode( $requested_redirect_to ),
+ ],
+ admin_url()
+ );
+ }
+
+ /**
+ * Maybe force the 2fa login page on a user.
+ *
+ * If 2fa is required for a user (based on universal or role settings),
+ * we display the 2-factor options page so that a user must validly enable
+ * a 2-factor authentication of some kind to perform any action on their site.
+ * This occurs both on the front and backend.
+ */
+ public static function maybe_redirect_to_2fa_settings() {
+ if ( ! self::should_user_redirect( get_current_user_id() ) || isset( $_GET['force_2fa_screen'] ) ) {
+ return;
+ }
+
+ // We are now forced to display the two-factor settings page.
+ wp_safe_redirect( add_query_arg(
+ 'force_2fa_screen',
+ 1,
+ admin_url()
+ ) );
+ exit;
+ }
+
+ /**
+ * On front and backend requests, display
+ */
+ public static function maybe_display_2fa_settings() {
+ // phpcs:ignore We are validating that the value exists and are not processing it.
+ if ( ! isset( $_GET['force_2fa_screen'] ) || ! $_GET['force_2fa_screen'] ) {
+ return;
+ }
+
+ if ( ! self::should_user_redirect( get_current_user_id() ) ) {
+ $url = admin_url();
+
+ if ( isset( $_GET['redirect_to'] ) ) {
+ // phpcs:ignore This IS the proper sanitization for URLs.
+ $url = esc_url_raw( urldecode( $_GET['redirect_to'] ) );
+ }
+
+ wp_safe_redirect( $url );
+ exit;
+ }
+
+ self::force_2fa_login_html();
+ exit;
+ }
+
+ /**
+ * Check whether or not a user should be redirected to the force 2fa screen.
+ *
+ * @param int $user_id ID of the user to check against.
+ * @return bool Whether or not user should be forced to 2fa screen.
+ */
+ public static function should_user_redirect( $user_id ) {
+ // This should not affect AJAX or REST requests, carry on.
+ if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
+ return false;
+ }
+
+ // Should not interrupt logging in or out.
+ if ( self::is_login_page() ) {
+ return false;
+ }
+
+ // If user is not logged in, they can't 2fa anyway.
+ if ( ! is_user_logged_in() ) {
+ return false;
+ }
+
+ // 2fa is not forced for current user, nothing to show.
+ if ( ! self::is_two_factor_forced( $user_id ) ) {
+ return false;
+ }
+
+ // The current user is already using two-factor, good for them!
+ if ( Two_Factor_Core::is_user_using_two_factor() ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Generates the html for adding 2-factor authentication to their account, if forced.
+ *
+ * If a user hits this screen, they must setup 2fa and do not get to skip.
+ *
+ * @since 0.1-dev
+ */
+ public static function force_2fa_login_html() {
+ wp_enqueue_script( 'jquery' );
+ wp_enqueue_script( 'two-factor-form-script' );
+
+ // If Fido is a valid 2fa Provider, enqueue its assets.
+ $providers = Two_Factor_Core::get_providers();
+ if ( in_array( 'Two_Factor_FIDO_U2F', array_keys( $providers ), true ) ) {
+ Two_Factor_FIDO_U2F_Admin::enqueue_assets( 'profile.php' );
+ wp_enqueue_style( 'common' );
+ wp_enqueue_style( 'list-tables' );
+ }
+
+ if ( ! function_exists( 'login_header' ) ) {
+ // We really should migrate login_header() out of `wp-login.php` so it can be called from an includes file.
+ include_once( TWO_FACTOR_DIR . 'includes/function.login-header.php' );
+ }
+
+ login_header();
+
+ $user = wp_get_current_user();
+
+ // Display the form for updating a user's two-factor options.
+ ?>
+
+
+
+ + + + + + + + + + + |
---|