v1.2.0
This commit is contained in:
parent
d405d8587d
commit
a115f08319
15 changed files with 1817 additions and 141 deletions
230
changelog.txt
230
changelog.txt
|
|
@ -1,98 +1,132 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-06-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Enable 2FA checkbox now defaults to email and pre-checks it in the UI on first activation.
|
||||
- QR code for authenticator apps now displays correctly in the user profile.
|
||||
- "Send the code again" link is disabled for 60 seconds after delivery with a live countdown.
|
||||
|
||||
### Changed
|
||||
|
||||
- Recovery codes section redesigned: plain list with code chips, no coloured notification box.
|
||||
- 2FA login screen links now in a vertical list for better readability.
|
||||
|
||||
### Removed
|
||||
|
||||
- "Generate new secret" button from the OTP section.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 6.4 – 7.1
|
||||
* PHP: 8.2 – 8.5
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0
|
||||
* PHPStan: level 9 — 0 errors
|
||||
* PHPCompatibility: 8.2–8.5 — 0 issues
|
||||
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
|
||||
|
||||
## [0.3.0] - 2026-06-05
|
||||
|
||||
### Added
|
||||
|
||||
- Delete-on-uninstall option (disabled by default — all plugin data is preserved on removal).
|
||||
|
||||
### Security
|
||||
|
||||
- Email verification codes now use `random_int()` (CSPRNG) for cryptographically secure code generation.
|
||||
- Recovery code preview transient TTL reduced from indefinite to 5 minutes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Network admin settings now save correctly via a dedicated handler writing to `wp_sitemeta`.
|
||||
- Settings option registered with `autoload=false` to avoid loading on every page request.
|
||||
- `update.json` corrected to reference the 2FA plugin instead of the SMTP plugin.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 6.4 – 7.1
|
||||
* PHP: 8.2 – 8.5
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0
|
||||
* PHPStan: level 9 — 0 errors
|
||||
* PHPCompatibility: 8.2–8.5 — 0 issues
|
||||
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
|
||||
|
||||
## [0.2.0] - 2026-06-05
|
||||
|
||||
### Added
|
||||
|
||||
- Per-role 2FA method matrix in admin settings: administrators can now choose exactly which verification methods (email, authenticator app, recovery codes) are required for each WordPress role, with select-all row and column controls.
|
||||
- Authenticator app (TOTP) support: stored secrets, QR code provisioning via `bacon/bacon-qr-code`, manual setup key display, and one-time activation confirmation.
|
||||
- Recovery codes: 10 single-use 8-digit codes with profile management, one-time display grid, copy-to-clipboard, and mandatory confirmation before activation.
|
||||
- Email-based verification codes with throttled delivery (60 s resend cooldown) and 10-minute expiry.
|
||||
- Configurable verification frequency (every login, daily, weekly, or every 28 days) remembered per device fingerprint; administrators can lock the global schedule.
|
||||
- Dedicated verification stage token (WP nonce) securing the 2FA screen between password validation and code submission.
|
||||
- Secure method-switch links on the verification screen letting users change between OTP, email, and recovery-code challenges without restarting the login.
|
||||
- Network-wide multisite support: network administrators can set and lock enforcement settings across all sites (`Network: true` header).
|
||||
- Uninstall routine removing all plugin options and user meta.
|
||||
- Admin settings page as a top-level menu with `manage_options` capability guard.
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings page now uses the per-role method matrix instead of a flat forced-roles list; old `force_roles` + `default_method` data is automatically migrated on first read.
|
||||
- Profile method checkboxes require explicit confirmation: OTP demands a valid 6-digit code, recovery codes demand entering one of the displayed codes before the method activates.
|
||||
- Profile screen regenerates the OTP secret when the authenticator method is disabled and provides a "Generate new secret" button.
|
||||
- Recovery code batch exhaustion now auto-generates a fresh batch and surfaces a confirmation prompt rather than locking the user out.
|
||||
- Login screen hides the username/password fields, removes the Remember Me checkbox, and focuses the verification code input during the 2FA stage.
|
||||
- Login form auto-submits once the input reaches the method's expected digit count.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hardened authenticator QR generation by catching library errors and guiding administrators to the required dependency.
|
||||
- Recovery-code preview persists via transient until explicitly acknowledged; fixes cases where codes disappeared before confirmation.
|
||||
|
||||
## [0.1.0] - 2024-04-08
|
||||
|
||||
### Added
|
||||
|
||||
- Initial skeleton for the 2FA (by ROBOTSTXT) plugin.
|
||||
- Initial documentation files (`readme.txt`, `AGENTS.md`, `changelog.txt`).
|
||||
- Placeholder classes for login handling, user profile integration, and admin settings.
|
||||
== Changelog ==
|
||||
|
||||
= 1.1.0 =
|
||||
|
||||
_Release date: 2026-06-05_
|
||||
|
||||
**Added**
|
||||
|
||||
* Developer filter/action hooks: `robotstxt_2fa_skip_challenge`, `robotstxt_2fa_verification_success`, `robotstxt_2fa_verification_failed`, `robotstxt_2fa_method_enabled`, `robotstxt_2fa_method_disabled`, `robotstxt_2fa_code_length`, `robotstxt_2fa_code_ttl`, `robotstxt_2fa_resend_interval`, `robotstxt_2fa_email_subject`, `robotstxt_2fa_email_message`, `robotstxt_2fa_before_send_email`.
|
||||
* `robotstxt_2fa_required_methods_for_user` filter to override per-user method requirements.
|
||||
* `robotstxt_2fa_profile_wrapper_class` filter for the frontend shortcode container.
|
||||
* `[robotstxt_2fa_profile]` shortcode — renders the full 2FA settings section on any WordPress page without requiring wp-admin access. Supports `user_id` and `redirect` attributes.
|
||||
* `top_up_codes_for_user()` method on `Recovery_Codes` — generates only the missing codes to fill the batch back to 10, preserving existing unused codes.
|
||||
* "Regenerate codes" button on the profile when recovery codes are active, without needing to disable and re-enable the method.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 6.4 – 7.1
|
||||
* PHP: 8.0 – 8.5
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0
|
||||
* PHPStan: level 9 — 0 errors
|
||||
* PHPCompatibility: 8.0–8.5 — 0 issues
|
||||
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
|
||||
|
||||
= 1.0.0 =
|
||||
|
||||
_Release date: 2026-06-05_
|
||||
|
||||
**Fixed**
|
||||
|
||||
* Enable 2FA checkbox now defaults to email and pre-checks it in the UI on first activation.
|
||||
* QR code for authenticator apps now displays correctly in the user profile.
|
||||
* "Send the code again" link is disabled for 60 seconds after delivery with a live countdown.
|
||||
|
||||
**Changed**
|
||||
|
||||
* Recovery codes section redesigned: plain list with code chips, no coloured notification box.
|
||||
* 2FA login screen links now in a vertical list for better readability.
|
||||
|
||||
**Removed**
|
||||
|
||||
* "Generate new secret" button from the OTP section.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 6.4 – 7.1
|
||||
* PHP: 8.0 – 8.5
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0
|
||||
* PHPStan: level 9 — 0 errors
|
||||
* PHPCompatibility: 8.0–8.5 — 0 issues
|
||||
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
|
||||
|
||||
= 0.3.0 =
|
||||
|
||||
_Release date: 2026-06-05_
|
||||
|
||||
**Added**
|
||||
|
||||
* Delete-on-uninstall option (disabled by default — all plugin data is preserved on removal).
|
||||
|
||||
**Security**
|
||||
|
||||
* Email verification codes now use `random_int()` (CSPRNG).
|
||||
* Recovery code preview transient TTL reduced from indefinite to 5 minutes.
|
||||
|
||||
**Fixed**
|
||||
|
||||
* Network admin settings now save correctly via a dedicated handler writing to `wp_sitemeta`.
|
||||
* Settings option registered with `autoload=false`.
|
||||
* `update.json` corrected to reference the 2FA plugin instead of the SMTP plugin.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 6.4 – 7.1
|
||||
* PHP: 8.0 – 8.5
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0
|
||||
* PHPStan: level 9 — 0 errors
|
||||
* PHPCompatibility: 8.0–8.5 — 0 issues
|
||||
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
|
||||
|
||||
= 0.2.0 =
|
||||
|
||||
_Release date: 2026-06-05_
|
||||
|
||||
**Added**
|
||||
|
||||
* Per-role 2FA method matrix with select-all row/column controls.
|
||||
* Authenticator app (TOTP) support with QR provisioning and manual setup key.
|
||||
* Recovery codes: 10 single-use 8-digit codes with confirmation workflow.
|
||||
* Email-based verification codes with 60-second resend throttle and 10-minute expiry.
|
||||
* Configurable verification frequency (every login, daily, weekly, monthly) per device.
|
||||
* Dedicated stage token (WP nonce) securing the 2FA screen.
|
||||
* Network-wide multisite support.
|
||||
* Admin settings page with top-level menu.
|
||||
|
||||
**Changed**
|
||||
|
||||
* Profile method checkboxes require explicit confirmation before activation.
|
||||
|
||||
**Fixed**
|
||||
|
||||
* QR generation errors caught; recovery-code preview persists until acknowledged.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
* WordPress: 6.4 – 7.0
|
||||
* PHP: 8.0 – 8.5
|
||||
|
||||
**Tests**
|
||||
|
||||
* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0
|
||||
* PHPStan: level 9 — 0 errors
|
||||
* PHPUnit: 9.6.34
|
||||
|
||||
= 0.1.0 =
|
||||
|
||||
_Release date: 2024-04-08_
|
||||
|
||||
**Added**
|
||||
|
||||
* Initial plugin skeleton.
|
||||
* Documentation files.
|
||||
* Placeholder classes for login handling, profile integration, and admin settings.
|
||||
|
|
|
|||
|
|
@ -28,10 +28,15 @@ class Settings_Page {
|
|||
public const OPTION_NAME = 'robotstxt_2fa_settings';
|
||||
|
||||
/**
|
||||
* Settings page slug.
|
||||
* Settings page slug (also serves as the top-level menu slug).
|
||||
*/
|
||||
private const PAGE_SLUG = 'robotstxt-2fa-settings';
|
||||
|
||||
/**
|
||||
* Documentation page slug.
|
||||
*/
|
||||
private const DOCS_PAGE_SLUG = 'robotstxt-2fa-docs';
|
||||
|
||||
/**
|
||||
* Verification methods available for per-role configuration.
|
||||
*
|
||||
|
|
@ -160,6 +165,53 @@ class Settings_Page {
|
|||
'robotstxt_2fa_general'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'robotstxt_2fa_security',
|
||||
__( 'Security', 'robotstxt-2fa' ),
|
||||
array( $this, 'render_security_section' ),
|
||||
self::PAGE_SLUG
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'robotstxt_2fa_max_attempts',
|
||||
__( 'Max failed attempts', 'robotstxt-2fa' ),
|
||||
array( $this, 'render_max_attempts_field' ),
|
||||
self::PAGE_SLUG,
|
||||
'robotstxt_2fa_security'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'robotstxt_2fa_lockout_duration',
|
||||
__( 'Lockout duration', 'robotstxt-2fa' ),
|
||||
array( $this, 'render_lockout_duration_field' ),
|
||||
self::PAGE_SLUG,
|
||||
'robotstxt_2fa_security'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'robotstxt_2fa_trust_device_days',
|
||||
__( 'Trust browser for', 'robotstxt-2fa' ),
|
||||
array( $this, 'render_trust_device_days_field' ),
|
||||
self::PAGE_SLUG,
|
||||
'robotstxt_2fa_security'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'robotstxt_2fa_grace_period_days',
|
||||
__( 'Grace period', 'robotstxt-2fa' ),
|
||||
array( $this, 'render_grace_period_days_field' ),
|
||||
self::PAGE_SLUG,
|
||||
'robotstxt_2fa_security'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'robotstxt_2fa_grace_period_action',
|
||||
__( 'After grace period', 'robotstxt-2fa' ),
|
||||
array( $this, 'render_grace_period_action_field' ),
|
||||
self::PAGE_SLUG,
|
||||
'robotstxt_2fa_security'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'robotstxt_2fa_data',
|
||||
__( 'Data management', 'robotstxt-2fa' ),
|
||||
|
|
@ -377,7 +429,7 @@ class Settings_Page {
|
|||
*
|
||||
* @param mixed $raw_settings Raw settings submitted by the user.
|
||||
*
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool}
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string}
|
||||
*/
|
||||
public function sanitize_settings( $raw_settings ): array {
|
||||
$capability = $this->get_required_capability();
|
||||
|
|
@ -446,13 +498,33 @@ class Settings_Page {
|
|||
$sanitized['delete_on_uninstall'] = '1' === ( is_scalar( $raw_settings['delete_on_uninstall'] ) ? (string) $raw_settings['delete_on_uninstall'] : '' );
|
||||
}
|
||||
|
||||
if ( isset( $raw_settings['max_attempts'] ) && is_numeric( $raw_settings['max_attempts'] ) ) {
|
||||
$sanitized['max_attempts'] = max( 0, absint( $raw_settings['max_attempts'] ) );
|
||||
}
|
||||
|
||||
if ( isset( $raw_settings['lockout_duration'] ) && is_numeric( $raw_settings['lockout_duration'] ) ) {
|
||||
$sanitized['lockout_duration'] = max( 1, absint( $raw_settings['lockout_duration'] ) );
|
||||
}
|
||||
|
||||
if ( isset( $raw_settings['trust_device_days'] ) && is_numeric( $raw_settings['trust_device_days'] ) ) {
|
||||
$sanitized['trust_device_days'] = max( 0, absint( $raw_settings['trust_device_days'] ) );
|
||||
}
|
||||
|
||||
if ( isset( $raw_settings['grace_period_days'] ) && is_numeric( $raw_settings['grace_period_days'] ) ) {
|
||||
$sanitized['grace_period_days'] = max( 0, absint( $raw_settings['grace_period_days'] ) );
|
||||
}
|
||||
|
||||
if ( isset( $raw_settings['grace_period_action'] ) && in_array( $raw_settings['grace_period_action'], array( 'block', 'wizard' ), true ) ) {
|
||||
$sanitized['grace_period_action'] = $raw_settings['grace_period_action'];
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve plugin settings, migrating from legacy format when necessary.
|
||||
*
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool}
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string}
|
||||
*/
|
||||
public function get_settings(): array {
|
||||
$options = array();
|
||||
|
|
@ -472,18 +544,25 @@ class Settings_Page {
|
|||
}
|
||||
}
|
||||
|
||||
$defaults = $this->get_default_settings();
|
||||
|
||||
return array(
|
||||
'role_methods' => $this->resolve_role_methods( $options ),
|
||||
'frequency' => Frequency_Options::sanitize( isset( $options['frequency'] ) && is_string( $options['frequency'] ) ? $options['frequency'] : '' ),
|
||||
'force_frequency' => ! empty( $options['force_frequency'] ),
|
||||
'delete_on_uninstall' => ! empty( $options['delete_on_uninstall'] ),
|
||||
'max_attempts' => isset( $options['max_attempts'] ) && is_numeric( $options['max_attempts'] ) ? max( 0, absint( $options['max_attempts'] ) ) : $defaults['max_attempts'],
|
||||
'lockout_duration' => isset( $options['lockout_duration'] ) && is_numeric( $options['lockout_duration'] ) ? max( 1, absint( $options['lockout_duration'] ) ) : $defaults['lockout_duration'],
|
||||
'trust_device_days' => isset( $options['trust_device_days'] ) && is_numeric( $options['trust_device_days'] ) ? max( 0, absint( $options['trust_device_days'] ) ) : $defaults['trust_device_days'],
|
||||
'grace_period_days' => isset( $options['grace_period_days'] ) && is_numeric( $options['grace_period_days'] ) ? max( 0, absint( $options['grace_period_days'] ) ) : $defaults['grace_period_days'],
|
||||
'grace_period_action' => isset( $options['grace_period_action'] ) && in_array( $options['grace_period_action'], array( 'block', 'wizard' ), true ) ? $options['grace_period_action'] : $defaults['grace_period_action'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve default settings.
|
||||
*
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool}
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string}
|
||||
*/
|
||||
private function get_default_settings(): array {
|
||||
return array(
|
||||
|
|
@ -491,6 +570,11 @@ class Settings_Page {
|
|||
'frequency' => Frequency_Options::FREQUENCY_SESSION,
|
||||
'force_frequency' => false,
|
||||
'delete_on_uninstall' => false,
|
||||
'max_attempts' => 3,
|
||||
'lockout_duration' => 15,
|
||||
'trust_device_days' => 30,
|
||||
'grace_period_days' => 7,
|
||||
'grace_period_action' => 'wizard',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -622,6 +706,104 @@ class Settings_Page {
|
|||
echo '<p class="description">' . esc_html__( 'When enabled, users will see the profile frequency control disabled and must follow the global schedule.', 'robotstxt-2fa' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Security section description.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_security_section(): void {
|
||||
echo '<p>' . esc_html__( 'Brute-force protection, trusted device cookies, and grace period for new enrollments.', 'robotstxt-2fa' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the max failed attempts field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_max_attempts_field(): void {
|
||||
$settings = $this->get_settings();
|
||||
printf(
|
||||
'<input type="number" name="%1$s[max_attempts]" value="%2$s" min="0" max="99" style="width:70px;" /> %3$s',
|
||||
esc_attr( self::OPTION_NAME ),
|
||||
absint( $settings['max_attempts'] ),
|
||||
esc_html__( '(0 = unlimited)', 'robotstxt-2fa' )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'Number of consecutive failed 2FA attempts before the user is temporarily locked out. The lock is cleared after the lockout duration expires.', 'robotstxt-2fa' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the lockout duration field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_lockout_duration_field(): void {
|
||||
$settings = $this->get_settings();
|
||||
printf(
|
||||
'<input type="number" name="%1$s[lockout_duration]" value="%2$s" min="1" max="1440" style="width:70px;" /> %3$s',
|
||||
esc_attr( self::OPTION_NAME ),
|
||||
absint( $settings['lockout_duration'] ),
|
||||
esc_html__( 'minutes', 'robotstxt-2fa' )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'How long the account is locked after too many failed attempts. Applies only when Max failed attempts is greater than 0.', 'robotstxt-2fa' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the trusted device days field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_trust_device_days_field(): void {
|
||||
$settings = $this->get_settings();
|
||||
printf(
|
||||
'<input type="number" name="%1$s[trust_device_days]" value="%2$s" min="0" max="365" style="width:70px;" /> %3$s',
|
||||
esc_attr( self::OPTION_NAME ),
|
||||
absint( $settings['trust_device_days'] ),
|
||||
esc_html__( 'days (0 = disable trusted devices)', 'robotstxt-2fa' )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'When greater than 0, users see a "Remember this browser" checkbox after a successful 2FA verification. Trusted browsers skip the 2FA challenge for the configured duration.', 'robotstxt-2fa' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the grace period days field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_grace_period_days_field(): void {
|
||||
$settings = $this->get_settings();
|
||||
printf(
|
||||
'<input type="number" name="%1$s[grace_period_days]" value="%2$s" min="0" max="90" style="width:70px;" /> %3$s',
|
||||
esc_attr( self::OPTION_NAME ),
|
||||
absint( $settings['grace_period_days'] ),
|
||||
esc_html__( 'days (0 = no grace period)', 'robotstxt-2fa' )
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'Users whose role requires 2FA but who have not yet configured any method are allowed to log in for this many days before enforcement begins. The timer starts on their first login after the role requirement is applied.', 'robotstxt-2fa' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the grace period action field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_grace_period_action_field(): void {
|
||||
$settings = $this->get_settings();
|
||||
$selected = $settings['grace_period_action'];
|
||||
|
||||
echo '<fieldset>';
|
||||
printf(
|
||||
'<label><input type="radio" name="%1$s[grace_period_action]" value="block" %2$s /> %3$s</label><br />',
|
||||
esc_attr( self::OPTION_NAME ),
|
||||
checked( 'block', $selected, false ),
|
||||
esc_html__( 'Block login — the user cannot access the dashboard until an administrator enables a method for them.', 'robotstxt-2fa' )
|
||||
);
|
||||
printf(
|
||||
'<label><input type="radio" name="%1$s[grace_period_action]" value="wizard" %2$s /> %3$s</label>',
|
||||
esc_attr( self::OPTION_NAME ),
|
||||
checked( 'wizard', $selected, false ),
|
||||
esc_html__( 'Forced setup — the user can log in but is redirected to the 2FA profile section on every page until at least one method is confirmed.', 'robotstxt-2fa' )
|
||||
);
|
||||
echo '</fieldset>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the data management section description.
|
||||
*
|
||||
|
|
@ -741,5 +923,128 @@ class Settings_Page {
|
|||
array( $this, 'render_page' ),
|
||||
'dashicons-shield'
|
||||
);
|
||||
|
||||
// Rename the first auto-created submenu entry to "Settings".
|
||||
add_submenu_page(
|
||||
self::PAGE_SLUG,
|
||||
__( 'Settings — 2FA', 'robotstxt-2fa' ),
|
||||
__( 'Settings', 'robotstxt-2fa' ),
|
||||
$capability,
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
self::PAGE_SLUG,
|
||||
__( 'Documentation — 2FA', 'robotstxt-2fa' ),
|
||||
__( 'Documentation', 'robotstxt-2fa' ),
|
||||
$capability,
|
||||
self::DOCS_PAGE_SLUG,
|
||||
array( $this, 'render_docs_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Documentation page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_docs_page(): void {
|
||||
$capability = $this->get_required_capability();
|
||||
|
||||
if ( ! current_user_can( $capability ) ) {
|
||||
wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'robotstxt-2fa' ) );
|
||||
}
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'Documentation — 2FA (by ROBOTSTXT)', 'robotstxt-2fa' ); ?></h1>
|
||||
<p><?php esc_html_e( 'Reference for developers: all actions, filters, and shortcodes exposed by the plugin.', 'robotstxt-2fa' ); ?></p>
|
||||
|
||||
<h2><?php esc_html_e( 'Shortcode', 'robotstxt-2fa' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Renders the full 2FA settings section on any page or post without requiring wp-admin access. Useful for membership sites with a frontend dashboard.', 'robotstxt-2fa' ); ?></p>
|
||||
<pre style="background:#f6f7f7;border:1px solid #dcdcde;padding:10px 14px;display:inline-block;">[robotstxt_2fa_profile]
|
||||
[robotstxt_2fa_profile user_id="42" redirect="https://example.com/account"]</pre>
|
||||
<table class="widefat striped" style="max-width:700px;margin-top:1em;">
|
||||
<thead><tr><th><?php esc_html_e( 'Attribute', 'robotstxt-2fa' ); ?></th><th><?php esc_html_e( 'Default', 'robotstxt-2fa' ); ?></th><th><?php esc_html_e( 'Description', 'robotstxt-2fa' ); ?></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>user_id</code></td><td><?php esc_html_e( 'Current user', 'robotstxt-2fa' ); ?></td><td><?php esc_html_e( 'ID of the user to edit. Admins can specify another user.', 'robotstxt-2fa' ); ?></td></tr>
|
||||
<tr><td><code>redirect</code></td><td><?php esc_html_e( 'Current page', 'robotstxt-2fa' ); ?></td><td><?php esc_html_e( 'URL to redirect to after a successful save.', 'robotstxt-2fa' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style="margin-top:2em;"><?php esc_html_e( 'Action hooks', 'robotstxt-2fa' ); ?></h2>
|
||||
<table class="widefat striped">
|
||||
<thead><tr>
|
||||
<th><?php esc_html_e( 'Hook', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Since', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Arguments', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Description', 'robotstxt-2fa' ); ?></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$actions = array(
|
||||
array( 'robotstxt_2fa_before_send_email', '1.0.0', 'int $user_id, string $code, int $expires', __( 'Fires just before the email verification code is delivered.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_verification_success', '1.0.0', 'WP_User $user, string $method', __( 'Fires after a successful 2FA challenge. Use for audit logs or trusted-device recording.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_verification_failed', '1.0.0', 'WP_User $user, string $method', __( 'Fires on each failed 2FA attempt. Use for rate-limiting or alerting.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_method_enabled', '1.0.0', 'WP_User $user, string $method', __( 'Fires when a user activates a method from their profile.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_method_disabled', '1.0.0', 'WP_User $user, string $method', __( 'Fires when a user deactivates a method from their profile.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_qr_generation_failed', '0.2.0', 'Throwable $e, string $uri', __( 'Fires when QR code SVG rendering throws an exception.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_recovery_generation_failed', '0.2.0', 'Throwable $e', __( 'Fires when recovery code generation throws an exception.', 'robotstxt-2fa' ) ),
|
||||
);
|
||||
foreach ( $actions as $hook ) :
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $hook[0] ); ?></code></td>
|
||||
<td><?php echo esc_html( $hook[1] ); ?></td>
|
||||
<td><code style="white-space:normal;"><?php echo esc_html( $hook[2] ); ?></code></td>
|
||||
<td><?php echo esc_html( $hook[3] ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style="margin-top:2em;"><?php esc_html_e( 'Filter hooks', 'robotstxt-2fa' ); ?></h2>
|
||||
<table class="widefat striped">
|
||||
<thead><tr>
|
||||
<th><?php esc_html_e( 'Filter', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Since', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Default', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Description', 'robotstxt-2fa' ); ?></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$filters = array(
|
||||
array( 'robotstxt_2fa_skip_challenge', '1.0.0', 'false', __( 'Return true to bypass the 2FA challenge entirely (REST API, WP-CLI, trusted IP…).', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_required_methods_for_user', '1.0.0', 'string[]', __( 'Override the required methods for a specific user; receives current role-based array and WP_User.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_code_length', '1.0.0', '7', __( 'Number of digits in the email verification code.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_code_ttl', '1.0.0', '600', __( 'Seconds before an email verification code expires.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_resend_interval', '1.0.0', '60', __( 'Minimum seconds between email resend requests.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_email_subject', '1.0.0', 'string', __( 'Subject line of the verification code email. Receives subject and WP_User.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_email_message', '1.0.0', 'string', __( 'Body of the verification code email. Receives message, WP_User, and plain-text code.', 'robotstxt-2fa' ) ),
|
||||
array( 'robotstxt_2fa_profile_wrapper_class', '1.0.0', 'robotstxt-2fa-frontend', __( 'CSS class on the [robotstxt_2fa_profile] shortcode container.', 'robotstxt-2fa' ) ),
|
||||
);
|
||||
foreach ( $filters as $hook ) :
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $hook[0] ); ?></code></td>
|
||||
<td><?php echo esc_html( $hook[1] ); ?></td>
|
||||
<td><code><?php echo esc_html( $hook[2] ); ?></code></td>
|
||||
<td><?php echo esc_html( $hook[3] ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:1.5em;">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: link to hooks.md file. */
|
||||
esc_html__( 'Full documentation with code examples: %s', 'robotstxt-2fa' ),
|
||||
'<a href="https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/src/branch/main/docs/hooks.md" target="_blank" rel="noopener">docs/hooks.md</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ namespace Robotstxt\TwoFA;
|
|||
|
||||
use Robotstxt\TwoFA\Admin\Settings_Page;
|
||||
use Robotstxt\TwoFA\Login\Login_Form_Manager;
|
||||
use Robotstxt\TwoFA\User\Frontend_Profile;
|
||||
use Robotstxt\TwoFA\User\Grace_Period;
|
||||
use Robotstxt\TwoFA\User\Profile_Settings;
|
||||
use Robotstxt\TwoFA\User\Recovery_Codes;
|
||||
use Robotstxt\TwoFA\User\Trusted_Devices;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
|
|
@ -55,6 +58,27 @@ class Plugin {
|
|||
*/
|
||||
private Settings_Page $settings_page;
|
||||
|
||||
/**
|
||||
* Frontend profile shortcode handler.
|
||||
*
|
||||
* @var Frontend_Profile
|
||||
*/
|
||||
private Frontend_Profile $frontend_profile;
|
||||
|
||||
/**
|
||||
* Trusted devices manager.
|
||||
*
|
||||
* @var Trusted_Devices
|
||||
*/
|
||||
private Trusted_Devices $trusted_devices;
|
||||
|
||||
/**
|
||||
* Grace period manager.
|
||||
*
|
||||
* @var Grace_Period
|
||||
*/
|
||||
private Grace_Period $grace_period;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
|
|
@ -76,6 +100,9 @@ class Plugin {
|
|||
$this->profile_settings = new Profile_Settings();
|
||||
$this->recovery_codes = new Recovery_Codes();
|
||||
$this->settings_page = new Settings_Page();
|
||||
$this->frontend_profile = new Frontend_Profile();
|
||||
$this->trusted_devices = new Trusted_Devices();
|
||||
$this->grace_period = new Grace_Period();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,6 +116,9 @@ class Plugin {
|
|||
$this->profile_settings->register_hooks();
|
||||
$this->recovery_codes->register_hooks();
|
||||
$this->settings_page->register_hooks();
|
||||
$this->frontend_profile->register_hooks();
|
||||
$this->trusted_devices->register_hooks();
|
||||
$this->grace_period->register_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,17 +18,17 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
*/
|
||||
class Email_Code_Sender {
|
||||
/**
|
||||
* Length of the generated numeric code.
|
||||
* Default length of the generated numeric code.
|
||||
*/
|
||||
private const CODE_LENGTH = 7;
|
||||
|
||||
/**
|
||||
* Number of seconds the generated code remains valid.
|
||||
* Default number of seconds the generated code remains valid.
|
||||
*/
|
||||
private const EXPIRATION_SECONDS = 600;
|
||||
|
||||
/**
|
||||
* Minimum delay between email deliveries in seconds.
|
||||
* Default minimum delay between email deliveries in seconds.
|
||||
*/
|
||||
private const RESEND_INTERVAL = 60;
|
||||
|
||||
|
|
@ -46,52 +46,106 @@ class Email_Code_Sender {
|
|||
$this->user_settings_repository = new User_Settings_Repository();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the expected code length.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
/**
|
||||
* Retrieve the expected code length.
|
||||
*
|
||||
* Filterable via {@see 'robotstxt_2fa_code_length'}.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_code_length(): int {
|
||||
return self::CODE_LENGTH;
|
||||
/**
|
||||
* Filter the length (digit count) of the email verification code.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $length Default code length.
|
||||
*/
|
||||
return max( 1, absint( apply_filters( 'robotstxt_2fa_code_length', self::CODE_LENGTH ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the minimum seconds that must elapse between resends.
|
||||
*
|
||||
* Filterable via {@see 'robotstxt_2fa_resend_interval'}.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_resend_interval(): int {
|
||||
return self::RESEND_INTERVAL;
|
||||
/**
|
||||
* Filter the minimum seconds between email code resend requests.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $seconds Default resend cooldown in seconds.
|
||||
*/
|
||||
return max( 1, absint( apply_filters( 'robotstxt_2fa_resend_interval', self::RESEND_INTERVAL ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the code expiry duration in seconds.
|
||||
*
|
||||
* Filterable via {@see 'robotstxt_2fa_code_ttl'}.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_code_ttl(): int {
|
||||
/**
|
||||
* Send a verification code to the provided user.
|
||||
* Filter how long (in seconds) an email verification code stays valid.
|
||||
*
|
||||
* @param \WP_User $user User who is requesting authentication.
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return bool True when the email was sent or throttled successfully, false on failure.
|
||||
* @param int $seconds Default TTL in seconds.
|
||||
*/
|
||||
return max( 60, absint( apply_filters( 'robotstxt_2fa_code_ttl', self::EXPIRATION_SECONDS ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a verification code to the provided user.
|
||||
*
|
||||
* @param \WP_User $user User who is requesting authentication.
|
||||
*
|
||||
* @return bool True when the email was sent or throttled successfully, false on failure.
|
||||
*/
|
||||
public function send_code( \WP_User $user ): bool {
|
||||
if ( empty( $user->user_email ) || ! is_email( $user->user_email ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$challenge = $this->user_settings_repository->get_email_challenge( $user->ID );
|
||||
$current_time = time();
|
||||
$challenge = $this->user_settings_repository->get_email_challenge( $user->ID );
|
||||
$current_time = time();
|
||||
$resend_interval = $this->get_resend_interval();
|
||||
|
||||
if ( ! empty( $challenge['hash'] ) && $challenge['expires'] > $current_time && $challenge['sent_at'] >= ( $current_time - self::RESEND_INTERVAL ) ) {
|
||||
if ( ! empty( $challenge['hash'] ) && $challenge['expires'] > $current_time && $challenge['sent_at'] >= ( $current_time - $resend_interval ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$code = $this->generate_code();
|
||||
$expires = $current_time + self::EXPIRATION_SECONDS;
|
||||
$expires = $current_time + $this->get_code_ttl();
|
||||
|
||||
$raw_blogname = get_option( 'blogname', '' );
|
||||
$blog_name = wp_specialchars_decode( is_string( $raw_blogname ) ? $raw_blogname : '', ENT_QUOTES );
|
||||
$display_name = wp_strip_all_tags( ! empty( $user->display_name ) ? $user->display_name : $user->user_login );
|
||||
|
||||
/* translators: %s: site name. */
|
||||
$email_subject = sprintf( __( '[%s] Your verification code', 'robotstxt-2fa' ), ! empty( $blog_name ) ? $blog_name : __( 'WordPress', 'robotstxt-2fa' ) );
|
||||
|
||||
/**
|
||||
* Filter the subject line of the email verification code message.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $subject Default subject line.
|
||||
* @param \WP_User $user User the code is being sent to.
|
||||
*/
|
||||
$email_subject = (string) apply_filters( 'robotstxt_2fa_email_subject', $email_subject, $user );
|
||||
|
||||
$message_lines = array(
|
||||
/* translators: %s: user display name. */
|
||||
sprintf( __( 'Hi %s,', 'robotstxt-2fa' ), $display_name ),
|
||||
|
|
@ -108,6 +162,28 @@ class Email_Code_Sender {
|
|||
|
||||
$message = implode( "\n", $message_lines );
|
||||
|
||||
/**
|
||||
* Filter the body of the email verification code message.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $message Plain-text message body.
|
||||
* @param \WP_User $user User the code is being sent to.
|
||||
* @param string $code Plain-text verification code.
|
||||
*/
|
||||
$message = (string) apply_filters( 'robotstxt_2fa_email_message', $message, $user, $code );
|
||||
|
||||
/**
|
||||
* Fires just before the verification code email is delivered.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $user_id User ID the code is being sent to.
|
||||
* @param string $code Plain-text verification code.
|
||||
* @param int $expires Unix timestamp when the code expires.
|
||||
*/
|
||||
do_action( 'robotstxt_2fa_before_send_email', $user->ID, $code, $expires );
|
||||
|
||||
$headers = array( 'Content-Type: text/plain; charset=UTF-8' );
|
||||
|
||||
if ( ! wp_mail( $user->user_email, $email_subject, $message, $headers ) ) {
|
||||
|
|
@ -128,8 +204,9 @@ class Email_Code_Sender {
|
|||
* @return bool
|
||||
*/
|
||||
public function verify_code( \WP_User $user, string $code ): bool {
|
||||
$challenge = $this->user_settings_repository->get_email_challenge( $user->ID );
|
||||
$code = preg_replace( '/[^0-9]/', '', $code ) ?? '';
|
||||
$challenge = $this->user_settings_repository->get_email_challenge( $user->ID );
|
||||
$code = preg_replace( '/[^0-9]/', '', $code ) ?? '';
|
||||
$code_length = $this->get_code_length();
|
||||
|
||||
if ( '' === $challenge['hash'] ) {
|
||||
return false;
|
||||
|
|
@ -139,7 +216,7 @@ class Email_Code_Sender {
|
|||
return false;
|
||||
}
|
||||
|
||||
if ( '' === $code ) {
|
||||
if ( '' === $code || strlen( $code ) !== $code_length ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -152,9 +229,10 @@ class Email_Code_Sender {
|
|||
* @return string
|
||||
*/
|
||||
private function generate_code(): string {
|
||||
$max_value = ( 10 ** self::CODE_LENGTH ) - 1;
|
||||
$length = $this->get_code_length();
|
||||
$max_value = ( 10 ** $length ) - 1;
|
||||
$code = (string) random_int( 0, $max_value );
|
||||
|
||||
return str_pad( $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
|
||||
return str_pad( $code, $length, '0', STR_PAD_LEFT );
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
namespace Robotstxt\TwoFA\Login;
|
||||
|
||||
use Robotstxt\TwoFA\Frequency_Options;
|
||||
use Robotstxt\TwoFA\User\Grace_Period;
|
||||
use Robotstxt\TwoFA\User\OTP_Manager;
|
||||
use Robotstxt\TwoFA\User\Recovery_Codes;
|
||||
use Robotstxt\TwoFA\User\Trusted_Devices;
|
||||
use Robotstxt\TwoFA\User\Two_Factor_Config;
|
||||
use Robotstxt\TwoFA\User\User_Settings_Repository;
|
||||
use WP_User;
|
||||
|
|
@ -132,6 +134,20 @@ class Login_Form_Manager {
|
|||
*/
|
||||
private Two_Factor_Config $config;
|
||||
|
||||
/**
|
||||
* Trusted devices manager.
|
||||
*
|
||||
* @var Trusted_Devices
|
||||
*/
|
||||
private Trusted_Devices $trusted_devices;
|
||||
|
||||
/**
|
||||
* Grace period manager.
|
||||
*
|
||||
* @var Grace_Period
|
||||
*/
|
||||
private Grace_Period $grace_period;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
|
|
@ -141,6 +157,8 @@ class Login_Form_Manager {
|
|||
$this->recovery_codes = new Recovery_Codes();
|
||||
$this->user_settings_repository = new User_Settings_Repository();
|
||||
$this->config = new Two_Factor_Config();
|
||||
$this->trusted_devices = new Trusted_Devices();
|
||||
$this->grace_period = new Grace_Period();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -287,12 +305,45 @@ class Login_Form_Manager {
|
|||
);
|
||||
}
|
||||
|
||||
$trust_days = $this->config->get_trust_device_days();
|
||||
|
||||
if ( $trust_days > 0 ) {
|
||||
?>
|
||||
<p class="robotstxt-2fa-trust-field robotstxt-2fa-stage-field">
|
||||
<label>
|
||||
<input type="checkbox" name="robotstxt_2fa_trust_device" value="1" />
|
||||
<?php
|
||||
/* translators: %d: number of days the device trust lasts. */
|
||||
$trust_label = _n(
|
||||
'Remember this browser for %d day',
|
||||
'Remember this browser for %d days',
|
||||
$trust_days,
|
||||
'robotstxt-2fa'
|
||||
);
|
||||
echo esc_html( sprintf( $trust_label, absint( $trust_days ) ) );
|
||||
?>
|
||||
</label>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
$back_url = wp_login_url( $this->get_requested_redirect() );
|
||||
?>
|
||||
<ul class="robotstxt-2fa-login-links robotstxt-2fa-stage-field">
|
||||
<?php if ( 'email' === $method && '' !== $stage_token ) : ?>
|
||||
<li>
|
||||
<a class="robotstxt-2fa-resend-link" href="<?php echo esc_url( $this->get_stage_url( array( self::QUERY_RESEND => '1', self::QUERY_NOTICE => null ) ) ); ?>">
|
||||
<a class="robotstxt-2fa-resend-link" href="
|
||||
<?php
|
||||
echo esc_url(
|
||||
$this->get_stage_url(
|
||||
array(
|
||||
self::QUERY_RESEND => '1',
|
||||
self::QUERY_NOTICE => null,
|
||||
)
|
||||
)
|
||||
);
|
||||
?>
|
||||
">
|
||||
<?php esc_html_e( 'Send the code again', 'robotstxt-2fa' ); ?>
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -538,6 +589,28 @@ class Login_Form_Manager {
|
|||
return new \WP_Error( 'robotstxt_2fa_unknown_user', __( 'We could not load the account requesting verification.', 'robotstxt-2fa' ) );
|
||||
}
|
||||
|
||||
// Brute-force: check if the account is currently locked out.
|
||||
$lock_key = 'robotstxt_2fa_locked_' . $authenticated_user->ID;
|
||||
$locked = get_transient( $lock_key );
|
||||
|
||||
if ( false !== $locked ) {
|
||||
$remaining_seconds = max( 0, ( is_numeric( $locked ) ? (int) $locked : 0 ) - time() );
|
||||
$remaining_minutes = (int) ceil( $remaining_seconds / 60 );
|
||||
return new \WP_Error(
|
||||
'robotstxt_2fa_locked',
|
||||
sprintf(
|
||||
/* translators: %d: minutes remaining. */
|
||||
_n(
|
||||
'Too many failed attempts. Please try again in %d minute.',
|
||||
'Too many failed attempts. Please try again in %d minutes.',
|
||||
$remaining_minutes,
|
||||
'robotstxt-2fa'
|
||||
),
|
||||
$remaining_minutes
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$stage_token = $this->get_requested_stage_token();
|
||||
|
||||
if ( '' === $stage_token || ! $this->validate_stage_token( $authenticated_user, $stage_token ) ) {
|
||||
|
|
@ -582,9 +655,38 @@ class Login_Form_Manager {
|
|||
}
|
||||
|
||||
if ( ! $is_valid ) {
|
||||
/**
|
||||
* Fires when a 2FA verification attempt fails.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param \WP_User $user User who failed the challenge.
|
||||
* @param string $method Verification method slug that was attempted.
|
||||
*/
|
||||
do_action( 'robotstxt_2fa_verification_failed', $authenticated_user, $method );
|
||||
|
||||
// Brute-force: increment attempt counter and lock if threshold reached.
|
||||
$max_attempts = $this->config->get_max_attempts();
|
||||
|
||||
if ( $max_attempts > 0 ) {
|
||||
$attempts_key = 'robotstxt_2fa_attempts_' . $authenticated_user->ID;
|
||||
$raw_attempts = get_transient( $attempts_key );
|
||||
$attempts = ( is_numeric( $raw_attempts ) ? (int) $raw_attempts : 0 ) + 1;
|
||||
set_transient( $attempts_key, $attempts, 30 * MINUTE_IN_SECONDS );
|
||||
|
||||
if ( $attempts >= $max_attempts ) {
|
||||
delete_transient( $attempts_key );
|
||||
$expires_at = time() + $this->config->get_lockout_duration() * MINUTE_IN_SECONDS;
|
||||
set_transient( $lock_key, $expires_at, $this->config->get_lockout_duration() * MINUTE_IN_SECONDS );
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error( 'robotstxt_2fa_invalid_code', __( 'The verification code is incorrect or has expired.', 'robotstxt-2fa' ) );
|
||||
}
|
||||
|
||||
// Brute-force: clear the attempt counter on success.
|
||||
delete_transient( 'robotstxt_2fa_attempts_' . $authenticated_user->ID );
|
||||
|
||||
$this->user_settings_repository->record_last_verification(
|
||||
$authenticated_user->ID,
|
||||
$method,
|
||||
|
|
@ -592,6 +694,23 @@ class Login_Form_Manager {
|
|||
time()
|
||||
);
|
||||
|
||||
// Trusted devices: set cookie when user checked "Remember this browser".
|
||||
$trust_days = $this->config->get_trust_device_days();
|
||||
|
||||
if ( $trust_days > 0 && isset( $_POST['robotstxt_2fa_trust_device'] ) && '1' === $_POST['robotstxt_2fa_trust_device'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce already verified above.
|
||||
$this->trusted_devices->trust_current_device( $authenticated_user, $trust_days );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after a successful 2FA verification.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param \WP_User $user Authenticated user.
|
||||
* @param string $method Verification method slug that succeeded.
|
||||
*/
|
||||
do_action( 'robotstxt_2fa_verification_success', $authenticated_user, $method );
|
||||
|
||||
return $authenticated_user;
|
||||
}
|
||||
|
||||
|
|
@ -615,6 +734,63 @@ class Login_Form_Manager {
|
|||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter whether to skip the 2FA challenge for the current request.
|
||||
*
|
||||
* Return true to bypass the challenge entirely. Use this for REST API
|
||||
* requests, WP-CLI, Application Passwords, trusted IPs, etc.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool $skip Whether to skip the challenge. Default false.
|
||||
* @param \WP_User $user Authenticated user about to be challenged.
|
||||
*/
|
||||
if ( (bool) apply_filters( 'robotstxt_2fa_skip_challenge', false, $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Trusted devices: skip challenge when the browser carries a valid trust cookie.
|
||||
if ( $this->config->get_trust_device_days() > 0 && $this->trusted_devices->is_trusted( $user ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$required_methods = $this->config->get_required_methods_for_user( $user );
|
||||
|
||||
// Grace period: if forced but no methods configured yet, check whether the user is within their grace window.
|
||||
if ( ! empty( $required_methods ) ) {
|
||||
$user_settings = $this->user_settings_repository->get_user_settings( $user->ID );
|
||||
$has_any_method = ! empty( $user_settings['methods'] );
|
||||
|
||||
if ( ! $has_any_method ) {
|
||||
$grace_days = $this->config->get_grace_period_days();
|
||||
|
||||
if ( $grace_days > 0 && $this->grace_period->is_active( $user, $grace_days ) ) {
|
||||
// Still within grace period — let the user through.
|
||||
$remaining = $this->grace_period->get_days_remaining( $user, $grace_days );
|
||||
set_transient(
|
||||
'robotstxt_2fa_grace_notice_' . $user->ID,
|
||||
$remaining,
|
||||
MINUTE_IN_SECONDS
|
||||
);
|
||||
return $user;
|
||||
}
|
||||
|
||||
if ( $grace_days > 0 ) {
|
||||
// Grace period has expired.
|
||||
if ( 'wizard' === $this->config->get_grace_period_action() ) {
|
||||
$this->grace_period->set_pending_setup( $user->ID );
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Block mode.
|
||||
return new \WP_Error(
|
||||
'robotstxt_2fa_setup_required',
|
||||
__( 'Your account requires two-factor authentication. Please contact your administrator to complete the setup.', 'robotstxt-2fa' )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$method = $this->determine_required_verification_method( $user );
|
||||
|
||||
if ( '' === $method ) {
|
||||
|
|
|
|||
188
includes/user/class-frontend-profile.php
Normal file
188
includes/user/class-frontend-profile.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
/**
|
||||
* Frontend profile shortcode.
|
||||
*
|
||||
* @package Robotstxt_2FA
|
||||
*/
|
||||
|
||||
namespace Robotstxt\TwoFA\User;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the [robotstxt_2fa_profile] shortcode and processes its form
|
||||
* submissions on the frontend without requiring wp-admin access.
|
||||
*
|
||||
* Usage:
|
||||
* [robotstxt_2fa_profile]
|
||||
* [robotstxt_2fa_profile user_id="42" redirect="https://example.com/account"]
|
||||
*
|
||||
* Attributes:
|
||||
* user_id – Optional. Defaults to the current user. Admins can specify another ID.
|
||||
* redirect – Optional. URL to redirect to after a successful save.
|
||||
*/
|
||||
class Frontend_Profile {
|
||||
/**
|
||||
* Profile settings handler.
|
||||
*
|
||||
* @var Profile_Settings
|
||||
*/
|
||||
private Profile_Settings $profile_settings;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->profile_settings = new Profile_Settings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_shortcode( 'robotstxt_2fa_profile', array( $this, 'render_shortcode' ) );
|
||||
add_action( 'init', array( $this, 'maybe_handle_frontend_save' ) );
|
||||
add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a frontend profile form POST before page output.
|
||||
*
|
||||
* Fires on the `init` hook so the save completes and settings errors are
|
||||
* populated before the shortcode renders on the same request.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_handle_frontend_save(): void {
|
||||
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! isset( $_POST[ Profile_Settings::NONCE_FIELD ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Full nonce verification happens inside save_profile_settings().
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! is_user_logged_in() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->profile_settings->save_profile_settings( get_current_user_id() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the [robotstxt_2fa_profile] shortcode.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param mixed $atts Shortcode attributes.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function render_shortcode( $atts ): string {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$atts = shortcode_atts(
|
||||
array(
|
||||
'user_id' => (string) get_current_user_id(),
|
||||
'redirect' => '',
|
||||
),
|
||||
is_array( $atts ) ? $atts : array(),
|
||||
'robotstxt_2fa_profile'
|
||||
);
|
||||
|
||||
$user_id = absint( $atts['user_id'] );
|
||||
$redirect = esc_url_raw( $atts['redirect'] );
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$user = get_user_by( 'ID', $user_id );
|
||||
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
|
||||
/**
|
||||
* Filter the CSS class applied to the frontend profile container.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $class Default container class.
|
||||
*/
|
||||
$wrapper_class = (string) apply_filters( 'robotstxt_2fa_profile_wrapper_class', 'robotstxt-2fa-frontend' );
|
||||
|
||||
settings_errors( 'robotstxt-2fa' );
|
||||
?>
|
||||
<div class="<?php echo esc_attr( $wrapper_class ); ?>">
|
||||
<form method="post" action="">
|
||||
<?php
|
||||
if ( '' !== $redirect ) {
|
||||
printf(
|
||||
'<input type="hidden" name="robotstxt_2fa_frontend_redirect" value="%s" />',
|
||||
esc_attr( $redirect )
|
||||
);
|
||||
}
|
||||
$this->profile_settings->render_profile_section( $user );
|
||||
submit_button( __( 'Save', 'robotstxt-2fa' ) );
|
||||
?>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue profile assets on pages that contain the shortcode.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_enqueue_assets(): void {
|
||||
global $post;
|
||||
|
||||
if ( ! is_a( $post, 'WP_Post' ) || ! has_shortcode( $post->post_content, 'robotstxt_2fa_profile' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'robotstxt-2fa-profile',
|
||||
ROBOTSTXT_2FA_URL . 'assets/css/profile.css',
|
||||
array(),
|
||||
ROBOTSTXT_2FA_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'robotstxt-2fa-recovery',
|
||||
ROBOTSTXT_2FA_URL . 'assets/js/recovery-codes.js',
|
||||
array(),
|
||||
ROBOTSTXT_2FA_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
wp_localize_script(
|
||||
'robotstxt-2fa-recovery',
|
||||
'robotstxt2FARecovery',
|
||||
array(
|
||||
'strings' => array(
|
||||
'copyConfirm' => __( 'The recovery codes have been copied to your clipboard.', 'robotstxt-2fa' ),
|
||||
'copyFallback' => __( 'Copying failed. Please copy the codes manually.', 'robotstxt-2fa' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
193
includes/user/class-grace-period.php
Normal file
193
includes/user/class-grace-period.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
wp_safe_redirect( admin_url( 'profile.php#robotstxt-2fa-settings' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ namespace Robotstxt\TwoFA\User;
|
|||
use Robotstxt\TwoFA\Frequency_Options;
|
||||
use Robotstxt\TwoFA\User\OTP_Manager;
|
||||
use Robotstxt\TwoFA\User\Recovery_Codes;
|
||||
use Robotstxt\TwoFA\User\Trusted_Devices;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
|
|
@ -25,9 +26,10 @@ class Profile_Settings {
|
|||
private const NONCE_ACTION = 'robotstxt_2fa_profile_settings';
|
||||
|
||||
/**
|
||||
* Nonce field name used in the profile form.
|
||||
* Nonce field name embedded in the profile form.
|
||||
* Public so the frontend shortcode class can detect frontend POST submissions.
|
||||
*/
|
||||
private const NONCE_FIELD = 'robotstxt_2fa_profile_nonce';
|
||||
public const NONCE_FIELD = 'robotstxt_2fa_profile_nonce';
|
||||
|
||||
/**
|
||||
* Anchor identifier used to focus the 2FA section after saving.
|
||||
|
|
@ -50,6 +52,16 @@ class Profile_Settings {
|
|||
*/
|
||||
private const RECOVERY_CONFIRM_FIELD = 'robotstxt_2fa_recovery_confirm_code';
|
||||
|
||||
/**
|
||||
* Form field that triggers recovery code regeneration.
|
||||
*/
|
||||
private const RECOVERY_REGENERATE_FIELD = 'robotstxt_2fa_regenerate_recovery';
|
||||
|
||||
/**
|
||||
* Form field that triggers trusted device revocation.
|
||||
*/
|
||||
private const REVOKE_DEVICE_FIELD = 'robotstxt_2fa_revoke_device';
|
||||
|
||||
/**
|
||||
* User settings repository instance.
|
||||
*
|
||||
|
|
@ -78,6 +90,13 @@ class Profile_Settings {
|
|||
*/
|
||||
private Recovery_Codes $recovery_codes;
|
||||
|
||||
/**
|
||||
* Trusted devices manager.
|
||||
*
|
||||
* @var Trusted_Devices
|
||||
*/
|
||||
private Trusted_Devices $trusted_devices;
|
||||
|
||||
/**
|
||||
* Whether the post-save redirect should focus the 2FA section.
|
||||
*
|
||||
|
|
@ -93,6 +112,7 @@ class Profile_Settings {
|
|||
$this->config = new Two_Factor_Config();
|
||||
$this->otp_manager = new OTP_Manager();
|
||||
$this->recovery_codes = new Recovery_Codes();
|
||||
$this->trusted_devices = new Trusted_Devices();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -411,7 +431,12 @@ class Profile_Settings {
|
|||
<?php endif; ?>
|
||||
<?php if ( $recovery_enabled ) : ?>
|
||||
<p class="description updated">
|
||||
<?php esc_html_e( 'Recovery codes are active. Disable this option to generate a fresh list.', 'robotstxt-2fa' ); ?>
|
||||
<?php esc_html_e( 'Recovery codes are active.', 'robotstxt-2fa' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit" class="button button-secondary" name="<?php echo esc_attr( self::RECOVERY_REGENERATE_FIELD ); ?>" value="1">
|
||||
<?php esc_html_e( 'Regenerate codes', 'robotstxt-2fa' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ( $has_preview ) : ?>
|
||||
|
|
@ -534,6 +559,53 @@ class Profile_Settings {
|
|||
}());
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// Trusted Devices section — show only for the own profile and when the feature is enabled.
|
||||
$trust_days = $this->config->get_trust_device_days();
|
||||
|
||||
if ( $trust_days > 0 && get_current_user_id() === $user->ID ) :
|
||||
$devices = $this->trusted_devices->get_devices( $user->ID );
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Trusted Devices', 'robotstxt-2fa' ); ?></h2>
|
||||
<?php if ( empty( $devices ) ) : ?>
|
||||
<p class="description"><?php esc_html_e( 'No trusted devices. Check "Remember this browser" after your next verification to trust a device.', 'robotstxt-2fa' ); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="widefat fixed striped" style="max-width:560px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Device', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Trusted since', 'robotstxt-2fa' ); ?></th>
|
||||
<th><?php esc_html_e( 'Expires', 'robotstxt-2fa' ); ?></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $devices as $token_hash => $device ) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html( $device['label'] ); ?></td>
|
||||
<?php
|
||||
$raw_fmt = get_option( 'date_format' );
|
||||
$fmt = is_string( $raw_fmt ) ? $raw_fmt : 'Y-m-d';
|
||||
?>
|
||||
<td><?php echo esc_html( wp_date( $fmt, $device['created_at'] ) ?: '' ); ?></td>
|
||||
<td><?php echo esc_html( wp_date( $fmt, $device['expires_at'] ) ?: '' ); ?></td>
|
||||
<td>
|
||||
<button type="submit" class="button button-small" name="<?php echo esc_attr( self::REVOKE_DEVICE_FIELD ); ?>" value="<?php echo esc_attr( $token_hash ); ?>">
|
||||
<?php esc_html_e( 'Revoke', 'robotstxt-2fa' ); ?>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top:0.75em;">
|
||||
<button type="submit" class="button button-secondary" name="<?php echo esc_attr( self::REVOKE_DEVICE_FIELD ); ?>" value="all">
|
||||
<?php esc_html_e( 'Revoke all trusted devices', 'robotstxt-2fa' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
}
|
||||
|
||||
|
|
@ -665,6 +737,22 @@ class Profile_Settings {
|
|||
return;
|
||||
}
|
||||
|
||||
// Trusted devices: handle revoke requests before any other processing.
|
||||
if ( isset( $_POST[ self::REVOKE_DEVICE_FIELD ] ) && is_string( $_POST[ self::REVOKE_DEVICE_FIELD ] ) ) {
|
||||
$revoke_value = sanitize_text_field( wp_unslash( $_POST[ self::REVOKE_DEVICE_FIELD ] ) );
|
||||
|
||||
if ( 'all' === $revoke_value ) {
|
||||
$this->trusted_devices->revoke_all( $user_id );
|
||||
add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-devices-revoked-all', __( 'All trusted devices have been revoked.', 'robotstxt-2fa' ), 'updated' );
|
||||
} elseif ( '' !== $revoke_value ) {
|
||||
$this->trusted_devices->revoke( $user_id, $revoke_value );
|
||||
add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-device-revoked', __( 'Trusted device revoked.', 'robotstxt-2fa' ), 'updated' );
|
||||
}
|
||||
|
||||
$this->focus_section = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
|
||||
$raw_settings = isset( $_POST['robotstxt_2fa_settings'] ) && is_array( $_POST['robotstxt_2fa_settings'] )
|
||||
? wp_unslash( $_POST['robotstxt_2fa_settings'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
|
|
@ -706,6 +794,29 @@ class Profile_Settings {
|
|||
$otp_was_enabled = in_array( 'otp', $previous_methods, true );
|
||||
$recovery_was_enabled = in_array( 'recovery_codes', $previous_methods, true );
|
||||
|
||||
// Handle recovery code regeneration request — replaces the full batch.
|
||||
if ( isset( $_POST[ self::RECOVERY_REGENERATE_FIELD ] ) && $recovery_was_enabled ) {
|
||||
try {
|
||||
$result = $this->recovery_codes->regenerate_codes_for_user( $user );
|
||||
} catch ( \Throwable $exception ) {
|
||||
/**
|
||||
* Fires when recovery code generation fails.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*
|
||||
* @param \Throwable $exception The caught exception.
|
||||
*/
|
||||
do_action( 'robotstxt_2fa_recovery_generation_failed', $exception );
|
||||
add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-regen-error', __( 'We could not regenerate recovery codes. Please try again.', 'robotstxt-2fa' ), 'error' );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->remember_recovery_preview( $user->ID, $result['codes'], $result['generated_at'] );
|
||||
$this->focus_section = true;
|
||||
add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-regenerated', __( 'New recovery codes generated. Copy them and confirm one to activate.', 'robotstxt-2fa' ), 'updated' );
|
||||
return;
|
||||
}
|
||||
|
||||
$otp_code = '';
|
||||
|
||||
if ( isset( $_POST[ self::OTP_CODE_FIELD ] ) && is_string( $_POST[ self::OTP_CODE_FIELD ] ) ) {
|
||||
|
|
@ -856,6 +967,9 @@ class Profile_Settings {
|
|||
$frequency = $this->config->get_frequency();
|
||||
}
|
||||
|
||||
$methods_added = array_diff( $selected_methods, $previous_methods );
|
||||
$methods_removed = array_diff( $previous_methods, $selected_methods );
|
||||
|
||||
$this->user_settings_repository->save_user_settings(
|
||||
$user_id,
|
||||
array(
|
||||
|
|
@ -868,6 +982,30 @@ class Profile_Settings {
|
|||
if ( ! $enabled ) {
|
||||
$this->user_settings_repository->delete_email_challenge( $user_id );
|
||||
}
|
||||
|
||||
foreach ( $methods_added as $added_method ) {
|
||||
/**
|
||||
* Fires when a user activates a 2FA method from their profile.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param \WP_User $user User who enabled the method.
|
||||
* @param string $method Method slug that was activated.
|
||||
*/
|
||||
do_action( 'robotstxt_2fa_method_enabled', $user, $added_method );
|
||||
}
|
||||
|
||||
foreach ( $methods_removed as $removed_method ) {
|
||||
/**
|
||||
* Fires when a user deactivates a 2FA method from their profile.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param \WP_User $user User who disabled the method.
|
||||
* @param string $method Method slug that was deactivated.
|
||||
*/
|
||||
do_action( 'robotstxt_2fa_method_disabled', $user, $removed_method );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ class Recovery_Codes {
|
|||
* @return array{codes: array<int, string>, generated_at: int, unused_count: int}
|
||||
*/
|
||||
public function regenerate_codes_for_user( WP_User $user ): array {
|
||||
$codes = $this->generate_codes();
|
||||
$codes = $this->generate_codes( self::CODES_PER_BATCH );
|
||||
|
||||
$this->store_codes( $user->ID, $codes );
|
||||
|
||||
|
|
@ -169,6 +169,44 @@ class Recovery_Codes {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Top up recovery codes to the full batch size without replacing existing unused codes.
|
||||
*
|
||||
* Generates only as many new codes as needed to bring the total of unused codes
|
||||
* back to CODES_PER_BATCH. If all codes are still active, returns an empty codes
|
||||
* array and makes no changes.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @param WP_User $user User whose codes are being topped up.
|
||||
*
|
||||
* @throws \Exception If secure randomness cannot be generated.
|
||||
*
|
||||
* @return array{codes: array<int, string>, generated_at: int, unused_count: int}
|
||||
*/
|
||||
public function top_up_codes_for_user( WP_User $user ): array {
|
||||
$unused = $this->count_unused_codes( $user->ID );
|
||||
$needed = max( 0, self::CODES_PER_BATCH - $unused );
|
||||
|
||||
if ( 0 === $needed ) {
|
||||
return array(
|
||||
'codes' => array(),
|
||||
'generated_at' => $this->get_last_generated_timestamp( $user->ID ),
|
||||
'unused_count' => $unused,
|
||||
);
|
||||
}
|
||||
|
||||
$new_codes = $this->generate_codes( $needed );
|
||||
|
||||
$this->append_codes( $user->ID, $new_codes );
|
||||
|
||||
return array(
|
||||
'codes' => $new_codes,
|
||||
'generated_at' => $this->get_last_generated_timestamp( $user->ID ),
|
||||
'unused_count' => $this->count_unused_codes( $user->ID ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the number of digits expected for each recovery code.
|
||||
*
|
||||
|
|
@ -308,10 +346,19 @@ class Recovery_Codes {
|
|||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function generate_codes(): array {
|
||||
/**
|
||||
* Generate a batch of unique recovery codes.
|
||||
*
|
||||
* @param int $count Number of codes to generate. Defaults to CODES_PER_BATCH.
|
||||
*
|
||||
* @throws \Exception If randomness cannot be generated.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function generate_codes( int $count = 0 ): array {
|
||||
$target_count = $count > 0 ? $count : self::CODES_PER_BATCH;
|
||||
$codes = array();
|
||||
$attempts = 0;
|
||||
$target_count = self::CODES_PER_BATCH;
|
||||
$max_attempts = $target_count * 3;
|
||||
$unique_count = 0;
|
||||
|
||||
|
|
@ -331,6 +378,35 @@ class Recovery_Codes {
|
|||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append new plain-text codes to the existing stored record.
|
||||
*
|
||||
* Updates generated_at to the current time.
|
||||
*
|
||||
* @param int $user_id User identifier.
|
||||
* @param array<int, string> $codes Plain-text codes to append.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function append_codes( int $user_id, array $codes ): void {
|
||||
$record = $this->get_stored_codes( $user_id );
|
||||
|
||||
foreach ( $codes as $code ) {
|
||||
$normalized = $this->normalize_code( $code );
|
||||
|
||||
if ( '' !== $normalized ) {
|
||||
$record['codes'][] = array(
|
||||
'hash' => wp_hash_password( $normalized ),
|
||||
'used_at' => 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$record['generated_at'] = time();
|
||||
|
||||
update_user_meta( $user_id, self::META_KEY, $record );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single recovery code.
|
||||
*
|
||||
|
|
|
|||
375
includes/user/class-trusted-devices.php
Normal file
375
includes/user/class-trusted-devices.php
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<?php
|
||||
/**
|
||||
* Trusted device management.
|
||||
*
|
||||
* @package Robotstxt_2FA
|
||||
*/
|
||||
|
||||
namespace Robotstxt\TwoFA\User;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages trusted-device cookies and their server-side records so that users
|
||||
* can skip the 2FA challenge on browsers they have previously trusted.
|
||||
*
|
||||
* Cookie design:
|
||||
* Name: robotstxt_2fa_trust
|
||||
* Value: {user_id}|{token_hex}|{hmac_sha256}
|
||||
* Flags: HttpOnly, Secure (when SSL), SameSite=Strict
|
||||
*
|
||||
* Server-side record (user meta robotstxt_2fa_trusted_devices):
|
||||
* array{ sha256(token) => array{ label: string, created_at: int, expires_at: int } }
|
||||
*/
|
||||
class Trusted_Devices {
|
||||
/**
|
||||
* User meta key for trusted device records.
|
||||
*/
|
||||
private const META_KEY = 'robotstxt_2fa_trusted_devices';
|
||||
|
||||
/**
|
||||
* Cookie name.
|
||||
*/
|
||||
public const COOKIE_NAME = 'robotstxt_2fa_trust';
|
||||
|
||||
/**
|
||||
* Maximum stored devices per user.
|
||||
*/
|
||||
private const MAX_DEVICES = 10;
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'robotstxt_2fa_method_enabled', array( $this, 'maybe_clear_on_method_change' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current request comes from a trusted device for the given user.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param \WP_User $user User to check.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_trusted( \WP_User $user ): bool {
|
||||
if ( ! isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cookie_value = is_string( $_COOKIE[ self::COOKIE_NAME ] ) ? $_COOKIE[ self::COOKIE_NAME ] : '';
|
||||
|
||||
return $this->verify_cookie( $user, $cookie_value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current device as trusted for the given user.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param \WP_User $user User who trusts the device.
|
||||
* @param int $days Number of days the trust is valid.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function trust_current_device( \WP_User $user, int $days ): void {
|
||||
if ( $days <= 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = bin2hex( random_bytes( 32 ) );
|
||||
$expires_at = time() + $days * DAY_IN_SECONDS;
|
||||
|
||||
$this->set_cookie( $user->ID, $token, $expires_at );
|
||||
$this->store_device( $user->ID, $token, $expires_at );
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific trusted device by its token hash.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param string $token_hash SHA-256 hash of the device token.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function revoke( int $user_id, string $token_hash ): void {
|
||||
$devices = $this->get_raw_devices( $user_id );
|
||||
unset( $devices[ $token_hash ] );
|
||||
update_user_meta( $user_id, self::META_KEY, $devices );
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all trusted devices for a user and clear the browser cookie.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function revoke_all( int $user_id ): void {
|
||||
delete_user_meta( $user_id, self::META_KEY );
|
||||
$this->clear_cookie();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of active trusted devices for a user.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
*
|
||||
* @return array<string, array{label: string, created_at: int, expires_at: int}>
|
||||
*/
|
||||
public function get_devices( int $user_id ): array {
|
||||
$now = time();
|
||||
$raw = $this->get_raw_devices( $user_id );
|
||||
$active = array();
|
||||
|
||||
foreach ( $raw as $hash => $entry ) {
|
||||
if ( is_array( $entry ) && isset( $entry['expires_at'] ) && is_numeric( $entry['expires_at'] ) && (int) $entry['expires_at'] > $now ) {
|
||||
$active[ $hash ] = array(
|
||||
'label' => is_string( $entry['label'] ?? null ) ? $entry['label'] : '',
|
||||
'created_at' => isset( $entry['created_at'] ) && is_int( $entry['created_at'] ) ? $entry['created_at'] : 0,
|
||||
'expires_at' => (int) $entry['expires_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear expired device entries for a user (housekeeping).
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function prune_expired( int $user_id ): void {
|
||||
$now = time();
|
||||
$raw = $this->get_raw_devices( $user_id );
|
||||
$kept = array();
|
||||
|
||||
foreach ( $raw as $hash => $entry ) {
|
||||
if ( is_array( $entry ) && isset( $entry['expires_at'] ) && is_numeric( $entry['expires_at'] ) && (int) $entry['expires_at'] > $now ) {
|
||||
$kept[ $hash ] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
update_user_meta( $user_id, self::META_KEY, $kept );
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally clear devices when a user enables a new 2FA method
|
||||
* (security-conscious sites may want to re-verify all existing sessions).
|
||||
*
|
||||
* No-op by default — this method exists as a hook target that site owners
|
||||
* can swap by removing and re-adding the action with different logic.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param \WP_User $user User who enabled the method.
|
||||
* @param string $method Method slug.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_clear_on_method_change( \WP_User $user, string $method ): void {
|
||||
unset( $method );
|
||||
// No-op: override via remove_action + add_action to customize behaviour.
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a human-readable device label from a User-Agent string.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $ua User-Agent header value.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function label_from_ua( string $ua ): string {
|
||||
$browser = 'Browser';
|
||||
$os = 'Unknown OS';
|
||||
|
||||
if ( str_contains( $ua, 'Edg/' ) ) {
|
||||
$browser = 'Edge';
|
||||
} elseif ( str_contains( $ua, 'Firefox/' ) ) {
|
||||
$browser = 'Firefox';
|
||||
} elseif ( str_contains( $ua, 'Chrome/' ) ) {
|
||||
$browser = 'Chrome';
|
||||
} elseif ( str_contains( $ua, 'Safari/' ) ) {
|
||||
$browser = 'Safari';
|
||||
}
|
||||
|
||||
if ( str_contains( $ua, 'iPhone' ) ) {
|
||||
$os = 'iPhone';
|
||||
} elseif ( str_contains( $ua, 'iPad' ) ) {
|
||||
$os = 'iPad';
|
||||
} elseif ( str_contains( $ua, 'Android' ) ) {
|
||||
$os = 'Android';
|
||||
} elseif ( str_contains( $ua, 'Macintosh' ) ) {
|
||||
$os = 'Mac';
|
||||
} elseif ( str_contains( $ua, 'Windows' ) ) {
|
||||
$os = 'Windows';
|
||||
} elseif ( str_contains( $ua, 'Linux' ) ) {
|
||||
$os = 'Linux';
|
||||
}
|
||||
|
||||
return "{$browser} on {$os}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a raw cookie value against the user's stored device records.
|
||||
*
|
||||
* @param \WP_User $user User to verify against.
|
||||
* @param string $cookie_value Raw cookie value.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_cookie( \WP_User $user, string $cookie_value ): bool {
|
||||
$parts = explode( '|', $cookie_value, 3 );
|
||||
|
||||
if ( 3 !== count( $parts ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[ $user_id_raw, $token, $sig ] = $parts;
|
||||
|
||||
if ( absint( $user_id_raw ) !== $user->ID ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! defined( 'AUTH_SALT' ) || '' === AUTH_SALT ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected_sig = hash_hmac( 'sha256', $user->ID . '|' . $token, AUTH_SALT );
|
||||
|
||||
if ( ! hash_equals( $expected_sig, $sig ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token_hash = hash( 'sha256', $token );
|
||||
$devices = $this->get_raw_devices( $user->ID );
|
||||
|
||||
if ( ! isset( $devices[ $token_hash ] ) || ! is_array( $devices[ $token_hash ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$entry = $devices[ $token_hash ];
|
||||
|
||||
return isset( $entry['expires_at'] ) && is_numeric( $entry['expires_at'] ) && (int) $entry['expires_at'] > time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the trust cookie to the browser.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param string $token Plain-text token (hex).
|
||||
* @param int $expires_at Unix timestamp when the trust expires.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function set_cookie( int $user_id, string $token, int $expires_at ): void {
|
||||
if ( ! defined( 'AUTH_SALT' ) || '' === AUTH_SALT ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sig = hash_hmac( 'sha256', $user_id . '|' . $token, AUTH_SALT );
|
||||
$value = $user_id . '|' . $token . '|' . $sig;
|
||||
|
||||
setcookie(
|
||||
self::COOKIE_NAME,
|
||||
$value,
|
||||
array(
|
||||
'expires' => $expires_at,
|
||||
'path' => defined( 'COOKIEPATH' ) ? COOKIEPATH : '/',
|
||||
'domain' => defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '',
|
||||
'secure' => is_ssl(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the trust cookie from the browser.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function clear_cookie(): void {
|
||||
setcookie(
|
||||
self::COOKIE_NAME,
|
||||
'',
|
||||
array(
|
||||
'expires' => time() - YEAR_IN_SECONDS,
|
||||
'path' => defined( 'COOKIEPATH' ) ? COOKIEPATH : '/',
|
||||
'domain' => defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '',
|
||||
'secure' => is_ssl(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new device record in user meta.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param string $token Plain-text token.
|
||||
* @param int $expires_at Expiry timestamp.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function store_device( int $user_id, string $token, int $expires_at ): void {
|
||||
$devices = $this->get_raw_devices( $user_id );
|
||||
|
||||
$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) && is_string( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- used only for label display.
|
||||
$label = self::label_from_ua( sanitize_text_field( $ua ) );
|
||||
|
||||
$devices[ hash( 'sha256', $token ) ] = array(
|
||||
'label' => $label,
|
||||
'created_at' => time(),
|
||||
'expires_at' => $expires_at,
|
||||
);
|
||||
|
||||
// Keep only the most recent MAX_DEVICES entries.
|
||||
if ( count( $devices ) > self::MAX_DEVICES ) {
|
||||
uasort(
|
||||
$devices,
|
||||
static function ( mixed $a, mixed $b ): int {
|
||||
$a_ts = is_array( $a ) && isset( $a['created_at'] ) && is_int( $a['created_at'] ) ? $a['created_at'] : 0;
|
||||
$b_ts = is_array( $b ) && isset( $b['created_at'] ) && is_int( $b['created_at'] ) ? $b['created_at'] : 0;
|
||||
return $b_ts <=> $a_ts; // Newest first.
|
||||
}
|
||||
);
|
||||
$devices = array_slice( $devices, 0, self::MAX_DEVICES, true );
|
||||
}
|
||||
|
||||
update_user_meta( $user_id, self::META_KEY, $devices );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the raw device map from user meta.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function get_raw_devices( int $user_id ): array {
|
||||
$raw = get_user_meta( $user_id, self::META_KEY, true );
|
||||
return is_array( $raw ) ? $raw : array();
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ class Two_Factor_Config {
|
|||
/**
|
||||
* Retrieve global two-factor settings with multisite support.
|
||||
*
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool}
|
||||
* @return array{role_methods: array<string, array<int, string>>, frequency: string, force_frequency: bool, delete_on_uninstall: bool, max_attempts: int, lockout_duration: int, trust_device_days: int, grace_period_days: int, grace_period_action: string}
|
||||
*/
|
||||
public function get_settings(): array {
|
||||
$settings_page = new Settings_Page();
|
||||
|
|
@ -79,7 +79,26 @@ class Two_Factor_Config {
|
|||
}
|
||||
}
|
||||
|
||||
return array_values( array_unique( $required ) );
|
||||
$required = array_values( array_unique( $required ) );
|
||||
|
||||
/**
|
||||
* Filter the required 2FA methods for a specific user.
|
||||
*
|
||||
* Return a modified array of method slugs to override or extend the
|
||||
* role-based configuration for this user.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array<int, string> $required Method slugs required by the user's roles.
|
||||
* @param \WP_User $user User being authenticated.
|
||||
*/
|
||||
/**
|
||||
* @var array<int, string> $filtered
|
||||
* Note: at runtime a callback may return any type; the map below sanitizes it.
|
||||
*/
|
||||
$filtered = apply_filters( 'robotstxt_2fa_required_methods_for_user', $required, $user );
|
||||
|
||||
return array_values( array_filter( $filtered ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,4 +129,59 @@ class Two_Factor_Config {
|
|||
public function is_frequency_forced(): bool {
|
||||
return $this->get_settings()['force_frequency'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the maximum failed 2FA attempts before lockout (0 = unlimited).
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_max_attempts(): int {
|
||||
return $this->get_settings()['max_attempts'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the lockout duration in minutes.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_lockout_duration(): int {
|
||||
return $this->get_settings()['lockout_duration'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve how many days a trusted browser cookie is valid (0 = disabled).
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_trust_device_days(): int {
|
||||
return $this->get_settings()['trust_device_days'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the grace period in days for users who have not yet configured 2FA (0 = disabled).
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_grace_period_days(): int {
|
||||
return $this->get_settings()['grace_period_days'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the action taken after the grace period expires: 'block' or 'wizard'.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_grace_period_action(): string {
|
||||
return $this->get_settings()['grace_period_action'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
readme.txt
15
readme.txt
|
|
@ -3,8 +3,8 @@ Contributors: robotstxt
|
|||
Tags: security, two-factor authentication, login, otp
|
||||
Requires at least: 6.4
|
||||
Tested up to: 7.0
|
||||
Requires PHP: 8.2
|
||||
Stable tag: 1.0.0
|
||||
Requires PHP: 8.0
|
||||
Stable tag: 1.2.0
|
||||
License: GPLv3 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
|
|
@ -54,11 +54,20 @@ Yes. Activate the plugin at the network level. Network administrators can set an
|
|||
== Compatibility ==
|
||||
|
||||
* WordPress: 6.4 – 7.0
|
||||
* PHP: 8.2 – 8.5
|
||||
* PHP: 8.0 – 8.5
|
||||
* MariaDB: 11.4 or newer
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.1.0 =
|
||||
|
||||
_Release date: 2026-06-05_
|
||||
|
||||
* Added `[robotstxt_2fa_profile]` shortcode for frontend 2FA management without wp-admin.
|
||||
* Added developer action/filter hooks: `robotstxt_2fa_skip_challenge`, `robotstxt_2fa_verification_success`, `robotstxt_2fa_verification_failed`, `robotstxt_2fa_code_length`, `robotstxt_2fa_code_ttl`, and more.
|
||||
* Added "Regenerate codes" button on the profile when recovery codes are active.
|
||||
* Added top-up mode for recovery codes: generates only the missing codes to refill the batch.
|
||||
|
||||
= 1.0.0 =
|
||||
|
||||
_Release date: 2026-06-05_
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
* Plugin Name: 2FA (by ROBOTSTXT)
|
||||
* Plugin URI: https://www.robotstxt.es/plugins/robotstxt-2fa/
|
||||
* Description: Adds two-factor authentication to the WordPress login flow.
|
||||
* Version: 1.0.0
|
||||
* Version: 1.2.0
|
||||
* Author: ROBOTSTXT
|
||||
* Author URI: https://www.robotstxt.es/
|
||||
* Text Domain: robotstxt-2fa
|
||||
* Requires at least: 6.4
|
||||
* Requires PHP: 8.2
|
||||
* Requires PHP: 8.0
|
||||
* Network: true
|
||||
* License: GPLv3 or later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
|
@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
}
|
||||
|
||||
if ( ! defined( 'ROBOTSTXT_2FA_VERSION' ) ) {
|
||||
define( 'ROBOTSTXT_2FA_VERSION', '1.0.0' );
|
||||
define( 'ROBOTSTXT_2FA_VERSION', '1.2.0' );
|
||||
}
|
||||
|
||||
if ( ! defined( 'ROBOTSTXT_2FA_FILE' ) ) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "2FA (by ROBOTSTXT)",
|
||||
"slug": "robotstxt-2fa",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.0.0/robotstxt-2fa-1.0.0.zip",
|
||||
"requires": "6.4",
|
||||
"requires_php": "8.2",
|
||||
"requires_php": "8.0",
|
||||
"tested": "7.1",
|
||||
"last_updated": "2026-06-05",
|
||||
"author": "ROBOTSTXT",
|
||||
|
|
|
|||
4
vendor/composer/installed.php
vendored
4
vendor/composer/installed.php
vendored
|
|
@ -3,7 +3,7 @@
|
|||
'name' => 'robotstxt/robotstxt-2fa',
|
||||
'pretty_version' => 'dev-main',
|
||||
'version' => 'dev-main',
|
||||
'reference' => '4ff3019f7db5f58be0ecb3a3f1bb97bd97bdd153',
|
||||
'reference' => '15bccb135d29767e53678ad70372dbd78c679c91',
|
||||
'type' => 'wordpress-plugin',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
'robotstxt/robotstxt-2fa' => array(
|
||||
'pretty_version' => 'dev-main',
|
||||
'version' => 'dev-main',
|
||||
'reference' => '4ff3019f7db5f58be0ecb3a3f1bb97bd97bdd153',
|
||||
'reference' => '15bccb135d29767e53678ad70372dbd78c679c91',
|
||||
'type' => 'wordpress-plugin',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue