self::hash( $token ), 'email' => '', 'sent' => false, 'time' => time(), ), '', false ); return $token; } /** * Is this the current link's token? * * @param mixed $token What the request carried. * @return bool */ public static function matches( $token ) { if ( ! is_string( $token ) || 1 !== preg_match( '/^[0-9a-f]{64}$/', $token ) ) { return false; } $record = self::record(); return null !== $record && hash_equals( $record['hash'], self::hash( $token ) ); } /** * Forget the link, so the one in the email stops working. * * @return void */ public static function revoke() { delete_option( self::OPTION ); } /** * The stored record, for the settings screen and the command line. * * @return array{hash: string, email: string, sent: bool, time: int}|null Null when there is no link. */ public static function record() { $record = get_option( self::OPTION ); if ( ! is_array( $record ) || empty( $record['hash'] ) || ! is_string( $record['hash'] ) ) { return null; } return array( 'hash' => $record['hash'], 'email' => isset( $record['email'] ) && is_string( $record['email'] ) ? $record['email'] : '', 'sent' => ! empty( $record['sent'] ), 'time' => isset( $record['time'] ) ? (int) $record['time'] : 0, ); } /** * The address the link opens. * * On admin-ajax.php, not wp-login.php: the link is for the site whose login screen is hidden, * replaced or redirected. Not on the front of the site either, where a page cache that ignores * unknown arguments would answer with the stored home page. admin-ajax.php is the one address * caches leave alone and login-hiding plugins keep open to visitors, because so much depends * on it. * * @param string $token The token from issue(). * @return string */ public static function url( $token ) { return add_query_arg( array( 'action' => self::ACTION, self::QUERY_ARG => $token, ), admin_url( 'admin-ajax.php' ) ); } /** * Email the link, and note on the record where it went and whether wp_mail() took it. * * @param string $email The recipient, or '' when nobody with a usable address could be found. * @param string $token The token from issue(). * @param string[] $roles Names of the roles that are asked for a code. * @return bool Whether wp_mail() reported success. */ public static function send( $email, $token, array $roles ) { $sent = false; if ( is_email( $email ) ) { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); /* translators: %s: site name. */ $subject = sprintf( __( '[%s] Two-Factor Login is on: keep this recovery link', 'adminkeep' ), $site ); $asked = $roles /* translators: %s: list of role names. */ ? sprintf( __( 'These roles are now asked for an emailed code after their password: %s.', 'adminkeep' ), implode( ', ', $roles ) ) : __( 'No role is chosen yet, so nobody is asked for a code until you choose one.', 'adminkeep' ); $text = sprintf( /* translators: 1: site name, 2: sentence naming the roles, 3: login URL, 4: recovery link. */ __( "Two-Factor Login was switched on for %1\$s. %2\$s\n\nThe code is asked for on the main login screen:\n%3\$s\n\nIf you ever cannot log in, this link turns Two-Factor Login off without logging in:\n%4\$s\n\nKeep this email. Anyone with the link can turn Two-Factor Login off. The link works once and is replaced each time Two-Factor Login is switched on.", 'adminkeep' ), $site, $asked, wp_login_url(), self::url( $token ) ); // The header makes the Email Log drop the body (MailRow), so the link is never // readable from the log table. $sent = self::mail( $email, $subject, $text, array( 'X-Adminkeep-Secret: 1' ) ); } $record = self::record(); // Only while the record is still this token's: a second switch-on in the same moment has // its own link, and its own result to note. if ( null !== $record && hash_equals( $record['hash'], self::hash( $token ) ) ) { $record['email'] = is_email( $email ) ? $email : ''; $record['sent'] = $sent; update_option( self::OPTION, $record, false ); } return $sent; } /** * Tell every administrator of this site that the link was used. * * The link signs nobody in, but it does take the second factor away, and whoever holds a * stolen password would want exactly that. So the people who can switch it back on hear about * it at once, with enough to tell their own click from someone else's. * * @param string $ip The address the request came from. * @return int How many emails wp_mail() accepted. */ public static function notify_administrators( $ip ) { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); /* translators: %s: site name. */ $subject = sprintf( __( '[%s] Two-Factor Login was turned off with the recovery link', 'adminkeep' ), $site ); $text = sprintf( /* translators: 1: site name, 2: date and time, 3: IP address, 4: settings screen URL. */ __( "Two-Factor Login on %1\$s was turned off through its recovery link.\n\nWhen: %2\$s\nIP address: %3\$s\n\nNobody is asked for a login code until it is switched on again. If this was not you or another administrator, change your passwords and switch Two-Factor Login back on:\n%4\$s", 'adminkeep' ), $site, wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) . ' T' ), '' !== $ip ? $ip : __( 'unknown', 'adminkeep' ), SettingsPage::url() ); // get_users() reads the current site's roles, so on multisite this stays with the site // whose feature was switched off. $emails = get_users( array( 'role' => 'administrator', 'fields' => 'user_email', ) ); $accepted = 0; // One email each: a shared "To" line would show every administrator's address to the // others, and one refused address would not stop the rest. foreach ( array_unique( array_filter( (array) $emails, 'is_email' ) ) as $email ) { $accepted += self::mail( $email, $subject, $text, array() ) ? 1 : 0; } return $accepted; } /** * Send through wp_mail(), so the SMTP feature delivers it, in the Nice Default Emails layout * whether or not that feature is on, as the code email does (see EmailCode::send()). * * @param string $to Recipient. * @param string $subject Subject. * @param string $text Plain-text body. * @param string[] $headers Extra headers. * @return bool Whether wp_mail() reported success. */ private static function mail( $to, $subject, $text, array $headers ) { $body = HtmlEmail::body( $text ); $message = '' === $body ? $text : HtmlEmail::document( $body ); if ( '' !== $body ) { $headers[] = 'Content-Type: text/html; charset=' . get_bloginfo( 'charset' ); } return (bool) wp_mail( $to, $subject, $message, $headers ); } /** * The stored form of a token. * * Plain SHA-256 rather than wp_hash(), which the trusted-device and pending-login tokens use. * Those live for days at most; this link has to work months later, and wp_hash() is keyed on * the salts in wp-config.php, so changing the salts would quietly kill the one link kept for * an emergency. A key adds nothing here anyway: the token is 256 random bits, so its hash * cannot be reversed or guessed. * * @param string $token The token. * @return string */ private static function hash( $token ) { return hash( 'sha256', 'adminkeep-recovery-link:' . $token ); } }