[Back] <?php
class OsOTPHelper {
private static int $max_verification_attempts = 10; // Max attempts per OTP
private static int $max_generation_attempts_per_hour = 60; // Max OTP generations per hour
public static int $otp_expires_in_minutes = 10; // Max OTP generations per hour
public static int $verification_expires_in_minutes = 30;
public static function create_verification_token($contact_value, $contact_type, $via = 'otp') : string {
$payload_data = [
'contact_value' => $contact_value,
'contact_type' => $contact_type,
'verified_via' => $via,
'exp' => time() + (self::$verification_expires_in_minutes * 60),
'iat' => time()
];
$payload = base64_encode(json_encode($payload_data));
$signature = hash_hmac('sha256', $payload, self::get_secret());
return $payload . '.' . $signature;
}
public static function get_secret(){
return wp_salt('secure_auth');
}
public static function validate_verification_token($token) : array {
if (empty($token)) {
return ['valid' => false, 'error' => 'Token required'];
}
$parts = explode('.', $token);
if (count($parts) !== 2) {
return ['valid' => false, 'error' => 'Malformed token'];
}
[$payload, $signature] = $parts;
// Verify signature
$expected_signature = hash_hmac('sha256', $payload, self::get_secret());
if (!hash_equals($expected_signature, $signature)) {
return ['valid' => false, 'error' => 'Invalid signature'];
}
// Decode payload
$data = json_decode(base64_decode($payload), true);
if (!$data) {
return ['valid' => false, 'error' => 'Invalid payload'];
}
// Check expiration
if (time() > $data['exp']) {
return ['valid' => false, 'error' => 'Token expired'];
}
return ['valid' => true, 'data' => $data];
}
public static function generateAndSendOTP($contact_value, $contact_type, $delivery_method) {
if (!self::isValidCombination($contact_type, $delivery_method)) {
return new WP_Error('otp_generation_error', 'Invalid delivery method for contact type');
}
if (!self::checkRateLimit($contact_value)) {
return new WP_Error('otp_generation_error', 'Too many attempts. Please try again later.');
}
if($contact_type == 'email'){
if(!OsUtilHelper::is_valid_email($contact_value)){
return new WP_Error('otp_generation_error', 'Invalid email address');
}
}
// Cancel old active OTPs for this contact
self::cancelOldOTPs($contact_value);
// Generate new OTP
$otp_code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$otp_hash = wp_hash_password($otp_code);
$expires_at = OsTimeHelper::custom_datetime_utc_in_db_format(sprintf('+%d minutes', self::$otp_expires_in_minutes));
$otp = new OsOTPModel();
$otp->contact_value = $contact_value;
$otp->contact_type = $contact_type;
$otp->delivery_method = $delivery_method;
$otp->otp_hash = $otp_hash;
$otp->expires_at = $expires_at;
$otp->status = LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE;
$otp->attempts = 0;
if(!$otp->save()){
return new WP_Error('otp_generation_error', $otp->get_error_messages());
}
// Send OTP
return self::sendOTP($otp_code, $otp);
}
public static function otp_input_box_html( string $contact_type, string $contact_value, string $delivery_method ) : string {
$message = '';
$message.= '<div class="latepoint-customer-otp-input-wrapper os-customer-wrapped-box">';
$message.= '<div class="latepoint-customer-otp-close"><i class="latepoint-icon latepoint-icon-common-01"></i></div>';
$message.= '<div class="latepoint-customer-box-title">'.esc_html__('Verify your email', 'latepoint').'</div>';
$message.= '<div class="latepoint-customer-box-desc">'.sprintf(esc_html__('Enter the code we sent to %s', 'latepoint'), $contact_value).'</div>';
$message.= '<div class="latepoint-customer-otp-input-code-wrapper">';
$message.= OsFormHelper::otp_code_field('otp[otp_code]');
$message.= '</div>';
$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>';
$message.= '<div class="latepoint-customer-otp-sub-wrapper">';
$message.= '<div class="latepoint-customer-otp-sub">'.sprintf(esc_html__('The code will expire in %s minutes', 'latepoint'), OsOTPHelper::$otp_expires_in_minutes).'</div>';
$message.= '<a tabindex="0" href="#" class="latepoint-customer-otp-resend" data-otp-request-route="'.OsRouterHelper::build_route_name('auth', 'request_otp').'">'.esc_html__('Resend code', 'latepoint').'</a>';
$message.= '</div>';
$message.= wp_nonce_field('otp_verify_otp_nonce', 'otp[verify_nonce]', true, false);
$message.= OsFormHelper::hidden_field('otp[contact_type]', $contact_type);
$message.= OsFormHelper::hidden_field('otp[contact_value]', $contact_value);
$message.= OsFormHelper::hidden_field('otp[delivery_method]', $delivery_method);
$message.= '</div>';
return $message;
}
public static function is_customer_contact_verified(OsCustomerModel $customer, string $contact_value, string $contact_type) : bool{
$verified_contact_values = json_decode($customer->get_meta_by_key('verified_contact_values', ''), true);
if($verified_contact_values){
return in_array($contact_value, $verified_contact_values[$contact_type]);
}else{
return false;
}
}
public static function add_verified_contact_for_customer_from_verification_token(OsCustomerModel $customer, string $verification_token) : void {
$verification_info = OsOTPHelper::validate_verification_token($verification_token);
if($verification_info['valid'] && !empty($verification_info['data']['contact_value'])){
self::add_verified_contact_for_customer($customer, $verification_info['data']['contact_value'], $verification_info['data']['contact_type']);
}
}
public static function add_verified_contact_for_customer(OsCustomerModel $customer, string $contact_value, string $contact_type){
if(!$customer->is_new_record() && !empty($contact_value) && in_array($contact_type, self::valid_contact_types_for_customer())){
if(!self::is_customer_contact_verified($customer, $contact_value, $contact_type)){
$verified_contact_values = json_decode($customer->get_meta_by_key('verified_contact_values', ''), true);
$verified_contact_values[$contact_type][] = $contact_value;
$customer->save_meta_by_key('verified_contact_values', wp_json_encode($verified_contact_values));
}
}
}
public static function verifyOTP($otp_code, $contact_value, $contact_type = 'email', $delivery_method = 'email') {
// Expire old OTPs first
self::expireExpiredOTPs();
$otp = new OsOTPModel();
$active_otp = $otp->where([
'contact_value' => $contact_value,
'contact_type' => $contact_type,
'delivery_method' => $delivery_method,
'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE,
'attempts <' => self::$max_verification_attempts
])->set_limit(1)->get_results_as_models();
if (empty($active_otp)) {
return new WP_Error('otp_generation_error', 'Invalid Code');
}
if (wp_check_password($otp_code, $active_otp->otp_hash)) {
// Mark this OTP as used
$active_otp->update_attributes(
[
'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED,
'used_at' => OsTimeHelper::now_datetime_in_format( LATEPOINT_DATETIME_DB_FORMAT )
]
);
// Cancel other active OTPs for this contact
$other_otps = new OsOTPModel();
$other_otps = $other_otps->where(['contact_value' => $contact_value, 'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE])->get_results_as_models();
if($other_otps){
foreach($other_otps as $otp){
$otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]);
}
}
return [
'status' => LATEPOINT_STATUS_SUCCESS,
'contact_value' => $contact_value
];
}
$active_otp->update_attributes(['attempts' => $active_otp->attempts + 1]);
return new WP_Error('otp_generation_error', 'Invalid Code');
}
private static function sendOTP(string $otp_code, OsOTPModel $otp) : array {
$result = [
'status' => LATEPOINT_STATUS_ERROR,
'message' => __('OTP was not sent.', 'latepoint'),
'to' => $otp->contact_value,
'delivery_method' => $otp->delivery_method,
'contact_type' => $otp->contact_type,
'processed_datetime' => '',
'extra_data' => [
'activity_data' => []
],
'errors' => [],
];
switch($otp->delivery_method) {
case 'email':
$subject = __('Your OTP Code', 'latepoint');
$content = sprintf(esc_html__('Your OTP code is: %s', 'latepoint'), $otp_code);
$send_result = OsNotificationsHelper::send($otp->delivery_method, ['to' => $otp->contact_value, 'subject' => $subject, 'content' => $content]);
if($send_result['status'] == LATEPOINT_STATUS_SUCCESS){
$result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format();
$result['status'] = LATEPOINT_STATUS_SUCCESS;
}
break;
case 'sms':
$subject = __('Your OTP Code', 'latepoint');
$content = sprintf(esc_html__('Your OTP code is: %s', 'latepoint'), $otp_code);
$send_result = OsNotificationsHelper::send($otp->delivery_method, ['to' => $otp->contact_value, 'subject' => $subject, 'content' => $content]);
if($send_result['status'] == LATEPOINT_STATUS_SUCCESS){
$result['processed_datetime'] = OsTimeHelper::now_datetime_in_db_format();
$result['status'] = LATEPOINT_STATUS_SUCCESS;
}
break;
}
/**
* Result of sending an OTP code
*
* @since 5.2.0
* @hook latepoint_notifications_send_otp_code
*
* @param {array} $result The array of data describing the result of operation
* @param {string} $otp_code
* @param {OsOTPModel} $otp
*
* @returns {array} The filtered array of data describing the result of operation
*/
$result = apply_filters('latepoint_notifications_send_otp_code', $result, $otp_code, $otp);
return $result;
}
public static function valid_contact_types_for_customer() : array{
$contact_types = ['email', 'phone'];
/**
* List of valid contact types for customers
*
* @since 5.2.0
* @hook latepoint_valid_contact_types_for_customer
*
* @param {array} $contact_types The array of contact types
*
* @returns {array} The filtered array of contact types
*/
$result = apply_filters('latepoint_valid_contact_types_for_customer', $contact_types);
return $result;
}
private static function cancelOldOTPs($contact_value) {
$old_otps = new OsOTPModel();
$old_otps = $old_otps->where(['contact_value' => $contact_value, 'status' => 'active'])->get_results_as_models();
if($old_otps){
foreach($old_otps as $otp){
$otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]);
}
}
}
private static function expireExpiredOTPs() {
$otps = new OsOTPModel();
$expired_otps = $otps->where([
'status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_ACTIVE,
'expires_at <' => OsTimeHelper::now_datetime_utc_in_db_format()
])->get_results_as_models();
if($expired_otps){
foreach($expired_otps as $otp){
$otp->update_attributes(['status' => LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED]);
}
}
}
private static function isValidCombination($contact_type, $delivery_method) {
$valid_combinations = [
'email' => ['email'],
'phone' => ['sms', 'whatsapp']
];
/**
* Delivery methods for contact types
*
* @since 5.2.0
* @hook latepoint_otp_delivery_methods_for_contact_types
*
* @param {array} $methods available delivery methods
* @returns {array} The filtered array of available delivery methods
*/
$valid_combinations = apply_filters( 'latepoint_otp_delivery_methods_for_contact_types', $valid_combinations );
return in_array($delivery_method, $valid_combinations[$contact_type] ?? []);
}
private static function checkRateLimit($contact_value) : bool {
$otps = new OsOTPModel();
$recent_attempts = $otps->where([
'contact_value' => $contact_value,
'created_at >' => OsTimeHelper::custom_datetime_utc_in_db_format('-1 hour')])->count();
return $recent_attempts < self::$max_generation_attempts_per_hour;
}
// Cleanup old records
public static function scheduledCleanup() {
$otps = new OsOTPModel();
$otps->delete_where([
'created_at <' => OsTimeHelper::custom_datetime_utc_in_db_format('-30 days'),
'status' => [LATEPOINT_CUSTOMER_OTP_CODE_STATUS_USED, LATEPOINT_CUSTOMER_OTP_CODE_STATUS_EXPIRED, LATEPOINT_CUSTOMER_OTP_CODE_STATUS_CANCELLED]]);
}
public static function is_otp_enabled_for_contact_type( string $contact_type, string $delivery_method ) : bool {
$is_enabled = false;
if($contact_type == 'email' && $delivery_method == 'email'){
$is_enabled = true;
}
/**
* Determines if OTP is enabled for a selected contact type and delivery method
*
* @since 5.2.0
* @hook latepoint_is_otp_enabled_for_contact_type
*
* @param {bool} $is_enabled if otp delivery is enabled for a supplied contact and delivery method
* @param {string} $contact_type a contact type for OTP
* @param {string} $delivery_method a delivery method for OTP
*
* @returns {bool} Filtered value of whether OTP is enabled for this delivery method
*/
return apply_filters('latepoint_is_otp_enabled_for_contact_type', $is_enabled, $contact_type, $delivery_method);
}
}