PluginProbe ʕ •ᴥ•ʔ
LatePoint – Calendar Booking Plugin for Appointments and Events / 5.2.11
LatePoint – Calendar Booking Plugin for Appointments and Events v5.2.11
5.6.6 5.6.5 5.6.4 5.6.3 5.6.2 5.6.1 5.6.0 5.5.2 5.5.1 5.5.0 5.4.2 trunk 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.1.8 5.1.9 5.1.91 5.1.92 5.1.93 5.1.94 5.2.0 5.2.1 5.2.10 5.2.11 5.2.2 5.2.3 5.2.4 5.2.5 5.2.6 5.2.7 5.2.8 5.2.9 5.3.0 5.3.1 5.3.2 5.4.0 5.4.1
latepoint / lib / helpers / otp_helper.php
latepoint / lib / helpers Last commit date
activities_helper.php 3 months ago agent_helper.php 3 months ago analytics_helper.php 4 months ago auth_helper.php 3 months ago blocks_helper.php 3 months ago booking_helper.php 3 months ago bricks_helper.php 3 months ago bundles_helper.php 3 months ago calendar_helper.php 3 months ago carts_helper.php 3 months ago connector_helper.php 3 months ago csv_helper.php 3 months ago customer_helper.php 3 months ago customer_import_helper.php 3 months ago database_helper.php 3 months ago debug_helper.php 3 months ago defaults_helper.php 3 months ago elementor_helper.php 3 months ago email_helper.php 3 months ago encrypt_helper.php 3 months ago events_helper.php 3 months ago form_helper.php 3 months ago icalendar_helper.php 3 months ago image_helper.php 3 months ago invoices_helper.php 3 months ago license_helper.php 3 months ago location_helper.php 3 months ago marketing_systems_helper.php 3 months ago meeting_systems_helper.php 3 months ago menu_helper.php 3 months ago meta_helper.php 3 months ago migrations_helper.php 3 months ago money_helper.php 3 months ago notifications_helper.php 3 months ago nps_survey_helper.php 3 months ago order_intent_helper.php 3 months ago orders_helper.php 3 months ago otp_helper.php 3 months ago pages_helper.php 3 months ago params_helper.php 3 months ago payments_helper.php 3 months ago price_breakdown_helper.php 3 months ago process_jobs_helper.php 3 months ago processes_helper.php 3 months ago replacer_helper.php 3 months ago resource_helper.php 3 months ago roles_helper.php 3 months ago router_helper.php 3 months ago service_helper.php 3 months ago sessions_helper.php 3 months ago settings_helper.php 3 months ago short_links_systems_helper.php 3 months ago shortcodes_helper.php 3 months ago sms_helper.php 3 months ago steps_helper.php 3 months ago stripe_connect_helper.php 3 months ago styles_helper.php 3 months ago support_topics_helper.php 3 months ago time_helper.php 3 months ago timeline_helper.php 3 months ago transaction_helper.php 3 months ago transaction_intent_helper.php 3 months ago util_helper.php 3 months ago version_specific_updates_helper.php 3 months ago whatsapp_helper.php 3 months ago work_periods_helper.php 3 months ago wp_datetime.php 3 months ago wp_user_helper.php 3 months ago
otp_helper.php
418 lines
1 <?php
2 class OsOTPHelper {
3
4 private static int $max_verification_attempts = 10; // Max attempts per OTP
5 private static int $max_generation_attempts_per_hour = 60; // Max OTP generations per hour
6 public static int $otp_expires_in_minutes = 10; // Max OTP generations per hour
7 public static int $verification_expires_in_minutes = 30;
8
9 public static function create_verification_token( $contact_value, $contact_type, $via = 'otp' ): string {
10 $payload_data = [
11 'contact_value' => $contact_value,
12 'contact_type' => $contact_type,
13 'verified_via' => $via,
14 'exp' => time() + ( self::$verification_expires_in_minutes * 60 ),
15 'iat' => time(),
16 ];
17
18 $payload = base64_encode( json_encode( $payload_data ) );
19 $signature = hash_hmac( 'sha256', $payload, self::get_secret() );
20
21 return $payload . '.' . $signature;
22 }
23
24 public static function get_secret() {
25 return wp_salt( 'secure_auth' );
26 }
27
28 public static function validate_verification_token( $token ): array {
29 if ( empty( $token ) ) {
30 return [
31 'valid' => false,
32 'error' => 'Token required',
33 ];
34 }
35
36 $parts = explode( '.', $token );
37 if ( count( $parts ) !== 2 ) {
38 return [
39 'valid' => false,
40 'error' => 'Malformed token',
41 ];
42 }
43
44 [$payload, $signature] = $parts;
45
46 // Verify signature
47 $expected_signature = hash_hmac( 'sha256', $payload, self::get_secret() );
48 if ( ! hash_equals( $expected_signature, $signature ) ) {
49 return [
50 'valid' => false,
51 'error' => 'Invalid signature',
52 ];
53 }
54
55 // Decode payload
56 $data = json_decode( base64_decode( $payload ), true );
57 if ( ! $data ) {
58 return [
59 'valid' => false,
60 'error' => 'Invalid payload',
61 ];
62 }
63
64 // Check expiration
65 if ( time() > $data['exp'] ) {
66 return [
67 'valid' => false,
68 'error' => 'Token expired',
69 ];
70 }
71
72 return [
73 'valid' => true,
74 'data' => $data,
75 ];
76 }
77
78
79 public static function generateAndSendOTP( $contact_value, $contact_type, $delivery_method ) {
80 if ( ! self::isValidCombination( $contact_type, $delivery_method ) ) {
81 return new WP_Error( 'otp_generation_error', 'Invalid delivery method for contact type' );
82 }
83
84 if ( ! self::checkRateLimit( $contact_value ) ) {
85 return new WP_Error( 'otp_generation_error', 'Too many attempts. Please try again later.' );
86 }
87
88 if ( $contact_type == 'email' ) {
89 if ( ! OsUtilHelper::is_valid_email( $contact_value ) ) {
90 return new WP_Error( 'otp_generation_error', 'Invalid email address' );
91 }
92 }
93
94 // Cancel old active OTPs for this contact
95 self::cancelOldOTPs( $contact_value );
96
97 // Generate new OTP
98 $otp_code = str_pad( random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT );
99 $otp_hash = wp_hash_password( $otp_code );
100 $expires_at = OsTimeHelper::custom_datetime_utc_in_db_format( sprintf( '+%d minutes', self::$otp_expires_in_minutes ) );
101
102 $otp = new OsOTPModel();
103 $otp->contact_value = $contact_value;
104 $otp->contact_type = $contact_type;
105 $otp->delivery_method = $delivery_method;
106 $otp->otp_hash = $otp_hash;
107 $otp->expires_at = $expires_at;
108 $otp->status = LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE;
109 $otp->attempts = 0;
110 if ( ! $otp->save() ) {
111 return new WP_Error( 'otp_generation_error', $otp->get_error_messages() );
112 }
113
114 // Send OTP
115 return self::sendOTP( $otp_code, $otp );
116 }
117
118
119 public static function otp_input_box_html( string $contact_type, string $contact_value, string $delivery_method ): string {
120 $message = '';
121 $message .= '<div class="latepoint-customer-otp-input-wrapper os-customer-wrapped-box">';
122 $message .= '<div class="latepoint-customer-otp-close"><i class="latepoint-icon latepoint-icon-common-01"></i></div>';
123 $message .= '<div class="latepoint-customer-box-title">' . esc_html__( 'Verify your email', 'latepoint' ) . '</div>';
124 $message .= '<div class="latepoint-customer-box-desc">' . sprintf( esc_html__( 'Enter the code we sent to %s', 'latepoint' ), $contact_value ) . '</div>';
125 $message .= '<div class="latepoint-customer-otp-input-code-wrapper">';
126 $message .= OsFormHelper::otp_code_field( 'otp[otp_code]' );
127 $message .= '</div>';
128 $message .= '<a tabindex="0" class="latepoint-btn latepoint-btn-block latepoint-btn-primary latepoint-verify-otp-button" data-route="' . OsRouterHelper::build_route_name( 'auth', 'verify_otp' ) . '"><span>' . __( 'Verify', 'latepoint' ) . '</span></a>';
129 $message .= '<div class="latepoint-customer-otp-sub-wrapper">';
130 $message .= '<div class="latepoint-customer-otp-sub">' . sprintf( esc_html__( 'The code will expire in %s minutes', 'latepoint' ), OsOTPHelper::$otp_expires_in_minutes ) . '</div>';
131 $message .= '<a tabindex="0" href="#" class="latepoint-customer-otp-resend" data-otp-resend-route="' . OsRouterHelper::build_route_name( 'auth', 'resend_otp' ) . '">' . esc_html__( 'Resend code', 'latepoint' ) . '</a>';
132 $message .= '</div>';
133 $message .= wp_nonce_field( 'otp_verify_otp_nonce', 'otp[verify_nonce]', true, false );
134 $message .= wp_nonce_field( 'otp_resend_otp_nonce', 'otp[resend_nonce]', true, false );
135 $message .= OsFormHelper::hidden_field( 'otp[contact_type]', $contact_type );
136 $message .= OsFormHelper::hidden_field( 'otp[contact_value]', $contact_value );
137 $message .= OsFormHelper::hidden_field( 'otp[delivery_method]', $delivery_method );
138 $message .= '</div>';
139 return $message;
140 }
141
142 public static function is_customer_contact_verified( OsCustomerModel $customer, string $contact_value, string $contact_type ): bool {
143 $verified_contact_values = json_decode( $customer->get_meta_by_key( 'verified_contact_values', '' ), true );
144 if ( $verified_contact_values ) {
145 return in_array( $contact_value, $verified_contact_values[ $contact_type ] );
146 } else {
147 return false;
148 }
149 }
150
151
152 public static function add_verified_contact_for_customer_from_verification_token( OsCustomerModel $customer, string $verification_token ): void {
153 $verification_info = OsOTPHelper::validate_verification_token( $verification_token );
154 if ( $verification_info['valid'] && ! empty( $verification_info['data']['contact_value'] ) ) {
155 self::add_verified_contact_for_customer( $customer, $verification_info['data']['contact_value'], $verification_info['data']['contact_type'] );
156 }
157 }
158
159 public static function is_token_matching_to_contact_value( string $verification_token, string $contact_value ): bool {
160 $verification_info = OsOTPHelper::validate_verification_token( $verification_token );
161 if ( $verification_info['valid'] && ! empty( $verification_info['data']['contact_value'] ) && $verification_info['data']['contact_value'] == $contact_value ) {
162 return true;
163 }
164 return false;
165 }
166
167 public static function add_verified_contact_for_customer( OsCustomerModel $customer, string $contact_value, string $contact_type ) {
168 if ( ! $customer->is_new_record() && ! empty( $contact_value ) && in_array( $contact_type, self::valid_contact_types_for_customer() ) ) {
169 if ( ! self::is_customer_contact_verified( $customer, $contact_value, $contact_type ) ) {
170 $verified_contact_values = json_decode( $customer->get_meta_by_key( 'verified_contact_values', '' ), true );
171 $verified_contact_values[ $contact_type ][] = $contact_value;
172 $customer->save_meta_by_key( 'verified_contact_values', wp_json_encode( $verified_contact_values ) );
173 }
174 }
175 }
176
177 public static function verifyOTP( $otp_code, $contact_value, $contact_type = 'email', $delivery_method = 'email' ) {
178 // Expire old OTPs first
179 self::expireExpiredOTPs();
180
181 $otp = new OsOTPModel();
182 $active_otp = $otp->where(
183 [
184 'contact_value' => $contact_value,
185 'contact_type' => $contact_type,
186 'delivery_method' => $delivery_method,
187 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE,
188 'attempts <' => self::$max_verification_attempts,
189 ]
190 )->set_limit( 1 )->get_results_as_models();
191
192 if ( empty( $active_otp ) ) {
193 return new WP_Error( 'otp_generation_error', 'Invalid Code' );
194 }
195
196 if ( wp_check_password( $otp_code, $active_otp->otp_hash ) ) {
197 // Mark this OTP as used
198 $active_otp->update_attributes(
199 [
200 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED,
201 'used_at' => OsTimeHelper::now_datetime_in_format( LATEPOINT_DATETIME_DB_FORMAT ),
202 ]
203 );
204
205 // Cancel other active OTPs for this contact
206 $other_otps = new OsOTPModel();
207 $other_otps = $other_otps->where(
208 [
209 'contact_value' => $contact_value,
210 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE,
211 ]
212 )->get_results_as_models();
213 if ( $other_otps ) {
214 foreach ( $other_otps as $otp ) {
215 $otp->update_attributes( [ 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED ] );
216 }
217 }
218
219 return [
220 'status' => LATEPOINT_STATUS_SUCCESS,
221 'contact_value' => $contact_value,
222 ];
223 }
224
225 $active_otp->update_attributes( [ 'attempts' => $active_otp->attempts + 1 ] );
226
227 return new WP_Error( 'otp_generation_error', 'Invalid Code' );
228 }
229
230 private static function sendOTP( string $otp_code, OsOTPModel $otp ): array {
231
232 $result = [
233 'status' => LATEPOINT_STATUS_ERROR,
234 'message' => __( 'OTP was not sent.', 'latepoint' ),
235 'to' => $otp->contact_value,
236 'delivery_method' => $otp->delivery_method,
237 'contact_type' => $otp->contact_type,
238 'processed_datetime' => '',
239 'extra_data' => [
240 'activity_data' => [],
241 ],
242 'errors' => [],
243 ];
244 switch ( $otp->delivery_method ) {
245 case 'email':
246 $subject = __( 'Your OTP Code', 'latepoint' );
247 $content = sprintf( esc_html__( 'Your OTP code is: %s', 'latepoint' ), $otp_code );
248 $send_result = OsNotificationsHelper::send(
249 $otp->delivery_method,
250 [
251 'to' => $otp->contact_value,
252 'subject' => $subject,
253 'content' => $content,
254 ]
255 );
256 if ( $send_result['status'] == LATEPOINT_STATUS_SUCCESS ) {
257 $result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format();
258 $result['status'] = LATEPOINT_STATUS_SUCCESS;
259 } else {
260 $result['message'] = __( 'Failed to send email', 'latepoint' );
261 }
262 break;
263 case 'sms':
264 $subject = __( 'Your OTP Code', 'latepoint' );
265 $content = sprintf( esc_html__( 'Your OTP code is: %s', 'latepoint' ), $otp_code );
266 $send_result = OsNotificationsHelper::send(
267 $otp->delivery_method,
268 [
269 'to' => $otp->contact_value,
270 'subject' => $subject,
271 'content' => $content,
272 ]
273 );
274 if ( $send_result['status'] == LATEPOINT_STATUS_SUCCESS ) {
275 $result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format();
276 $result['status'] = LATEPOINT_STATUS_SUCCESS;
277 } else {
278 $result['message'] = __( 'Failed to send SMS', 'latepoint' );
279 }
280 break;
281 }
282
283
284 /**
285 * Result of sending an OTP code
286 *
287 * @since 5.2.0
288 * @hook latepoint_notifications_send_otp_code
289 *
290 * @param {array} $result The array of data describing the result of operation
291 * @param {string} $otp_code
292 * @param {OsOTPModel} $otp
293 *
294 * @returns {array} The filtered array of data describing the result of operation
295 */
296 $result = apply_filters( 'latepoint_notifications_send_otp_code', $result, $otp_code, $otp );
297
298 return $result;
299 }
300
301 public static function valid_contact_types_for_customer(): array {
302 $contact_types = [ 'email', 'phone' ];
303 /**
304 * List of valid contact types for customers
305 *
306 * @since 5.2.0
307 * @hook latepoint_valid_contact_types_for_customer
308 *
309 * @param {array} $contact_types The array of contact types
310 *
311 * @returns {array} The filtered array of contact types
312 */
313 $result = apply_filters( 'latepoint_valid_contact_types_for_customer', $contact_types );
314
315 return $result;
316 }
317
318 private static function cancelOldOTPs( $contact_value ) {
319 $old_otps = new OsOTPModel();
320 $old_otps = $old_otps->where(
321 [
322 'contact_value' => $contact_value,
323 'status' => 'active',
324 ]
325 )->get_results_as_models();
326 if ( $old_otps ) {
327 foreach ( $old_otps as $otp ) {
328 $otp->update_attributes( [ 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED ] );
329 }
330 }
331 }
332
333 private static function expireExpiredOTPs() {
334 $otps = new OsOTPModel();
335 $expired_otps = $otps->where(
336 [
337 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE,
338 'expires_at <' => OsTimeHelper::now_datetime_utc_in_db_format(),
339 ]
340 )->get_results_as_models();
341 if ( $expired_otps ) {
342 foreach ( $expired_otps as $otp ) {
343 $otp->update_attributes( [ 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED ] );
344 }
345 }
346 }
347
348 private static function isValidCombination( $contact_type, $delivery_method ) {
349 $valid_combinations = [
350 'email' => [ 'email' ],
351 'phone' => [ 'sms', 'whatsapp' ],
352 ];
353 /**
354 * Delivery methods for contact types
355 *
356 * @since 5.2.0
357 * @hook latepoint_otp_delivery_methods_for_contact_types
358 *
359 * @param {array} $methods available delivery methods
360 * @returns {array} The filtered array of available delivery methods
361 */
362 $valid_combinations = apply_filters( 'latepoint_otp_delivery_methods_for_contact_types', $valid_combinations );
363
364 return in_array( $delivery_method, $valid_combinations[ $contact_type ] ?? [] );
365 }
366
367 private static function checkRateLimit( $contact_value ): bool {
368
369 $otps = new OsOTPModel();
370 $recent_attempts = $otps->where(
371 [
372 'contact_value' => $contact_value,
373 'created_at >' => OsTimeHelper::custom_datetime_utc_in_db_format( '-1 hour' ),
374 ]
375 )->count();
376
377
378 return $recent_attempts < self::$max_generation_attempts_per_hour;
379 }
380
381
382 // Cleanup old records
383 public static function scheduledCleanup() {
384 $otps = new OsOTPModel();
385 $otps->delete_where(
386 [
387 'created_at <' => OsTimeHelper::custom_datetime_utc_in_db_format( '-30 days' ),
388 'status' => [
389 LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED,
390 LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED,
391 LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED,
392 ],
393 ]
394 );
395 }
396
397 public static function is_otp_enabled_for_contact_type( string $contact_type, string $delivery_method ): bool {
398 $is_enabled = false;
399 if ( $contact_type == 'email' && $delivery_method == 'email' ) {
400 $is_enabled = true;
401 }
402
403 /**
404 * Determines if OTP is enabled for a selected contact type and delivery method
405 *
406 * @since 5.2.0
407 * @hook latepoint_is_otp_enabled_for_contact_type
408 *
409 * @param {bool} $is_enabled if otp delivery is enabled for a supplied contact and delivery method
410 * @param {string} $contact_type a contact type for OTP
411 * @param {string} $delivery_method a delivery method for OTP
412 *
413 * @returns {bool} Filtered value of whether OTP is enabled for this delivery method
414 */
415 return apply_filters( 'latepoint_is_otp_enabled_for_contact_type', $is_enabled, $contact_type, $delivery_method );
416 }
417 }
418