HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: //wordpress/plugins/wp-cloud-client/1.1.5/src/Handler/OneClickLoginHandler.php
<?php

declare(strict_types=1);

namespace VPlugins\WPCloudClient\Handler;

use VPlugins\WPCloudClient\Access\LoginTokenManager;
use VPlugins\WPCloudClient\Multisite\SuperAdminManager;
use VPlugins\WPCloudClient\Support\Logger;

/**
 * Handles the `one_click_login` action — issues a short-lived single-use SSO
 * token for a resolved WordPress user.
 *
 * Supported `$params` keys (all optional unless noted):
 *   - `user_email`             (string) Preferred identifier. `@vendasta.com` is rewritten via `wpcc_whitelabel_email()` before lookup.
 *   - `user_id`                (int)    Fallback when email is not supplied.
 *   - `user_login`             (string) Fallback when neither email nor id is supplied.
 *   - `create_user_if_missing` (bool)   When true and `user_email` matches no existing user, auto-creates an administrator with the supplied email (since 0.1.10). Opt-in; default `false` preserves the throw-on-unknown-email behavior.
 *   - `ttl`                    (int)    Token lifetime in seconds; clamped to [60, 600].
 *   - `redirect_to`            (string) Optional post-login destination.
 *   - `is_super_admin`         (bool)   Multisite-only; promotes the user before the SSO redirect via `SuperAdminManager`.
 *
 * Identifier precedence when multiple are supplied: `user_email` → `user_id` → `user_login` → first administrator on the site.
 */
final class OneClickLoginHandler extends AbstractHandler {

	private const DEFAULT_TTL = 300;

	/**
	 * Constructor.
	 *
	 * @param LoginTokenManager $tokenManager The login token manager instance.
	 */
	public function __construct(
		private readonly LoginTokenManager $tokenManager,
	) {}

	/**
	 * Return the action name this handler responds to.
	 *
	 * @return string The action identifier.
	 */
	public function action(): string {
		return 'one_click_login';
	}

	/**
	 * Execute the one-click login action.
	 *
	 * @param array $params The request parameters.
	 * @return array The login URL and metadata.
	 * @throws \InvalidArgumentException When user_id or user_login is provided but no matching user exists,
	 *     when user_email is unknown and create_user_if_missing is not set, or when an auto-create email is malformed.
	 * @throws \RuntimeException When no administrator exists (fallback path) or when user auto-creation fails.
	 */
	public function execute( array $params ): array {
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		error_log(
			sprintf(
				'WP Cloud Client one_click_login received: user_email=%s user_id=%s user_login=%s create_user_if_missing=%s redirect_to=%s',
				$params['user_email'] ?? '(unset)',
				isset( $params['user_id'] ) ? (string) $params['user_id'] : '(unset)',
				$params['user_login'] ?? '(unset)',
				isset( $params['create_user_if_missing'] ) ? ( $params['create_user_if_missing'] ? 'true' : 'false' ) : '(unset)',
				$params['redirect_to'] ?? '(unset)'
			)
		);

		// Accept user_email (preferred), user_id, or user_login.
		$userId     = $this->resolveUserId( $params );
		$ttl        = (int) ( $params['ttl'] ?? self::DEFAULT_TTL );
		$redirectTo = $params['redirect_to'] ?? '';

		// Clamp TTL: minimum 60s, maximum 600s
		$ttl = max( 60, min( 600, $ttl ) );

		// Persist the platform's super admin decision so SuperAdminManager can
		// apply it when wp_login fires after the token is consumed.
		if ( isset( $params['is_super_admin'] ) && is_multisite() ) {
			SuperAdminManager::setFlag( $userId, (bool) $params['is_super_admin'] );
		}

		$result = $this->tokenManager->create( $userId, $ttl );

		$loginUrl = add_query_arg(
			[
				'wpcc_login_token' => $result['token'],
			],
			site_url( '/' )
		);

		if ( '' !== $redirectTo ) {
			$loginUrl = add_query_arg( 'redirect_to', rawurlencode( $redirectTo ), $loginUrl );
		}

		return [
			'login_url'  => $loginUrl,
			'expires_at' => $result['expires_at'],
			'ttl'        => $ttl,
			'user_id'    => $userId,
		];
	}

	/**
	 * Resolve the user ID from the request parameters.
	 *
	 * Precedence: `user_email` → `user_id` → `user_login` → first administrator.
	 * Email is the canonical identifier supplied by the upstream WP Cloud Manager
	 * integration; `user_id` and `user_login` remain supported for callers that
	 * already know the WordPress user.
	 *
	 * `@vendasta.com` emails are rewritten to their `@support.websitepro.hosting`
	 * equivalents via `wpcc_whitelabel_email()` before the lookup, so platform
	 * support staff don't need pre-existing accounts under their real domain.
	 *
	 * When `user_email` is supplied but does not match an existing user, the
	 * caller can opt into auto-creation by setting `create_user_if_missing` to
	 * `true`. Without the flag, the handler throws so the caller can decide.
	 *
	 * @param array $params The request parameters containing user identification.
	 * @return int The resolved WordPress user ID.
	 * @throws \InvalidArgumentException When the specified user is not found
	 *     and `create_user_if_missing` is not set, or when an auto-create email is malformed.
	 * @throws \RuntimeException When no administrator user exists on the site,
	 *     or when user creation fails for a reason other than a benign race.
	 */
	private function resolveUserId( array $params ): int {
		if ( ! empty( $params['user_email'] ) ) {
			$email = \wpcc_whitelabel_email( $params['user_email'] );
			$user  = get_user_by( 'email', $email );
			if ( $user ) {
				return (int) $user->ID;
			}
			if ( ! empty( $params['create_user_if_missing'] ) ) {
				return $this->createUserFromEmail( $email );
			}
			throw new \InvalidArgumentException( sprintf( 'User with email "%s" not found.', $email ) );
		}

		if ( ! empty( $params['user_id'] ) ) {
			$userId = (int) $params['user_id'];
			$user   = get_user_by( 'id', $userId );
			if ( ! $user ) {
				throw new \InvalidArgumentException( sprintf( 'User ID %d not found.', $userId ) );
			}
			return $userId;
		}

		if ( ! empty( $params['user_login'] ) ) {
			$user = get_user_by( 'login', $params['user_login'] );
			if ( ! $user ) {
				throw new \InvalidArgumentException( sprintf( 'User "%s" not found.', $params['user_login'] ) );
			}
			return $user->ID;
		}

		// Default: first administrator
		$admins = get_users(
			[
				'role'    => 'administrator',
				'number'  => 1,
				'orderby' => 'ID',
				'order'   => 'ASC',
			]
		);
		if ( empty( $admins ) ) {
			throw new \RuntimeException( 'No administrator user found on this site.' );
		}

		return $admins[0]->ID;
	}

	/**
	 * Create a new administrator user with the supplied email.
	 *
	 * Username is derived from the email local-part (sanitized) with a random
	 * suffix appended on collision. Password is random 24-char and is never
	 * surfaced to the caller — the user authenticates via the SSO token.
	 *
	 * Privilege grant is observable: emits a `Logger::info` audit line and
	 * fires the `wpcc_user_auto_created` action so incident response can
	 * answer "why is there a new admin on this site?".
	 *
	 * @param string $email The email for the new user.
	 * @return int The WordPress user ID of the newly-created (or race-recovered) user.
	 * @throws \InvalidArgumentException When the email is malformed.
	 * @throws \RuntimeException When user creation fails for a reason other than
	 *     a benign race (e.g., database error).
	 */
	private function createUserFromEmail( string $email ): int {
		if ( ! is_email( $email ) ) {
			throw new \InvalidArgumentException(
				sprintf( 'Cannot create user: "%s" is not a valid email address.', $email )
			);
		}

		$login = $this->deriveLoginFromEmail( $email );
		if ( username_exists( $login ) ) {
			// 6-char random suffix gives ~2B-namespace per base; single attempt is
			// fine because wp_insert_user will reject any leftover collision and
			// the race-recovery block below handles concurrent creators.
			$login = substr( $login, 0, 53 ) . '_' . wp_generate_password( 6, false );
		}

		$userId = wp_insert_user(
			[
				'user_login'   => $login,
				'user_email'   => $email,
				'user_pass'    => wp_generate_password( 24, true, true ),
				// Auto-created users default to administrator because one_click_login is only
				// invoked by the trusted platform (via API-key auth) for support access.
				'role'         => 'administrator',
				'display_name' => $email,
			]
		);

		if ( is_wp_error( $userId ) ) {
			// Race recovery: another request may have created the user between
			// our `get_user_by` check and `wp_insert_user`.
			$existing = get_user_by( 'email', $email );
			if ( $existing ) {
				return (int) $existing->ID;
			}
			throw new \RuntimeException(
				sprintf(
					'Failed to create WP user with email "%s" [%s]: %s',
					$email,
					$userId->get_error_code(),
					$userId->get_error_message()
				)
			);
		}

		$userId = (int) $userId;

		if ( is_multisite() ) {
			add_user_to_blog( get_current_blog_id(), $userId, 'administrator' );
		}

		( new Logger() )->info(
			sprintf( 'one_click_login: auto-created administrator "%s" (ID %d) for email "%s"', $login, $userId, $email )
		);
		do_action( 'wpcc_user_auto_created', $userId, $email, 'one_click_login' );

		return $userId;
	}

	/**
	 * Derive a sanitized WordPress login from an email address.
	 *
	 * Uses the email local-part, lowercased, with disallowed characters
	 * replaced by underscores. Clamped to 55 characters so a collision suffix
	 * still fits the VARCHAR(60) `user_login` column. Falls back to
	 * `wpcc_user` when the derived login is empty.
	 *
	 * @param string $email The email to derive a login from.
	 * @return string A sanitized WP login.
	 */
	private function deriveLoginFromEmail( string $email ): string {
		$at    = strpos( $email, '@' );
		$local = false !== $at ? substr( $email, 0, $at ) : $email;
		$login = strtolower( (string) preg_replace( '/[^a-zA-Z0-9_\-\.]/', '_', $local ) );
		$login = '' !== $login ? $login : 'wpcc_user';
		// wp_users.user_login is VARCHAR(60); leave room for a 7-char "_xxxxxx" collision suffix.
		return substr( $login, 0, 55 );
	}
}