normalizePhoneNumber($phoneNumber); if ($this->phoneNumberBelongsToAnotherUser($phoneNumber)) { throw ValidationException::withMessages([ 'phone_number' => ['Nombor telefon ini telah didaftarkan.'], ]); } $this->dispatchOtp($phoneNumber); } public function sendForUser(User $user, string $phoneNumber): void { $phoneNumber = $this->normalizePhoneNumber($phoneNumber); $this->assertPhoneNumberAvailableForUser($user, $phoneNumber); if ($user->phone_verified_at && $user->phone_number === $phoneNumber) { throw ValidationException::withMessages([ 'phone_number' => ['Nombor telefon ini sama dengan nombor sedia ada.'], ]); } $this->dispatchOtp($phoneNumber); } public function verifyForUser(User $user, string $phoneNumber, string $otp): User { $phoneNumber = $this->normalizePhoneNumber($phoneNumber); $this->assertPhoneNumberAvailableForUser($user, $phoneNumber); $record = $this->validateOtpRecord($phoneNumber, $otp); $record->delete(); $user->forceFill([ 'phone_number' => $phoneNumber, 'phone_verified_at' => now(), ])->save(); return $user->fresh(); } /** * @return array{verification_token: string, phone_number: string} */ public function verify(string $phoneNumber, string $otp): array { $phoneNumber = $this->normalizePhoneNumber($phoneNumber); $record = $this->validateOtpRecord($phoneNumber, $otp); $verificationToken = Str::random(64); $record->forceFill([ 'verified_at' => now(), 'verification_token' => Hash::make($verificationToken), 'verification_token_expires_at' => now()->addMinutes($this->tokenExpiryMinutes()), ])->save(); return [ 'verification_token' => $verificationToken, 'phone_number' => $phoneNumber, ]; } public function consumeRegistrationToken(string $phoneNumber, string $verificationToken): void { $phoneNumber = $this->normalizePhoneNumber($phoneNumber); $record = PhoneVerificationOtp::query() ->where('phone_number', $phoneNumber) ->whereNotNull('verified_at') ->latest() ->first(); if (! $record || $record->isVerificationTokenExpired()) { throw ValidationException::withMessages([ 'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'], ]); } if (! Hash::check($verificationToken, (string) $record->verification_token)) { throw ValidationException::withMessages([ 'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'], ]); } $record->delete(); } protected function dispatchOtp(string $phoneNumber): void { $otp = $this->generateOtp(); PhoneVerificationOtp::query() ->where('phone_number', $phoneNumber) ->delete(); PhoneVerificationOtp::create([ 'phone_number' => $phoneNumber, 'code' => Hash::make($otp), 'expires_at' => now()->addMinutes($this->otpExpiryMinutes()), 'attempts' => 0, ]); $this->smsSender->send( $phoneNumber, $this->buildOtpMessage($otp), ); } protected function validateOtpRecord(string $phoneNumber, string $otp): PhoneVerificationOtp { $record = PhoneVerificationOtp::query() ->where('phone_number', $phoneNumber) ->latest() ->first(); if (! $record) { throw ValidationException::withMessages([ 'otp' => ['Kod OTP tidak dijumpai. Sila minta kod baharu.'], ]); } if ($record->isExpired()) { $record->delete(); throw ValidationException::withMessages([ 'otp' => ['Kod OTP telah tamat tempoh. Sila minta kod baharu.'], ]); } if ($record->hasExceededMaxAttempts()) { $record->delete(); throw ValidationException::withMessages([ 'otp' => ['Terlalu banyak percubaan. Sila minta kod baharu.'], ]); } if (! Hash::check($otp, $record->code)) { $record->increment('attempts'); if ($record->fresh()->hasExceededMaxAttempts()) { $record->delete(); } throw ValidationException::withMessages([ 'otp' => ['Kod OTP tidak sah.'], ]); } return $record; } protected function assertPhoneNumberAvailableForUser(User $user, string $phoneNumber): void { if ($this->phoneNumberBelongsToAnotherUser($phoneNumber, $user->id)) { throw ValidationException::withMessages([ 'phone_number' => ['Nombor telefon ini telah digunakan oleh akaun lain.'], ]); } } protected function phoneNumberBelongsToAnotherUser(string $phoneNumber, ?string $exceptUserId = null): bool { return User::query() ->where('phone_number', $phoneNumber) ->when($exceptUserId, fn ($query) => $query->where('id', '!=', $exceptUserId)) ->exists(); } public function normalizePhoneNumber(string $phoneNumber): string { $phoneNumber = preg_replace('/[\s\-]/', '', trim($phoneNumber)) ?? ''; if (str_starts_with($phoneNumber, '+60')) { $phoneNumber = '0'.substr($phoneNumber, 3); } elseif (str_starts_with($phoneNumber, '60') && strlen($phoneNumber) > 10) { $phoneNumber = '0'.substr($phoneNumber, 2); } return $phoneNumber; } public function phoneNumberRules(): array { return [ 'required', 'string', 'max:20', 'regex:/^(\+?60|0)1[0-9]{8,9}$/', ]; } protected function generateOtp(): string { return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } protected function buildOtpMessage(string $otp): string { $template = (string) config('auth.phone_verification.message'); return str_replace( [':otp', ':minutes'], [$otp, (string) $this->otpExpiryMinutes()], $template, ); } protected function otpExpiryMinutes(): int { return (int) config('auth.phone_verification.otp_expiry_minutes', 10); } protected function tokenExpiryMinutes(): int { return (int) config('auth.phone_verification.token_expiry_minutes', 30); } }