PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.0
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / component / class-session-protection.php

class-session-protection.php in Defender Security – Malware Scanner, Login Security & Firewall 6.3.0, at src/component/class-session-protection.php

518 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles session protection functionality within the WP Defender plugin.
4 *
5 * @package WP_Defender\Component
6 * @since 5.2.0
7 */
8
9 namespace WP_Defender\Component;
10
11 use WP_Defender\Component;
12 use WP_Defender\Traits\IP;
13 use WP_Defender\Traits\User;
14 use WP_User_Meta_Session_Tokens;
15 use WP_Defender\Model\Setting\Session_Protection as Settings;
16
17 /**
18 * This class handles session protection functionality within the WP Defender plugin.
19 */
20 class Session_Protection extends Component {
21 use IP;
22 use User;
23
24 /**
25 * Key to lock the session to the browser.
26 */
27 const LOCK_BROWSER = 'browser';
28
29 /**
30 * Key to lock the session to the hostname.
31 */
32 const LOCK_HOSTNAME = 'hostname';
33
34 /**
35 * Key to lock the session to the IP address.
36 */
37 const LOCK_IP_ADDRESS = 'ip_address';
38
39 /**
40 * The name of the log file.
41 *
42 * @var string
43 */
44 const LOG_FILE_NAME = 'session-protection';
45
46 /**
47 * Transient key to show custom logout message in the login modal.
48 *
49 * @var string
50 */
51 const LOGOUT_MSG_TRANSIENT_KEY = 'wpdef_session_logout_msg';
52
53 /**
54 * The session protection settings.
55 *
56 * @var Settings|null
57 */
58 protected ?Settings $settings;
59
60 /**
61 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
62 *
63 * @return void
64 */
65 public function __construct() {
66 $this->settings = wd_di()->get( Settings::class );
67 }
68
69 /**
70 * Retrieve an array of default session lock properties.
71 *
72 * @return array An array of session lock properties.
73 */
74 public static function session_lock_properties(): array {
75 return array(
76 self::LOCK_BROWSER => esc_html__( 'Browser', 'defender-security' ),
77 self::LOCK_HOSTNAME => esc_html__( 'Hostname', 'defender-security' ),
78 self::LOCK_IP_ADDRESS => esc_html__( 'IP Address', 'defender-security' ),
79 );
80 }
81
82 /**
83 * Prepare details of the Session Lock property for MP event.
84 * No need to translate.
85 *
86 * @return string
87 */
88 public function get_session_lock_string(): string {
89 if ( ! $this->settings->has_properties() ) {
90 return 'Not Set';
91 }
92 $checked_properties = array();
93 if ( $this->settings->is_property_locked( self::LOCK_IP_ADDRESS ) ) {
94 $checked_properties[] = 'IP';
95 }
96 if ( $this->settings->is_property_locked( self::LOCK_HOSTNAME ) ) {
97 $checked_properties[] = 'Hostname';
98 }
99 if ( $this->settings->is_property_locked( self::LOCK_BROWSER ) ) {
100 $checked_properties[] = 'Browser';
101 }
102
103 return implode( '/', $checked_properties );
104 }
105
106 /**
107 * Handles session timeout logic on init hook.
108 *
109 * @return void
110 */
111 public function handle_session_timeout() {
112 if (
113 ! is_user_logged_in()
114 || wp_doing_ajax()
115 || wp_doing_cron()
116 || defender_is_rest_api_request()
117 || $this->request_is_from_server()
118 ) {
119 return;
120 }
121
122 if ( ! $this->can_apply_session_protection() ) {
123 return;
124 }
125
126 if ( $this->settings->has_properties() ) {
127 $this->handle_session_lock();
128 return;
129 }
130
131 $idle_timeout = $this->get_idle_timeout();
132 $last_activity = $this->get_last_activity();
133
134 if ( $last_activity && ( defender_get_current_time() - $last_activity > $idle_timeout ) ) {
135 $this->log(
136 sprintf( 'User session timed out due to inactivity during %s hours', $this->settings->idle_timeout ),
137 self::LOG_FILE_NAME
138 );
139 $user_id = get_current_user_id();
140 // Fires during session timeout.
141 do_action( 'wpdef_session_timeout', $user_id );
142 $this->logout( $user_id );
143
144 return;
145 }
146 $this->update_last_activity();
147 }
148
149 /**
150 * Get transient key for server-side session last activity.
151 *
152 * @param int|null $user_id User ID.
153 * @return string|null
154 */
155 private function get_session_activity_key( $user_id = null ): ?string {
156 $user_id = $user_id ?? get_current_user_id();
157 if ( ! $user_id ) {
158 return null;
159 }
160
161 $token = wp_get_session_token();
162 if ( ! $token ) {
163 return 'wpdef_last_act_' . $user_id;
164 }
165
166 return 'wpdef_last_act_' . $user_id . '_' . substr( md5( $token ), 0, 16 );
167 }
168
169 /**
170 * Retrieves the last activity timestamp.
171 *
172 * @return int|null Last activity timestamp or null if not set.
173 */
174 private function get_last_activity() {
175 // First check server-side session state if available to prevent client tampering.
176 $key = $this->get_session_activity_key();
177 if ( $key ) {
178 $server_activity = get_transient( $key );
179 if ( is_numeric( $server_activity ) && (int) $server_activity > 0 ) {
180 return (int) $server_activity;
181 }
182 }
183
184 // Fallback to client-side cookie if server transient is missing.
185 if ( isset( $_COOKIE['wpdef_last_activity'] ) && is_numeric( $_COOKIE['wpdef_last_activity'] ) ) {
186 $timestamp = (int) $_COOKIE['wpdef_last_activity'];
187 $now = time();
188
189 // Clamp future timestamps to current server time to prevent session timeout bypass.
190 return min( $timestamp, $now );
191 }
192
193 return null;
194 }
195
196 /**
197 * Logs out the current user due to inactivity.
198 *
199 * @param int|null $user_id User ID.
200 *
201 * @return void
202 */
203 public function logout( $user_id = null ) {
204 if ( is_user_logged_in() ) {
205 // Case without $user_id may be from Ajax.
206 if ( is_null( $user_id ) ) {
207 $user_id = get_current_user_id();
208 $this->log(
209 sprintf( 'User session timed out due to inactivity during %s hours', $this->settings->idle_timeout ),
210 self::LOG_FILE_NAME
211 );
212 // Fires during session timeout.
213 do_action( 'wpdef_session_timeout', $user_id );
214 }
215
216 $key = $this->get_session_activity_key( $user_id );
217 if ( $key ) {
218 delete_transient( $key );
219 }
220
221 // Get current session token.
222 $current_session_token = wp_get_session_token();
223 if ( $current_session_token ) {
224 // Get session manager for the user.
225 $session_manager = \WP_Session_Tokens::get_instance( $user_id );
226
227 // Destroy ONLY the current session.
228 $session_manager->destroy( $current_session_token );
229 } else {
230 $manager = \WP_Session_Tokens::get_instance( $user_id );
231 $manager->destroy_all();
232 }
233
234 // Reset last activity cookie.
235 setcookie( 'wpdef_last_activity', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
236
237 // Set transient to show custom message in the login modal.
238 set_site_transient( self::LOGOUT_MSG_TRANSIENT_KEY, 1, 30 );
239
240 // Redirect to login page after session destruction.
241 wp_safe_redirect( wp_login_url() );
242 }
243 }
244
245 /**
246 * Show custom message in the login modal when session is expired.
247 *
248 * @param \WP_Error $errors WP Error object.
249 *
250 * @return \WP_Error
251 */
252 public function login_modal_message( $errors ) {
253 $interim_login = defender_get_data_from_request( 'interim-login', 'g' );
254 if ( is_null( $interim_login ) || '' === $interim_login || 1 !== get_site_transient( self::LOGOUT_MSG_TRANSIENT_KEY ) ) {
255 return $errors;
256 }
257
258 delete_site_transient( self::LOGOUT_MSG_TRANSIENT_KEY );
259
260 if ( ! ( $errors instanceof \WP_Error ) ) {
261 return $errors;
262 }
263
264 $error_keys = array( 'expired', 'expired_session' );
265 foreach ( $error_keys as $key ) {
266 if ( $errors->get_error_message( $key ) ) {
267 $errors->remove( $key );
268 $errors->add(
269 $key,
270 __( 'You\'ve been logged out due to inactivity. Please sign in to continue.', 'defender-security' ),
271 'message'
272 );
273
274 break;
275 }
276 }
277
278 return $errors;
279 }
280
281 /**
282 * Styles for the custom message in the login modal.
283 *
284 * @return void
285 */
286 public function login_modal_message_styles(): void {
287 if ( 1 === get_site_transient( self::LOGOUT_MSG_TRANSIENT_KEY ) ) {
288 // Don't delete the transient here, it will be deleted in the login_modal_message() method.
289
290 echo '<style>
291 #login_error {
292 border-left-color: #72aee6;
293 }
294 </style>';
295 }
296 }
297
298 /**
299 * Enqueues JavaScript for client-side idle detection.
300 *
301 * @return void
302 */
303 public function enqueue_idle_scripts() {
304 if ( is_user_logged_in() && $this->can_apply_session_protection() ) {
305 wp_enqueue_script( 'wpdef-session-idle', plugins_url( 'assets/js/idle-session.js', WP_DEFENDER_FILE ), array( 'jquery' ), DEFENDER_VERSION, true );
306 wp_localize_script(
307 'wpdef-session-idle',
308 'wpdef_idle_params',
309 array(
310 'timeout' => $this->get_idle_timeout(),
311 'ajax_url' => admin_url( 'admin-ajax.php' ),
312 'login_url' => wp_login_url(),
313 // Exclude sending Logout-callbacks if the user is logged out and the login form is displayed in the popup.
314 'stop' => false,
315 )
316 );
317 }
318 }
319
320 /**
321 * Retrieves the idle timeout duration.
322 *
323 * @return int The idle timeout duration in seconds.
324 */
325 public function get_idle_timeout() {
326 /**
327 * Filter the idle timeout duration in seconds.
328 *
329 * @param int $idle_timeout The idle timeout duration in seconds.
330 *
331 * @return int The idle timeout duration in seconds.
332 */
333 $idle_timeout = apply_filters( 'wpdef_idle_timeout', $this->settings->idle_timeout * HOUR_IN_SECONDS );
334 return ! is_int( $idle_timeout ) ? (int) $idle_timeout : $idle_timeout;
335 }
336
337 /**
338 * Updates the last activity timestamp efficiently.
339 *
340 * @return void
341 */
342 public function update_last_activity() {
343 $current_time = time();
344
345 $key = $this->get_session_activity_key();
346 if ( $key ) {
347 set_transient( $key, $current_time, DAY_IN_SECONDS );
348 }
349
350 $last_activity = $this->get_last_activity();
351
352 // Only update the cookie if minutes have passed since the last update.
353 if ( ! is_null( $last_activity ) || ( $current_time - $last_activity > MINUTE_IN_SECONDS ) ) {
354 setcookie( 'wpdef_last_activity', $current_time, time() + HOUR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
355 }
356 }
357
358 /**
359 * Attach session information to the current user session.
360 *
361 * @param array $session Array of extra data.
362 *
363 * @return array Array of extra data.
364 */
365 public function attach_session_information( $session ) {
366 if ( $this->settings->is_property_locked( self::LOCK_IP_ADDRESS ) ) {
367 $session['defender_ip'] = implode( '|', $this->get_user_ip() );
368 }
369
370 if ( $this->settings->is_property_locked( self::LOCK_HOSTNAME ) ) {
371 $session['defender_hostname'] = defender_get_domain();
372 }
373
374 if ( $this->settings->is_property_locked( self::LOCK_BROWSER ) ) {
375 $session['defender_browser'] = defender_get_user_agent();
376 }
377
378 return $session;
379 }
380
381 /**
382 * Handles session lock logic.
383 *
384 * @return void
385 */
386 public function handle_session_lock() {
387 $token = wp_get_session_token();
388 $user_id = get_current_user_id();
389 $manager = WP_User_Meta_Session_Tokens::get_instance( $user_id );
390 $session_data = $manager->get( $token );
391 if ( ! is_array( $session_data ) ) {
392 $this->log( 'Session data is missing or malformed. Destroying all sessions.', self::LOG_FILE_NAME );
393 $this->logout( $user_id );
394 return;
395 }
396 $user_ips = $this->get_user_ip();
397 // Exclude Loopback case. If condition returns true, then there is no Loopback intersection.
398 $common_ips = array_intersect( $user_ips, $this->get_localhost_ips() );
399 if ( array() !== $common_ips ) {
400 return;
401 }
402 // Work with the Session Lock options.
403 if ( $this->settings->is_property_locked( self::LOCK_IP_ADDRESS ) && isset( $session_data['defender_ip'] ) ) {
404 $ips = implode( '|', $user_ips );
405 $this->is_ip_allowed( $ips, $session_data['defender_ip'], $user_id );
406 }
407
408 if ( $this->settings->is_property_locked( self::LOCK_HOSTNAME ) && isset( $session_data['defender_hostname'] ) ) {
409 $hostname = defender_get_domain();
410 $this->is_hostname_allowed( $hostname, $session_data['defender_hostname'], $user_id );
411 }
412
413 if ( $this->settings->is_property_locked( self::LOCK_BROWSER ) && isset( $session_data['defender_browser'] ) ) {
414 $browser = defender_get_user_agent();
415 $this->is_browser_allowed( $browser, $session_data['defender_browser'], $user_id );
416 }
417 }
418
419 /**
420 * Verifies if the given IP is allowed based on the session information.
421 *
422 * @param string $ip IP addresses to check.
423 * @param string $session_ip Session IP address.
424 * @param int $user_id User ID.
425 *
426 * @return void
427 */
428 private function is_ip_allowed( $ip, $session_ip, $user_id ) {
429 if ( '' === $ip || '' === $session_ip || $ip !== $session_ip ) {
430 $this->log(
431 sprintf(
432 'IP address mismatch detected. Detected: %s, Expected: %s.',
433 $ip,
434 $session_ip
435 ),
436 self::LOG_FILE_NAME
437 );
438 /**
439 * Fires during session lock.
440 *
441 * @param int $user_id
442 * @param string $session_lock_type
443 */
444 do_action( 'wpdef_session_lock', $user_id, 'IP address' );
445 $this->logout( $user_id );
446 }
447 }
448
449 /**
450 * Verifies if the given hostname is allowed based on the session information.
451 *
452 * @param string $hostname Hostname to check.
453 * @param string $session_hostname Session hostname.
454 * @param int $user_id User ID.
455 *
456 * @return void
457 */
458 private function is_hostname_allowed( $hostname, $session_hostname, $user_id ) {
459 if ( '' === $hostname || '' === $session_hostname || strcasecmp( $hostname, $session_hostname ) !== 0 ) {
460 $this->log(
461 sprintf(
462 'Hostname mismatch detected. Detected: %s, Expected: %s.',
463 $hostname,
464 $session_hostname
465 ),
466 self::LOG_FILE_NAME
467 );
468 do_action( 'wpdef_session_lock', $user_id, 'hostname' );
469 $this->logout( $user_id );
470 }
471 }
472
473 /**
474 * Verifies if the given browser is allowed based on the session information.
475 *
476 * @param string $browser Browser to check.
477 * @param string $session_browser Session browser.
478 * @param int $user_id User ID.
479 *
480 * @return void
481 */
482 private function is_browser_allowed( $browser, $session_browser, $user_id ) {
483 if ( '' === $browser || '' === $session_browser || strcasecmp( $browser, $session_browser ) !== 0 ) {
484 $this->log(
485 sprintf(
486 'Browser mismatch detected. Detected: %s, Expected: %s.',
487 $browser,
488 $session_browser
489 ),
490 self::LOG_FILE_NAME
491 );
492 do_action( 'wpdef_session_lock', $user_id, 'browser' );
493 $this->logout( $user_id );
494 }
495 }
496
497 /**
498 * Should we apply this feature for the current user?
499 *
500 * @return bool
501 */
502 public function can_apply_session_protection(): bool {
503 $current_user = $this->get_user( get_current_user_id() );
504
505 /**
506 * Filter whether to apply the feature for the current user.
507 *
508 * @param bool $apply Whether to apply the feature.
509 * @param \WP_User $current_user The current user object.
510 *
511 * @return bool
512 */
513 $protection = apply_filters( 'wpdef_toggle_session_protection', $this->should_enforce_for_user( $current_user, $this->settings ), $current_user );
514
515 return is_bool( $protection ) ? $protection : (bool) $protection;
516 }
517 }
518