Files
My-KOPKB/be/Modules/Auth/Services/PhoneVerificationOtpService.php
ismailmasseran b05e074456 Feature/phone register (#11)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #11
2026-07-14 12:03:22 +08:00

242 lines
7.2 KiB
PHP

<?php
namespace Modules\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Contracts\SmsSender;
use Modules\Auth\Entities\PhoneVerificationOtp;
use Modules\Auth\Entities\User;
class PhoneVerificationOtpService
{
public function __construct(
protected SmsSender $smsSender,
) {}
public function send(string $phoneNumber): void
{
$phoneNumber = $this->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);
}
}