| 1 |
<?php |
| 2 |
/** |
| 3 |
* Auth Controller |
| 4 |
* |
| 5 |
* Main controller class for handling authentication functionality. |
| 6 |
* |
| 7 |
* @package SureCookie\Inc\Modules\Auth |
| 8 |
* @since 0.0.1-beta.3 |
| 9 |
*/ |
| 10 |
|
| 11 |
namespace SureCookie\Inc\Modules\Auth; |
| 12 |
|
| 13 |
use SureCookie\Inc\Functions\Update; |
| 14 |
use SureCookie\Inc\Modules\SiteScanner\SaasClient; |
| 15 |
use SureCookie\Inc\Traits\GetInstance; |
| 16 |
use WP_Error; |
| 17 |
|
| 18 |
if ( ! defined( 'ABSPATH' ) ) { |
| 19 |
exit; // Exit if accessed directly. |
| 20 |
} |
| 21 |
|
| 22 |
/** |
| 23 |
* Controller class |
| 24 |
* |
| 25 |
* Main controller class for authentication functionality. |
| 26 |
*/ |
| 27 |
class Controller { |
| 28 |
use GetInstance; |
| 29 |
|
| 30 |
/** |
| 31 |
* Module settings key. |
| 32 |
* |
| 33 |
* @since 0.0.1-beta.3 |
| 34 |
*/ |
| 35 |
public const SETTINGS_KEY = 'surecookie_auth'; |
| 36 |
|
| 37 |
/** |
| 38 |
* TTL for the per-flow encryption key transient (seconds). |
| 39 |
* |
| 40 |
* @since 0.0.1-beta.3 |
| 41 |
*/ |
| 42 |
public const FLOW_KEY_TTL = 300; |
| 43 |
|
| 44 |
/** |
| 45 |
* Encryption key. |
| 46 |
* |
| 47 |
* @since 0.0.1-beta.3 |
| 48 |
* @var string |
| 49 |
*/ |
| 50 |
public $key; |
| 51 |
|
| 52 |
/** |
| 53 |
* Build the auth payload the frontend POSTs to the billing portal (issue #466). |
| 54 |
* |
| 55 |
* Returns the action URL + hidden field values for a form-based POST so the |
| 56 |
* AES key and flow identifier never appear in any URL, browser history, |
| 57 |
* Referer header, or access log. |
| 58 |
* |
| 59 |
* Each call mints a fresh 256-bit key and a fresh flow_id (UUID); the key |
| 60 |
* is stored server-side under a transient keyed by flow_id (NOT user_id - |
| 61 |
* eliminates multi-tab key reuse). |
| 62 |
* |
| 63 |
* @since 0.0.1-beta.3 |
| 64 |
* |
| 65 |
* @return array{action_url: string, method: string, fields: array<string, string>}|WP_Error |
| 66 |
*/ |
| 67 |
public function get_auth_payload() { |
| 68 |
try { |
| 69 |
$this->key = bin2hex( random_bytes( 32 ) ); |
| 70 |
} catch ( \Throwable $e ) { |
| 71 |
return new WP_Error( 'csprng_unavailable', __( 'Could not generate secure auth key.', 'surecookie' ) ); |
| 72 |
} |
| 73 |
|
| 74 |
$flow_id = wp_generate_uuid4(); |
| 75 |
|
| 76 |
set_transient( 'scap_auth_key_' . $flow_id, $this->key, self::FLOW_KEY_TTL ); |
| 77 |
|
| 78 |
$token_data = [ |
| 79 |
'redirect-back' => admin_url( 'admin.php?page=surecookie' ), |
| 80 |
'key' => $this->key, |
| 81 |
'site-url' => site_url(), |
| 82 |
'flow_id' => $flow_id, |
| 83 |
]; |
| 84 |
|
| 85 |
$encoded_token_data = wp_json_encode( $token_data ); |
| 86 |
|
| 87 |
if ( empty( $encoded_token_data ) ) { |
| 88 |
return new WP_Error( 'failed_to_encode_token_data', __( 'Failed to encode the token data.', 'surecookie' ) ); |
| 89 |
} |
| 90 |
|
| 91 |
return [ |
| 92 |
'action_url' => SURECOOKIE_BILLING_PORTAL . 'auth/', |
| 93 |
'method' => 'POST', |
| 94 |
'fields' => [ |
| 95 |
'token' => base64_encode( $encoded_token_data ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode |
| 96 |
'flow_id' => $flow_id, |
| 97 |
], |
| 98 |
]; |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Get Auth status. |
| 103 |
* |
| 104 |
* Checks if user is authenticated via stored credentials or via Pro license. |
| 105 |
* |
| 106 |
* @since 0.0.1-beta.3 |
| 107 |
* @return bool |
| 108 |
*/ |
| 109 |
public function get_auth_status() { |
| 110 |
$auth_status = get_option( self::SETTINGS_KEY, false ); |
| 111 |
$is_authenticated = ! empty( $auth_status ); |
| 112 |
|
| 113 |
/** |
| 114 |
* Filter to allow Pro plugin to override authentication status. |
| 115 |
* |
| 116 |
* @since 0.0.1-beta.3 |
| 117 |
* @param bool $is_authenticated Whether user is authenticated. |
| 118 |
*/ |
| 119 |
return apply_filters( 'surecookie_is_authenticated', $is_authenticated ); |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* Get authenticated user email. |
| 124 |
* |
| 125 |
* Retained for UI display ("Logged in as X"). The email is NOT |
| 126 |
* transmitted to the SaaS scanner on any request after issue #469. |
| 127 |
* |
| 128 |
* @since 0.0.1-beta.3 |
| 129 |
* @return string|null |
| 130 |
*/ |
| 131 |
public function get_auth_email() { |
| 132 |
$auth_data = get_option( self::SETTINGS_KEY, [] ); |
| 133 |
return $auth_data['user_email'] ?? null; |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* Get the opaque billing-portal account reference (UUID v4) - issue #469. |
| 138 |
* |
| 139 |
* The only identifier sent to the SaaS alongside HMAC site auth. |
| 140 |
* Returns null for corrupted/non-v4 stored values. |
| 141 |
* |
| 142 |
* @since 0.0.1-beta.3 |
| 143 |
*/ |
| 144 |
public function get_account_ref(): ?string { |
| 145 |
$auth_data = get_option( self::SETTINGS_KEY, [] ); |
| 146 |
$ref = $auth_data['account_ref'] ?? null; |
| 147 |
|
| 148 |
if ( ! is_string( $ref ) || $ref === '' ) { |
| 149 |
return null; |
| 150 |
} |
| 151 |
|
| 152 |
// RFC 4122 v4: 8-4-4-4-12 hex, version nibble = 4, variant in [89ab]. |
| 153 |
if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $ref ) ) { |
| 154 |
return null; |
| 155 |
} |
| 156 |
|
| 157 |
return $ref; |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Save Auth. |
| 162 |
* |
| 163 |
* Decrypts the access key using the per-flow transient key and random IV. |
| 164 |
* Expected input format: base64( iv + ciphertext ) |
| 165 |
* |
| 166 |
* Issue #466: keyed by flow_id (UUID), not user_id - eliminates multi-tab |
| 167 |
* key reuse. The WP nonce was removed from the payload (cross-origin nonce |
| 168 |
* is meaningless; replay protection survives via one-time transient + TTL). |
| 169 |
* |
| 170 |
* @since 0.0.1-beta.3 |
| 171 |
* @param string $data Base64-encoded encrypted data. |
| 172 |
* @param string $flow_id UUID from the inbound POST identifying which key transient to use. |
| 173 |
* @param string $method Encryption method. Default is AES-256-CBC. |
| 174 |
* @return bool|WP_Error |
| 175 |
*/ |
| 176 |
public function save_auth( $data, $flow_id, $method = 'AES-256-CBC' ) { |
| 177 |
|
| 178 |
if ( ! is_string( $flow_id ) || ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $flow_id ) ) { |
| 179 |
return new WP_Error( 'invalid_flow_id', __( 'Invalid flow identifier.', 'surecookie' ) ); |
| 180 |
} |
| 181 |
|
| 182 |
// Convert URL-safe base64 (RFC 4648 §5) back to standard base64. |
| 183 |
$data = strtr( (string) $data, '-_', '+/' ); |
| 184 |
|
| 185 |
$decoded_data = base64_decode( $data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 186 |
|
| 187 |
if ( empty( $decoded_data ) ) { |
| 188 |
return new WP_Error( 'failed_to_decode', __( 'Failed to decode the access key.', 'surecookie' ) ); |
| 189 |
} |
| 190 |
|
| 191 |
$iv_length = openssl_cipher_iv_length( $method ); |
| 192 |
if ( $iv_length === false || strlen( $decoded_data ) <= $iv_length ) { |
| 193 |
return new WP_Error( 'invalid_data_format', __( 'Invalid data format.', 'surecookie' ) ); |
| 194 |
} |
| 195 |
|
| 196 |
$iv = substr( $decoded_data, 0, $iv_length ); |
| 197 |
$encrypted = substr( $decoded_data, $iv_length ); |
| 198 |
|
| 199 |
$decrypted = $this->attempt_transient_decrypt( $encrypted, $iv, $method, $flow_id ); |
| 200 |
|
| 201 |
// One-time use: consume the transient regardless of decrypt outcome. |
| 202 |
// Even a failed attempt (wrong ciphertext / wrong key) invalidates the |
| 203 |
// transient so a captured payload can't be replayed against a fresh attempt. |
| 204 |
delete_transient( 'scap_auth_key_' . $flow_id ); |
| 205 |
|
| 206 |
if ( empty( $decrypted ) ) { |
| 207 |
return new WP_Error( 'failed_to_decrypt', __( 'Failed to decrypt the access key.', 'surecookie' ) ); |
| 208 |
} |
| 209 |
|
| 210 |
$decrypted_data_array = json_decode( $decrypted, true ); |
| 211 |
|
| 212 |
if ( ! is_array( $decrypted_data_array ) || empty( $decrypted_data_array ) ) { |
| 213 |
return new WP_Error( 'failed_to_json_decode', __( 'Failed to json decode the decrypted data.', 'surecookie' ) ); |
| 214 |
} |
| 215 |
|
| 216 |
if ( empty( $decrypted_data_array['user_email'] ) ) { |
| 217 |
return new WP_Error( 'no_user_email', __( 'No user email found in the decrypted data.', 'surecookie' ) ); |
| 218 |
} |
| 219 |
|
| 220 |
if ( isset( $decrypted_data_array['is_subscribed'] ) ) { |
| 221 |
$is_subscribed = is_string( $decrypted_data_array['is_subscribed'] ) |
| 222 |
? $decrypted_data_array['is_subscribed'] === 'true' |
| 223 |
: (bool) $decrypted_data_array['is_subscribed']; |
| 224 |
|
| 225 |
update_option( 'surecookie_usage_optin', $is_subscribed ? 'yes' : 'no' ); |
| 226 |
unset( $decrypted_data_array['is_subscribed'] ); |
| 227 |
} |
| 228 |
|
| 229 |
// Strip any stray nonce field the billing portal might still echo for |
| 230 |
// pre-#466 clients - not validated, just not persisted. |
| 231 |
unset( $decrypted_data_array['nonce'] ); |
| 232 |
|
| 233 |
// Non-autoloaded - the auth payload is only read by admin/REST paths, never |
| 234 |
// on the front-end banner render, so it stays out of the alloptions cache. |
| 235 |
Update::option( self::SETTINGS_KEY, $decrypted_data_array ); |
| 236 |
|
| 237 |
// Push account_ref to SaaS so it can populate Site::tier without |
| 238 |
// seeing the user's email (issue #469). Fire-and-forget. |
| 239 |
$account_ref = $decrypted_data_array['account_ref'] ?? null; |
| 240 |
if ( is_string( $account_ref ) && $account_ref !== '' ) { |
| 241 |
SaasClient::get_instance()->link_billing_account( $account_ref ); |
| 242 |
} |
| 243 |
|
| 244 |
return true; |
| 245 |
} |
| 246 |
|
| 247 |
/** |
| 248 |
* Clear Auth data. |
| 249 |
* |
| 250 |
* @since 0.0.1-beta.3 |
| 251 |
* @return bool |
| 252 |
*/ |
| 253 |
public function clear_auth() { |
| 254 |
return delete_option( self::SETTINGS_KEY ); |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Attempt decryption using the key stored in the per-flow transient. |
| 259 |
* |
| 260 |
* The transient holds the hex-encoded key (transport-safe in JSON). |
| 261 |
* We hex2bin() it here so OpenSSL receives the full 32 raw bytes - |
| 262 |
* passing the 64-char hex string directly would silently truncate |
| 263 |
* inside openssl and reduce the AES-256 key to 128 effective bits. |
| 264 |
* |
| 265 |
* @since 0.0.1-beta.3 |
| 266 |
* @param string $encrypted Raw encrypted data (without IV). |
| 267 |
* @param string $iv The initialization vector. |
| 268 |
* @param string $method Encryption method. |
| 269 |
* @param string $flow_id UUID identifying which transient to read. |
| 270 |
* @return string|false Decrypted string or false on failure. |
| 271 |
*/ |
| 272 |
private function attempt_transient_decrypt( string $encrypted, string $iv, string $method, string $flow_id ) { |
| 273 |
$transient_key = get_transient( 'scap_auth_key_' . $flow_id ); |
| 274 |
|
| 275 |
if ( ! is_string( $transient_key ) || strlen( $transient_key ) !== 64 || ! ctype_xdigit( $transient_key ) ) { |
| 276 |
return false; |
| 277 |
} |
| 278 |
|
| 279 |
$key_bytes = hex2bin( $transient_key ); |
| 280 |
if ( ! is_string( $key_bytes ) || strlen( $key_bytes ) !== 32 ) { |
| 281 |
return false; |
| 282 |
} |
| 283 |
|
| 284 |
return openssl_decrypt( $encrypted, $method, $key_bytes, OPENSSL_RAW_DATA, $iv ); |
| 285 |
} |
| 286 |
|
| 287 |
} |
| 288 |
|