int, // site default; 0 = off * 'roles' => array( * '' => array( * 'idle' => int|null, // null = use the default, 0 = exempt * 'limit' => int, // 0 = unlimited * ), * ), * ) * * A user with several roles gets the STRICTEST answer among them: the * smallest positive idle timeout and the smallest positive session limit. * An exempt role (idle 0) exempts nobody who also holds a role with a * timeout -- "exempt" is a property of the role, and a second role that is * not exempt is a second reason to time out, not a conflict to resolve in * the user's favour. * * Every number is bounded here, at the one place a value enters: the * idle timeout to [MIN_IDLE_MINUTES, MAX_IDLE_MINUTES] (or 0), the limit * to [0, MAX_SESSIONS]. A stored option that fails the shape is read as * "no policy", never as a partial one. */ final class SessionPolicy { public const OPTION = 'ops_activity_log_session_policy'; /** * The shortest idle timeout a site can set. Five minutes, because the * write throttle below needs room under the timeout (see * throttle_seconds()), and because a shorter one logs people out while * they are reading a long screen. */ public const MIN_IDLE_MINUTES = 5; /** Fourteen days: core's own "remember me" session length. */ public const MAX_IDLE_MINUTES = 20160; /** The highest per-role concurrent-session limit the form accepts. */ public const MAX_SESSIONS = 50; /** * The last-activity write throttle's bounds, in seconds. See * throttle_seconds() for how the two combine with the timeout. */ public const THROTTLE_MIN_SECONDS = 60; public const THROTTLE_MAX_SECONDS = 300; /** * The stored policy, normalised. Never throws, never returns a partial * shape. * * @return array{idle_minutes: int, roles: array} */ public static function get(): array { return self::sanitize( get_option( self::OPTION, array() ) ); } /** * Normalise anything into a valid policy. Used on read (a hand-edited or * older option) and on save (the form's POST), so the two can never * disagree about what a value means. * * Out-of-range positive timeouts are CLAMPED rather than refused: a site * owner who types 2 minutes wants the shortest timeout on offer, not a * silently-ignored field. Anything non-numeric or negative is 0 (off / * unlimited); a role override left blank is null (inherit). * * @param mixed $raw The stored option or the submitted form values. * @return array{idle_minutes: int, roles: array} */ public static function sanitize( $raw ): array { $raw = is_array( $raw ) ? $raw : array(); $roles = array(); if ( isset( $raw['roles'] ) && is_array( $raw['roles'] ) ) { foreach ( $raw['roles'] as $role => $entry ) { $role = sanitize_key( (string) $role ); if ( '' === $role || ! is_array( $entry ) ) { continue; } $idle = array_key_exists( 'idle', $entry ) ? self::idle_or_inherit( $entry['idle'] ) : null; $limit = isset( $entry['limit'] ) ? self::limit( $entry['limit'] ) : 0; // A role entry that says nothing is not stored at all. if ( null === $idle && 0 === $limit ) { continue; } $roles[ $role ] = array( 'idle' => $idle, 'limit' => $limit, ); } } ksort( $roles ); return array( 'idle_minutes' => isset( $raw['idle_minutes'] ) ? self::idle_minutes( $raw['idle_minutes'] ) : 0, 'roles' => $roles, ); } /** * The idle timeout that applies to this user, in seconds; 0 = none. * * @param \WP_User $user The signed-in user. */ public static function idle_seconds_for( \WP_User $user ): int { $policy = self::get(); $roles = array_values( (array) $user->roles ); if ( array() === $roles ) { return $policy['idle_minutes'] * MINUTE_IN_SECONDS; } $strictest = 0; foreach ( $roles as $role ) { $minutes = $policy['roles'][ $role ]['idle'] ?? null; $minutes = null === $minutes ? $policy['idle_minutes'] : $minutes; if ( $minutes > 0 && ( 0 === $strictest || $minutes < $strictest ) ) { $strictest = $minutes; } } return $strictest * MINUTE_IN_SECONDS; } /** * How many sessions this user may hold at once; 0 = unlimited. * * @param \WP_User $user The user signing in. */ public static function session_limit_for( \WP_User $user ): int { $policy = self::get(); $strictest = 0; foreach ( (array) $user->roles as $role ) { $limit = $policy['roles'][ $role ]['limit'] ?? 0; if ( $limit > 0 && ( 0 === $strictest || $limit < $strictest ) ) { $strictest = $limit; } } return $strictest; } /** * Whether any idle timeout is configured anywhere. Last-activity is * only ever recorded while this is true -- no policy, no tracking. */ public static function idle_policy_configured(): bool { $policy = self::get(); if ( $policy['idle_minutes'] > 0 ) { return true; } foreach ( $policy['roles'] as $entry ) { if ( null !== $entry['idle'] && $entry['idle'] > 0 ) { return true; } } return false; } /** * How often, at most, a session's last-activity is written: a fifth of * the timeout, clamped to [60 s, 300 s]. * * The throttle is what keeps this feature from writing user meta on * every request. Its cost is precision: a recorded last-activity can lag * the real one by up to one throttle interval. The enforcer pays that * lag at the END -- a session is ended only once it has been idle for * the timeout PLUS the throttle, measured from the recorded time -- so * it is never ended early, and ended at most one throttle interval late * (at most 20% of the timeout, and never more than five minutes). * * @param int $idle_seconds The applicable timeout. */ public static function throttle_seconds( int $idle_seconds ): int { return max( self::THROTTLE_MIN_SECONDS, min( self::THROTTLE_MAX_SECONDS, intdiv( $idle_seconds, 5 ) ) ); } /** * @param mixed $value Submitted or stored minutes. */ private static function idle_minutes( $value ): int { $minutes = is_numeric( $value ) ? (int) $value : 0; if ( $minutes <= 0 ) { return 0; } return max( self::MIN_IDLE_MINUTES, min( self::MAX_IDLE_MINUTES, $minutes ) ); } /** * @param mixed $value Submitted or stored per-role minutes; blank = inherit. */ private static function idle_or_inherit( $value ): ?int { if ( null === $value || '' === $value || ( is_string( $value ) && '' === trim( $value ) ) ) { return null; } if ( ! is_numeric( $value ) ) { return null; } return self::idle_minutes( $value ); } /** * @param mixed $value Submitted or stored limit. */ private static function limit( $value ): int { $limit = is_numeric( $value ) ? (int) $value : 0; return max( 0, min( self::MAX_SESSIONS, $limit ) ); } }