mirror of
https://gitlab.com/ArkHost/WP-Security-Pack.git
synced 2026-09-19 17:37:30 +02:00
v1.0
This commit is contained in:
@@ -0,0 +1,786 @@
|
||||
<?php
|
||||
/**
|
||||
* Two-Factor Authentication for Security Pack.
|
||||
*
|
||||
* @package Security_Pack
|
||||
*/
|
||||
|
||||
// Prevent direct access.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-Factor Authentication class using TOTP.
|
||||
*/
|
||||
class WPSP_Two_Factor {
|
||||
|
||||
/**
|
||||
* User meta key for 2FA secret.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const SECRET_META_KEY = '_wpsp_2fa_secret';
|
||||
|
||||
/**
|
||||
* User meta key for 2FA enabled status.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const ENABLED_META_KEY = '_wpsp_2fa_enabled';
|
||||
|
||||
/**
|
||||
* User meta key for backup codes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BACKUP_CODES_META_KEY = '_wpsp_2fa_backup_codes';
|
||||
|
||||
/**
|
||||
* TOTP code length.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
/**
|
||||
* TOTP time step (30 seconds).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const TIME_STEP = 30;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
if ( ! Security_Pack::get_setting( 'two_factor_enabled', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept authentication to check for 2FA - runs before cookies are set.
|
||||
add_filter( 'authenticate', array( $this, 'check_2fa_on_authenticate' ), 100, 3 );
|
||||
|
||||
// Handle the 2FA verification form.
|
||||
// Use login_form_wpsp_2fa for standard wp-login.php.
|
||||
add_action( 'login_form_wpsp_2fa', array( $this, 'render_2fa_form' ) );
|
||||
// Also check on login_init for custom login URLs.
|
||||
add_action( 'login_init', array( $this, 'maybe_render_2fa_form' ), 5 );
|
||||
|
||||
// User profile settings.
|
||||
add_action( 'show_user_profile', array( $this, 'show_user_2fa_settings' ) );
|
||||
add_action( 'edit_user_profile', array( $this, 'show_user_2fa_settings' ) );
|
||||
add_action( 'personal_options_update', array( $this, 'save_user_2fa_settings' ) );
|
||||
add_action( 'edit_user_profile_update', array( $this, 'save_user_2fa_settings' ) );
|
||||
|
||||
// Enqueue scripts on profile pages.
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_profile_scripts' ) );
|
||||
|
||||
// AJAX handlers.
|
||||
add_action( 'wp_ajax_wpsp_generate_2fa_secret', array( $this, 'ajax_generate_secret' ) );
|
||||
add_action( 'wp_ajax_wpsp_verify_2fa_setup', array( $this, 'ajax_verify_setup' ) );
|
||||
add_action( 'wp_ajax_wpsp_disable_2fa', array( $this, 'ajax_disable_2fa' ) );
|
||||
add_action( 'wp_ajax_wpsp_regenerate_backup_codes', array( $this, 'ajax_regenerate_backup_codes' ) );
|
||||
|
||||
// Enforce 2FA for admins.
|
||||
if ( Security_Pack::get_setting( 'two_factor_enforce_admin', false ) ) {
|
||||
add_action( 'admin_init', array( $this, 'enforce_admin_2fa' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue scripts for profile pages.
|
||||
*
|
||||
* @param string $hook Current admin page hook.
|
||||
*/
|
||||
public function enqueue_profile_scripts( $hook ) {
|
||||
if ( 'profile.php' !== $hook && 'user-edit.php' !== $hook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'wpsp-qrcode',
|
||||
WPSP_PLUGIN_URL . 'assets/js/qrcode.min.js',
|
||||
array(),
|
||||
WPSP_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 2FA is required during authentication.
|
||||
* This runs BEFORE cookies are set, making it reliable for 2FA.
|
||||
*
|
||||
* @param WP_User|WP_Error|null $user User object or error.
|
||||
* @param string $username Username.
|
||||
* @param string $password Password.
|
||||
* @return WP_User|WP_Error
|
||||
*/
|
||||
public function check_2fa_on_authenticate( $user, $username, $password ) {
|
||||
// If not a valid user, let WordPress handle it.
|
||||
if ( ! $user instanceof WP_User ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled for this user.
|
||||
if ( ! $this->is_2fa_enabled_for_user( $user->ID ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Generate a token for the 2FA session.
|
||||
$token = wp_generate_password( 32, false );
|
||||
|
||||
// Nonce not possible during 2FA authentication flow; transient token validates the session.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$redirect = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url();
|
||||
set_transient( 'wpsp_2fa_' . $token, array(
|
||||
'user_id' => $user->ID,
|
||||
'redirect' => $redirect,
|
||||
), 5 * MINUTE_IN_SECONDS );
|
||||
|
||||
// Redirect to 2FA form immediately.
|
||||
wp_safe_redirect( add_query_arg( array(
|
||||
'action' => 'wpsp_2fa',
|
||||
'token' => $token,
|
||||
), wp_login_url() ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce 2FA setup for administrators.
|
||||
*/
|
||||
public function enforce_admin_2fa() {
|
||||
// Skip AJAX requests.
|
||||
if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = wp_get_current_user();
|
||||
|
||||
// Only apply to administrators.
|
||||
if ( ! $user || ! user_can( $user, 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already has 2FA enabled.
|
||||
if ( $this->is_2fa_enabled_for_user( $user->ID ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow access to profile page for setup.
|
||||
global $pagenow;
|
||||
if ( 'profile.php' === $pagenow || 'admin-ajax.php' === $pagenow ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to profile page with notice.
|
||||
wp_safe_redirect( add_query_arg( 'wpsp_2fa_required', '1', admin_url( 'profile.php' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should render the 2FA form (for custom login URLs).
|
||||
*/
|
||||
public function maybe_render_2fa_form() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( isset( $_GET['action'] ) && 'wpsp_2fa' === $_GET['action'] ) {
|
||||
$this->render_2fa_form();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render 2FA verification form.
|
||||
*/
|
||||
public function render_2fa_form() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
|
||||
|
||||
if ( empty( $token ) ) {
|
||||
wp_safe_redirect( wp_login_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = get_transient( 'wpsp_2fa_' . $token );
|
||||
|
||||
if ( ! $data ) {
|
||||
wp_safe_redirect( wp_login_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
|
||||
// Handle form submission - 2FA verification uses transient token instead of nonce; user already authenticated via password.
|
||||
if ( isset( $_POST['wpsp_2fa_code'] ) || isset( $_POST['wpsp_backup_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
// Check TOTP code first, then backup code.
|
||||
$code = '';
|
||||
if ( ! empty( $_POST['wpsp_2fa_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$code = sanitize_text_field( wp_unslash( $_POST['wpsp_2fa_code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
} elseif ( ! empty( $_POST['wpsp_backup_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$code = sanitize_text_field( wp_unslash( $_POST['wpsp_backup_code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
$user_id = $data['user_id'];
|
||||
|
||||
if ( ! empty( $code ) && $this->verify_code( $user_id, $code ) ) {
|
||||
// Delete the token.
|
||||
delete_transient( 'wpsp_2fa_' . $token );
|
||||
|
||||
// Log the user in.
|
||||
wp_set_auth_cookie( $user_id, false );
|
||||
wp_set_current_user( $user_id );
|
||||
|
||||
// Redirect.
|
||||
wp_safe_redirect( $data['redirect'] );
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = __( 'Invalid verification code.', 'security-pack' );
|
||||
}
|
||||
|
||||
// Render the form.
|
||||
login_header( __( 'Two-Factor Authentication', 'security-pack' ) );
|
||||
?>
|
||||
<form name="wpsp_2fa_form" id="wpsp_2fa_form" action="" method="post">
|
||||
<?php if ( $error ) : ?>
|
||||
<div id="login_error"><?php echo esc_html( $error ); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p><?php esc_html_e( 'Enter the verification code from your authenticator app.', 'security-pack' ); ?></p>
|
||||
|
||||
<p>
|
||||
<label for="wpsp_2fa_code"><?php esc_html_e( 'Verification Code', 'security-pack' ); ?></label>
|
||||
<input type="text" name="wpsp_2fa_code" id="wpsp_2fa_code" class="input" size="20" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" autofocus />
|
||||
</p>
|
||||
|
||||
<p class="submit">
|
||||
<input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Verify', 'security-pack' ); ?>" />
|
||||
</p>
|
||||
|
||||
<p class="wpsp-backup-code-link">
|
||||
<a href="#" onclick="document.getElementById('wpsp-backup-field').style.display='block';this.style.display='none';return false;">
|
||||
<?php esc_html_e( 'Use a backup code', 'security-pack' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div id="wpsp-backup-field" style="display:none;">
|
||||
<p><?php esc_html_e( 'Or enter a backup code:', 'security-pack' ); ?></p>
|
||||
<p>
|
||||
<input type="text" name="wpsp_backup_code" id="wpsp_backup_code" class="input" size="20" />
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
login_footer();
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show 2FA settings on user profile.
|
||||
*
|
||||
* @param WP_User $user User object.
|
||||
*/
|
||||
public function show_user_2fa_settings( $user ) {
|
||||
if ( ! Security_Pack::get_setting( 'two_factor_enabled', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$is_enabled = $this->is_2fa_enabled_for_user( $user->ID );
|
||||
|
||||
// Show notice if 2FA is required.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( isset( $_GET['wpsp_2fa_required'] ) && ! $is_enabled ) :
|
||||
?>
|
||||
<div class="notice notice-warning" style="margin-bottom: 20px;">
|
||||
<p><strong><?php esc_html_e( 'Two-Factor Authentication Required', 'security-pack' ); ?></strong></p>
|
||||
<p><?php esc_html_e( 'You must set up 2FA to access the dashboard.', 'security-pack' ); ?></p>
|
||||
</div>
|
||||
<?php
|
||||
endif;
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Two-Factor Authentication', 'security-pack' ); ?></h2>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e( 'Status', 'security-pack' ); ?></th>
|
||||
<td>
|
||||
<?php if ( $is_enabled ) : ?>
|
||||
<?php $remaining_codes = absint( $this->get_backup_codes_count( $user->ID ) ); ?>
|
||||
<span class="wpsp-2fa-status wpsp-2fa-enabled"><?php esc_html_e( 'Enabled', 'security-pack' ); ?></span>
|
||||
<p>
|
||||
<button type="button" class="button" id="wpsp-disable-2fa"><?php esc_html_e( 'Disable 2FA', 'security-pack' ); ?></button>
|
||||
<button type="button" class="button" id="wpsp-regenerate-backup-codes"><?php esc_html_e( 'Regenerate Backup Codes', 'security-pack' ); ?></button>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: number of remaining backup codes */
|
||||
esc_html__( 'You have %d backup codes remaining.', 'security-pack' ),
|
||||
intval( $remaining_codes )
|
||||
);
|
||||
?>
|
||||
<?php if ( $remaining_codes < 3 ) : ?>
|
||||
<strong style="color: #dc3232;"><?php esc_html_e( 'Consider regenerating your backup codes.', 'security-pack' ); ?></strong>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div id="wpsp-backup-codes-display" style="display:none; background: #f6f7f7; padding: 15px; margin-top: 10px; border-radius: 4px;">
|
||||
<p><strong><?php esc_html_e( 'New Backup Codes:', 'security-pack' ); ?></strong></p>
|
||||
<pre id="wpsp-new-backup-codes" style="background: #fff; padding: 10px;"></pre>
|
||||
<p class="description" style="color: #dc3232;">
|
||||
<strong><?php esc_html_e( 'IMPORTANT: Save these codes now!', 'security-pack' ); ?></strong><br>
|
||||
<?php esc_html_e( 'These codes will NOT be shown again. Store them in a safe place.', 'security-pack' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<span class="wpsp-2fa-status wpsp-2fa-disabled"><?php esc_html_e( 'Disabled', 'security-pack' ); ?></span>
|
||||
<p>
|
||||
<button type="button" class="button button-primary" id="wpsp-setup-2fa"><?php esc_html_e( 'Set Up 2FA', 'security-pack' ); ?></button>
|
||||
</p>
|
||||
<div id="wpsp-2fa-setup" style="display:none;">
|
||||
<p><?php esc_html_e( 'Scan this QR code with your authenticator app:', 'security-pack' ); ?></p>
|
||||
<div id="wpsp-2fa-qr"></div>
|
||||
<p><strong><?php esc_html_e( 'Manual entry key:', 'security-pack' ); ?></strong> <code id="wpsp-2fa-secret"></code></p>
|
||||
<p>
|
||||
<label for="wpsp-2fa-verify-code"><?php esc_html_e( 'Enter verification code to confirm:', 'security-pack' ); ?></label>
|
||||
<input type="text" id="wpsp-2fa-verify-code" class="regular-text" autocomplete="off" />
|
||||
<button type="button" class="button button-primary" id="wpsp-verify-2fa-setup"><?php esc_html_e( 'Verify & Enable', 'security-pack' ); ?></button>
|
||||
</p>
|
||||
<div id="wpsp-2fa-setup-result"></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<script>
|
||||
jQuery(document).ready(function($) {
|
||||
$('#wpsp-setup-2fa').on('click', function() {
|
||||
$('#wpsp-2fa-setup').show();
|
||||
$(this).hide();
|
||||
|
||||
// Generate new secret.
|
||||
$.post(ajaxurl, {
|
||||
action: 'wpsp_generate_2fa_secret',
|
||||
user_id: <?php echo (int) $user->ID; ?>,
|
||||
_ajax_nonce: '<?php echo esc_js( wp_create_nonce( 'wpsp_2fa_setup' ) ); ?>'
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#wpsp-2fa-secret').text(response.data.secret);
|
||||
|
||||
// Generate QR code client-side.
|
||||
var qr = qrcode(0, 'M');
|
||||
qr.addData(response.data.otpauth);
|
||||
qr.make();
|
||||
$('#wpsp-2fa-qr').html(qr.createSvgTag(5, 0));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#wpsp-verify-2fa-setup').on('click', function() {
|
||||
var code = $('#wpsp-2fa-verify-code').val();
|
||||
$.post(ajaxurl, {
|
||||
action: 'wpsp_verify_2fa_setup',
|
||||
user_id: <?php echo (int) $user->ID; ?>,
|
||||
code: code,
|
||||
_ajax_nonce: '<?php echo esc_js( wp_create_nonce( 'wpsp_2fa_setup' ) ); ?>'
|
||||
}, function(response) {
|
||||
if (response.success && response.data.backup_codes) {
|
||||
// Show backup codes - user MUST save these.
|
||||
var codesHtml = '<div style="background:#d4edda;border:1px solid #c3e6cb;padding:20px;margin:10px 0;border-radius:4px;">';
|
||||
codesHtml += '<h3 style="margin-top:0;color:#155724;"><?php echo esc_js( __( '2FA Enabled! Save Your Backup Codes', 'security-pack' ) ); ?></h3>';
|
||||
codesHtml += '<p style="color:#dc3232;font-weight:bold;"><?php echo esc_js( __( 'IMPORTANT: These codes will NOT be shown again!', 'security-pack' ) ); ?></p>';
|
||||
codesHtml += '<pre style="background:#fff;padding:15px;font-size:14px;line-height:1.8;">' + response.data.backup_codes.join('\n') + '</pre>';
|
||||
codesHtml += '<p><?php echo esc_js( __( 'Store these codes in a safe place. Each code can only be used once.', 'security-pack' ) ); ?></p>';
|
||||
codesHtml += '<button type="button" class="button button-primary" onclick="location.reload();"><?php echo esc_js( __( 'I have saved my codes', 'security-pack' ) ); ?></button>';
|
||||
codesHtml += '</div>';
|
||||
$('#wpsp-2fa-setup').html(codesHtml);
|
||||
} else if (response.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
$('#wpsp-2fa-setup-result').html('<p style="color:red;">' + response.data.message + '</p>');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#wpsp-disable-2fa').on('click', function() {
|
||||
if (confirm('<?php echo esc_js( __( 'Are you sure you want to disable 2FA?', 'security-pack' ) ); ?>')) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'wpsp_disable_2fa',
|
||||
user_id: <?php echo (int) $user->ID; ?>,
|
||||
_ajax_nonce: '<?php echo esc_js( wp_create_nonce( 'wpsp_2fa_setup' ) ); ?>'
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#wpsp-regenerate-backup-codes').on('click', function() {
|
||||
if (confirm('<?php echo esc_js( __( 'This will invalidate all existing backup codes. Are you sure?', 'security-pack' ) ); ?>')) {
|
||||
$.post(ajaxurl, {
|
||||
action: 'wpsp_regenerate_backup_codes',
|
||||
user_id: <?php echo (int) $user->ID; ?>,
|
||||
_ajax_nonce: '<?php echo esc_js( wp_create_nonce( 'wpsp_2fa_setup' ) ); ?>'
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$('#wpsp-new-backup-codes').text(response.data.codes.join('\n'));
|
||||
$('#wpsp-backup-codes-display').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user 2FA settings.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
*/
|
||||
public function save_user_2fa_settings( $user_id ) {
|
||||
// Settings are saved via AJAX.
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new 2FA secret via AJAX.
|
||||
*/
|
||||
public function ajax_generate_secret() {
|
||||
check_ajax_referer( 'wpsp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'security-pack' ) ) );
|
||||
}
|
||||
|
||||
$secret = $this->generate_secret();
|
||||
|
||||
// Store temporarily (not enabled yet).
|
||||
update_user_meta( $user_id, self::SECRET_META_KEY . '_pending', $secret );
|
||||
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
$site = wp_parse_url( home_url(), PHP_URL_HOST );
|
||||
|
||||
// Generate otpauth URL for QR code.
|
||||
$otpauth = sprintf(
|
||||
'otpauth://totp/%s:%s?secret=%s&issuer=%s',
|
||||
rawurlencode( $site ),
|
||||
rawurlencode( $user->user_email ),
|
||||
$secret,
|
||||
rawurlencode( $site )
|
||||
);
|
||||
|
||||
wp_send_json_success( array(
|
||||
'secret' => $secret,
|
||||
'otpauth' => $otpauth,
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify 2FA setup via AJAX.
|
||||
*/
|
||||
public function ajax_verify_setup() {
|
||||
check_ajax_referer( 'wpsp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
$code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'security-pack' ) ) );
|
||||
}
|
||||
|
||||
$pending_secret = get_user_meta( $user_id, self::SECRET_META_KEY . '_pending', true );
|
||||
|
||||
if ( empty( $pending_secret ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No pending setup found.', 'security-pack' ) ) );
|
||||
}
|
||||
|
||||
// Verify the code.
|
||||
if ( ! $this->verify_totp( $pending_secret, $code ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Invalid code. Please try again.', 'security-pack' ) ) );
|
||||
}
|
||||
|
||||
// Enable 2FA.
|
||||
update_user_meta( $user_id, self::SECRET_META_KEY, $pending_secret );
|
||||
update_user_meta( $user_id, self::ENABLED_META_KEY, '1' );
|
||||
delete_user_meta( $user_id, self::SECRET_META_KEY . '_pending' );
|
||||
|
||||
// Generate backup codes - show plain to user, store hashed.
|
||||
$plain_codes = $this->generate_backup_codes();
|
||||
$hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes );
|
||||
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes );
|
||||
|
||||
wp_send_json_success( array(
|
||||
'message' => __( '2FA enabled successfully. Save your backup codes now - they will not be shown again!', 'security-pack' ),
|
||||
'backup_codes' => $plain_codes,
|
||||
'show_codes' => true,
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable 2FA via AJAX.
|
||||
*/
|
||||
public function ajax_disable_2fa() {
|
||||
check_ajax_referer( 'wpsp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'security-pack' ) ) );
|
||||
}
|
||||
|
||||
delete_user_meta( $user_id, self::SECRET_META_KEY );
|
||||
delete_user_meta( $user_id, self::ENABLED_META_KEY );
|
||||
delete_user_meta( $user_id, self::BACKUP_CODES_META_KEY );
|
||||
|
||||
wp_send_json_success( array( 'message' => __( '2FA disabled.', 'security-pack' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate backup codes via AJAX.
|
||||
*/
|
||||
public function ajax_regenerate_backup_codes() {
|
||||
check_ajax_referer( 'wpsp_2fa_setup' );
|
||||
|
||||
$user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0;
|
||||
|
||||
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'security-pack' ) ) );
|
||||
}
|
||||
|
||||
// Generate new codes.
|
||||
$plain_codes = $this->generate_backup_codes();
|
||||
|
||||
// Hash them before storing.
|
||||
$hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes );
|
||||
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes );
|
||||
|
||||
// Return plain codes to show user ONCE.
|
||||
wp_send_json_success( array(
|
||||
'codes' => $plain_codes,
|
||||
'message' => __( 'Backup codes regenerated. Save these codes now - they will not be shown again.', 'security-pack' ),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 2FA is enabled for a user.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return bool
|
||||
*/
|
||||
public function is_2fa_enabled_for_user( $user_id ) {
|
||||
return '1' === get_user_meta( $user_id, self::ENABLED_META_KEY, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a code (TOTP or backup).
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param string $code Code to verify.
|
||||
* @return bool
|
||||
*/
|
||||
public function verify_code( $user_id, $code ) {
|
||||
$code = preg_replace( '/\s+/', '', $code );
|
||||
|
||||
// Try TOTP first.
|
||||
$secret = get_user_meta( $user_id, self::SECRET_META_KEY, true );
|
||||
|
||||
if ( $this->verify_totp( $secret, $code ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try backup code.
|
||||
return $this->verify_backup_code( $user_id, $code );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify TOTP code.
|
||||
*
|
||||
* @param string $secret Secret key.
|
||||
* @param string $code Code to verify.
|
||||
* @param int $window Time window tolerance.
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_totp( $secret, $code, $window = 1 ) {
|
||||
if ( empty( $secret ) || empty( $code ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$time = floor( time() / self::TIME_STEP );
|
||||
|
||||
for ( $i = -$window; $i <= $window; $i++ ) {
|
||||
$calculated = $this->calculate_totp( $secret, $time + $i );
|
||||
if ( hash_equals( $calculated, $code ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate TOTP code.
|
||||
*
|
||||
* @param string $secret Secret key.
|
||||
* @param int $time Time counter.
|
||||
* @return string
|
||||
*/
|
||||
private function calculate_totp( $secret, $time ) {
|
||||
// Decode base32 secret.
|
||||
$secret_decoded = $this->base32_decode( $secret );
|
||||
|
||||
// Pack time.
|
||||
$time_packed = pack( 'N*', 0, $time );
|
||||
|
||||
// Calculate HMAC.
|
||||
$hash = hash_hmac( 'sha1', $time_packed, $secret_decoded, true );
|
||||
|
||||
// Dynamic truncation.
|
||||
$offset = ord( substr( $hash, -1 ) ) & 0x0F;
|
||||
$code = ( ord( $hash[ $offset ] ) & 0x7F ) << 24;
|
||||
$code |= ( ord( $hash[ $offset + 1 ] ) & 0xFF ) << 16;
|
||||
$code |= ( ord( $hash[ $offset + 2 ] ) & 0xFF ) << 8;
|
||||
$code |= ( ord( $hash[ $offset + 3 ] ) & 0xFF );
|
||||
$code = $code % pow( 10, self::CODE_LENGTH );
|
||||
|
||||
return str_pad( $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secret key.
|
||||
*
|
||||
* @param int $length Length in bytes (16 = 26 base32 chars).
|
||||
* @return string
|
||||
*/
|
||||
private function generate_secret( $length = 16 ) {
|
||||
$random = wp_generate_password( $length, false, false );
|
||||
return $this->base32_encode( $random );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup codes.
|
||||
*
|
||||
* @param int $count Number of codes.
|
||||
* @return array
|
||||
*/
|
||||
private function generate_backup_codes( $count = 10 ) {
|
||||
$codes = array();
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$codes[] = strtoupper( wp_generate_password( 8, false, false ) );
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup codes count for user (hashed codes stored).
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return int
|
||||
*/
|
||||
public function get_backup_codes_count( $user_id ) {
|
||||
$codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true );
|
||||
return is_array( $codes ) ? count( $codes ) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup codes for user (internal use only).
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return array
|
||||
*/
|
||||
private function get_backup_codes( $user_id ) {
|
||||
$codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true );
|
||||
return is_array( $codes ) ? $codes : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a backup code for secure storage.
|
||||
*
|
||||
* @param string $code Plain backup code.
|
||||
* @return string Hashed code.
|
||||
*/
|
||||
private function hash_backup_code( $code ) {
|
||||
return wp_hash( strtoupper( $code ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and consume a backup code.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param string $code Backup code.
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_backup_code( $user_id, $code ) {
|
||||
$stored_codes = $this->get_backup_codes( $user_id );
|
||||
$code = strtoupper( preg_replace( '/[^A-Z0-9]/', '', $code ) );
|
||||
$hashed_input = $this->hash_backup_code( $code );
|
||||
|
||||
// Check against hashed codes.
|
||||
foreach ( $stored_codes as $index => $stored_code ) {
|
||||
// Support both hashed (new) and plain (legacy) codes.
|
||||
if ( hash_equals( $stored_code, $hashed_input ) || hash_equals( $stored_code, $code ) ) {
|
||||
// Remove used code.
|
||||
unset( $stored_codes[ $index ] );
|
||||
update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, array_values( $stored_codes ) );
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 encode.
|
||||
*
|
||||
* @param string $data Data to encode.
|
||||
* @return string
|
||||
*/
|
||||
private function base32_encode( $data ) {
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$binary = '';
|
||||
$encoded = '';
|
||||
|
||||
foreach ( str_split( $data ) as $char ) {
|
||||
$binary .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
$chunks = str_split( $binary, 5 );
|
||||
|
||||
foreach ( $chunks as $chunk ) {
|
||||
$chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
|
||||
$encoded .= $alphabet[ bindec( $chunk ) ];
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 decode.
|
||||
*
|
||||
* @param string $data Data to decode.
|
||||
* @return string
|
||||
*/
|
||||
private function base32_decode( $data ) {
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$data = strtoupper( $data );
|
||||
$binary = '';
|
||||
$decoded = '';
|
||||
|
||||
foreach ( str_split( $data ) as $char ) {
|
||||
$pos = strpos( $alphabet, $char );
|
||||
if ( false !== $pos ) {
|
||||
$binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
|
||||
}
|
||||
}
|
||||
|
||||
$chunks = str_split( $binary, 8 );
|
||||
|
||||
foreach ( $chunks as $chunk ) {
|
||||
if ( strlen( $chunk ) === 8 ) {
|
||||
$decoded .= chr( bindec( $chunk ) );
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user