229 lines
6.5 KiB
PHP
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;
|
|
}
|
|
}
|