array( 10, 15 * MINUTE_IN_SECONDS ), // failed sign-ins 'register' => array( 5, HOUR_IN_SECONDS ), 'lost' => array( 5, 15 * MINUTE_IN_SECONDS ), 'reset' => array( 10, 15 * MINUTE_IN_SECONDS ), ); public function register(): void { add_action( 'login_init', array( $this, 'redirect_wp_login' ) ); add_action( 'wp_login_failed', array( $this, 'login_failed' ), 10, 2 ); add_filter( 'authenticate', array( $this, 'throttle_login' ), 99 ); add_action( 'login_form_register', array( $this, 'handle_register' ) ); add_action( 'login_form_lostpassword', array( $this, 'handle_lostpassword' ) ); add_action( 'login_form_retrievepassword', array( $this, 'handle_lostpassword' ) ); add_action( 'login_form_rp', array( $this, 'redirect_rp' ) ); add_action( 'login_form_resetpass', array( $this, 'redirect_rp' ) ); add_action( 'template_redirect', array( $this, 'handle_reset_post' ), 0 ); add_filter( 'lostpassword_url', array( $this, 'lostpassword_url' ), 10, 2 ); add_filter( 'register_url', array( $this, 'register_url' ) ); add_filter( 'login_url', array( $this, 'login_url' ), 10, 3 ); } public static function enabled(): bool { return (bool) Settings::get( 'redirect_login' ); } public static function url( string $screen, array $args = array() ): string { return add_query_arg( $args, Settings::app_url( $screen ) ); } /** Core's screens plus any an add-on adds through `dashbread_auth_routes` (Pro: two-factor). @return string[] */ public static function routes(): array { return array_values( array_unique( array_merge( self::ROUTES, array_map( 'strval', (array) apply_filters( 'dashbread_auth_routes', array() ) ) ) ) ); } public static function is_auth_route( string $route ): bool { return in_array( $route, self::routes(), true ); } /** The nonce field for one of Dashbread's own account forms ('login', 'register' or 'lost'). */ public static function nonce_field( string $form ): string { return wp_nonce_field( 'dashbread_auth_' . $form, self::NONCE, false, false ); } /* ---------- URL filters so every "Log in / Register / Lost password" link in WP points at us ---------- */ public function login_url( string $url, string $redirect, bool $force_reauth ): string { if ( ! self::enabled() || $force_reauth ) { return $url; } return self::url( 'login', $redirect ? array( 'redirect_to' => rawurlencode( $redirect ) ) : array() ); } public function register_url( string $url ): string { return self::enabled() ? self::url( 'register' ) : $url; } public function lostpassword_url( string $url, string $redirect ): string { return self::enabled() ? self::url( 'lost-password' ) : $url; } /* ---------- wp-login.php GETs become Dashbread screens; POSTs are processed by WordPress ---------- */ public function redirect_wp_login(): void { // Read-only GET: wp-login.php links are sent on to the matching Dashbread screen with the same // display arguments. Nothing is changed, so there is no nonce to check. // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! self::enabled() || 'GET' !== \Dashbread\Core\Router::method() || isset( $_GET[ self::BYPASS ] ) ) { return; } $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : 'login'; if ( isset( $_GET['interim-login'] ) || in_array( $action, array( 'logout', 'postpass', 'confirmaction', 'confirm_admin_email' ), true ) ) { return; } $map = array( 'login' => 'login', 'register' => 'register', 'lostpassword' => 'lost-password', 'retrievepassword' => 'lost-password' ); if ( 'rp' === $action || 'resetpass' === $action ) { return; // handled by redirect_rp with key + login. } if ( ! isset( $map[ $action ] ) ) { return; } $args = array(); foreach ( array( 'redirect_to', 'checkemail', 'registration', 'loggedout', 'reauth' ) as $k ) { if ( isset( $_GET[ $k ] ) ) { $args[ $k ] = rawurlencode( sanitize_text_field( wp_unslash( $_GET[ $k ] ) ) ); } } // phpcs:enable WordPress.Security.NonceVerification.Recommended wp_safe_redirect( self::url( $map[ $action ], $args ) ); exit; } /** * Refuse a sign-in from Dashbread's own forms whose nonce fails, and (when Dashbread's account screens are on) * too many failed sign-ins from this address, before WordPress accepts the password. * * @param \WP_User|\WP_Error|null $user * @return \WP_User|\WP_Error|null */ public function throttle_login( $user ) { if ( 'POST' !== Router::method() ) { return $user; } // Dashbread's sign-in forms (the app screen and [dashbread_login]) send a nonce: a stale or forged one // refuses the sign-in even with the right password. A post with no Dashbread nonce field at all is // another sign-in form (wp-login.php's own), which WordPress checks as usual. if ( isset( $_POST[ self::NONCE ] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ self::NONCE ] ) ), 'dashbread_auth_login' ) ) { return new \WP_Error( 'dashbread_form_expired', __( 'That form expired. Please try again.', 'dashbread' ) ); } if ( ! self::enabled() ) { return $user; } if ( RateLimit::blocked( 'login', RateLimit::ip(), self::LIMITS['login'][0] ) ) { return new \WP_Error( 'dashbread_rate_limited', __( 'Too many attempts. Wait a few minutes and try again.', 'dashbread' ) ); } return $user; } /** @param \WP_Error|null $error */ public function login_failed( string $username, $error = null ): void { if ( ! self::enabled() ) { return; } $codes = array( 'dashbread_rate_limited' => 'rate_limited', 'dashbread_form_expired' => 'form_expired', ); $code = $error instanceof \WP_Error && isset( $codes[ $error->get_error_code() ] ) ? $codes[ $error->get_error_code() ] : 'invalid'; if ( 'invalid' === $code ) { RateLimit::hit( 'login', RateLimit::ip(), ...self::LIMITS['login'] ); } // Keep where the member was headed only when the post came from Dashbread's own form with a valid // nonce; any other sign-in form still lands on the Dashbread screen with the error, without it. $redirect = ''; if ( isset( $_POST[ self::NONCE ], $_POST['redirect_to'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ self::NONCE ] ) ), 'dashbread_auth_login' ) ) { $redirect = wp_validate_redirect( esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ), '' ); } wp_safe_redirect( self::url( 'login', array( 'sui_err' => $code, 'log' => rawurlencode( $username ), 'redirect_to' => rawurlencode( $redirect ) ) ) ); exit; } public function handle_register(): void { if ( ! self::enabled() ) { return; } if ( 'POST' !== \Dashbread\Core\Router::method() ) { wp_safe_redirect( self::url( 'register' ) ); exit; } if ( ! get_option( 'users_can_register' ) ) { wp_safe_redirect( self::url( 'login', array( 'sui_err' => 'registration_closed' ) ) ); exit; } if ( ! RateLimit::hit( 'register', RateLimit::ip(), ...self::LIMITS['register'] ) ) { wp_safe_redirect( self::url( 'register', array( 'sui_err' => 'rate_limited' ) ) ); exit; } // Dashbread's register screen sends a nonce. A post without one is another registration form // (wp-login.php's own), which WordPress processes itself. A stale nonce changes nothing. if ( ! isset( $_POST[ self::NONCE ] ) ) { return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ self::NONCE ] ) ), 'dashbread_auth_register' ) ) { wp_safe_redirect( self::url( 'register', array( 'sui_err' => 'form_expired' ) ) ); exit; } // register_new_user() runs WordPress's checks and the registration_errors filter, gives the // site's default role and sends WordPress's own new-user email. Nobody is signed in. $login = isset( $_POST['user_login'] ) ? sanitize_user( wp_unslash( $_POST['user_login'] ) ) : ''; $email = isset( $_POST['user_email'] ) ? sanitize_email( wp_unslash( $_POST['user_email'] ) ) : ''; $result = register_new_user( $login, $email ); if ( is_wp_error( $result ) ) { wp_safe_redirect( self::url( 'register', array( 'sui_err' => $result->get_error_code(), 'user_login' => rawurlencode( $login ), 'user_email' => rawurlencode( $email ) ) ) ); exit; } wp_safe_redirect( self::url( 'login', array( 'checkemail' => 'registered' ) ) ); exit; } public function handle_lostpassword(): void { if ( ! self::enabled() ) { return; } if ( 'POST' !== \Dashbread\Core\Router::method() ) { wp_safe_redirect( self::url( 'lost-password' ) ); exit; } if ( ! RateLimit::hit( 'lost', RateLimit::ip(), ...self::LIMITS['lost'] ) ) { wp_safe_redirect( self::url( 'lost-password', array( 'sui_err' => 'rate_limited' ) ) ); exit; } // Same as registration: Dashbread's form sends a nonce; WordPress handles any other form itself. if ( ! isset( $_POST[ self::NONCE ] ) ) { return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ self::NONCE ] ) ), 'dashbread_auth_lost' ) ) { wp_safe_redirect( self::url( 'lost-password', array( 'sui_err' => 'form_expired' ) ) ); exit; } $result = retrieve_password(); if ( is_wp_error( $result ) ) { wp_safe_redirect( self::url( 'lost-password', array( 'sui_err' => $result->get_error_code() ) ) ); exit; } wp_safe_redirect( self::url( 'login', array( 'checkemail' => 'confirm' ) ) ); exit; } /** The email's reset link (wp-login.php?action=rp&key&login) opens our reset screen instead. */ public function redirect_rp(): void { // Read-only GET from the reset email: the key and login are passed on to Dashbread's reset screen, // which checks them with check_password_reset_key(). Nothing is changed here. // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! self::enabled() || 'GET' !== \Dashbread\Core\Router::method() || isset( $_GET[ self::BYPASS ] ) ) { return; } $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; $login = isset( $_GET['login'] ) ? sanitize_text_field( wp_unslash( $_GET['login'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended wp_safe_redirect( self::url( 'reset-password', array( 'key' => rawurlencode( $key ), 'login' => rawurlencode( $login ) ) ) ); exit; } /** Our reset form posts to itself; validate the key and set the password with core functions. */ public function handle_reset_post(): void { if ( ! Router::is_app() || 'reset-password' !== Router::current_route() || 'POST' !== \Dashbread\Core\Router::method() ) { return; } // A failed nonce stops here with an error; nothing is changed. if ( ! isset( $_POST['dashbread_reset_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['dashbread_reset_nonce'] ) ), 'dashbread_reset' ) ) { wp_safe_redirect( self::url( 'reset-password', array( 'sui_err' => 'expired' ) ) ); exit; } if ( ! RateLimit::hit( 'reset', RateLimit::ip(), ...self::LIMITS['reset'] ) ) { wp_safe_redirect( self::url( 'reset-password', array( 'sui_err' => 'rate_limited' ) ) ); exit; } $key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : ''; $login = isset( $_POST['login'] ) ? sanitize_text_field( wp_unslash( $_POST['login'] ) ) : ''; $pass1 = isset( $_POST['pass1'] ) ? (string) wp_unslash( $_POST['pass1'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a password is used as typed (sanitizing would change it); reset_password() hashes it; it is never stored or printed as typed. $pass2 = isset( $_POST['pass2'] ) ? (string) wp_unslash( $_POST['pass2'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as above; only compared with pass1. $user = check_password_reset_key( $key, $login ); $back = array( 'key' => rawurlencode( $key ), 'login' => rawurlencode( $login ) ); if ( is_wp_error( $user ) ) { wp_safe_redirect( self::url( 'reset-password', array( 'sui_err' => 'expired' ) ) ); exit; } if ( strlen( $pass1 ) < 8 || $pass1 !== $pass2 ) { wp_safe_redirect( self::url( 'reset-password', $back + array( 'sui_err' => 'mismatch' ) ) ); exit; } $errors = new \WP_Error(); do_action( 'validate_password_reset', $errors, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook. if ( $errors->has_errors() ) { wp_safe_redirect( self::url( 'reset-password', $back + array( 'sui_err' => $errors->get_error_code() ) ) ); exit; } reset_password( $user, $pass1 ); // WordPress core: sets the password, clears the key and ends the account's sessions. No sign-in. wp_safe_redirect( self::url( 'login', array( 'sui_ok' => 'password_changed' ) ) ); exit; } /** Human copy for the `error` and `checkemail` query args the screens receive. @return array{type:string,text:string}|null */ public static function notice(): ?array { // Read-only: which message to show after a redirect; nothing is changed. // phpcs:disable WordPress.Security.NonceVerification.Recommended $err = isset( $_GET['sui_err'] ) ? sanitize_key( wp_unslash( $_GET['sui_err'] ) ) : ''; $check = isset( $_GET['checkemail'] ) ? sanitize_key( wp_unslash( $_GET['checkemail'] ) ) : ''; $ok = isset( $_GET['sui_ok'] ) ? sanitize_key( wp_unslash( $_GET['sui_ok'] ) ) : ''; $loggedout = isset( $_GET['loggedout'] ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $errors = array( 'invalid' => __( 'That email or password doesn\'t match. Try again or reset your password.', 'dashbread' ), 'invalid_email' => __( 'That doesn\'t look like a valid email address.', 'dashbread' ), 'invalidcombo' => __( 'There is no account with that email or username.', 'dashbread' ), 'empty_username' => __( 'Enter your email or username.', 'dashbread' ), 'empty_email' => __( 'Enter your email address.', 'dashbread' ), 'username_exists' => __( 'That username is taken.', 'dashbread' ), 'email_exists' => __( 'There is already an account with that email. Try signing in.', 'dashbread' ), 'invalid_username' => __( 'Usernames can only contain letters, numbers, and a few symbols.', 'dashbread' ), 'registration_closed' => __( 'Registration is closed on this site.', 'dashbread' ), 'expired' => __( 'That reset link has expired or was already used. Request a new one.', 'dashbread' ), 'mismatch' => __( 'The two passwords must match and be at least 8 characters.', 'dashbread' ), 'rate_limited' => __( 'Too many attempts. Wait a few minutes and try again.', 'dashbread' ), 'form_expired' => __( 'That form expired. Please try again.', 'dashbread' ), 'retrieve_password_email_failure' => __( "We couldn't send the reset email. Contact the site administrator.", 'dashbread' ), ); /** Add-ons (Pro field builder) add their own `sui_err` codes and copy here. */ $errors = (array) apply_filters( 'dashbread_auth_messages', $errors ); if ( '' !== $err ) { return array( 'type' => 'error', 'text' => $errors[ $err ] ?? __( 'Something went wrong. Please try again.', 'dashbread' ) ); } if ( 'confirm' === $check ) { return array( 'type' => 'ok', 'text' => __( 'Check your email for a link to reset your password. It expires soon.', 'dashbread' ) ); } if ( 'registered' === $check ) { return array( 'type' => 'ok', 'text' => __( 'Check your email to confirm your account and set a password.', 'dashbread' ) ); } if ( 'password_changed' === $ok ) { return array( 'type' => 'ok', 'text' => __( 'Your password was changed. Sign in with the new one.', 'dashbread' ) ); } if ( $loggedout ) { return array( 'type' => 'ok', 'text' => __( 'You are signed out.', 'dashbread' ) ); } return null; } }