on( 'enable' ) ) { return; } // Shorten how long a session is valid for at all. $this->filter( 'auth_cookie_expiration', [$this, 'cookie_lifetime'], 10, 3 ); // Idle timeout. if ( $this->idle_minutes() > 0 ) { $this->action( 'init', [$this, 'enforce_idle_timeout'], 1 ); } // Explain the sign-out on the login screen. $this->filter( 'login_message', [$this, 'timeout_notice'] ); // Invalidate every other session when the password changes, so a // stolen session cannot survive the response to it. if ( $this->on( 'logout-on-password-change' ) ) { $this->action( 'after_password_reset', [$this, 'destroy_other_sessions'], 10, 1 ); $this->action( 'profile_update', [$this, 'maybe_destroy_on_profile_update'], 10, 2 ); } // Refuse to serve a session from a different address than it was // issued to. if ( $this->on( 'bind-to-ip' ) ) { $this->action( 'init', [$this, 'enforce_ip_binding'], 2 ); } } /* --------------------------------------------------------------------- * Session lifetime * ------------------------------------------------------------------- */ /** * Cap how long an auth cookie stays valid. * * @param int $length Default length in seconds. * @param int $user_id User ID. * @param bool $remember Whether "Remember Me" was ticked. * * @return int */ public function cookie_lifetime( $length, $user_id = 0, $remember = false ) { $hours = $remember ? $this->remember_hours() : $this->session_hours(); if ( $hours <= 0 ) { return $length; } return $hours * HOUR_IN_SECONDS; } /* --------------------------------------------------------------------- * Idle timeout * ------------------------------------------------------------------- */ /** * Sign out a session that has been idle too long. * * @return void */ public function enforce_idle_timeout() { if ( !is_user_logged_in() ) { return; } $user_id = get_current_user_id(); $limit = $this->idle_minutes() * MINUTE_IN_SECONDS; $last = (int) get_user_meta( $user_id, self::LAST_SEEN_META, true ); $now = time(); // A background request must never sign anyone out mid-flight, but it is // still evidence that someone is working. The block editor, the media // library and the Customizer talk to the site over REST and AJAX for // long stretches without a single full page load, so ignoring those // requests entirely signed out people who were actively editing. if ( $this->is_exempt_request() ) { if ( $this->is_user_activity_request() && ( !$last || ( $now - $last ) > self::TOUCH_INTERVAL ) ) { update_user_meta( $user_id, self::LAST_SEEN_META, $now ); } return; } if ( $last && ( $now - $last ) > $limit ) { delete_user_meta( $user_id, self::LAST_SEEN_META ); wp_logout(); // Send them to the login screen with an explanation rather than a // silent bounce to the home page. wp_safe_redirect( add_query_arg( 'tpsa_timeout', '1', wp_login_url() ) ); exit; } // Throttled write: at most one update per TOUCH_INTERVAL. if ( !$last || ( $now - $last ) > self::TOUCH_INTERVAL ) { update_user_meta( $user_id, self::LAST_SEEN_META, $now ); } } /* --------------------------------------------------------------------- * IP binding * ------------------------------------------------------------------- */ /** * End a session that arrives from a different address than it started on. * * Off by default: mobile networks and some ISPs change address mid-session, * which would sign those users out repeatedly. It is a strong control on a * site whose administrators work from fixed connections. * * @return void */ public function enforce_ip_binding() { if ( !is_user_logged_in() || $this->is_exempt_request() ) { return; } $manager = \WP_Session_Tokens::get_instance( get_current_user_id() ); $token = wp_get_session_token(); if ( !$token ) { return; } $session = $manager->get( $token ); if ( !$session ) { return; } $current_ip = tpsa_get_client_ip(); // First request on this session: remember where it came from. if ( empty( $session['tpsa_ip'] ) ) { $session['tpsa_ip'] = $current_ip; $manager->update( $token, $session ); return; } if ( hash_equals( (string) $session['tpsa_ip'], $current_ip ) ) { return; } ThreatLog::report( 'session_ip_change', 'was ' . $session['tpsa_ip'] ); $manager->destroy( $token ); wp_logout(); wp_safe_redirect( add_query_arg( 'tpsa_timeout', 'ip', wp_login_url() ) ); exit; } /* --------------------------------------------------------------------- * Password changes * ------------------------------------------------------------------- */ /** * Drop every other session for a user after a password reset. * * @param \WP_User $user User whose password changed. * @return void */ public function destroy_other_sessions( $user ) { $user_id = ( $user instanceof \WP_User ) ? (int) $user->ID : (int) $user; if ( $user_id <= 0 ) { return; } \WP_Session_Tokens::get_instance( $user_id )->destroy_all(); } /** * Same, when the password is changed from the profile screen. * * @param int $user_id User ID. * @param \WP_User $old_user_data Data before the update. * * @return void */ public function maybe_destroy_on_profile_update( $user_id, $old_user_data = null ) { if ( !( $old_user_data instanceof \WP_User ) ) { return; } $new = get_userdata( $user_id ); if ( !$new || $new->user_pass === $old_user_data->user_pass ) { return; } // Keep the session that made the change; end all the others. if ( (int) $user_id === get_current_user_id() ) { wp_destroy_other_sessions(); return; } \WP_Session_Tokens::get_instance( (int) $user_id )->destroy_all(); } /* --------------------------------------------------------------------- * Helpers * ------------------------------------------------------------------- */ /** * Requests that must never be interrupted mid-flight. * * @return bool */ private function is_exempt_request() { return wp_doing_cron() || wp_doing_ajax() || ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ); } /** * Whether a background request represents a person actually using the site. * * @return bool */ private function is_user_activity_request() { // Nobody is at the keyboard for these. if ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { return false; } // Heartbeat polls on a timer for as long as a tab is open, and autosaves // run on their own schedule. Counting either as activity would keep an // unattended screen signed in forever, which is the exact thing the idle // timeout exists to prevent. if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return false; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reading the action name only, no state change. $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( 'heartbeat' === $action ) { return false; } // Only a cookie-authenticated request comes from a browser session. // Application passwords have no session to keep alive, so an integration // polling the REST API must not extend a human's sign-in. return '' !== (string) wp_get_session_token(); } /* --------------------------------------------------------------------- * Login screen * ------------------------------------------------------------------- */ /** * Tell the user why they were signed out. * * enforce_idle_timeout() and enforce_ip_binding() both redirect to the login * screen with `tpsa_timeout` set; without this the sign-out looks like the * session simply vanished. * * @param string $message Existing login screen message markup. * @return string */ public function timeout_notice( $message ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag on the login screen. $reason = isset( $_GET['tpsa_timeout'] ) ? sanitize_key( wp_unslash( $_GET['tpsa_timeout'] ) ) : ''; if ( '' === $reason ) { return $message; } $text = 'ip' === $reason ? __( 'You were signed out because your session was used from a different IP address. Please sign in again.', 'admin-safety-guard' ) : __( 'You were signed out after a period of inactivity. Please sign in again.', 'admin-safety-guard' ); return $message . '

' . esc_html( $text ) . '

'; } private function idle_minutes() { $settings = $this->get_settings(); $value = isset( $settings['idle-minutes'] ) ? (int) $settings['idle-minutes'] : 0; return $value > 0 ? max( 5, min( 1440, $value ) ) : 0; } private function session_hours() { $settings = $this->get_settings(); $value = isset( $settings['session-hours'] ) ? (int) $settings['session-hours'] : 0; return $value > 0 ? max( 1, min( 720, $value ) ) : 0; } private function remember_hours() { $settings = $this->get_settings(); $value = isset( $settings['remember-hours'] ) ? (int) $settings['remember-hours'] : 0; return $value > 0 ? max( 1, min( 2160, $value ) ) : 0; } private function on( $key ) { $settings = $this->get_settings(); return !empty( $settings[$key] ); } private function get_settings() { if ( null === $this->settings ) { $settings = get_option( get_tpsa_settings_option_name( $this->features_id ), [] ); $this->settings = is_array( $settings ) ? $settings : []; } return $this->settings; } }