get_record( $user_id ) !== null; } public function get_record( $user_id ) { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table ($wpdb->prefix + a literal) that no WordPress API reads; every value is a prepare placeholder or a fragment prepared on its own. return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}guardforge_2fa WHERE user_id = %d", (int) $user_id ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching } /** * Save TOTP secret + recovery codes for a user. Recovery codes are * salt-hashed (SHA-256) so a DB read alone can't replay them. * * @return string The plain-text recovery codes (to display once). */ public function enrol_user( $user_id, $secret ) { global $wpdb; $codes = GUARDFORGE_Totp::generate_recovery_codes(); $salt = bin2hex( random_bytes( 16 ) ); $hashes = array_map( fn ( $c ) => GUARDFORGE_Totp::hash_recovery_code( $c, $salt ), $codes ); update_user_meta( $user_id, self::META_SALT, $salt ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->replace( $wpdb->prefix . 'guardforge_2fa', [ 'user_id' => (int) $user_id, 'totp_secret' => $secret, 'recovery_hashes' => wp_json_encode( $hashes ), 'enrolled_at' => current_time( 'mysql' ), 'last_used_at' => null, ] ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return $codes; } public function disable_for_user( $user_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->delete( $wpdb->prefix . 'guardforge_2fa', [ 'user_id' => (int) $user_id ] ); delete_user_meta( $user_id, self::META_SALT ); } /** * Verify a TOTP code OR a recovery code for the user. If a recovery * code matches, it's removed from the stored list (single-use). */ public function verify_code( $user_id, $code ) { $record = $this->get_record( $user_id ); if ( ! $record ) { return false; } if ( GUARDFORGE_Totp::verify( $record->totp_secret, $code ) ) { $this->touch_last_used( $user_id ); return 'totp'; } $salt = (string) get_user_meta( $user_id, self::META_SALT, true ); if ( $salt === '' ) { return false; } $supplied_hash = GUARDFORGE_Totp::hash_recovery_code( $code, $salt ); $hashes = json_decode( (string) $record->recovery_hashes, true ); if ( ! is_array( $hashes ) ) { return false; } foreach ( $hashes as $idx => $stored ) { if ( hash_equals( $stored, $supplied_hash ) ) { array_splice( $hashes, $idx, 1 ); global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->update( $wpdb->prefix . 'guardforge_2fa', [ 'recovery_hashes' => wp_json_encode( $hashes ) ], [ 'user_id' => (int) $user_id ] ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $this->touch_last_used( $user_id ); return 'recovery'; } } return false; } private function touch_last_used( $user_id ) { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->update( $wpdb->prefix . 'guardforge_2fa', [ 'last_used_at' => current_time( 'mysql' ) ], [ 'user_id' => (int) $user_id ] ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching } // ────────── Login challenge ──────────────────────────── /** * Hooked at priority 99 on wp_authenticate_user, which core applies * BEFORE wp_check_password — so this method verifies the password itself * before challenging. If the user is enrolled we DON'T return a WP_User * directly — instead we stash a pending token and redirect to the * challenge form. */ public function maybe_challenge_user( $user, $password ) { if ( is_wp_error( $user ) || ! $user instanceof \WP_User ) { return $user; } if ( ! $this->is_user_enrolled( $user->ID ) ) { return $user; } // wp-login.php posts no nonce: nobody is logged in yet. The password // was checked above, and a wrong code only refuses the login. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see above. if ( ! empty( $_POST['guardforge_2fa_code'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see above. $ok = $this->verify_code( $user->ID, sanitize_text_field( wp_unslash( $_POST['guardforge_2fa_code'] ) ) ); if ( $ok ) { return $user; } return new WP_Error( 'gf_2fa_wrong', __( 'Error: two-factor code is invalid.', 'guardforge' ) ); } // Core applies wp_authenticate_user BEFORE wp_check_password, so the // password is still unverified here — check it ourselves, or the // pending token below would buy an auth cookie on the code alone. if ( ! wp_check_password( (string) $password, $user->user_pass, $user->ID ) ) { return $user; } $token = wp_generate_password( 32, false ); set_transient( self::PENDING_TRANSIENT . $token, // phpcs:ignore WordPress.Security.NonceVerification.Missing -- core's own "Remember Me" box on wp-login.php, which posts no nonce; read as a boolean. [ 'user_id' => $user->ID, 'remember' => ! empty( $_POST['rememberme'] ) ], 5 * MINUTE_IN_SECONDS ); $url = add_query_arg( [ 'action' => 'validate_2fa', 'gf_token' => $token, ], wp_login_url() ); wp_safe_redirect( $url ); exit; } public function render_challenge_form() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token IS the proof: 32 random characters minted only after the password checked out, stored for five minutes, useless to anyone who did not pass that step. $token = isset( $_REQUEST['gf_token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['gf_token'] ) ) : ''; if ( $token === '' || ! self::read_transient( self::PENDING_TRANSIENT, self::LEGACY_PENDING_TRANSIENT, $token ) ) { wp_safe_redirect( wp_login_url() ); exit; } login_header( __( 'Two-factor verification', 'guardforge' ) ); ?>

verify_code( (int) $pending['user_id'], $code ) ) { $url = add_query_arg( [ 'action' => 'validate_2fa', 'gf_token' => $token, 'gf_err' => '1', ], wp_login_url() ); wp_safe_redirect( $url ); exit; } self::forget_transient( self::PENDING_TRANSIENT, self::LEGACY_PENDING_TRANSIENT, $token ); wp_set_auth_cookie( (int) $pending['user_id'], ! empty( $pending['remember'] ) ); wp_safe_redirect( admin_url() ); exit; } // ────────── Enrolment ───────────────────────────────── public function handle_enrolment() { if ( ! current_user_can( self::ENROL_CAP ) ) { wp_die( esc_html__( 'Access denied.', 'guardforge' ) ); } check_admin_referer( 'guardforge_2fa_enrol' ); $secret = isset( $_POST['gf_2fa_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['gf_2fa_secret'] ) ) : ''; $code = isset( $_POST['gf_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['gf_2fa_code'] ) ) : ''; if ( $secret === '' || ! GUARDFORGE_Totp::verify( $secret, $code ) ) { wp_safe_redirect( add_query_arg( [ 'page' => 'guardforge-2fa', 'gf_err' => '1' ], admin_url( 'admin.php' ) ) ); exit; } $codes = $this->enrol_user( get_current_user_id(), $secret ); set_transient( self::CODES_TRANSIENT . get_current_user_id(), $codes, 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( [ 'page' => 'guardforge-2fa', 'gf_enrolled' => '1' ], admin_url( 'admin.php' ) ) ); exit; } public function handle_disable() { if ( ! current_user_can( self::ENROL_CAP ) ) { wp_die( esc_html__( 'Access denied.', 'guardforge' ) ); } check_admin_referer( 'guardforge_2fa_disable' ); $this->disable_for_user( get_current_user_id() ); wp_safe_redirect( add_query_arg( [ 'page' => 'guardforge-2fa', 'gf_disabled' => '1' ], admin_url( 'admin.php' ) ) ); exit; } }