DONE: phone number verification, admin can add user employment; WIP: wire with onewaysms
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user