Files
SIGE-WEB-snapshot/wp-content/plugins/google-analytics-for-wordpress/includes/api/class-monsterinsights-api-token.php

313 lines
8.1 KiB
PHP

<?php
/**
* API Token class for MonsterInsights.
*
* Generates encrypted tokens for secure browser-to-API communication.
* Used by Custom Dashboard, AI Chat, and any feature needing direct
* browser-to-Laravel requests without proxying through WordPress.
*
* Token Format: {publickey}.{base64(hmac + iv + ciphertext)}
*
* Security Features:
* - AES-256-CBC encryption
* - HMAC-SHA256 integrity verification
* - 30-minute token expiration
* - Site-specific encryption key (relay token)
*
* @since 9.x.x
* @package MonsterInsights
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* MonsterInsights API Token class.
*
* @since 9.x.x
*/
class MonsterInsights_API_Token {
/**
* Token expiration time in seconds (30 minutes).
*
* @var int
*/
const TOKEN_EXPIRATION = 1800;
/**
* Cache group for storing generated tokens.
*
* @var string
*/
const CACHE_GROUP = 'api_tokens';
/**
* Encryption cipher.
*
* @var string
*/
const CIPHER = 'AES-256-CBC';
/**
* Generate an encrypted token for browser-to-API communication.
*
* The token contains encrypted user and site context that can be
* validated by Laravel/Python without calling back to WordPress.
*
* @since 9.x.x
*
* @param bool $network Whether to use network credentials.
* @return array|WP_Error Token data array or WP_Error on failure.
* {
* 'token' => string, // The encrypted token
* 'expires_at' => int, // Unix timestamp
* }
*/
public static function generate( $network = false ) {
$auth = MonsterInsights()->auth;
// Get relay credentials.
$public_key = $network ? $auth->get_network_key() : $auth->get_key();
$token_key = $network ? $auth->get_network_token() : $auth->get_token();
if ( empty( $public_key ) || empty( $token_key ) ) {
return new WP_Error(
'not_authenticated',
__( 'Site is not authenticated with MonsterInsights.', 'google-analytics-for-wordpress' )
);
}
// Build payload - minimal data needed for Laravel validation.
$timestamp = time();
$expires_at = $timestamp + self::TOKEN_EXPIRATION;
$payload = array(
'site_url' => $network ? network_admin_url() : home_url(),
'issued_at' => $timestamp,
'expires_at' => $expires_at,
);
// Encrypt the payload.
$encrypted = self::encrypt_payload( $payload, $token_key );
if ( is_wp_error( $encrypted ) ) {
return $encrypted;
}
// Final token format: publickey.encrypted_payload
$token = $public_key . '.' . $encrypted;
return array(
'token' => $token,
'expires_at' => $expires_at,
);
}
/**
* Get a cached token or generate a new one.
*
* Tokens are cached at site-level since all users see the same analytics data.
* A 5-minute buffer is used to refresh tokens before they expire.
*
* @since 9.x.x
*
* @param bool $network Whether to use network credentials.
* @return array|WP_Error Token data array or WP_Error on failure.
*/
public static function get_token( $network = false ) {
// Ensure user has permission to view dashboard data.
if ( ! current_user_can( 'monsterinsights_view_dashboard' ) ) {
return new WP_Error(
'unauthorized',
__( 'You do not have permission to access this data.', 'google-analytics-for-wordpress' )
);
}
$cache_key = 'api_token_site' . ( $network ? '_network' : '' );
// Try to get cached token.
$cached = monsterinsights_cache_get( $cache_key, self::CACHE_GROUP );
// Check if cached token is still valid (with 5-minute buffer).
if ( $cached && isset( $cached['expires_at'] ) ) {
$buffer = 300; // 5 minutes.
if ( $cached['expires_at'] > ( time() + $buffer ) ) {
return $cached;
}
}
// Generate new token.
$token_data = self::generate( $network );
if ( is_wp_error( $token_data ) ) {
return $token_data;
}
// Cache the token (TTL = expiration - buffer).
$ttl = ( $token_data['expires_at'] - time() ) - 300;
if ( $ttl > 0 ) {
monsterinsights_cache_set( $cache_key, $token_data, self::CACHE_GROUP, $ttl );
}
return $token_data;
}
/**
* AJAX handler to fetch a fresh API token for the current context.
*
* This mirrors the behavior on full page load, but can be invoked
* from JavaScript via wp.ajax to refresh the Bearer token when it is
* close to expiring without reloading the page.
*
* @since 9.x.x
*
* Expects:
* - $_POST['nonce'] = wp_create_nonce( 'mi-admin-nonce' )
*/
public static function ajax_get_token() {
// Validate nonce.
check_ajax_referer( 'mi-admin-nonce', 'nonce' );
// Get token for current admin context (site or network).
$token_data = self::get_token( is_network_admin() );
if ( is_wp_error( $token_data ) ) {
wp_send_json_error(
array(
'code' => $token_data->get_error_code(),
'message' => $token_data->get_error_message(),
),
403
);
}
wp_send_json_success( $token_data );
}
/**
* Get just the token string (convenience method for wp_localize_script).
*
* @since 9.x.x
*
* @param bool $network Whether to use network credentials.
* @return string The token string or empty string on failure.
*/
public static function get_token_string( $network = false ) {
$token_data = self::get_token( $network );
if ( is_wp_error( $token_data ) ) {
return '';
}
return $token_data['token'];
}
/**
* Get the token expiration timestamp.
*
* @since 9.x.x
*
* @param bool $network Whether to use network credentials.
* @return int Unix timestamp or 0 on failure.
*/
public static function get_expiration( $network = false ) {
$token_data = self::get_token( $network );
if ( is_wp_error( $token_data ) ) {
return 0;
}
return $token_data['expires_at'];
}
/**
* Invalidate cached token for the site.
*
* Call this when relay credentials change or are deauthenticated.
*
* @since 9.x.x
*
* @param bool $network Whether to invalidate network token.
* @return bool True on success.
*/
public static function invalidate( $network = false ) {
$cache_key = 'api_token_site' . ( $network ? '_network' : '' );
return monsterinsights_cache_delete( $cache_key, self::CACHE_GROUP );
}
/**
* Encrypt the payload using AES-256-CBC.
*
* @since 9.x.x
*
* @param array $payload The data to encrypt.
* @param string $key_seed The seed for deriving the encryption key (relay token).
* @return string|WP_Error Base64-encoded encrypted data or WP_Error.
*/
private static function encrypt_payload( $payload, $key_seed ) {
// Check if OpenSSL is available.
if ( ! function_exists( 'openssl_encrypt' ) ) {
return new WP_Error(
'openssl_missing',
__( 'OpenSSL extension is required for secure API tokens.', 'google-analytics-for-wordpress' )
);
}
$payload_json = wp_json_encode( $payload );
if ( false === $payload_json ) {
return new WP_Error(
'json_encode_failed',
__( 'Failed to encode token payload.', 'google-analytics-for-wordpress' )
);
}
// Derive encryption key (SHA256 of the relay token).
$key = hash( 'sha256', $key_seed, true ); // 32 bytes.
// Generate random IV.
$iv = openssl_random_pseudo_bytes( 16 );
if ( false === $iv ) {
return new WP_Error(
'iv_generation_failed',
__( 'Failed to generate secure IV.', 'google-analytics-for-wordpress' )
);
}
// Encrypt with AES-256-CBC.
$ciphertext = openssl_encrypt(
$payload_json,
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
$iv
);
if ( false === $ciphertext ) {
return new WP_Error(
'encryption_failed',
__( 'Failed to encrypt token payload.', 'google-analytics-for-wordpress' )
);
}
// Create HMAC for integrity (sign: iv + ciphertext).
$hmac = hash_hmac( 'sha256', $iv . $ciphertext, $key_seed, true ); // 32 bytes.
// Combine: hmac(32) + iv(16) + ciphertext.
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return base64_encode( $hmac . $iv . $ciphertext );
}
}
// AJAX action to allow JS to request a fresh Bearer token without full page reload.
add_action(
'wp_ajax_monsterinsights_get_bearer_token',
array( 'MonsterInsights_API_Token', 'ajax_get_token' )
);