242 lines
7.2 KiB
PHP
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);
|
|
}
|
|
}
|