new LogOtpSender(), default => new LogOtpSender(), }; } public function sendPairingOtp(Registration $registration): void { $ttl = config('pxg.otp_ttl_minutes', 5); $maxPer10m = config('pxg.otp_rate_limit_per_phone', 3); $phone = $this->toE164($registration->player->phone); // Basic rate-limit: count last 10 minutes $recentCount = OtpRequest::where('phone_e164', $phone) ->where('created_at', '>=', now()->subMinutes(10)) ->count(); if ($recentCount >= $maxPer10m) { abort(429, 'Too many OTP requests. Please try again later.'); } $otp = (string) random_int(100000, 999999); OtpRequest::create([ 'purpose' => 'PAIRING_VIEW', 'registration_id' => $registration->id, 'phone_e164' => $phone, 'otp_hash' => Hash::make($otp), 'expires_at' => now()->addMinutes($ttl), 'attempts' => 0, 'status' => 'SENT', 'request_ip' => request()->ip(), 'user_agent' => (string) request()->userAgent(), ]); $msg = "TOURNAMENT PXG 2026\nYour OTP: {$otp}\nValid for {$ttl} minutes."; $this->sender()->sendWhatsApp($phone, $msg); } public function verifyPairingOtp(Registration $registration, string $otp): void { $maxAttempts = config('pxg.otp_max_attempts', 5); $phone = $this->toE164($registration->player->phone); $req = OtpRequest::where('registration_id', $registration->id) ->where('phone_e164', $phone) ->whereIn('status', ['SENT']) ->orderByDesc('id') ->first(); if (!$req) abort(404, 'OTP not found. Please request OTP again.'); if ($req->expires_at->isPast()) { $req->status = 'EXPIRED'; $req->save(); abort(410, 'OTP expired. Please request OTP again.'); } if ($req->attempts >= $maxAttempts) { $req->status = 'LOCKED'; $req->save(); abort(423, 'Too many attempts. Please request OTP again later.'); } $req->attempts += 1; $req->save(); if (!Hash::check($otp, $req->otp_hash)) { abort(401, 'Invalid OTP.'); } $req->status = 'VERIFIED'; $req->save(); } private function toE164(string $phone): string { // Basic normalization for Indonesia numbers: 08xx -> +628xx $p = preg_replace('/\D+/', '', $phone); if (Str::startsWith($p, '0')) { $p = '62'.substr($p, 1); } if (!Str::startsWith($p, '62')) { // fallback $p = '62'.$p; } return '+'.$p; } }