robotstxt-2fa/includes/user/class-grace-period.php
2026-09-18 04:36:07 +00:00

229 lines
6.5 KiB
PHP

<?php
/**
* Grace period management for users who have not yet configured 2FA.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\User;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Tracks the grace period for users whose role requires 2FA but who have not
* yet configured any verification method. Administrators can choose what
* happens when the grace period expires:
*
* - block: login is rejected; the user must contact an administrator.
* - wizard: login succeeds but every admin page redirects to the profile
* 2FA settings until at least one method is confirmed.
*/
class Grace_Period {
/**
* User meta key storing the grace period start timestamp.
*/
private const META_KEY = 'robotstxt_2fa_grace';
/**
* User meta key indicating pending mandatory 2FA setup (wizard mode).
*/
private const PENDING_SETUP_KEY = 'robotstxt_2fa_pending_setup';
/**
* Register hooks.
*
* @since 1.2.0
*
* @return void
*/
public function register_hooks(): void {
add_action( 'admin_init', array( $this, 'maybe_redirect_to_setup_wizard' ) );
add_action( 'robotstxt_2fa_method_enabled', array( $this, 'clear_pending_setup' ), 10, 2 );
add_action( 'admin_notices', array( $this, 'maybe_show_setup_required_notice' ) );
}
/**
* Determine whether a user is still within their grace period.
*
* Starts the grace period on first call if none exists yet.
*
* @since 1.2.0
*
* @param \WP_User $user User to check.
* @param int $days Configured grace period in days.
*
* @return bool True if the grace period is still active.
*/
public function is_active( \WP_User $user, int $days ): bool {
if ( $days <= 0 ) {
return false;
}
$started_at = $this->get_or_start( $user );
return ( $started_at + $days * DAY_IN_SECONDS ) > time();
}
/**
* Return the number of whole days remaining in the grace period.
*
* @since 1.2.0
*
* @param \WP_User $user User to check.
* @param int $days Configured grace period in days.
*
* @return int Days remaining (0 if expired or disabled).
*/
public function get_days_remaining( \WP_User $user, int $days ): int {
if ( $days <= 0 ) {
return 0;
}
$started_at = $this->get_or_start( $user );
$expires_at = $started_at + $days * DAY_IN_SECONDS;
$remaining = $expires_at - time();
return max( 0, (int) ceil( $remaining / DAY_IN_SECONDS ) );
}
/**
* Mark a user as requiring mandatory 2FA setup (wizard mode).
*
* @since 1.2.0
*
* @param int $user_id User ID.
*
* @return void
*/
public function set_pending_setup( int $user_id ): void {
update_user_meta( $user_id, self::PENDING_SETUP_KEY, '1' );
}
/**
* Clear the pending-setup flag once the user configures a method.
*
* Hooked to {@see 'robotstxt_2fa_method_enabled'}.
*
* @since 1.2.0
*
* @param \WP_User $user User who configured a method.
* @param string $method Method slug (unused).
*
* @return void
*/
public function clear_pending_setup( \WP_User $user, string $method ): void {
unset( $method );
delete_user_meta( $user->ID, self::PENDING_SETUP_KEY );
}
/**
* Check whether the current user must complete 2FA setup before accessing the dashboard.
*
* @since 1.2.0
*
* @return bool
*/
public function is_pending_setup(): bool {
$user_id = get_current_user_id();
if ( 0 === $user_id ) {
return false;
}
$val = get_user_meta( $user_id, self::PENDING_SETUP_KEY, true );
return '1' === ( is_scalar( $val ) ? (string) $val : '' );
}
/**
* Redirect wizard-mode users to the 2FA profile section.
*
* Fires on {@see 'admin_init'}. Allows the profile page, user-edit page,
* admin-ajax, and logout to pass through so the user is not locked out.
*
* @since 1.2.0
*
* @return void
*/
public function maybe_redirect_to_setup_wizard(): void {
if ( ! $this->is_pending_setup() ) {
return;
}
// Allow the pages needed to complete setup or log out.
$allowed_pages = array( 'profile.php', 'user-edit.php', 'admin-ajax.php', 'async-upload.php' );
$php_self = isset( $_SERVER['PHP_SELF'] ) && is_string( $_SERVER['PHP_SELF'] ) ? $_SERVER['PHP_SELF'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- used only for basename comparison.
$current_page = basename( $php_self );
if ( in_array( $current_page, $allowed_pages, true ) ) {
return;
}
// Also allow the logout action.
$raw_action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) ? $_REQUEST['action'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- action parameter only, no state change.
$action = sanitize_key( $raw_action );
if ( 'logout' === $action ) {
return;
}
// The query flag lets the profile show WHY the user was redirected,
// so the mandatory setup does not feel like a silent loop.
$profile_url = add_query_arg( 'robotstxt-2fa-setup-required', '1', admin_url( 'profile.php' ) );
wp_safe_redirect( $profile_url . '#robotstxt-2fa-settings' );
exit;
}
/**
* Explain the setup wizard redirect on the profile screen.
*
* Rendered after {@see self::maybe_redirect_to_setup_wizard()} bounced the
* user back to their profile. Without it, users land on a regular profile
* page with no hint that 2FA enrollment is mandatory before they can
* continue to the dashboard — which reads as a broken redirect loop.
*
* @since 1.6.7
*
* @return void
*/
public function maybe_show_setup_required_notice(): void {
if ( ! $this->is_pending_setup() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag; no state change.
if ( ! isset( $_GET['robotstxt-2fa-setup-required'] ) ) {
return;
}
?>
<div class="notice notice-warning robotstxt-2fa-setup-required">
<p>
<strong><?php esc_html_e( 'Two-factor authentication is required.', 'robotstxt-2fa' ); ?></strong>
<?php esc_html_e( 'You must activate at least one verification method in the Two-Factor Authentication section of this page, then save, before you can continue to the dashboard.', 'robotstxt-2fa' ); ?>
</p>
</div>
<?php
}
/**
* Retrieve the grace period start timestamp, creating it on first call.
*
* @param \WP_User $user User to look up.
*
* @return int Unix timestamp when the grace period started.
*/
private function get_or_start( \WP_User $user ): int {
$existing = get_user_meta( $user->ID, self::META_KEY, true );
if ( is_numeric( $existing ) && (int) $existing > 0 ) {
return (int) $existing;
}
$now = time();
update_user_meta( $user->ID, self::META_KEY, $now );
return $now;
}
}