914b7f97f3
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.
136 lines
3.6 KiB
PHP
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.',
|
|
);
|
|
}
|
|
}
|