<?php
 namespace App\Services; use App\Jobs\SendClientSmsJob; use App\Jobs\SendEmailJob; use App\Models\Admin; use App\Models\Client; use Illuminate\Support\Facades\Hash; class TwoFactorChannelService { public const OTP_TTL_MINUTES = 5; public const RESEND_MIN_SECONDS = 30; public function __construct(private SmsService $smsService) {} public static function methods(): array { return ['totp', 'email', 'sms']; } public function generateLoginOtp(): string { return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } public function storeOtp(Client|Admin $user, string $code): void { $user->forceFill([ 'two_factor_otp_hash' => Hash::make($code), 'two_factor_otp_expires_at' => now()->addMinutes(self::OTP_TTL_MINUTES), 'two_factor_otp_sent_at' => now(), ])->saveQuietly(); } public function verifyOtp(Client|Admin $user, string $code): bool { if (empty($user->two_factor_otp_hash) || empty($user->two_factor_otp_expires_at)) { return false; } if (now()->greaterThan($user->two_factor_otp_expires_at)) { return false; } if (! Hash::check($code, $user->two_factor_otp_hash)) { return false; } $user->forceFill([ 'two_factor_otp_hash' => null, 'two_factor_otp_expires_at' => null, ])->saveQuietly(); return true; } public function hasActiveOtp(Client|Admin $user): bool { return ! empty($user->two_factor_otp_hash) && ! empty($user->two_factor_otp_expires_at) && now()->lessThan($user->two_factor_otp_expires_at); } public function maskedTarget(Client|Admin $user, ?string $method = null): string { $method = $method ?? $user->two_factor_method; if ($method === 'sms') { $phone = (string) ($user->phone ?? ''); return $phone === '' ? '' : ('•••• ' . substr($phone, -4)); } $email = (string) ($user->email ?? ''); if (! str_contains($email, '@')) { return $email; } [$local, $domain] = explode('@', $email, 2); $head = substr($local, 0, 1); $tail = strlen($local) > 1 ? substr($local, -1) : ''; return $head . str_repeat('•', 3) . $tail . '@' . $domain; } public function canResend(Client|Admin $user): bool { if (empty($user->two_factor_otp_sent_at)) { return true; } return $user->two_factor_otp_sent_at->diffInSeconds(now()) >= self::RESEND_MIN_SECONDS; } public function dispatchOtp(Client|Admin $user, ?string $method = null): void { $code = $this->generateLoginOtp(); $this->storeOtp($user, $code); $method = $method ?? $user->two_factor_method; $merge = ['OTP_CODE' => $code, 'OTP_EXPIRY_MINUTES' => self::OTP_TTL_MINUTES]; if ($user instanceof Client) { if ($method === 'email') { SendEmailJob::dispatch('Two-Factor Login Code', 'client', $user->id, $merge); } elseif ($method === 'sms') { SendClientSmsJob::dispatch('Two-Factor Login Code', 'client', $user->id, $merge); } return; } if ($method === 'email') { SendEmailJob::dispatch('Two-Factor Login Code', 'system', $user->id, $merge, null, $user->email); } elseif ($method === 'sms' && ! empty($user->phone)) { $this->smsService->send( $user->phone, __('admin/common.two_factor_otp_sms', ['code' => $code, 'minutes' => self::OTP_TTL_MINUTES]) ); } } } 