PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / modules / auth / controller.php

controller.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/auth/controller.php

288 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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