diff --git a/10up-experience.php b/10up-experience.php index 0fea0e2..c514101 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -81,6 +81,13 @@ function( $plugin_info, $http_response = null ) { SupportMonitor\Monitor::instance()->setup(); SupportMonitor\Debug::instance()->setup(); Notifications\Welcome::instance()->setup(); +Notifications\Welcome::instance()->setup(); + +if( ( ! defined( 'TENUP_DISABLE_PASSWORD_POLICY' ) || ! TENUP_DISABLE_PASSWORD_POLICY ) ){ + if ( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ) { + Authentication\PastPasswords::instance()->setup(); + } +} /** * We load this later to make sure there are no conflicts with other plugins. @@ -92,6 +99,16 @@ function() { } ); +/** + * Clean up when plugin is deactivated + */ +register_deactivation_hook( + __FILE__, + function() { + Authentication\PastPasswords::instance()->deactivate(); + } +); + /** * Disable plugin/theme editor */ diff --git a/README.md b/README.md index 70f1c87..10da44f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ *Configured in `Settings > General` or `Settings > Network Settings` if network activated.* +* __Password Policy__ + + Enable site wide Password Policy for administrator, editors and authors. Control how long passwords are good for, prevent users from repeating passwords and configure when to send reminder emails to users whose password will be expiring soon. The reminder email can be fully customized and dynamic information can be added via a set of replacement tags. Once a password is expired the user will be prompted to reset their password before they can login. + + *Configured in `Users > Password Policy` + + *Note:* Password Policy can be disabled by defining the constant `TENUP_DISABLE_PASSWORD_POLICY` as `true`. + * __Headers__ `X-Frame-Origins` is set to `sameorigin` to prevent click jacking. diff --git a/includes/classes/AdminCustomizations/PasswordPolicy.php b/includes/classes/AdminCustomizations/PasswordPolicy.php new file mode 100644 index 0000000..7c66723 --- /dev/null +++ b/includes/classes/AdminCustomizations/PasswordPolicy.php @@ -0,0 +1,213 @@ + [ $this, 'sanitize_settings' ], + ] + ); + + add_settings_section( + self::PASSWORD_POLICY_OPTION_NAME, + '', + '__return_empty_string', + self::PASSWORD_POLICY_OPTION_NAME + ); + + $settings = [ + 'enabled' => [ + 'label' => __( 'Enable Password Policy', 'tenup' ), + 'type' => 'checkbox', + ], + 'expires' => [ + 'label' => __( 'Password Expires', 'tenup' ), + 'type' => 'number', + 'description' => __( 'The number of days a passwords is good for before it needs to be changed.', 'tenup' ), + ], + 'reminder' => [ + 'label' => __( 'Send Password Reminder', 'tenup' ), + 'type' => 'number', + 'description' => __( 'The number of days before a password expires to send an email reminder.', 'tenup' ), + ], + 'past_passwords' => [ + 'label' => __( 'Past Passwords', 'tenup' ), + 'type' => 'number', + 'description' => __( 'The number of past passwords a user can\'t repeat.', 'tenup' ), + ], + 'reminder_email' => [ + 'label' => __( 'Reminder Email', 'tenup' ), + 'description' => __( '###USERNAME###, ###ADMIN_EMAIL###, ###EMAIL###, ###SITENAME###, ###SITEURL###, ###DAYSLEFT###, and ###EXPIRATIONDATE### are replacement tags that can be used to populate the reminder email with dynamic information.', 'tenup' ), + 'type' => 'tinymce', + ], + ]; + + foreach ( $settings as $setting_id => $setting ) { + $options = [ + 'name' => self::PASSWORD_POLICY_OPTION_NAME . "[$setting_id]", + 'id' => $setting_id, + 'type' => $setting['type'] ?? 'text', + 'description' => $setting['description'] ?? '', + ]; + + add_settings_field( + $setting_id, + $setting['label'], + [ $this, 'field' ], + self::PASSWORD_POLICY_OPTION_NAME, + self::PASSWORD_POLICY_OPTION_NAME, + $options + ); + } + } + + /** + * Output setting fields + * + * @param array $args field options + */ + public function field( $args ) { + $settings = get_option( self::PASSWORD_POLICY_OPTION_NAME, [] ); + $value = $settings[ $args['id'] ] ?? ''; + + if ( 'checkbox' === $args['type'] ) { + printf( '', esc_attr( $args['type'] ), esc_attr( $args['id'] ), esc_attr( $args['name'] ), esc_attr( checked( 'on', $value, false ) ) ); + if ( ! empty( $args['description'] ) ) { + printf( '', esc_attr( $args['id'] ), esc_html( $args['description'] ) ); + } + } elseif ( 'tinymce' === $args['type'] ) { + wp_editor( + $value, + $args['id'], + [ + 'media_buttons' => false, + 'textarea_name' => $args['name'], + ] + ); + if ( ! empty( $args['description'] ) ) { + printf( '

%s

', wp_kses_post( $args['description'] ) ); + } + } else { + printf( '', esc_attr( $args['type'] ), esc_attr( $args['id'] ), esc_attr( $args['name'] ), esc_attr( $value ) ); + + if ( ! empty( $args['description'] ) ) { + printf( '

%s

', esc_html( $args['description'] ) ); + } + } + } + + /** + * Sanitize settings fields + * + * @param array $settings setting being saved + * + * @return array + */ + public function sanitize_settings( $settings ) { + $clean_settings = array(); + foreach ( $settings as $key => $setting ) { + if ( in_array( $key, [ 'reminder_email', 'token_email' ], true ) ) { + $clean_settings[ $key ] = wp_kses_post( $setting ); + } else { + $clean_settings[ $key ] = sanitize_text_field( $setting ); + } + } + + return $clean_settings; + } + + /** + * Password policy screen + * + * @return void + */ + public function password_policy_screen() { + ?> +
+

+ + + +
+ +
+
+ get_setting( 'enabled' ) ) && apply_filters( 'tenup_experience_enable_password_policy', true ); + } + +} diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php new file mode 100644 index 0000000..8300fe7 --- /dev/null +++ b/includes/classes/Authentication/PastPasswords.php @@ -0,0 +1,293 @@ +format( 'Y-m-d' ) . ' 1:00:00' ); // Run at 1AM everyday. + $time->modify( '+1 day' ); + wp_schedule_event( $time->getTimestamp(), 'daily', 'tenup_notify_expired_passwords' ); + } + } + + /** + * If the current users profile update includes changing their password + * let's confirm that their new password does not match the current + * or past passwords. + * + * @param WP_Error $errors Errors object to add any custom errors to + * @param boolean|int $update true if updating an existing user, false if saving a new user + * @param object $user User object for user being edited + */ + public function update_profile( $errors, $update, $user ) { + $new_password = filter_input( INPUT_POST, 'pass1', FILTER_SANITIZE_STRING ); + $new_password_confirm = filter_input( INPUT_POST, 'pass2', FILTER_SANITIZE_STRING ); + if ( $update && ! empty( $new_password ) && $new_password === $new_password_confirm ) { + + $this->validate_password_reset( $errors, $user ); + + if ( empty( $errors->get_error_codes() ) ) { + $this->save_current_password( $user ); + } + } + } + + /** + * Confirm that the password being used in a reset does not match the + * existing or past passwords. + * + * @param WP_Error $errors Errors object to add any custom errors to + * @param object $user User object for user being edited + */ + public function validate_password_reset( $errors, $user ) { + $new_password = filter_input( INPUT_POST, 'pass1', FILTER_SANITIZE_STRING ); + $new_password_confirm = filter_input( INPUT_POST, 'pass2', FILTER_SANITIZE_STRING ); + $max_password = (int) PasswordPolicy::instance()->get_setting( 'past_passwords' ); + $user = get_userdata($user->ID ); + + if ( $max_password > 0 && ! empty( $new_password ) && $new_password === $new_password_confirm && is_array( $user->roles ) && ! empty( array_intersect( $user->roles, $this->get_password_expire_roles() ) ) ) { + if ( $this->invalid_duplicate( $user, $new_password ) ) { + $errors->add( 'duplicate_password', __( 'This password has previously been used, you must select a unique password', 'tenup' ) ); + } + } + } + + /** + * Store the users existing password before resetting their password. + * + * @param object $user User object for user being edited + * @param string $new_password New password + */ + public function password_reset( $user, $new_password ) { + $this->save_current_password( $user ); + } + + /** + * Check to see if the new password does not match the users existing + * password or one of their previous ones. + * + * @param object $user User object for user being edited + * @param string $new_password New password + * + * @return bool + */ + private function invalid_duplicate( $user, $new_password ) { + + $is_invalid = false; + $old_passwords = (array) get_user_meta( $user->ID, self::METAKEY_PASSWORD, true ); + $old_passwords[] = $this->get_current_password( $user ); + + foreach ( $old_passwords as $old_password ) { + if ( wp_check_password( $new_password, $old_password, $user->ID ) ) { + $is_invalid = true; + break; + } + } + + return $is_invalid; + } + + /** + * Save the current users password to meta so we can use it + * in the future to make sure they are not using an existing password + * + * @param object $user User object for user being edited + */ + private function save_current_password( $user ) { + if ( is_object( $user ) && is_array( $user->roles ) && ! empty( array_intersect( $user->roles, $this->get_password_expire_roles() ) ) ) { + $max_password = (int) PasswordPolicy::instance()->get_setting( 'past_passwords' ); + + // only save past passwords if the pass password setting is greater than 1 + if( $max_password > 0 ) { + $old_passwords = (array) get_user_meta( $user->ID, self::METAKEY_PASSWORD, true ); + + $old_passwords[] = $this->get_current_password( $user ); + $old_passwords = array_filter( $old_passwords ); + $old_password_count = count( $old_passwords ); + + // Limit the old password based on the password policy setting + if ( $old_password_count > $max_password ) { + array_splice( $old_passwords, $old_password_count - $max_password ); + } + + update_user_meta( $user->ID, self::METAKEY_PASSWORD, $old_passwords ); + update_user_meta( $user->ID, self::METAKEY_PASSWORD_EXPIRE, current_datetime()->format( 'Y-m-d' ) ); + } + + } + } + + /** + * Prevent users from authenticating if their current password is expired + * + * @param WP_User $user User object + * @param string $password current password + * + * @return \WP_User|\WP_Error + */ + public function prevent_login_for_expired_passwords( $user, $password ) { + $last_updated_password = get_user_meta( $user->ID, self::METAKEY_PASSWORD_EXPIRE, true ); + $password_expiration = $this->get_password_expired_date(); + $days_password_is_good_for = (int) PasswordPolicy::instance()->get_setting( 'expires' ); + + if ( $days_password_is_good_for > 0 && ( empty( $last_updated_password ) || $password_expiration > $last_updated_password ) && is_array( $user->roles ) && ! empty( array_intersect( $user->roles, $this->get_password_expire_roles() ) ) ) { + return new \WP_Error( + 'Password Expired', + // translators: URL to the reset password screen + sprintf( __( 'Your password has expired please reset your password.', 'tenup' ), esc_url( wp_lostpassword_url() ) ) + ); + } + + return $user; + } + + /** + * Notify all users that have an expired password + * + * @return void + */ + public function notify_expired_passwords() { + $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); + + // reminder days needs to be greater than 1 to run + if( $reminder_days < 1 ){ + return; + } + + $users = new \WP_User_Query( + array( + 'role__in' => $this->get_password_expire_roles(), + 'meta_query' => array( + array( + 'key' => self::METAKEY_PASSWORD_EXPIRE, + 'value' => $this->get_password_reminder_date(), + 'compare' => '=', + ), + ), + 'number' => apply_filters( 'tenup_number_user_query', 250 ), + 'field' => array( 'user_email', 'user_login' ), + ) + ); + + if ( ! empty( $users->get_results() ) ) { + $blog_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $expiration_text = current_datetime()->modify( "+$reminder_days day" )->format( 'F dS, Y' ); + $message = PasswordPolicy::instance()->get_setting( 'reminder_email' ); + $message .= sprintf( '

%s

', esc_html__( 'To reset your password, visit the following address:', 'tenup' ) ); + // translators: %1$s is the URL to the reset password screen + $message .= sprintf( '

%1$s

', esc_url( wp_lostpassword_url() ) ); + // translators: %1$s the site url and %2$d Numbers of days a uses password is still good for. + $subject = sprintf( _n( '[%1$s] Password expires in %2$d day', '[%1$s] Password expires in %2$d days', $reminder_days, 'tenup' ), $blog_name, number_format_i18n( $reminder_days ) ); + + foreach ( $users->get_results() as $user ) { + $custom_message = $message; + $custom_message = str_replace( '###USERNAME###', $user->user_login, $custom_message ); + $custom_message = str_replace( '###ADMIN_EMAIL###', get_option( 'admin_email' ), $custom_message ); + $custom_message = str_replace( '###EMAIL###', $user->user_email, $custom_message ); + $custom_message = str_replace( '###SITENAME###', $blog_name, $custom_message ); + $custom_message = str_replace( '###SITEURL###', home_url(), $custom_message ); + $custom_message = str_replace( '###DAYSLEFT###', $reminder_days, $custom_message ); + $custom_message = str_replace( '###EXPIRATIONDATE###', $expiration_text, $custom_message ); + + wp_mail( $user->user_email, $subject, $custom_message, array( 'Content-Type: text/html; charset=UTF-8' ) ); + } + } + } + + /** + * List of roles that qualify for password policy + * + * @return array + */ + private function get_password_expire_roles() { + return apply_filters( 'tenup_password_expire_roles', array( 'administrator', 'editor', 'author' ) ); + } + + /** + * Get date for todays expired passwords + * + * @return string + */ + private function get_password_expired_date() { + $today = current_datetime(); + $days_password_is_good_for = (int) PasswordPolicy::instance()->get_setting( 'expires' ); + return $today->modify( "-$days_password_is_good_for day" )->format( 'Y-m-d' ); + } + + /** + * Get date for todays passwords reminders + * + * @return string + */ + private function get_password_reminder_date() { + $today = current_datetime(); + $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); + $days_password_is_good_for = (int) PasswordPolicy::instance()->get_setting( 'expires' ); + $days_till_reminder = $days_password_is_good_for - $reminder_days; + return $today->modify( "-$days_till_reminder day" )->format( 'Y-m-d' ); + } + + /** + * Return the current password for the user from the Database + * + * @param object $user User object for user being edited + * + * @return mixed + */ + private function get_current_password( $user ) { + $user_data = get_user_by( 'id', $user->ID ); + + return $user_data->user_pass; + } +}