From 8df397754449badcc14f3ebeb982ba4f587add6b Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Fri, 7 May 2021 15:43:31 -0400 Subject: [PATCH 01/16] add admin page for password policy screens --- 10up-experience.php | 2 + .../AdminCustomizations/PasswordPolicy.php | 212 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 includes/classes/AdminCustomizations/PasswordPolicy.php diff --git a/10up-experience.php b/10up-experience.php index 0fea0e2..d0be6d1 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -81,6 +81,8 @@ function( $plugin_info, $http_response = null ) { SupportMonitor\Monitor::instance()->setup(); SupportMonitor\Debug::instance()->setup(); Notifications\Welcome::instance()->setup(); +Notifications\Welcome::instance()->setup(); +AdminCustomizations\PasswordPolicy::instance()->setup(); /** * We load this later to make sure there are no conflicts with other plugins. diff --git a/includes/classes/AdminCustomizations/PasswordPolicy.php b/includes/classes/AdminCustomizations/PasswordPolicy.php new file mode 100644 index 0000000..2ea2a40 --- /dev/null +++ b/includes/classes/AdminCustomizations/PasswordPolicy.php @@ -0,0 +1,212 @@ + array( $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 need to be changed reminder email.', 'tenup' ), + ], + 'past_passwords' => [ + 'label' => __( 'Past Passwords', 'tenup' ), + 'type' => 'number', + 'description' => __( 'The number of past passwords a user can repeat.', 'tenup' ), + ], + 'reminder_email' => [ + 'label' => __( 'Reminder Email', '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'], + array( $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 ); + } + +} From 744430ef66da7aaa583dc4b2e5ad7b03dc0abd17 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Fri, 14 May 2021 16:04:41 -0400 Subject: [PATCH 02/16] add past password class that keeps track of a users past passwords and compares it to the password they are trying to set --- 10up-experience.php | 3 +- .../AdminCustomizations/PasswordPolicy.php | 4 +- .../classes/Authentication/PastPasswords.php | 151 ++++++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 includes/classes/Authentication/PastPasswords.php diff --git a/10up-experience.php b/10up-experience.php index d0be6d1..da5528d 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -72,7 +72,9 @@ function( $plugin_info, $http_response = null ) { } API\API::instance()->setup(); +AdminCustomizations\PasswordPolicy::instance()->setup(); Authentication\Usernames::instance()->setup(); +Authentication\PastPasswords::instance()->setup(); Authors\Authors::instance()->setup(); Gutenberg\Gutenberg::instance()->setup(); Headers\Headers::instance()->setup(); @@ -82,7 +84,6 @@ function( $plugin_info, $http_response = null ) { SupportMonitor\Debug::instance()->setup(); Notifications\Welcome::instance()->setup(); Notifications\Welcome::instance()->setup(); -AdminCustomizations\PasswordPolicy::instance()->setup(); /** * We load this later to make sure there are no conflicts with other plugins. diff --git a/includes/classes/AdminCustomizations/PasswordPolicy.php b/includes/classes/AdminCustomizations/PasswordPolicy.php index 2ea2a40..1dd2d4b 100644 --- a/includes/classes/AdminCustomizations/PasswordPolicy.php +++ b/includes/classes/AdminCustomizations/PasswordPolicy.php @@ -48,7 +48,7 @@ public function register_password_policy_settings() { self::PASSWORD_POLICY_OPTION_NAME, self::PASSWORD_POLICY_OPTION_NAME, [ - 'sanitize_callback' => array( $this, 'sanitize_settings' ), + 'sanitize_callback' => [ $this, 'sanitize_settings' ], ] ); @@ -96,7 +96,7 @@ public function register_password_policy_settings() { add_settings_field( $setting_id, $setting['label'], - array( $this, 'field' ), + [ $this, 'field' ], self::PASSWORD_POLICY_OPTION_NAME, self::PASSWORD_POLICY_OPTION_NAME, $options diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php new file mode 100644 index 0000000..799e43c --- /dev/null +++ b/includes/classes/Authentication/PastPasswords.php @@ -0,0 +1,151 @@ +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 ); + + if ( ! empty( $new_password ) && $new_password === $new_password_confirm ) { + 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 ) ) { + $max_password = (int) PasswordPolicy::instance()->get_setting( 'past_passwords' ); + $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_time( '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; + } +} From 0b1370c6cc1d7e874645db6b58e2fd6c74422a54 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Fri, 14 May 2021 16:20:57 -0400 Subject: [PATCH 03/16] add login password policy logic --- 10up-experience.php | 1 + .../AdminCustomizations/PasswordPolicy.php | 10 +- includes/classes/Authentication/Login.php | 292 ++++++++++++++++++ .../classes/Authentication/PastPasswords.php | 4 +- 4 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 includes/classes/Authentication/Login.php diff --git a/10up-experience.php b/10up-experience.php index da5528d..a0482c6 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -75,6 +75,7 @@ function( $plugin_info, $http_response = null ) { AdminCustomizations\PasswordPolicy::instance()->setup(); Authentication\Usernames::instance()->setup(); Authentication\PastPasswords::instance()->setup(); +Authentication\Login::instance()->setup(); Authors\Authors::instance()->setup(); Gutenberg\Gutenberg::instance()->setup(); Headers\Headers::instance()->setup(); diff --git a/includes/classes/AdminCustomizations/PasswordPolicy.php b/includes/classes/AdminCustomizations/PasswordPolicy.php index 1dd2d4b..cb14368 100644 --- a/includes/classes/AdminCustomizations/PasswordPolicy.php +++ b/includes/classes/AdminCustomizations/PasswordPolicy.php @@ -25,17 +25,17 @@ class PasswordPolicy extends Singleton { * @since 1.7 */ public function setup() { - if ( apply_filters( 'tenup_experience_enable_password_policy', true ) ) { - add_action( 'admin_menu', [ $this, 'register_admin_pages' ] ); - add_filter( 'admin_init', [ $this, 'register_password_policy_settings' ], 10, 2 ); - } + add_action( 'admin_menu', [ $this, 'register_admin_pages' ] ); + add_filter( 'admin_init', [ $this, 'register_password_policy_settings' ], 10, 2 ); } /** * Register admin pages with output callbacks */ public function register_admin_pages() { - add_users_page( esc_html__( 'Password Policy', 'tenup' ), esc_html__( 'Password Policy', 'tenup' ), 'manage_options', '10up-password-policy', [ $this, 'password_policy_screen' ] ); + if ( apply_filters( 'tenup_experience_enable_password_policy', true ) ) { + add_users_page( esc_html__( 'Password Policy', 'tenup' ), esc_html__( 'Password Policy', 'tenup' ), 'manage_options', '10up-password-policy', [ $this, 'password_policy_screen' ] ); + } } /** diff --git a/includes/classes/Authentication/Login.php b/includes/classes/Authentication/Login.php new file mode 100644 index 0000000..3230e37 --- /dev/null +++ b/includes/classes/Authentication/Login.php @@ -0,0 +1,292 @@ +is_resetpass = 'resetpass' === $action; + } + + /** + * Setup hooks and filters + * + * @return void + */ + public function setup() { + add_action( 'wp_login_failed', array( $this, 'log_attempt' ) ); + add_action( 'init', array( $this, 'cron_clean_blacklist' ) ); + add_action( 'clean_ip_blacklist', array( $this, 'clean_blacklist' ) ); + + add_filter( 'authenticate', array( $this, 'authenticate' ), PHP_INT_MAX ); + add_filter( 'login_errors', array( $this, 'login_errors' ) ); + add_filter( 'shake_error_codes', array( $this, 'shake_error_codes' ) ); + + } + + /** + * Log failed attempt. + * + * @return void + */ + public function log_attempt() { + $this->increment_attempts(); + + if ( $this->attempts_remaining() <= 0 ) { + $this->add_to_blacklist(); + } + } + + /** + * Filter login errors. + * + * @param string $error_message Error message. + * @return string + */ + public function login_errors( $error_message ) { + if ( ! $this->is_blacklisted() && ! $this->is_resetpass ) { + /* translators: %d is the number of remaining attempts. */ + $error_message .= '
' . sprintf( __( '%d Login Attempts Remaining', 'tenup' ), $this->attempts_remaining() ) . ''; + } + + if ( 0 === $this->attempts_remaining() ) { + $error_message .= $this->blacklisted_message(); + } + + return $error_message; + } + + /** + * Disallow authentication if user IP is blacklisted. + * + * @param object $user User object. + * @return object|WP_Error + */ + public function authenticate( $user ) { + $error = new \WP_Error(); + + if ( $this->is_blacklisted() ) { + $error->add( 'max_attempts_reached', $this->blacklisted_message() ); + return $error; + } + + if ( ! is_wp_error( $user ) && $user instanceof \WP_User ) { + $this->clear_attempts(); + $this->remove_from_blacklist(); + } + + return $user; + } + + /** + * Add shake error code when max attempts are reached. + * + * @param array $codes Error codes. + * @return array + */ + public function shake_error_codes( $codes ) { + $codes[] = 'max_attempts_reached'; + + return $codes; + } + + /** + * Cron task to clean blacklist daily. + * + * @return void + */ + public function cron_clean_blacklist() { + if ( ! wp_next_scheduled( 'clean_ip_blacklist' ) ) { + wp_schedule_event( time(), 'daily', 'clean_ip_blacklist' ); + } + } + + /** + * Get request IP address and anonymize for storage. + * + * @return string + */ + private function get_ip() { + return md5( $_SERVER['REMOTE_ADDR'] ); + } + + /** + * Get transient key. + * + * @return string + */ + private function get_key() { + return 'failed_login' . $this->get_ip(); + } + + /** + * Get the number of attempts an IP has made to login. + * + * @return int + */ + private function get_attempts() { + return ( false === get_transient( $this->get_key() ) ) ? 0 : get_transient( $this->get_key() ); + } + + /** + * Get the number of remaining attempts. + * + * @return int + */ + private function attempts_remaining() { + $max_attempts = (int) apply_filters( 'tenup_max_login_attemps', self::MAX_ATTEMPTS ); + return ( $max_attempts - $this->get_attempts() ); + } + + /** + * Increment attempts for IP. + * + * @return bool + */ + private function increment_attempts() { + $attempts = $this->get_attempts() + 1; + return set_transient( $this->get_key(), $attempts, DAY_IN_SECONDS ); + } + + /** + * Clear attempts for IP. + * + * @return bool + */ + private function clear_attempts() { + return delete_transient( $this->get_key() ); + } + + /** + * Get blacklisted IP. + * + * @return array + */ + private function get_blacklist() { + return ( false === get_transient( self::BLACKLIST_KEY ) ) ? [] : get_transient( self::BLACKLIST_KEY ); + } + + /** + * Add IP to blacklist. + * + * @return bool + */ + private function add_to_blacklist() { + $blacklist = $this->get_blacklist(); + $blacklist[ $this->get_ip() ] = time() + ( 24 * HOUR_IN_SECONDS ); + + return set_transient( self::BLACKLIST_KEY, $blacklist ); + } + + /** + * Remove IPs that have expired from blacklist. + * + * @return bool + */ + public function clean_blacklist() { + $blacklist = $this->get_blacklist(); + $clean = array_filter( + $blacklist, + function( $expiration ) { + return $expiration > time(); + } + ); + + return set_transient( self::BLACKLIST_KEY, $clean ); + } + + /** + * Remove IP from blacklist + * + * @return bool + */ + private function remove_from_blacklist() { + $blacklist = $this->get_blacklist(); + + unset( $blacklist[ $this->get_ip() ] ); + + return set_transient( self::BLACKLIST_KEY, $blacklist ); + } + + /** + * Check if IP is blacklisted. + * + * @return bool + */ + private function is_blacklisted() { + $blacklisted = array_key_exists( $this->get_ip(), $this->get_blacklist() ); + + if ( $blacklisted ) { + $expiration = $this->get_blacklist()[ $this->get_ip() ]; + + if ( time() > $expiration ) { + $this->remove_from_blacklist(); + return false; + } + return true; + } + return false; + } + + /** + * Get blacklisted message. + * + * @return string + */ + private function blacklisted_message() { + $output = '' . __( 'There were too many failed login attempts.', 'tenup' ) . '
'; + $output .= __( 'Try again ', 'tenup' ); + + if ( $this->is_blacklisted() ) { + $expiration = $this->get_blacklist()[ $this->get_ip() ]; + $remaining = $expiration - time(); + $hours = floor( $remaining / 3600 ); + $minutes = floor( ( $remaining / 60 ) % 60 ); + /* translators: %d is the number of hours remaining until IP can retry logging in */ + $htext = sprintf( _n( '%d hour', 'in %d hours', absint( $hours ), 'tenup' ), absint( $hours ) ); + /* translators: %d is the number of minutes remaining until IP can retry logging in */ + $mtext = sprintf( _n( '%d minute', '%d minutes', absint( $minutes ), 'tenup' ), absint( $minutes ) ); + + if ( $hours || $minutes ) { + $output .= absint( $hours ) ? $htext . __( ' and ', 'tenup' ) : __( 'in ', 'tenup' ); + $output .= $mtext . '.'; + } else { + $output .= __( ' later.', 'tenup' ); + } + } + + return $output; + } +} diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php index 799e43c..31b6ab4 100644 --- a/includes/classes/Authentication/PastPasswords.php +++ b/includes/classes/Authentication/PastPasswords.php @@ -18,12 +18,12 @@ class PastPasswords extends Singleton { /** * Meta key for list of past password hashes */ - const METAKEY_PASSWORD = '_past_passwords'; + const METAKEY_PASSWORD = '_tenup_past_passwords'; /** * Meta key for last time password was updated */ - const METAKEY_PASSWORD_EXPIRE = '_password_last_updated'; + const METAKEY_PASSWORD_EXPIRE = '_tenup_password_last_updated'; /** * Setup hook sand filters From 4ef53f9978cba4877145677907a4467797f8687b Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Mon, 7 Jun 2021 09:23:20 -0400 Subject: [PATCH 04/16] prevent users from logging in if their password is expired --- 10up-experience.php | 6 +- includes/classes/Authentication/Login.php | 292 ------------------ .../classes/Authentication/PastPasswords.php | 29 +- 3 files changed, 31 insertions(+), 296 deletions(-) delete mode 100644 includes/classes/Authentication/Login.php diff --git a/10up-experience.php b/10up-experience.php index a0482c6..54a29e8 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -74,8 +74,6 @@ function( $plugin_info, $http_response = null ) { API\API::instance()->setup(); AdminCustomizations\PasswordPolicy::instance()->setup(); Authentication\Usernames::instance()->setup(); -Authentication\PastPasswords::instance()->setup(); -Authentication\Login::instance()->setup(); Authors\Authors::instance()->setup(); Gutenberg\Gutenberg::instance()->setup(); Headers\Headers::instance()->setup(); @@ -86,6 +84,10 @@ function( $plugin_info, $http_response = null ) { Notifications\Welcome::instance()->setup(); Notifications\Welcome::instance()->setup(); +if( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ){ + Authentication\PastPasswords::instance()->setup(); +} + /** * We load this later to make sure there are no conflicts with other plugins. */ diff --git a/includes/classes/Authentication/Login.php b/includes/classes/Authentication/Login.php deleted file mode 100644 index 3230e37..0000000 --- a/includes/classes/Authentication/Login.php +++ /dev/null @@ -1,292 +0,0 @@ -is_resetpass = 'resetpass' === $action; - } - - /** - * Setup hooks and filters - * - * @return void - */ - public function setup() { - add_action( 'wp_login_failed', array( $this, 'log_attempt' ) ); - add_action( 'init', array( $this, 'cron_clean_blacklist' ) ); - add_action( 'clean_ip_blacklist', array( $this, 'clean_blacklist' ) ); - - add_filter( 'authenticate', array( $this, 'authenticate' ), PHP_INT_MAX ); - add_filter( 'login_errors', array( $this, 'login_errors' ) ); - add_filter( 'shake_error_codes', array( $this, 'shake_error_codes' ) ); - - } - - /** - * Log failed attempt. - * - * @return void - */ - public function log_attempt() { - $this->increment_attempts(); - - if ( $this->attempts_remaining() <= 0 ) { - $this->add_to_blacklist(); - } - } - - /** - * Filter login errors. - * - * @param string $error_message Error message. - * @return string - */ - public function login_errors( $error_message ) { - if ( ! $this->is_blacklisted() && ! $this->is_resetpass ) { - /* translators: %d is the number of remaining attempts. */ - $error_message .= '
' . sprintf( __( '%d Login Attempts Remaining', 'tenup' ), $this->attempts_remaining() ) . ''; - } - - if ( 0 === $this->attempts_remaining() ) { - $error_message .= $this->blacklisted_message(); - } - - return $error_message; - } - - /** - * Disallow authentication if user IP is blacklisted. - * - * @param object $user User object. - * @return object|WP_Error - */ - public function authenticate( $user ) { - $error = new \WP_Error(); - - if ( $this->is_blacklisted() ) { - $error->add( 'max_attempts_reached', $this->blacklisted_message() ); - return $error; - } - - if ( ! is_wp_error( $user ) && $user instanceof \WP_User ) { - $this->clear_attempts(); - $this->remove_from_blacklist(); - } - - return $user; - } - - /** - * Add shake error code when max attempts are reached. - * - * @param array $codes Error codes. - * @return array - */ - public function shake_error_codes( $codes ) { - $codes[] = 'max_attempts_reached'; - - return $codes; - } - - /** - * Cron task to clean blacklist daily. - * - * @return void - */ - public function cron_clean_blacklist() { - if ( ! wp_next_scheduled( 'clean_ip_blacklist' ) ) { - wp_schedule_event( time(), 'daily', 'clean_ip_blacklist' ); - } - } - - /** - * Get request IP address and anonymize for storage. - * - * @return string - */ - private function get_ip() { - return md5( $_SERVER['REMOTE_ADDR'] ); - } - - /** - * Get transient key. - * - * @return string - */ - private function get_key() { - return 'failed_login' . $this->get_ip(); - } - - /** - * Get the number of attempts an IP has made to login. - * - * @return int - */ - private function get_attempts() { - return ( false === get_transient( $this->get_key() ) ) ? 0 : get_transient( $this->get_key() ); - } - - /** - * Get the number of remaining attempts. - * - * @return int - */ - private function attempts_remaining() { - $max_attempts = (int) apply_filters( 'tenup_max_login_attemps', self::MAX_ATTEMPTS ); - return ( $max_attempts - $this->get_attempts() ); - } - - /** - * Increment attempts for IP. - * - * @return bool - */ - private function increment_attempts() { - $attempts = $this->get_attempts() + 1; - return set_transient( $this->get_key(), $attempts, DAY_IN_SECONDS ); - } - - /** - * Clear attempts for IP. - * - * @return bool - */ - private function clear_attempts() { - return delete_transient( $this->get_key() ); - } - - /** - * Get blacklisted IP. - * - * @return array - */ - private function get_blacklist() { - return ( false === get_transient( self::BLACKLIST_KEY ) ) ? [] : get_transient( self::BLACKLIST_KEY ); - } - - /** - * Add IP to blacklist. - * - * @return bool - */ - private function add_to_blacklist() { - $blacklist = $this->get_blacklist(); - $blacklist[ $this->get_ip() ] = time() + ( 24 * HOUR_IN_SECONDS ); - - return set_transient( self::BLACKLIST_KEY, $blacklist ); - } - - /** - * Remove IPs that have expired from blacklist. - * - * @return bool - */ - public function clean_blacklist() { - $blacklist = $this->get_blacklist(); - $clean = array_filter( - $blacklist, - function( $expiration ) { - return $expiration > time(); - } - ); - - return set_transient( self::BLACKLIST_KEY, $clean ); - } - - /** - * Remove IP from blacklist - * - * @return bool - */ - private function remove_from_blacklist() { - $blacklist = $this->get_blacklist(); - - unset( $blacklist[ $this->get_ip() ] ); - - return set_transient( self::BLACKLIST_KEY, $blacklist ); - } - - /** - * Check if IP is blacklisted. - * - * @return bool - */ - private function is_blacklisted() { - $blacklisted = array_key_exists( $this->get_ip(), $this->get_blacklist() ); - - if ( $blacklisted ) { - $expiration = $this->get_blacklist()[ $this->get_ip() ]; - - if ( time() > $expiration ) { - $this->remove_from_blacklist(); - return false; - } - return true; - } - return false; - } - - /** - * Get blacklisted message. - * - * @return string - */ - private function blacklisted_message() { - $output = '' . __( 'There were too many failed login attempts.', 'tenup' ) . '
'; - $output .= __( 'Try again ', 'tenup' ); - - if ( $this->is_blacklisted() ) { - $expiration = $this->get_blacklist()[ $this->get_ip() ]; - $remaining = $expiration - time(); - $hours = floor( $remaining / 3600 ); - $minutes = floor( ( $remaining / 60 ) % 60 ); - /* translators: %d is the number of hours remaining until IP can retry logging in */ - $htext = sprintf( _n( '%d hour', 'in %d hours', absint( $hours ), 'tenup' ), absint( $hours ) ); - /* translators: %d is the number of minutes remaining until IP can retry logging in */ - $mtext = sprintf( _n( '%d minute', '%d minutes', absint( $minutes ), 'tenup' ), absint( $minutes ) ); - - if ( $hours || $minutes ) { - $output .= absint( $hours ) ? $htext . __( ' and ', 'tenup' ) : __( 'in ', 'tenup' ); - $output .= $mtext . '.'; - } else { - $output .= __( ' later.', 'tenup' ); - } - } - - return $output; - } -} diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php index 31b6ab4..ecaddfc 100644 --- a/includes/classes/Authentication/PastPasswords.php +++ b/includes/classes/Authentication/PastPasswords.php @@ -30,10 +30,10 @@ class PastPasswords extends Singleton { */ public function setup() { add_action( 'user_profile_update_errors', [ $this, 'update_profile' ], 10, 3 ); - // Run duplicate password check after password strength test in TenUpExperience\Authentication\PassWord add_action( 'validate_password_reset', [ $this, 'validate_password_reset' ], 11, 2 ); add_action( 'password_reset', [ $this, 'password_reset' ], 10, 2 ); + add_filter( 'wp_authenticate_user', [ $this, 'prevent_login_for_expired_passwords' ], 10, 2 ); } /** @@ -132,10 +132,35 @@ private function save_current_password( $user ) { } update_user_meta( $user->ID, self::METAKEY_PASSWORD, $old_passwords ); - update_user_meta( $user->ID, self::METAKEY_PASSWORD_EXPIRE, current_time( 'Y-m-d' ) ); + 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 ) { + $today = current_datetime(); + $last_updated_password = get_user_meta( $user->ID, self::METAKEY_PASSWORD_EXPIRE, true ); + $days_password_is_good_for = (int) PasswordPolicy::instance()->get_setting( 'expires' ); + $password_expiration = $today->modify( "-$days_password_is_good_for day" ); + + if ( empty( $last_updated_password ) || $last_updated_password > $password_expiration ) { + 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; + } + /** * Return the current password for the user from the Database * From abb9bb2e35a7623ac5af3a6fd3bb98d493b1dddb Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Mon, 7 Jun 2021 10:11:46 -0400 Subject: [PATCH 05/16] register daily cron event to notify all users who qualify for an soon to be expired password --- .../classes/Authentication/PastPasswords.php | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php index ecaddfc..ed67779 100644 --- a/includes/classes/Authentication/PastPasswords.php +++ b/includes/classes/Authentication/PastPasswords.php @@ -29,6 +29,8 @@ class PastPasswords extends Singleton { * Setup hook sand filters */ public function setup() { + add_action( 'init', [ $this, 'maybe_schedule_event' ] ); + add_action( 'tenup_check_expired_passwords', [ $this, 'notify_expired_passwords' ] ); add_action( 'user_profile_update_errors', [ $this, 'update_profile' ], 10, 3 ); // Run duplicate password check after password strength test in TenUpExperience\Authentication\PassWord add_action( 'validate_password_reset', [ $this, 'validate_password_reset' ], 11, 2 ); @@ -36,6 +38,19 @@ public function setup() { add_filter( 'wp_authenticate_user', [ $this, 'prevent_login_for_expired_passwords' ], 10, 2 ); } + /** + * Schedule tenup_notify_expired_passwords cron event + * + * @return void + */ + public function maybe_schedule_event() { + if ( ! wp_next_scheduled( 'tenup_notify_expired_passwords' ) ) { + $time = new \DateTime( current_datetime()->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 @@ -69,7 +84,7 @@ 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 ); - if ( ! empty( $new_password ) && $new_password === $new_password_confirm ) { + if ( ! 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' ) ); } @@ -118,7 +133,7 @@ private function invalid_duplicate( $user, $new_password ) { * @param object $user User object for user being edited */ private function save_current_password( $user ) { - if ( is_object( $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' ); $old_passwords = (array) get_user_meta( $user->ID, self::METAKEY_PASSWORD, true ); @@ -145,12 +160,10 @@ private function save_current_password( $user ) { * @return \WP_User|\WP_Error */ public function prevent_login_for_expired_passwords( $user, $password ) { - $today = current_datetime(); - $last_updated_password = get_user_meta( $user->ID, self::METAKEY_PASSWORD_EXPIRE, true ); - $days_password_is_good_for = (int) PasswordPolicy::instance()->get_setting( 'expires' ); - $password_expiration = $today->modify( "-$days_password_is_good_for day" ); + $last_updated_password = get_user_meta( $user->ID, self::METAKEY_PASSWORD_EXPIRE, true ); + $password_expiration = $this->get_password_expired_date(); - if ( empty( $last_updated_password ) || $last_updated_password > $password_expiration ) { + if ( empty( $last_updated_password ) || $last_updated_password > $password_expiration && 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 @@ -161,6 +174,61 @@ public function prevent_login_for_expired_passwords( $user, $password ) { return $user; } + /** + * Notify all users that have an expired password + * + * @return void + */ + public function notify_expired_passwords() { + $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_expired_date(), + 'compare' => '=', + ), + ), + 'number' => apply_filters( 'tenup_number_user_query', 250 ), + 'fields' => 'email', + ) + ); + + if ( ! empty( $users->get_results() ) ) { + $message = PasswordPolicy::instance()->get_setting( 'reminder_email' ); + $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); + // translators: %1$s is the URL to the reset password screen and %2$s is the link to the reset password screen + $reminder_days = sprintf( '

%2$s

', esc_url( wp_lostpassword_url() ), esc_html__( 'Rest your password', 'tenup' ) ); + // translators: Numbers of days a uses password is still good for + $subject = sprintf( _n( 'Password expires in %s day', 'Password expires in %s days', $reminder_days, 'text-domain' ), number_format_i18n( $reminder_days ) ); + + foreach ( $users->get_results() as $user_email ) { + wp_mail( $user_email, $subject, $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' ); + } + /** * Return the current password for the user from the Database * From 11642ecf2573abd7891a8f4098ad58f0d88e8bac Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Mon, 7 Jun 2021 15:30:44 -0400 Subject: [PATCH 06/16] fix email message and add deactivation cleanup code --- 10up-experience.php | 7 ++++++ .../classes/Authentication/PastPasswords.php | 25 +++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/10up-experience.php b/10up-experience.php index 54a29e8..c5fd3eb 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -98,6 +98,13 @@ function() { } ); +/** + * Clean up when plugin is deactivated + */ +register_deactivation_hook( __FILE__, function(){ + Authentication\PastPasswords::instance()->deactivate(); +} ); + /** * Disable plugin/theme editor */ diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php index ed67779..fe28727 100644 --- a/includes/classes/Authentication/PastPasswords.php +++ b/includes/classes/Authentication/PastPasswords.php @@ -30,14 +30,24 @@ class PastPasswords extends Singleton { */ public function setup() { add_action( 'init', [ $this, 'maybe_schedule_event' ] ); - add_action( 'tenup_check_expired_passwords', [ $this, 'notify_expired_passwords' ] ); + add_action( 'tenup_notify_expired_passwords', [ $this, 'notify_expired_passwords' ] ); add_action( 'user_profile_update_errors', [ $this, 'update_profile' ], 10, 3 ); + // Run duplicate password check after password strength test in TenUpExperience\Authentication\PassWord add_action( 'validate_password_reset', [ $this, 'validate_password_reset' ], 11, 2 ); add_action( 'password_reset', [ $this, 'password_reset' ], 10, 2 ); add_filter( 'wp_authenticate_user', [ $this, 'prevent_login_for_expired_passwords' ], 10, 2 ); } + /** + * Remove cron event when plugin is deactivated + * + * @return void + */ + public function deactivate() { + wp_clear_scheduled_hook( 'tenup_notify_expired_passwords' ); + } + /** * Schedule tenup_notify_expired_passwords cron event * @@ -191,19 +201,20 @@ public function notify_expired_passwords() { ), ), 'number' => apply_filters( 'tenup_number_user_query', 250 ), - 'fields' => 'email', + 'fields' => array( 'user_email' ), ) ); if ( ! empty( $users->get_results() ) ) { - $message = PasswordPolicy::instance()->get_setting( 'reminder_email' ); $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); + $message = PasswordPolicy::instance()->get_setting( 'reminder_email' ); // translators: %1$s is the URL to the reset password screen and %2$s is the link to the reset password screen - $reminder_days = sprintf( '

%2$s

', esc_url( wp_lostpassword_url() ), esc_html__( 'Rest your password', 'tenup' ) ); - // translators: Numbers of days a uses password is still good for - $subject = sprintf( _n( 'Password expires in %s day', 'Password expires in %s days', $reminder_days, 'text-domain' ), number_format_i18n( $reminder_days ) ); + $message .= sprintf( '

%2$s

', esc_url( wp_lostpassword_url() ), esc_html__( 'Rest your password', 'tenup' ) ); + // translators: %d Numbers of days a uses password is still good for. %s the site url + $subject = sprintf( _n( 'Password expires in %1$d day for %2$s', 'Password expires in %1$d days for %2$s', $reminder_days, 'text-domain' ), number_format_i18n( $reminder_days ), esc_url( home_url() ) ); - foreach ( $users->get_results() as $user_email ) { + $user_emails = wp_list_pluck( $users->get_results(), 'user_email' ); + foreach ( $user_emails as $user_email ) { wp_mail( $user_email, $subject, $message, array( 'Content-Type: text/html; charset=UTF-8' ) ); } } From c37baa2e9ac7b2c9d4bc55ad983a21d1c125150d Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Tue, 8 Jun 2021 10:56:09 -0400 Subject: [PATCH 07/16] fix email notification logic and add the ability to include dynamic text in the reminder email --- .../AdminCustomizations/PasswordPolicy.php | 5 +- .../classes/Authentication/PastPasswords.php | 51 +++++++++++++++---- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/includes/classes/AdminCustomizations/PasswordPolicy.php b/includes/classes/AdminCustomizations/PasswordPolicy.php index cb14368..4b63ec7 100644 --- a/includes/classes/AdminCustomizations/PasswordPolicy.php +++ b/includes/classes/AdminCustomizations/PasswordPolicy.php @@ -80,8 +80,9 @@ public function register_password_policy_settings() { 'description' => __( 'The number of past passwords a user can repeat.', 'tenup' ), ], 'reminder_email' => [ - 'label' => __( 'Reminder Email', 'tenup' ), - 'type' => 'tinymce', + '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', ], ]; diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php index fe28727..00dbe51 100644 --- a/includes/classes/Authentication/PastPasswords.php +++ b/includes/classes/Authentication/PastPasswords.php @@ -190,6 +190,14 @@ public function prevent_login_for_expired_passwords( $user, $password ) { * @return void */ public function notify_expired_passwords() { + $today = current_datetime(); + $reminder_date = $this->get_password_reminder_date(); + $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); + + if ( (int) $today->diff( new \DateTime( $reminder_date ) )->format( '%a' ) !== $reminder_days ) { + return; + } + $users = new \WP_User_Query( array( 'role__in' => $this->get_password_expire_roles(), @@ -201,21 +209,31 @@ public function notify_expired_passwords() { ), ), 'number' => apply_filters( 'tenup_number_user_query', 250 ), - 'fields' => array( 'user_email' ), + 'field' => array( 'user_email', 'user_login' ), ) ); if ( ! empty( $users->get_results() ) ) { - $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); - $message = PasswordPolicy::instance()->get_setting( 'reminder_email' ); - // translators: %1$s is the URL to the reset password screen and %2$s is the link to the reset password screen - $message .= sprintf( '

%2$s

', esc_url( wp_lostpassword_url() ), esc_html__( 'Rest your password', 'tenup' ) ); - // translators: %d Numbers of days a uses password is still good for. %s the site url - $subject = sprintf( _n( 'Password expires in %1$d day for %2$s', 'Password expires in %1$d days for %2$s', $reminder_days, 'text-domain' ), number_format_i18n( $reminder_days ), esc_url( home_url() ) ); - - $user_emails = wp_list_pluck( $users->get_results(), 'user_email' ); - foreach ( $user_emails as $user_email ) { - wp_mail( $user_email, $subject, $message, array( 'Content-Type: text/html; charset=UTF-8' ) ); + $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' ) ); } } } @@ -240,6 +258,17 @@ private function get_password_expired_date() { 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' ); + return $today->modify( "-$reminder_days day" )->format( 'Y-m-d' ); + } + /** * Return the current password for the user from the Database * From 64d43a19c401a17866df757efcfc3c3de28342ff Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 08:25:02 -0400 Subject: [PATCH 08/16] update reminder date logic so that emails get sent out to users how passoword expire in x days --- 10up-experience.php | 11 +++++++---- .../classes/Authentication/PastPasswords.php | 18 +++++++----------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/10up-experience.php b/10up-experience.php index c5fd3eb..dfa512c 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -84,7 +84,7 @@ function( $plugin_info, $http_response = null ) { Notifications\Welcome::instance()->setup(); Notifications\Welcome::instance()->setup(); -if( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ){ +if ( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ) { Authentication\PastPasswords::instance()->setup(); } @@ -101,9 +101,12 @@ function() { /** * Clean up when plugin is deactivated */ -register_deactivation_hook( __FILE__, function(){ - Authentication\PastPasswords::instance()->deactivate(); -} ); +register_deactivation_hook( + __FILE__, + function() { + Authentication\PastPasswords::instance()->deactivate(); + } +); /** * Disable plugin/theme editor diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php index 00dbe51..0745ca5 100644 --- a/includes/classes/Authentication/PastPasswords.php +++ b/includes/classes/Authentication/PastPasswords.php @@ -173,7 +173,7 @@ 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(); - if ( empty( $last_updated_password ) || $last_updated_password > $password_expiration && is_array( $user->roles ) && ! empty( array_intersect( $user->roles, $this->get_password_expire_roles() ) ) ) { + if ( 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 @@ -190,21 +190,15 @@ public function prevent_login_for_expired_passwords( $user, $password ) { * @return void */ public function notify_expired_passwords() { - $today = current_datetime(); - $reminder_date = $this->get_password_reminder_date(); $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); - if ( (int) $today->diff( new \DateTime( $reminder_date ) )->format( '%a' ) !== $reminder_days ) { - 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_expired_date(), + 'value' => $this->get_password_reminder_date(), 'compare' => '=', ), ), @@ -264,9 +258,11 @@ private function get_password_expired_date() { * @return string */ private function get_password_reminder_date() { - $today = current_datetime(); - $reminder_days = (int) PasswordPolicy::instance()->get_setting( 'reminder' ); - return $today->modify( "-$reminder_days day" )->format( 'Y-m-d' ); + $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' ); } /** From bd8d0277ebd6c8c0eceed6c27a2dac78149cdf24 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 08:36:19 -0400 Subject: [PATCH 09/16] add constant to disable password policy functionality --- 10up-experience.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/10up-experience.php b/10up-experience.php index dfa512c..1efb31b 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -72,7 +72,6 @@ function( $plugin_info, $http_response = null ) { } API\API::instance()->setup(); -AdminCustomizations\PasswordPolicy::instance()->setup(); Authentication\Usernames::instance()->setup(); Authors\Authors::instance()->setup(); Gutenberg\Gutenberg::instance()->setup(); @@ -84,8 +83,10 @@ function( $plugin_info, $http_response = null ) { Notifications\Welcome::instance()->setup(); Notifications\Welcome::instance()->setup(); -if ( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ) { - Authentication\PastPasswords::instance()->setup(); +if( ( ! defined( 'TENUP_DISABLE_PAST_PASSWORDS' ) || ! TENUP_DISABLE_PAST_PASSWORDS ) ){ + if ( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ) { + Authentication\PastPasswords::instance()->setup(); + } } /** From 29a39d043bb7168da0b6caf01cffc19c425ed1c1 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 08:58:52 -0400 Subject: [PATCH 10/16] update readme file --- 10up-experience.php | 4 ++-- README.md | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/10up-experience.php b/10up-experience.php index 1efb31b..2328e23 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -2,7 +2,7 @@ /** * Plugin Name: 10up Experience * Description: The 10up Experience plugin configures WordPress to better protect and inform clients, aligned to 10up’s best practices. - * Version: 1.8.0 + * Version: 1.9.0 * Author: 10up * Author URI: https://10up.com * License: GPLv2 or later @@ -83,7 +83,7 @@ function( $plugin_info, $http_response = null ) { Notifications\Welcome::instance()->setup(); Notifications\Welcome::instance()->setup(); -if( ( ! defined( 'TENUP_DISABLE_PAST_PASSWORDS' ) || ! TENUP_DISABLE_PAST_PASSWORDS ) ){ +if( ( ! defined( 'TENUP_DISABLE_PAST_POLICY' ) || ! TENUP_DISABLE_PAST_POLICY ) ){ if ( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ) { Authentication\PastPasswords::instance()->setup(); } diff --git a/README.md b/README.md index 70f1c87..804883f 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_PAST_POLICY` as `true`. + * __Headers__ `X-Frame-Origins` is set to `sameorigin` to prevent click jacking. From 7f903c5f797e85a471793afcfdeadb4058323f62 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 09:00:29 -0400 Subject: [PATCH 11/16] fix typo --- includes/classes/AdminCustomizations/PasswordPolicy.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/classes/AdminCustomizations/PasswordPolicy.php b/includes/classes/AdminCustomizations/PasswordPolicy.php index 4b63ec7..1f7d078 100644 --- a/includes/classes/AdminCustomizations/PasswordPolicy.php +++ b/includes/classes/AdminCustomizations/PasswordPolicy.php @@ -72,12 +72,12 @@ public function register_password_policy_settings() { 'reminder' => [ 'label' => __( 'Send Password Reminder', 'tenup' ), 'type' => 'number', - 'description' => __( 'The number of days before a password need to be changed reminder email.', 'tenup' ), + 'description' => __( 'The number of days before a password need to be changed to send a reminder email.', 'tenup' ), ], 'past_passwords' => [ 'label' => __( 'Past Passwords', 'tenup' ), 'type' => 'number', - 'description' => __( 'The number of past passwords a user can repeat.', 'tenup' ), + 'description' => __( 'The number of past passwords a user can\'t repeat.', 'tenup' ), ], 'reminder_email' => [ 'label' => __( 'Reminder Email', 'tenup' ), From b4f630f6d355ca47e0a2b6d2293fc5e0676b4ce4 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 09:02:48 -0400 Subject: [PATCH 12/16] remove extra space --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 804883f..e9ce089 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ *Configured in `Settings > General` or `Settings > Network Settings` if network activated.* - * __Password Policy__ +* __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. From fca8e3b037745f2b32bb571736d658650d628abb Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 09:03:43 -0400 Subject: [PATCH 13/16] update constant name --- 10up-experience.php | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/10up-experience.php b/10up-experience.php index 2328e23..9d421e1 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -83,7 +83,7 @@ function( $plugin_info, $http_response = null ) { Notifications\Welcome::instance()->setup(); Notifications\Welcome::instance()->setup(); -if( ( ! defined( 'TENUP_DISABLE_PAST_POLICY' ) || ! TENUP_DISABLE_PAST_POLICY ) ){ +if( ( ! defined( 'TENUP_DISABLE_PASSWORD_POLICY' ) || ! TENUP_DISABLE_PASSWORD_POLICY ) ){ if ( AdminCustomizations\PasswordPolicy::instance()->is_enabled() ) { Authentication\PastPasswords::instance()->setup(); } diff --git a/README.md b/README.md index e9ce089..10da44f 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ *Configured in `Users > Password Policy` - *Note:* Password Policy can be disabled by defining the constant `TENUP_DISABLE_PAST_POLICY` as `true`. + *Note:* Password Policy can be disabled by defining the constant `TENUP_DISABLE_PASSWORD_POLICY` as `true`. * __Headers__ From 615ead26278b107bfd97bc54c7e44ece6cfe1783 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 09:48:46 -0400 Subject: [PATCH 14/16] add additional checks to make sure our password policy setting are at least greater than one day --- .../classes/Authentication/PastPasswords.php | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/includes/classes/Authentication/PastPasswords.php b/includes/classes/Authentication/PastPasswords.php index 0745ca5..8300fe7 100644 --- a/includes/classes/Authentication/PastPasswords.php +++ b/includes/classes/Authentication/PastPasswords.php @@ -93,8 +93,10 @@ public function update_profile( $errors, $update, $user ) { 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 ); - - if ( ! empty( $new_password ) && $new_password === $new_password_confirm && is_array( $user->roles ) && ! empty( array_intersect( $user->roles, $this->get_password_expire_roles() ) ) ) { + $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' ) ); } @@ -145,19 +147,24 @@ private function invalid_duplicate( $user, $new_password ) { 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' ); - $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 ); + // 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' ) ); } - - 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' ) ); + } } @@ -172,8 +179,9 @@ private function save_current_password( $user ) { 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 ( empty( $last_updated_password ) || $password_expiration > $last_updated_password && is_array( $user->roles ) && ! empty( array_intersect( $user->roles, $this->get_password_expire_roles() ) ) ) { + 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 @@ -192,6 +200,11 @@ public function prevent_login_for_expired_passwords( $user, $password ) { 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(), From d8a5ac1f995ec7aa04c2b18a66ef87b9aac8a7b4 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Wed, 23 Jun 2021 14:17:10 -0400 Subject: [PATCH 15/16] fix expire description --- includes/classes/AdminCustomizations/PasswordPolicy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/classes/AdminCustomizations/PasswordPolicy.php b/includes/classes/AdminCustomizations/PasswordPolicy.php index 1f7d078..7c66723 100644 --- a/includes/classes/AdminCustomizations/PasswordPolicy.php +++ b/includes/classes/AdminCustomizations/PasswordPolicy.php @@ -72,7 +72,7 @@ public function register_password_policy_settings() { 'reminder' => [ 'label' => __( 'Send Password Reminder', 'tenup' ), 'type' => 'number', - 'description' => __( 'The number of days before a password need to be changed to send a reminder email.', 'tenup' ), + 'description' => __( 'The number of days before a password expires to send an email reminder.', 'tenup' ), ], 'past_passwords' => [ 'label' => __( 'Past Passwords', 'tenup' ), From ed77f1d280435b2ff3ed5883133b544dc46bb8c1 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Date: Thu, 24 Jun 2021 08:58:33 -0400 Subject: [PATCH 16/16] rollback version --- 10up-experience.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/10up-experience.php b/10up-experience.php index 9d421e1..c514101 100644 --- a/10up-experience.php +++ b/10up-experience.php @@ -2,7 +2,7 @@ /** * Plugin Name: 10up Experience * Description: The 10up Experience plugin configures WordPress to better protect and inform clients, aligned to 10up’s best practices. - * Version: 1.9.0 + * Version: 1.8.0 * Author: 10up * Author URI: https://10up.com * License: GPLv2 or later