is_valid_request() ) { return; } $token = $this->post_field( 'token' ); if ( $token === '' ) { $this->refuse( 'missing_token' ); } $claims = MagicLoginToken::parse( $token, flywp()->get_login_public_key(), time() ); if ( $claims === null ) { $this->refuse( 'invalid_token' ); } if ( ! $this->spend_token( $claims['jti'], $claims['exp'] ) ) { $this->refuse( 'token_already_used', $claims['sid'] ); } $user = get_user_by( 'login', $claims['sub'] ); if ( ! $user instanceof WP_User ) { // Fail closed on an unknown user; never substitute another account. $this->refuse( 'unknown_user', $claims['sid'] ); } wp_set_current_user( $user->ID, $user->user_login ); wp_set_auth_cookie( $user->ID ); /** * Fires after magic login has signed a user in. * * @since 1.6.0 * * @param int $user_id ID of the user signed in. * @param string $user_login Login name of the user signed in. * @param int $site_id FlyWP site id the token was minted for. */ do_action( 'flywp_magic_login_success', $user->ID, $user->user_login, $claims['sid'] ); $this->redirect_to_admin(); } /** * Mark a token as spent, refusing a second use of the same one. * * Written before the cookie is issued. Uses an options row rather than a transient so the * unique index on `option_name` decides the outcome; keep it that way. * * @param string $jti Token identifier. * @param int $exp Token expiry, as a unix timestamp. * * @return bool False when this token has been used already, or the marker could not be stored. */ private function spend_token( $jti, $exp ) { global $wpdb; $this->forget_spent_tokens(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", self::SPENT_PREFIX . $jti, (string) $exp ) ); // 0 rows means the marker was already there; false means the write failed. Neither is a // login: a token we cannot prove is unused is a token we refuse. return $inserted === 1; } /** * Drop spent-token markers that have outlived the tokens they describe. * * Options carry no expiry of their own, so nothing else would ever collect these. * * @return void */ private function forget_spent_tokens() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST( option_value AS UNSIGNED ) < %d", $wpdb->esc_like( self::SPENT_PREFIX ) . '%', time() - MagicLoginToken::DEFAULT_SKEW ) ); } /** * Turn away a request, recording why. Never returns. * * @param string $reason Machine-readable reason, for logs and listeners. * @param int|null $site_id FlyWP site id, when the token got far enough to name one. * * @return void */ private function refuse( $reason, $site_id = null ) { /** * Fires when magic login turns a request away. * * @since 1.6.0 * * @param string $reason Machine-readable reason the request was refused. * @param int|null $site_id FlyWP site id, when the token got far enough to name one. */ do_action( 'flywp_magic_login_failed', $reason, $site_id ); // Behind WP_DEBUG_LOG. Listeners on the action above get every attempt regardless. if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( sprintf( 'FlyWP magic login refused (%s) for site %s', $reason, $site_id === null ? 'unknown' : $site_id ) ); } $this->redirect_to_home(); } /** * Read a field from the request body. * * @param string $field Field name. * * @return string */ private function post_field( $field ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return isset( $_POST[ $field ] ) ? sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) : ''; } }