activities_helper.php
1 year ago
agent_helper.php
1 year ago
auth_helper.php
9 months ago
blocks_helper.php
1 year ago
booking_helper.php
1 year ago
bricks_helper.php
1 year ago
bundles_helper.php
1 year ago
calendar_helper.php
9 months ago
carts_helper.php
1 year ago
connector_helper.php
9 months ago
csv_helper.php
9 months ago
customer_helper.php
9 months ago
customer_import_helper.php
4 months ago
database_helper.php
9 months ago
debug_helper.php
1 year ago
defaults_helper.php
1 year ago
elementor_helper.php
1 year ago
email_helper.php
9 months ago
encrypt_helper.php
1 year ago
events_helper.php
9 months ago
form_helper.php
9 months ago
icalendar_helper.php
1 year ago
image_helper.php
1 year ago
invoices_helper.php
9 months ago
license_helper.php
1 year ago
location_helper.php
1 year ago
marketing_systems_helper.php
1 year ago
meeting_systems_helper.php
1 year ago
menu_helper.php
9 months ago
meta_helper.php
1 year ago
migrations_helper.php
1 year ago
money_helper.php
1 year ago
notifications_helper.php
9 months ago
order_intent_helper.php
9 months ago
orders_helper.php
1 year ago
otp_helper.php
9 months ago
pages_helper.php
1 year ago
params_helper.php
1 year ago
payments_helper.php
1 year ago
price_breakdown_helper.php
1 year ago
process_jobs_helper.php
1 year ago
processes_helper.php
1 year ago
replacer_helper.php
1 year ago
resource_helper.php
1 year ago
roles_helper.php
7 months ago
router_helper.php
1 year ago
service_helper.php
1 year ago
sessions_helper.php
1 year ago
settings_helper.php
4 months ago
short_links_systems_helper.php
9 months ago
shortcodes_helper.php
9 months ago
sms_helper.php
1 year ago
steps_helper.php
5 months ago
stripe_connect_helper.php
9 months ago
styles_helper.php
9 months ago
support_topics_helper.php
1 year ago
time_helper.php
9 months ago
timeline_helper.php
9 months ago
transaction_helper.php
1 year ago
transaction_intent_helper.php
1 year ago
util_helper.php
7 months ago
version_specific_updates_helper.php
9 months ago
whatsapp_helper.php
1 year ago
work_periods_helper.php
5 months ago
wp_datetime.php
1 year ago
wp_user_helper.php
1 year ago
otp_helper.php
361 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 ['valid' => false, 'error' => 'Token required']; |
| 31 | } |
| 32 | |
| 33 | $parts = explode('.', $token); |
| 34 | if (count($parts) !== 2) { |
| 35 | return ['valid' => false, 'error' => 'Malformed token']; |
| 36 | } |
| 37 | |
| 38 | [$payload, $signature] = $parts; |
| 39 | |
| 40 | // Verify signature |
| 41 | $expected_signature = hash_hmac('sha256', $payload, self::get_secret()); |
| 42 | if (!hash_equals($expected_signature, $signature)) { |
| 43 | return ['valid' => false, 'error' => 'Invalid signature']; |
| 44 | } |
| 45 | |
| 46 | // Decode payload |
| 47 | $data = json_decode(base64_decode($payload), true); |
| 48 | if (!$data) { |
| 49 | return ['valid' => false, 'error' => 'Invalid payload']; |
| 50 | } |
| 51 | |
| 52 | // Check expiration |
| 53 | if (time() > $data['exp']) { |
| 54 | return ['valid' => false, 'error' => 'Token expired']; |
| 55 | } |
| 56 | |
| 57 | return ['valid' => true, 'data' => $data]; |
| 58 | } |
| 59 | |
| 60 | |
| 61 | public static function generateAndSendOTP($contact_value, $contact_type, $delivery_method) { |
| 62 | if (!self::isValidCombination($contact_type, $delivery_method)) { |
| 63 | return new WP_Error('otp_generation_error', 'Invalid delivery method for contact type'); |
| 64 | } |
| 65 | |
| 66 | if (!self::checkRateLimit($contact_value)) { |
| 67 | return new WP_Error('otp_generation_error', 'Too many attempts. Please try again later.'); |
| 68 | } |
| 69 | |
| 70 | if($contact_type == 'email'){ |
| 71 | if(!OsUtilHelper::is_valid_email($contact_value)){ |
| 72 | return new WP_Error('otp_generation_error', 'Invalid email address'); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // Cancel old active OTPs for this contact |
| 77 | self::cancelOldOTPs($contact_value); |
| 78 | |
| 79 | // Generate new OTP |
| 80 | $otp_code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); |
| 81 | $otp_hash = wp_hash_password($otp_code); |
| 82 | $expires_at = OsTimeHelper::custom_datetime_utc_in_db_format(sprintf('+%d minutes', self::$otp_expires_in_minutes)); |
| 83 | |
| 84 | $otp = new OsOTPModel(); |
| 85 | $otp->contact_value = $contact_value; |
| 86 | $otp->contact_type = $contact_type; |
| 87 | $otp->delivery_method = $delivery_method; |
| 88 | $otp->otp_hash = $otp_hash; |
| 89 | $otp->expires_at = $expires_at; |
| 90 | $otp->status = LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE; |
| 91 | $otp->attempts = 0; |
| 92 | if(!$otp->save()){ |
| 93 | return new WP_Error('otp_generation_error', $otp->get_error_messages()); |
| 94 | } |
| 95 | |
| 96 | // Send OTP |
| 97 | return self::sendOTP($otp_code, $otp); |
| 98 | } |
| 99 | |
| 100 | |
| 101 | public static function otp_input_box_html( string $contact_type, string $contact_value, string $delivery_method ) : string { |
| 102 | $message = ''; |
| 103 | $message.= '<div class="latepoint-customer-otp-input-wrapper os-customer-wrapped-box">'; |
| 104 | $message.= '<div class="latepoint-customer-otp-close"><i class="latepoint-icon latepoint-icon-common-01"></i></div>'; |
| 105 | $message.= '<div class="latepoint-customer-box-title">'.esc_html__('Verify your email', 'latepoint').'</div>'; |
| 106 | $message.= '<div class="latepoint-customer-box-desc">'.sprintf(esc_html__('Enter the code we sent to %s', 'latepoint'), $contact_value).'</div>'; |
| 107 | $message.= '<div class="latepoint-customer-otp-input-code-wrapper">'; |
| 108 | $message.= OsFormHelper::otp_code_field('otp[otp_code]'); |
| 109 | $message.= '</div>'; |
| 110 | $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>'; |
| 111 | $message.= '<div class="latepoint-customer-otp-sub-wrapper">'; |
| 112 | $message.= '<div class="latepoint-customer-otp-sub">'.sprintf(esc_html__('The code will expire in %s minutes', 'latepoint'), OsOTPHelper::$otp_expires_in_minutes).'</div>'; |
| 113 | $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>'; |
| 114 | $message.= '</div>'; |
| 115 | $message.= wp_nonce_field('otp_verify_otp_nonce', 'otp[verify_nonce]', true, false); |
| 116 | $message.= wp_nonce_field('otp_resend_otp_nonce', 'otp[resend_nonce]', true, false); |
| 117 | $message.= OsFormHelper::hidden_field('otp[contact_type]', $contact_type); |
| 118 | $message.= OsFormHelper::hidden_field('otp[contact_value]', $contact_value); |
| 119 | $message.= OsFormHelper::hidden_field('otp[delivery_method]', $delivery_method); |
| 120 | $message.= '</div>'; |
| 121 | return $message; |
| 122 | } |
| 123 | |
| 124 | public static function is_customer_contact_verified(OsCustomerModel $customer, string $contact_value, string $contact_type) : bool{ |
| 125 | $verified_contact_values = json_decode($customer->get_meta_by_key('verified_contact_values', ''), true); |
| 126 | if($verified_contact_values){ |
| 127 | return in_array($contact_value, $verified_contact_values[$contact_type]); |
| 128 | }else{ |
| 129 | return false; |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | |
| 134 | public static function add_verified_contact_for_customer_from_verification_token(OsCustomerModel $customer, string $verification_token) : void { |
| 135 | $verification_info = OsOTPHelper::validate_verification_token($verification_token); |
| 136 | if($verification_info['valid'] && !empty($verification_info['data']['contact_value'])){ |
| 137 | self::add_verified_contact_for_customer($customer, $verification_info['data']['contact_value'], $verification_info['data']['contact_type']); |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | public static function is_token_matching_to_contact_value(string $verification_token, string $contact_value) : bool{ |
| 142 | $verification_info = OsOTPHelper::validate_verification_token($verification_token); |
| 143 | if($verification_info['valid'] && !empty($verification_info['data']['contact_value']) && $verification_info['data']['contact_value'] == $contact_value){ |
| 144 | return true; |
| 145 | } |
| 146 | return false; |
| 147 | } |
| 148 | |
| 149 | public static function add_verified_contact_for_customer(OsCustomerModel $customer, string $contact_value, string $contact_type){ |
| 150 | if(!$customer->is_new_record() && !empty($contact_value) && in_array($contact_type, self::valid_contact_types_for_customer())){ |
| 151 | if(!self::is_customer_contact_verified($customer, $contact_value, $contact_type)){ |
| 152 | $verified_contact_values = json_decode($customer->get_meta_by_key('verified_contact_values', ''), true); |
| 153 | $verified_contact_values[$contact_type][] = $contact_value; |
| 154 | $customer->save_meta_by_key('verified_contact_values', wp_json_encode($verified_contact_values)); |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | public static function verifyOTP($otp_code, $contact_value, $contact_type = 'email', $delivery_method = 'email') { |
| 160 | // Expire old OTPs first |
| 161 | self::expireExpiredOTPs(); |
| 162 | |
| 163 | $otp = new OsOTPModel(); |
| 164 | $active_otp = $otp->where([ |
| 165 | 'contact_value' => $contact_value, |
| 166 | 'contact_type' => $contact_type, |
| 167 | 'delivery_method' => $delivery_method, |
| 168 | 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE, |
| 169 | 'attempts <' => self::$max_verification_attempts |
| 170 | ])->set_limit(1)->get_results_as_models(); |
| 171 | |
| 172 | if (empty($active_otp)) { |
| 173 | return new WP_Error('otp_generation_error', 'Invalid Code'); |
| 174 | } |
| 175 | |
| 176 | if (wp_check_password($otp_code, $active_otp->otp_hash)) { |
| 177 | // Mark this OTP as used |
| 178 | $active_otp->update_attributes( |
| 179 | [ |
| 180 | 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED, |
| 181 | 'used_at' => OsTimeHelper::now_datetime_in_format( LATEPOINT_DATETIME_DB_FORMAT ) |
| 182 | ] |
| 183 | ); |
| 184 | |
| 185 | // Cancel other active OTPs for this contact |
| 186 | $other_otps = new OsOTPModel(); |
| 187 | $other_otps = $other_otps->where(['contact_value' => $contact_value, 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE])->get_results_as_models(); |
| 188 | if($other_otps){ |
| 189 | foreach($other_otps as $otp){ |
| 190 | $otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]); |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | return [ |
| 195 | 'status' => LATEPOINT_STATUS_SUCCESS, |
| 196 | 'contact_value' => $contact_value |
| 197 | ]; |
| 198 | } |
| 199 | |
| 200 | $active_otp->update_attributes(['attempts' => $active_otp->attempts + 1]); |
| 201 | |
| 202 | return new WP_Error('otp_generation_error', 'Invalid Code'); |
| 203 | } |
| 204 | |
| 205 | private static function sendOTP(string $otp_code, OsOTPModel $otp) : array { |
| 206 | |
| 207 | $result = [ |
| 208 | 'status' => LATEPOINT_STATUS_ERROR, |
| 209 | 'message' => __('OTP was not sent.', 'latepoint'), |
| 210 | 'to' => $otp->contact_value, |
| 211 | 'delivery_method' => $otp->delivery_method, |
| 212 | 'contact_type' => $otp->contact_type, |
| 213 | 'processed_datetime' => '', |
| 214 | 'extra_data' => [ |
| 215 | 'activity_data' => [] |
| 216 | ], |
| 217 | 'errors' => [], |
| 218 | ]; |
| 219 | switch($otp->delivery_method) { |
| 220 | case 'email': |
| 221 | $subject = __('Your OTP Code', 'latepoint'); |
| 222 | $content = sprintf(esc_html__('Your OTP code is: %s', 'latepoint'), $otp_code); |
| 223 | $send_result = OsNotificationsHelper::send($otp->delivery_method, ['to' => $otp->contact_value, 'subject' => $subject, 'content' => $content]); |
| 224 | if($send_result['status'] == LATEPOINT_STATUS_SUCCESS){ |
| 225 | $result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format(); |
| 226 | $result['status'] = LATEPOINT_STATUS_SUCCESS; |
| 227 | }else{ |
| 228 | $result['message'] = __('Failed to send email', 'latepoint'); |
| 229 | } |
| 230 | break; |
| 231 | case 'sms': |
| 232 | $subject = __('Your OTP Code', 'latepoint'); |
| 233 | $content = sprintf(esc_html__('Your OTP code is: %s', 'latepoint'), $otp_code); |
| 234 | $send_result = OsNotificationsHelper::send($otp->delivery_method, ['to' => $otp->contact_value, 'subject' => $subject, 'content' => $content]); |
| 235 | if($send_result['status'] == LATEPOINT_STATUS_SUCCESS){ |
| 236 | $result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format(); |
| 237 | $result['status'] = LATEPOINT_STATUS_SUCCESS; |
| 238 | }else{ |
| 239 | $result['message'] = __('Failed to send SMS', 'latepoint'); |
| 240 | } |
| 241 | break; |
| 242 | } |
| 243 | |
| 244 | |
| 245 | /** |
| 246 | * Result of sending an OTP code |
| 247 | * |
| 248 | * @since 5.2.0 |
| 249 | * @hook latepoint_notifications_send_otp_code |
| 250 | * |
| 251 | * @param {array} $result The array of data describing the result of operation |
| 252 | * @param {string} $otp_code |
| 253 | * @param {OsOTPModel} $otp |
| 254 | * |
| 255 | * @returns {array} The filtered array of data describing the result of operation |
| 256 | */ |
| 257 | $result = apply_filters('latepoint_notifications_send_otp_code', $result, $otp_code, $otp); |
| 258 | |
| 259 | return $result; |
| 260 | } |
| 261 | |
| 262 | public static function valid_contact_types_for_customer() : array{ |
| 263 | $contact_types = ['email', 'phone']; |
| 264 | /** |
| 265 | * List of valid contact types for customers |
| 266 | * |
| 267 | * @since 5.2.0 |
| 268 | * @hook latepoint_valid_contact_types_for_customer |
| 269 | * |
| 270 | * @param {array} $contact_types The array of contact types |
| 271 | * |
| 272 | * @returns {array} The filtered array of contact types |
| 273 | */ |
| 274 | $result = apply_filters('latepoint_valid_contact_types_for_customer', $contact_types); |
| 275 | |
| 276 | return $result; |
| 277 | } |
| 278 | |
| 279 | private static function cancelOldOTPs($contact_value) { |
| 280 | $old_otps = new OsOTPModel(); |
| 281 | $old_otps = $old_otps->where(['contact_value' => $contact_value, 'status' => 'active'])->get_results_as_models(); |
| 282 | if($old_otps){ |
| 283 | foreach($old_otps as $otp){ |
| 284 | $otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]); |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | private static function expireExpiredOTPs() { |
| 290 | $otps = new OsOTPModel(); |
| 291 | $expired_otps = $otps->where([ |
| 292 | 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE, |
| 293 | 'expires_at <' => OsTimeHelper::now_datetime_utc_in_db_format() |
| 294 | ])->get_results_as_models(); |
| 295 | if($expired_otps){ |
| 296 | foreach($expired_otps as $otp){ |
| 297 | $otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED]); |
| 298 | } |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | private static function isValidCombination($contact_type, $delivery_method) { |
| 303 | $valid_combinations = [ |
| 304 | 'email' => ['email'], |
| 305 | 'phone' => ['sms', 'whatsapp'] |
| 306 | ]; |
| 307 | /** |
| 308 | * Delivery methods for contact types |
| 309 | * |
| 310 | * @since 5.2.0 |
| 311 | * @hook latepoint_otp_delivery_methods_for_contact_types |
| 312 | * |
| 313 | * @param {array} $methods available delivery methods |
| 314 | * @returns {array} The filtered array of available delivery methods |
| 315 | */ |
| 316 | $valid_combinations = apply_filters( 'latepoint_otp_delivery_methods_for_contact_types', $valid_combinations ); |
| 317 | |
| 318 | return in_array($delivery_method, $valid_combinations[$contact_type] ?? []); |
| 319 | } |
| 320 | |
| 321 | private static function checkRateLimit($contact_value) : bool { |
| 322 | |
| 323 | $otps = new OsOTPModel(); |
| 324 | $recent_attempts = $otps->where([ |
| 325 | 'contact_value' => $contact_value, |
| 326 | 'created_at >' => OsTimeHelper::custom_datetime_utc_in_db_format('-1 hour')])->count(); |
| 327 | |
| 328 | |
| 329 | return $recent_attempts < self::$max_generation_attempts_per_hour; |
| 330 | } |
| 331 | |
| 332 | |
| 333 | // Cleanup old records |
| 334 | public static function scheduledCleanup() { |
| 335 | $otps = new OsOTPModel(); |
| 336 | $otps->delete_where([ |
| 337 | 'created_at <' => OsTimeHelper::custom_datetime_utc_in_db_format('-30 days'), |
| 338 | 'status' => [LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED, LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED, LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]]); |
| 339 | } |
| 340 | |
| 341 | public static function is_otp_enabled_for_contact_type( string $contact_type, string $delivery_method ) : bool { |
| 342 | $is_enabled = false; |
| 343 | if($contact_type == 'email' && $delivery_method == 'email'){ |
| 344 | $is_enabled = true; |
| 345 | } |
| 346 | |
| 347 | /** |
| 348 | * Determines if OTP is enabled for a selected contact type and delivery method |
| 349 | * |
| 350 | * @since 5.2.0 |
| 351 | * @hook latepoint_is_otp_enabled_for_contact_type |
| 352 | * |
| 353 | * @param {bool} $is_enabled if otp delivery is enabled for a supplied contact and delivery method |
| 354 | * @param {string} $contact_type a contact type for OTP |
| 355 | * @param {string} $delivery_method a delivery method for OTP |
| 356 | * |
| 357 | * @returns {bool} Filtered value of whether OTP is enabled for this delivery method |
| 358 | */ |
| 359 | return apply_filters('latepoint_is_otp_enabled_for_contact_type', $is_enabled, $contact_type, $delivery_method); |
| 360 | } |
| 361 | } |