Last active
July 31, 2026 13:02
-
-
Save apermo/d97512cae6e158136d593f3f7fc7fe7e to your computer and use it in GitHub Desktop.
MU Plugin: signs all users out at a set time, warns beforehand with a JS countdown, and restricts logins to chosen user IDs, roles or super admins for the maintenance window
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Timed Maintenance Lockdown. | |
| * | |
| * Plugin Name: Timed Maintenance Lockdown | |
| * Description: Locks everyone out of a site at a set time, warns beforehand with a live countdown, and restricts | |
| * logins to chosen user IDs, roles or super admins until the maintenance window closes. | |
| * Version: 1.0.0 | |
| * Author: Christoph Daum | |
| * Requires PHP: 8.3 | |
| * | |
| * Drop this single file into wp-content/mu-plugins/ and edit the constants at the top of the class. Nothing is | |
| * written to the database: no options, no cron events, no cleanup. Once MAINTENANCE_END has passed the plugin does | |
| * nothing at all, so a forgotten file is harmless - though deleting it is still the tidier ending. | |
| * | |
| * Sessions are not destroyed, only ignored. Locked out users are treated as anonymous for the duration of the | |
| * window and are signed back in by their existing cookie the moment it ends, with no second login. If you need the | |
| * sessions themselves gone - a leaked password, a migration that rewrites user data - this is the wrong tool; call | |
| * WP_Session_Tokens::destroy_all() for the accounts in question instead. | |
| * | |
| * @package MaintenanceLockdown | |
| */ | |
| declare(strict_types=1); | |
| namespace Apermo; | |
| use DateTimeImmutable; | |
| use Exception; | |
| use WP_Error; | |
| use WP_User; | |
| if ( ! \defined( 'ABSPATH' ) ) { | |
| exit(); | |
| } | |
| /** | |
| * Locks users out at a set time and restricts logins for the duration of a maintenance window. | |
| * | |
| * @package MaintenanceLockdown | |
| */ | |
| final class Maintenance_Lockdown { | |
| /* | |
| * ----------------------------------------------------------------------- | |
| * Configuration - this is the only block you need to touch. | |
| * ----------------------------------------------------------------------- | |
| */ | |
| /** | |
| * Window start, read in the site's timezone (Settings -> General). | |
| * | |
| * Everyone outside the allowlist loses access at this moment. | |
| */ | |
| public const MAINTENANCE_START = '2026-08-15 22:00:00'; | |
| /** Window end. Until then, only the allowlist may log in. */ | |
| public const MAINTENANCE_END = '2026-08-16 02:00:00'; | |
| /** Users who keep their session and may still log in. Integers, no quotes. */ | |
| public const ALLOWED_USER_IDS = [ 1 ]; | |
| /** Roles that keep their session and may still log in. */ | |
| public const ALLOWED_ROLES = [ 'administrator' ]; | |
| /** Whether network super admins are always allowed. On single site this covers anyone holding delete_users. */ | |
| public const ALLOW_SUPER_ADMINS = true; | |
| /** How long before the start the warning notice and countdown appear. */ | |
| public const NOTICE_LEAD_TIME = 2 * \HOUR_IN_SECONDS; | |
| /** Below this remaining time the warning notice turns red. */ | |
| public const NOTICE_URGENT_THRESHOLD = 10 * \MINUTE_IN_SECONDS; | |
| /** | |
| * Whether the admin screen reloads itself when the countdown reaches zero. | |
| * | |
| * Off by default: a reload throws away unsaved block editor content, and the forced logout happens on the next | |
| * request either way. | |
| */ | |
| public const RELOAD_ON_LOGOUT = false; | |
| /** Date format used in every message. */ | |
| public const DISPLAY_FORMAT = 'D, d.m.Y H:i T'; | |
| /** Text shown in place of the countdown once it has run out. */ | |
| public const EXPIRED_LABEL = 'any moment now'; | |
| /* | |
| * ----------------------------------------------------------------------- | |
| * No configuration below this line. | |
| * ----------------------------------------------------------------------- | |
| */ | |
| /** | |
| * Resolved window as a start and end timestamp, null when the configuration is unusable. | |
| * | |
| * @var array|null | |
| */ | |
| private static ?array $window = null; | |
| /** | |
| * Whether the window has already been resolved. | |
| * | |
| * @var bool | |
| */ | |
| private static bool $resolved = false; | |
| /** | |
| * Whether the countdown script has already been emitted this request. | |
| * | |
| * @var bool | |
| */ | |
| private static bool $script_printed = false; | |
| /** | |
| * Registers the hooks, unless this request is exempt. | |
| * | |
| * @return void | |
| */ | |
| public static function init(): void { | |
| // WP-CLI is never locked out, so a mistake in the configuration can always be undone from the command line. | |
| if ( \defined( 'WP_CLI' ) && \WP_CLI ) { | |
| return; | |
| } | |
| add_filter( 'determine_current_user', [ self::class, 'maybe_revoke_session' ], 100 ); | |
| add_action( 'auth_redirect', [ self::class, 'maybe_block_admin' ] ); | |
| add_filter( 'authenticate', [ self::class, 'maybe_block_login' ], 31 ); | |
| add_action( 'admin_notices', [ self::class, 'render_notice' ] ); | |
| add_action( 'network_admin_notices', [ self::class, 'render_notice' ] ); | |
| add_filter( 'login_message', [ self::class, 'render_login_message' ] ); | |
| } | |
| /** | |
| * Treats everyone outside the allowlist as anonymous while the window is running. | |
| * | |
| * The auth cookie is deliberately left alone. Nobody is signed out, they are only not recognised for as long as | |
| * the window lasts, so the same cookie logs them straight back in afterwards without a second login. It also | |
| * covers every entry point at once - front end, admin, AJAX, REST and application passwords all resolve the | |
| * current user through this filter. | |
| * | |
| * @param mixed $user_id Resolved user ID, or false for an anonymous request. | |
| * | |
| * @return mixed | |
| */ | |
| public static function maybe_revoke_session( mixed $user_id ): mixed { | |
| // Core hands on an int or false, but the filter is public, so anything can arrive here. | |
| if ( ! \is_numeric( $user_id ) || (int) $user_id <= 0 ) { | |
| return $user_id; | |
| } | |
| if ( ! self::is_locked_down() ) { | |
| return $user_id; | |
| } | |
| // Building the object directly avoids re-entering this filter, which wp_get_current_user() would do. | |
| return self::is_user_allowed( new WP_User( (int) $user_id ) ) ? $user_id : false; | |
| } | |
| /** | |
| * Sends locked out users from an admin screen to the login form. | |
| * | |
| * Core validates the auth cookie inside auth_redirect() directly rather than going through | |
| * determine_current_user, so a locked out user passes it and would otherwise land on a bare "you are not | |
| * allowed" screen. This action fires there with the cookie's user ID, which is the moment to bounce them. | |
| * | |
| * @param int $user_id User ID the auth cookie resolves to. | |
| * | |
| * @return void | |
| */ | |
| public static function maybe_block_admin( int $user_id ): void { | |
| if ( ! self::is_locked_down() || self::is_user_allowed( new WP_User( $user_id ) ) ) { | |
| return; | |
| } | |
| wp_safe_redirect( wp_login_url() ); | |
| exit(); | |
| } | |
| /** | |
| * Rejects logins from users outside the allowlist while the window is running. | |
| * | |
| * Priority 31 is deliberate. Core authenticates at 20 (wp_authenticate_username_password, | |
| * wp_authenticate_email_password, wp_authenticate_application_password) and at 30 (wp_authenticate_cookie), so | |
| * 31 is the first slot where every core authenticator has had its say and $user is a resolved WP_User. | |
| * | |
| * Security trade-off: this only fires once the credentials have already been accepted, so the message below | |
| * confirms to the visitor that the password was correct. That is intentional - a locked out colleague should | |
| * not be left believing their password broke - but a brute forcer learns the same thing, which a generic | |
| * failure would not reveal. Put the login screen behind HTTP auth, an IP allowlist or a rate limiter if that | |
| * matters for your site. | |
| * | |
| * @param mixed $user Null, WP_User or WP_Error, depending on what the previous authenticators returned. Typed | |
| * as mixed rather than as a union, because third party authenticators may hand on anything. | |
| * | |
| * @return mixed | |
| */ | |
| public static function maybe_block_login( mixed $user ): mixed { | |
| if ( ! $user instanceof WP_User || ! self::is_locked_down() || self::is_user_allowed( $user ) ) { | |
| return $user; | |
| } | |
| $window = self::window(); | |
| return new WP_Error( | |
| 'maintenance_lockdown_blocked', | |
| \sprintf( | |
| '<strong>Maintenance in progress.</strong><br>Hello %1$s, your credentials are correct, but %2$s ' | |
| . 'is locked for maintenance until %3$s. Logins are restricted to the maintenance team until ' | |
| . 'then.', | |
| esc_html( $user->display_name ), | |
| esc_html( get_bloginfo( 'name' ) ), | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ), | |
| ), | |
| ); | |
| } | |
| /** | |
| * Displays the warning countdown, and the running state for whoever is still allowed in. | |
| * | |
| * @return void | |
| */ | |
| public static function render_notice(): void { | |
| $window = self::window(); | |
| if ( $window === null ) { | |
| wp_admin_notice( | |
| '<strong>Maintenance Lockdown:</strong> the configured window is invalid. Check MAINTENANCE_START ' | |
| . 'and MAINTENANCE_END in mu-plugins/mu-maintenance-lockdown.php.', | |
| [ 'type' => 'error' ], | |
| ); | |
| return; | |
| } | |
| $current = \time(); | |
| if ( $current >= $window[1] ) { | |
| return; | |
| } | |
| // Anyone still looking at an admin screen during the window is on the allowlist by definition. | |
| if ( $current >= $window[0] ) { | |
| wp_admin_notice( | |
| \sprintf( | |
| '<strong>Maintenance mode is active.</strong> Everyone outside the maintenance team is locked ' | |
| . 'out and regains access at %1$s, in %2$s.', | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ), | |
| self::countdown_markup( $window[1] - $current ), | |
| ), | |
| [ 'type' => 'info' ], | |
| ); | |
| echo self::countdown_script(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by wp_get_inline_script_tag(). | |
| return; | |
| } | |
| $remaining = $window[0] - $current; | |
| if ( $remaining <= self::NOTICE_LEAD_TIME ) { | |
| self::render_pending_notice( $window, $remaining ); | |
| } | |
| } | |
| /** | |
| * Prepends the maintenance window to the login form. | |
| * | |
| * @param string $message Message rendered by core and other plugins. | |
| * | |
| * @return string | |
| */ | |
| public static function render_login_message( string $message ): string { | |
| $window = self::window(); | |
| if ( $window === null ) { | |
| return $message; | |
| } | |
| $current = \time(); | |
| if ( $current >= $window[1] ) { | |
| return $message; | |
| } | |
| if ( $current >= $window[0] ) { | |
| $notice = \sprintf( | |
| '<strong>Maintenance in progress.</strong><br>%1$s is locked from %2$s until %3$s. Only the ' | |
| . 'maintenance team can sign in right now.', | |
| esc_html( get_bloginfo( 'name' ) ), | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ), | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ), | |
| ); | |
| } elseif ( $window[0] - $current <= self::NOTICE_LEAD_TIME ) { | |
| $notice = \sprintf( | |
| '<strong>Scheduled maintenance.</strong><br>%1$s goes into maintenance at %2$s, in %3$s. Everyone ' | |
| . 'outside the maintenance team will be locked out.', | |
| esc_html( get_bloginfo( 'name' ) ), | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ), | |
| self::countdown_markup( $window[0] - $current ), | |
| ) . self::countdown_script(); | |
| } else { | |
| return $message; | |
| } | |
| // Appended rather than replaced, so password reset and interim-login messaging survives. | |
| return $message . '<div class="message">' . $notice . '</div>'; | |
| } | |
| /** | |
| * Displays the countdown that runs up to the start of the window. | |
| * | |
| * The allowlist keeps working throughout, so the two audiences get different wording: warning the maintenance | |
| * team about a lockout that will not apply to them would be misleading, and they have no work to save. | |
| * | |
| * @param array $window Start and end timestamp of the window. | |
| * @param int $remaining Seconds left until it opens. | |
| * | |
| * @return void | |
| */ | |
| private static function render_pending_notice( array $window, int $remaining ): void { | |
| if ( self::is_user_allowed( wp_get_current_user() ) ) { | |
| $message = \sprintf( | |
| '<strong>Scheduled maintenance at %1$s.</strong> Everyone outside the maintenance team will be ' | |
| . 'locked out in %2$s. Your own access is unaffected.', | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ), | |
| self::countdown_markup( $remaining ), | |
| ); | |
| $type = 'info'; | |
| } else { | |
| $message = \sprintf( | |
| '<strong>Scheduled maintenance at %1$s.</strong> You will lose access in %2$s and get it back ' | |
| . 'automatically at %3$s, without logging in again. Please save your work.', | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ), | |
| self::countdown_markup( $remaining ), | |
| esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ), | |
| ); | |
| $type = $remaining <= self::NOTICE_URGENT_THRESHOLD ? 'error' : 'warning'; | |
| } | |
| wp_admin_notice( $message, [ 'type' => $type ] ); | |
| echo self::countdown_script(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by wp_get_inline_script_tag(). | |
| } | |
| /** | |
| * Resolves the configured window to a pair of Unix timestamps. | |
| * | |
| * Reading the constants through wp_timezone() keeps them in site time and avoids the UTC drift that hardcoded | |
| * mktime() values suffer from. The lookup is deferred and memoised because wp_timezone() calls get_option(), | |
| * which should not happen while mu-plugins are still loading. | |
| * | |
| * @return array|null Start and end timestamp, or null when the configuration is unusable. | |
| */ | |
| private static function window(): ?array { | |
| if ( self::$resolved ) { | |
| return self::$window; | |
| } | |
| self::$resolved = true; | |
| try { | |
| $timezone = wp_timezone(); | |
| $start_date = new DateTimeImmutable( self::MAINTENANCE_START, $timezone ); | |
| $end_date = new DateTimeImmutable( self::MAINTENANCE_END, $timezone ); | |
| } catch ( Exception ) { | |
| return self::$window; | |
| } | |
| if ( $end_date > $start_date ) { | |
| self::$window = [ $start_date->getTimestamp(), $end_date->getTimestamp() ]; | |
| } | |
| return self::$window; | |
| } | |
| /** | |
| * Determines whether the maintenance window is running right now. | |
| * | |
| * @return bool | |
| */ | |
| private static function is_locked_down(): bool { | |
| $window = self::window(); | |
| if ( $window === null ) { | |
| return false; | |
| } | |
| $current = \time(); | |
| return $current >= $window[0] && $current < $window[1]; | |
| } | |
| /** | |
| * Determines whether a user is exempt from the lockdown. | |
| * | |
| * @param WP_User $user User to test. | |
| * | |
| * @return bool | |
| */ | |
| private static function is_user_allowed( WP_User $user ): bool { | |
| if ( ! $user->exists() ) { | |
| return false; | |
| } | |
| // Cast every entry, so a quoted ID in the configuration does not silently lock someone out. | |
| foreach ( self::ALLOWED_USER_IDS as $allowed_id ) { | |
| if ( (int) $allowed_id === $user->ID ) { | |
| return true; | |
| } | |
| } | |
| if ( self::ALLOW_SUPER_ADMINS && is_super_admin( $user->ID ) ) { | |
| return true; | |
| } | |
| // On multisite $user->roles holds the roles for the site being requested, not a network wide set. | |
| return (bool) \array_intersect( self::ALLOWED_ROLES, $user->roles ); | |
| } | |
| /** | |
| * Builds the countdown placeholder. | |
| * | |
| * The value is rendered server side and carried in a data attribute, so it stays correct without JavaScript and | |
| * never flashes an empty element. | |
| * | |
| * @param int $remaining Seconds left until the window opens. | |
| * | |
| * @return string | |
| */ | |
| private static function countdown_markup( int $remaining ): string { | |
| $remaining = \max( 0, $remaining ); | |
| return \sprintf( | |
| '<span class="apermo-lockdown-countdown" data-remaining="%1$d">%2$s</span>', | |
| $remaining, | |
| esc_html( self::format_duration( $remaining ) ), | |
| ); | |
| } | |
| /** | |
| * Builds the countdown script tag, once per request. | |
| * | |
| * @return string | |
| */ | |
| private static function countdown_script(): string { | |
| if ( self::$script_printed ) { | |
| return ''; | |
| } | |
| self::$script_printed = true; | |
| return wp_get_inline_script_tag( self::countdown_javascript() ); | |
| } | |
| /** | |
| * Builds the countdown JavaScript. | |
| * | |
| * The clock starts from the seconds remaining according to the server and advances with performance.now(), | |
| * which is monotonic. A workstation with a wrong or shifting system clock still shows the correct countdown. | |
| * | |
| * @return string | |
| */ | |
| private static function countdown_javascript(): string { | |
| return \sprintf( | |
| '( function () { | |
| var nodes = document.querySelectorAll( ".apermo-lockdown-countdown" ); | |
| if ( ! nodes.length ) { | |
| return; | |
| } | |
| var expired = %1$s; | |
| var reload = %2$s; | |
| var started = performance.now(); | |
| var items = Array.prototype.map.call( nodes, function ( node ) { | |
| return { node: node, start: parseInt( node.getAttribute( "data-remaining" ), 10 ) || 0 }; | |
| } ); | |
| function pad( value ) { | |
| return value < 10 ? "0" + value : String( value ); | |
| } | |
| function format( total ) { | |
| var days = Math.floor( total / 86400 ); | |
| var clock = pad( Math.floor( total %% 86400 / 3600 ) ) + ":" + | |
| pad( Math.floor( total %% 3600 / 60 ) ) + ":" + pad( total %% 60 ); | |
| return days > 0 ? days + "d " + clock : clock; | |
| } | |
| var timer = setInterval( function () { | |
| var gone = Math.round( ( performance.now() - started ) / 1000 ); | |
| var running = false; | |
| items.forEach( function ( item ) { | |
| var left = item.start - gone; | |
| running = running || left > 0; | |
| item.node.textContent = left > 0 ? format( left ) : expired; | |
| } ); | |
| if ( ! running ) { | |
| clearInterval( timer ); | |
| if ( reload ) { | |
| window.location.reload(); | |
| } | |
| } | |
| }, 1000 ); | |
| }() );', | |
| wp_json_encode( self::EXPIRED_LABEL ), | |
| wp_json_encode( self::RELOAD_ON_LOGOUT ), | |
| ); | |
| } | |
| /** | |
| * Formats a number of seconds the way the countdown displays it. | |
| * | |
| * @param int $seconds Seconds to format. | |
| * | |
| * @return string | |
| */ | |
| private static function format_duration( int $seconds ): string { | |
| if ( $seconds <= 0 ) { | |
| return self::EXPIRED_LABEL; | |
| } | |
| $clock = \sprintf( | |
| '%02d:%02d:%02d', | |
| \intdiv( $seconds % \DAY_IN_SECONDS, \HOUR_IN_SECONDS ), | |
| \intdiv( $seconds % \HOUR_IN_SECONDS, \MINUTE_IN_SECONDS ), | |
| $seconds % \MINUTE_IN_SECONDS, | |
| ); | |
| $days = \intdiv( $seconds, \DAY_IN_SECONDS ); | |
| return $days > 0 ? $days . 'd ' . $clock : $clock; | |
| } | |
| } | |
| Maintenance_Lockdown::init(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment