Files
famous-ly4-ev/app-modules/identity/src/Models/RegistrationVerification.php
T
Nyan Lin Paing 914b7f97f3 Add phone-verified user registration
Two-step flow: request a one-time code by email/phone (RegistrationVerification,
mailed via RegistrationCodeMail, rate-limited by the new api-otp limiter keyed
to the identifier), then verify the code and register with RegistrationController.
User gains a phone column/fillable.
2026-08-30 14:53:18 +07:00

136 lines
3.6 KiB
PHP

<?php
namespace Modules\Identity\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Modules\Identity\Database\Factories\RegistrationVerificationFactory;
use Modules\Identity\Mail\RegistrationCodeMail;
use Modules\Shared\Sms\SmsService;
class RegistrationVerification extends Model
{
/** @use HasFactory<RegistrationVerificationFactory> */
use HasFactory;
/**
* A code is only good for this long — kept short since it's delivered
* over email/SMS and re-requesting a fresh one is cheap (throttled by
* the api-otp rate limiter).
*/
private const CODE_LIFETIME_MINUTES = 10;
/**
* Wrong-code guesses allowed before the code is locked out and a fresh
* one must be requested.
*/
private const MAX_ATTEMPTS = 5;
/**
* @var list<string>
*/
protected $fillable = [
'identifier',
'type',
'code',
'attempts',
'verified_at',
'verification_token',
'consumed_at',
'expires_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'verified_at' => 'datetime',
'consumed_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/**
* Generates a fresh code for the identifier and delivers it over
* email or SMS, replacing any previous pending verification for the
* same identifier (resend just supersedes the old code).
*/
public static function issueFor(string $identifier, string $type): self
{
$code = (string) random_int(100000, 999999);
$verification = self::query()->updateOrCreate(
['identifier' => $identifier],
[
'type' => $type,
'code' => Hash::make($code),
'attempts' => 0,
'verified_at' => null,
'verification_token' => null,
'consumed_at' => null,
'expires_at' => now()->addMinutes(self::CODE_LIFETIME_MINUTES),
],
);
$verification->deliver($code);
return $verification;
}
/**
* Checks the given code against this pending verification. On success,
* marks it verified and issues the one-time token step 3 (registration)
* will need to complete the flow.
*/
public function attemptVerify(string $code): bool
{
if ($this->isExpired() || $this->attempts >= self::MAX_ATTEMPTS || ! Hash::check($code, $this->code)) {
$this->increment('attempts');
return false;
}
$this->forceFill([
'verified_at' => now(),
'verification_token' => Str::random(64),
])->save();
return true;
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isVerified(): bool
{
return $this->verified_at !== null;
}
public function isConsumed(): bool
{
return $this->consumed_at !== null;
}
private function deliver(string $code): void
{
if ($this->type === 'email') {
Mail::to($this->identifier)->send(new RegistrationCodeMail($code));
return;
}
$appName = config('app.name');
app(SmsService::class)->send(
$this->identifier,
"{$appName}: Your verification code is {$code}. It expires in ".self::CODE_LIFETIME_MINUTES.' minutes.',
);
}
}