feature = $feature; } /** * Handle a request that carries the recovery argument. Every other request returns at once. * * @return void */ public function handle() { // phpcs:disable WordPress.Security.NonceVerification -- the token is the credential; see the class comment. if ( ! isset( $_GET[ RecoveryLink::QUERY_ARG ] ) && ! isset( $_POST[ RecoveryLink::QUERY_ARG ] ) ) { return; } $posting = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD']; // A POST is believed only for the token in its own body: a link with the token in its // address, sent with POST by something that prefetches, must not count as the button. // Raw on purpose: the value is matched by hash and never stored; only a matched token, // which is 64 hex characters, is printed. $source = $posting ? $_POST : $_GET; $token = isset( $source[ RecoveryLink::QUERY_ARG ] ) ? wp_unslash( $source[ RecoveryLink::QUERY_ARG ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized // phpcs:enable WordPress.Security.NonceVerification $this->send_headers(); // Page-cache plugins read this constant; headers alone do not stop all of them from // storing a page that carries the token in its form. if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the name the cache plugins agreed on. } if ( ! RecoveryLink::matches( $token ) ) { $this->page( __( 'This link is no longer valid', 'adminkeep' ), $this->invalid_page(), 403 ); return; } if ( ! $posting ) { $this->page( __( 'Turn off Two-Factor Login?', 'adminkeep' ), $this->confirm_page( $token ), 200 ); return; } // Through the writer, so this is the same change as `wp adminkeep feature disable // two_factor`: only the switch moves, the roles and the other settings stay. The writer // tells the feature it went off, and the feature forgets the link; revoked here as well so // the link is single use even if that ever changes. ( new SettingsWriter( Plugin::instance()->registry() ) )->set_enabled( $this->feature, false ); RecoveryLink::revoke(); RecoveryLink::notify_administrators( $this->request_ip() ); $this->page( __( 'Two-Factor Login is off', 'adminkeep' ), $this->done_page(), 200 ); } /** * Headers for every answer to the link. * * The address carries the token, so the answer must not be cached, indexed, or named as the * referrer when the visitor follows a link away from the page. * * @return void */ private function send_headers() { // Headers are already out when PHPUnit drives this, and header() would only warn. if ( headers_sent() ) { return; } nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow' ); header( 'Referrer-Policy: no-referrer' ); } /** * Print one standalone page and stop. * * @param string $title Page title. * @param string $html Body, already escaped. * @param int $response HTTP status. * @return void */ private function page( $title, $html, $response ) { // On admin-ajax.php wp_die() prints bare text for scripts to read. This is a page for a // person, so it gets the same handler any other request would. add_filter( 'wp_die_ajax_handler', static function () { return '_default_wp_die_handler'; } ); wp_die( $html, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts by the three methods below. esc_html( $title ), array( 'response' => (int) $response ) ); } /** * The page a GET with the right token gets: what the button does, and the button. * * @param string $token The matched token. * @return string */ private function confirm_page( $token ) { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); return '
' . esc_html( sprintf( /* translators: %s: site name. */ __( 'This turns off Two-Factor Login on %s. After that, nobody is asked for a login code, and a password alone signs in. The roles you chose are kept for when you switch it back on.', 'adminkeep' ), $site ) ) . '
' . '' . esc_html__( 'The administrators of this site are told by email that this link was used. The link works once.', 'adminkeep' ) . '
' . ''; } /** * The page after the button: it is off, and where to log in. * * @return string */ private function done_page() { return '' . esc_html__( 'Nobody is asked for a login code now. Log in with your password, then fix what kept you out before you switch Two-Factor Login back on.', 'adminkeep' ) . '
' . '' . esc_html__( 'The administrators of this site have been emailed that this link was used. It does not work again.', 'adminkeep' ) . '
' . '' . esc_html__( 'Log in', 'adminkeep' ) . '
'; } /** * The one page for a wrong, used, replaced or missing token, on GET and POST alike. * * @return string */ private function invalid_page() { return '' . esc_html__( 'A recovery link works once, and a new one replaces it each time Two-Factor Login is switched on. If you are locked out, there are two other ways back in:', 'adminkeep' ) . '
' . 'wp adminkeep feature disable two_factor'
) . 'define( \'ADMINKEEP_DISABLE_TWO_FACTOR\', true );'
) . '