Three faults, all on the same screen, all only visible once passwords were actually turned off — which until now nobody had done on a live instance. wp-login.php runs wp_signon() on its way to drawing the form, even for a plain GET. Core tolerates that by discarding the result, but only when the error codes are exactly the pair its own password filters produce. Removing those filters is the whole point of turning passwords off, so what was left behind was the generic authentication_failed — printed, in red, above a form with no username and no password, on a site that has neither. Every visitor was told their sign-in had failed before they tried. The paste-a-signature path had no way to submit. It borrowed core's login button, and the stylesheet that retires the password form hid that button too. The one route for people whose keys live on another machine — sign in Electron Cash, paste the result — was therefore broken exactly on the sites most committed to not having passwords. It now carries its own plain submit button, so it also works on the screens where there is no core login form to borrow from, and with JavaScript off. And the username field outlived the password field, although authenticate() never reads it either. "Username or Email Address" sat at the top of a wallet login form: the one thing this fork exists to remove, in the first place anyone looks. Both go now, with their required attributes cleared, because a hidden required field does not fail quietly — it refuses the submit and points an error bubble at a control nobody can see.
383 lines
14 KiB
PHP
383 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* Signing in.
|
|
*
|
|
* The whole substitution happens in one filter. WordPress asks its
|
|
* `authenticate` chain "who is this?", and this plugin answers first: if the
|
|
* request carries a wallet signature over a challenge this site issued, the
|
|
* signer's address identifies the account, and a `WP_User` comes back. Core
|
|
* then sets its ordinary auth cookie, and from that point on every
|
|
* capability, role check, nonce and REST permission works exactly as it does
|
|
* on stock WordPress. Nothing downstream knows the login was different.
|
|
*
|
|
* The form degrades honestly. With JavaScript, a button signs the challenge
|
|
* in the page or hands it to the browser's own wallet. Without it, the
|
|
* challenge is printed in a box next to a field for the signature — which is
|
|
* precisely the "Sign message" workflow every desktop BCH wallet already has.
|
|
* That path is not a courtesy to the JavaScript-averse; it is the path for
|
|
* someone whose keys live on a machine that never touches this site.
|
|
*
|
|
* @package SiriusPress
|
|
*/
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
final class SPA_Login {
|
|
|
|
const OPT_ALLOW_PASSWORDS = 'sirius_press_allow_password_login';
|
|
|
|
public static function hooks() {
|
|
add_filter( 'authenticate', array( __CLASS__, 'authenticate' ), 5, 3 );
|
|
add_action( 'login_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
|
|
add_action( 'login_form', array( __CLASS__, 'render_form' ) );
|
|
add_filter( 'login_message', array( __CLASS__, 'login_message' ) );
|
|
add_action( 'login_footer', array( __CLASS__, 'footer_config' ) );
|
|
|
|
if ( ! self::passwords_allowed() ) {
|
|
// Pull core's password checks out of the chain entirely rather
|
|
// than letting them run and fail: a site that has turned
|
|
// passwords off should not have a password oracle on its login
|
|
// page at all.
|
|
remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
|
|
remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
|
|
add_action( 'login_head', array( __CLASS__, 'hide_credential_fields' ) );
|
|
add_filter( 'wp_login_errors', array( __CLASS__, 'quiet_empty_form_error' ) );
|
|
}
|
|
}
|
|
|
|
/** Whether username+password sign-in is still accepted. */
|
|
public static function passwords_allowed() {
|
|
return (bool) get_option( self::OPT_ALLOW_PASSWORDS, true );
|
|
}
|
|
|
|
/**
|
|
* The substitution itself.
|
|
*
|
|
* @param null|WP_User|WP_Error $user
|
|
* @param string $username
|
|
* @param string $password
|
|
* @return null|WP_User|WP_Error
|
|
*/
|
|
public static function authenticate( $user, $username, $password ) {
|
|
if ( $user instanceof WP_User ) {
|
|
return $user;
|
|
}
|
|
// Nonce checking is not applicable here: the signed challenge *is* the
|
|
// anti-forgery token, and it is stronger than one — it is bound to a
|
|
// key, single-use, and expires.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
|
if ( empty( $_POST['sirius_signature'] ) || empty( $_POST['sirius_nonce'] ) ) {
|
|
return $user;
|
|
}
|
|
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
|
|
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) );
|
|
// Optional at sign-in: the recovered address is looked up against real
|
|
// accounts, so a wrong one simply finds nothing. When the page does
|
|
// send it, checking it turns "no account uses that wallet" into the
|
|
// more useful "that signature is not over the text we asked for".
|
|
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
|
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
|
|
$limited = SPA_Challenge::check_rate_limit( 'login' );
|
|
if ( is_wp_error( $limited ) ) {
|
|
return $limited;
|
|
}
|
|
|
|
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LOGIN, $claimed );
|
|
if ( is_wp_error( $address ) ) {
|
|
return $address;
|
|
}
|
|
|
|
$found = SP_Identity::user_by_address( $address );
|
|
if ( ! $found ) {
|
|
if ( SP_Settings::open_registration() ) {
|
|
return new WP_Error(
|
|
'sirius_no_account',
|
|
sprintf(
|
|
/* translators: %s: URL of the registration page. */
|
|
__( 'No account here uses that wallet yet. <a href="%s">Create one</a> — it takes one more signature.', 'sirius-press' ),
|
|
esc_url( SPA_Register::url() )
|
|
)
|
|
);
|
|
}
|
|
return new WP_Error(
|
|
'sirius_no_account',
|
|
__( 'No account on this site uses that wallet, and registration is closed.', 'sirius-press' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fires after a wallet signature has been accepted for a user.
|
|
*
|
|
* A plugin that wants a second factor can return a WP_Error from the
|
|
* `authenticate` chain at a later priority; this is the hook that
|
|
* tells it a wallet proof already succeeded.
|
|
*
|
|
* @param WP_User $found
|
|
* @param string $address
|
|
*/
|
|
do_action( 'sirius_press_wallet_authenticated', $found, $address );
|
|
return $found;
|
|
}
|
|
|
|
// ------------------------------------------------------------------- UI
|
|
|
|
public static function enqueue() {
|
|
self::enqueue_wallet();
|
|
wp_enqueue_style(
|
|
'sirius-press-login',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/login.css',
|
|
array(),
|
|
SIRIUS_PRESS_AUTH_VERSION
|
|
);
|
|
}
|
|
|
|
/** Shared by the login screen, the registration screen and the profile. */
|
|
public static function enqueue_wallet() {
|
|
wp_enqueue_script(
|
|
'sirius-press-bip39',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/bip39-en.js',
|
|
array(),
|
|
SIRIUS_PRESS_AUTH_VERSION,
|
|
true
|
|
);
|
|
wp_enqueue_script(
|
|
'sirius-press-wallet',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/wallet.js',
|
|
array( 'sirius-press-bip39' ),
|
|
SIRIUS_PRESS_AUTH_VERSION,
|
|
true
|
|
);
|
|
wp_enqueue_script(
|
|
'sirius-press-login-js',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/login.js',
|
|
array( 'sirius-press-wallet' ),
|
|
SIRIUS_PRESS_AUTH_VERSION,
|
|
true
|
|
);
|
|
}
|
|
|
|
/** Configuration the scripts need, printed once. */
|
|
public static function footer_config() {
|
|
self::print_config();
|
|
}
|
|
|
|
public static function print_config() {
|
|
static $printed = false;
|
|
if ( $printed ) {
|
|
return;
|
|
}
|
|
$printed = true;
|
|
printf(
|
|
'<script>window.SIRIUS_PRESS = %s;</script>',
|
|
wp_json_encode(
|
|
array(
|
|
'prefix' => SP_Settings::prefix(),
|
|
'path' => SP_Settings::derivation_path(),
|
|
'network' => SP_Settings::network(),
|
|
'site' => home_url( '/' ),
|
|
// So the page can stop core's username and password inputs
|
|
// from blocking a submit they are no longer part of.
|
|
'passwords' => self::passwords_allowed(),
|
|
'strings' => array(
|
|
'signing' => __( 'Signing…', 'sirius-press' ),
|
|
'noWallet' => __( 'No wallet found in this browser.', 'sirius-press' ),
|
|
'generated' => __( 'Write these words down. They are the only way back into this account — nobody, including this site, can reset them for you.', 'sirius-press' ),
|
|
),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The wallet block on wp-login.php.
|
|
*
|
|
* Rendered by `login_form`, which puts it directly under the password
|
|
* field, so the wallet path reads as the primary one and passwords as the
|
|
* leftover they are.
|
|
*/
|
|
public static function render_form() {
|
|
$nonce = SPA_Challenge::issue();
|
|
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_LOGIN );
|
|
self::render_signing_block( $nonce, $message, SPA_Challenge::PURPOSE_LOGIN, __( 'Sign in with your wallet', 'sirius-press' ) );
|
|
}
|
|
|
|
/**
|
|
* The signing widget, shared by login and registration.
|
|
*
|
|
* @param string $nonce
|
|
* @param string $message Exact text to be signed.
|
|
* @param string $purpose
|
|
* @param string $button Label for the primary action.
|
|
*/
|
|
public static function render_signing_block( $nonce, $message, $purpose, $button ) {
|
|
?>
|
|
<div class="sirius-wallet" data-sirius-purpose="<?php echo esc_attr( $purpose ); ?>">
|
|
<input type="hidden" name="sirius_nonce" value="<?php echo esc_attr( $nonce ); ?>" />
|
|
<input type="hidden" name="sirius_purpose" value="<?php echo esc_attr( $purpose ); ?>" />
|
|
<?php
|
|
/*
|
|
* The address the signer claims. Filled in by the script after
|
|
* signing; typed by hand on the paste-a-signature path.
|
|
*
|
|
* Not decoration: recovering a key from a signature always
|
|
* succeeds, so without something to compare against, a signature
|
|
* over the wrong text yields a stranger's address instead of an
|
|
* error. See the note in class-spa-challenge.php.
|
|
*/
|
|
?>
|
|
<input type="hidden" name="sirius_address" class="sirius-wallet__address" value="" />
|
|
<textarea
|
|
class="sirius-wallet__message"
|
|
readonly
|
|
rows="8"
|
|
aria-label="<?php esc_attr_e( 'The exact text to sign', 'sirius-press' ); ?>"
|
|
><?php echo esc_textarea( $message ); ?></textarea>
|
|
|
|
<div class="sirius-wallet__actions">
|
|
<button type="button" class="button button-primary sirius-wallet__sign" hidden>
|
|
<?php echo esc_html( $button ); ?>
|
|
</button>
|
|
<button type="button" class="button sirius-wallet__external" hidden>
|
|
<?php esc_html_e( 'Use the browser wallet', 'sirius-press' ); ?>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="sirius-wallet__phrase" hidden>
|
|
<label for="sirius_phrase_<?php echo esc_attr( $purpose ); ?>">
|
|
<?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?>
|
|
</label>
|
|
<textarea
|
|
id="sirius_phrase_<?php echo esc_attr( $purpose ); ?>"
|
|
class="sirius-wallet__phrase-input"
|
|
rows="2"
|
|
autocomplete="off"
|
|
autocapitalize="none"
|
|
spellcheck="false"
|
|
></textarea>
|
|
<p class="sirius-wallet__hint">
|
|
<?php esc_html_e( 'Typed here, the phrase never leaves this page — only the signature is sent. If you would rather not type it, sign the text above in your own wallet and paste the result below.', 'sirius-press' ); ?>
|
|
</p>
|
|
</div>
|
|
|
|
<details class="sirius-wallet__manual">
|
|
<summary><?php esc_html_e( 'Paste a signature instead', 'sirius-press' ); ?></summary>
|
|
<p class="sirius-wallet__hint">
|
|
<?php esc_html_e( 'Sign the text above in any Bitcoin Cash wallet — Electron Cash calls it “Sign message” — and paste what it gives you.', 'sirius-press' ); ?>
|
|
</p>
|
|
<textarea
|
|
name="sirius_signature"
|
|
class="sirius-wallet__signature"
|
|
rows="3"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder="<?php esc_attr_e( 'base64 signature', 'sirius-press' ); ?>"
|
|
></textarea>
|
|
<label class="sirius-wallet__address-label" for="sirius_address_<?php echo esc_attr( $purpose ); ?>">
|
|
<?php esc_html_e( 'The address you signed with', 'sirius-press' ); ?>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="sirius_address_<?php echo esc_attr( $purpose ); ?>"
|
|
class="sirius-wallet__address-input"
|
|
autocomplete="off"
|
|
autocapitalize="none"
|
|
spellcheck="false"
|
|
placeholder="<?php echo esc_attr( SP_Settings::prefix() ); ?>:…"
|
|
/>
|
|
<p class="sirius-wallet__hint">
|
|
<?php esc_html_e( 'Your wallet shows this next to the signature. It is checked against the signature, so a mistyped address is caught rather than quietly creating the wrong account.', 'sirius-press' ); ?>
|
|
</p>
|
|
<?php
|
|
/*
|
|
* This path's own submit button.
|
|
*
|
|
* It used to borrow core's, which is wrong twice over: on
|
|
* wp-login.php that button is hidden once passwords are off,
|
|
* leaving a pasted signature with no way to be sent; and on
|
|
* every other screen this block appears on there is no core
|
|
* login button to borrow. Plain markup, so it still works with
|
|
* JavaScript switched off — which is the whole reason this
|
|
* path exists.
|
|
*/
|
|
?>
|
|
<button type="submit" class="button button-primary sirius-wallet__submit">
|
|
<?php esc_html_e( 'Use this signature', 'sirius-press' ); ?>
|
|
</button>
|
|
</details>
|
|
|
|
<p class="sirius-wallet__status" role="status" aria-live="polite"></p>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
public static function login_message( $message ) {
|
|
if ( ! empty( $_GET['action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
|
return $message;
|
|
}
|
|
$intro = '<p class="message sirius-intro">'
|
|
. esc_html__( 'This site has no passwords to forget and no email to confirm. Your wallet is the account.', 'sirius-press' )
|
|
. '</p>';
|
|
return $intro . $message;
|
|
}
|
|
|
|
/**
|
|
* Stop the login screen greeting every visitor with a failed sign-in.
|
|
*
|
|
* `wp-login.php` calls `wp_signon()` on its way to drawing the form, even
|
|
* for a plain GET with nothing submitted. Core tolerates that by throwing
|
|
* the result away — but only when the error codes are exactly the pair its
|
|
* own two password filters produce for an empty form. Removing those
|
|
* filters, which is the entire point of turning passwords off, leaves the
|
|
* generic `authentication_failed` instead. Core does not recognise it, so
|
|
* it prints it: a red "Invalid username, email address or incorrect
|
|
* password" above a form that has neither field, on a site that has no
|
|
* passwords.
|
|
*
|
|
* @param WP_Error $errors
|
|
* @return WP_Error
|
|
*/
|
|
public static function quiet_empty_form_error( $errors ) {
|
|
// Nothing was submitted, so there is nothing to forge: this only reads
|
|
// whether the request carried a body at all.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
|
if ( ! empty( $_POST ) ) {
|
|
return $errors;
|
|
}
|
|
|
|
if ( ! is_wp_error( $errors ) || array( 'authentication_failed' ) !== $errors->get_error_codes() ) {
|
|
return $errors;
|
|
}
|
|
|
|
return new WP_Error();
|
|
}
|
|
|
|
/**
|
|
* Visually retire the username and password fields when passwords are off.
|
|
*
|
|
* Both of them, not just the password. With passwords off, `authenticate()`
|
|
* never reads the username either — the account is found from the address
|
|
* recovered out of the signature — so leaving "Username or Email Address"
|
|
* at the top of the form puts the exact thing this fork exists to remove
|
|
* in the first position a visitor looks.
|
|
*
|
|
* Core's own submit button goes too. The paste-a-signature path — the one
|
|
* for people whose keys are on another machine — carries its own, so it no
|
|
* longer depends on a control that belongs to the password form it is
|
|
* replacing. The `required` attributes those two inputs carry are cleared
|
|
* in login.js,
|
|
* because a hidden required field does not fail validation quietly: it
|
|
* refuses the submit and points an error bubble at a control nobody can
|
|
* see.
|
|
*/
|
|
public static function hide_credential_fields() {
|
|
echo '<style>'
|
|
. '#user_login, label[for="user_login"],'
|
|
. '#user_pass, label[for="user_pass"], .user-pass-wrap,'
|
|
. '.forgetmenot + p { display:none !important; }'
|
|
// Core wraps each field in a bare <p>, so hiding the input alone
|
|
// leaves its margins behind as a gap above the wallet block.
|
|
. 'form p:has( > #user_login ), form p:has( .user-pass-wrap ) { display:none !important; }'
|
|
. '</style>';
|
|
}
|
|
}
|