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.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<?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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user