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,76 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Identity\Enums\TokenAbility;
|
||||
use Modules\Identity\Http\Requests\RegisterRequest;
|
||||
use Modules\Identity\Http\Requests\RequestRegistrationCodeRequest;
|
||||
use Modules\Identity\Http\Requests\VerifyRegistrationCodeRequest;
|
||||
use Modules\Identity\Models\RegistrationVerification;
|
||||
|
||||
/**
|
||||
* Confirm-first registration (mini app / mobile app): request a code for an
|
||||
* email or phone, verify it, then complete registration with the
|
||||
* verification_token that step returns. Kept as three actions on one
|
||||
* controller since they're steps of a single flow sharing the same model.
|
||||
*/
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
public function requestCode(RequestRegistrationCodeRequest $request): array
|
||||
{
|
||||
RegistrationVerification::issueFor($request->string('identifier')->toString(), $request->identifierType());
|
||||
|
||||
return ['message' => 'A verification code has been sent.'];
|
||||
}
|
||||
|
||||
public function verifyCode(VerifyRegistrationCodeRequest $request): array
|
||||
{
|
||||
$verification = RegistrationVerification::where('identifier', $request->string('identifier'))->first();
|
||||
|
||||
if (! $verification || $verification->isExpired()) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['This code has expired. Please request a new one.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $verification->attemptVerify($request->string('code')->toString())) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['The provided code is incorrect.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return ['verification_token' => $verification->verification_token];
|
||||
}
|
||||
|
||||
public function store(RegisterRequest $request): array
|
||||
{
|
||||
$verification = RegistrationVerification::where('verification_token', $request->string('verification_token'))->first();
|
||||
|
||||
if (! $verification || ! $verification->isVerified() || $verification->isConsumed() || $verification->isExpired()) {
|
||||
throw ValidationException::withMessages([
|
||||
'verification_token' => ['This verification has expired or was already used. Please start again.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->string('name'),
|
||||
'email' => $verification->type === 'email' ? $verification->identifier : null,
|
||||
'phone' => $verification->type === 'phone' ? $verification->identifier : null,
|
||||
'password' => $request->string('password'),
|
||||
]);
|
||||
|
||||
$verification->update(['consumed_at' => now()]);
|
||||
|
||||
$token = $user->createToken(
|
||||
$request->string('device_name')->toString(),
|
||||
TokenAbility::customerAbilities(),
|
||||
);
|
||||
|
||||
return [
|
||||
'token' => $token->plainTextToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'verification_token' => ['required', 'string'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
'device_name' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RequestRegistrationCodeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* "email" or "phone" — the identifier's format determines the channel
|
||||
* the code is delivered over.
|
||||
*/
|
||||
public function identifierType(): string
|
||||
{
|
||||
return filter_var($this->string('identifier'), FILTER_VALIDATE_EMAIL) !== false ? 'email' : 'phone';
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$identifier = $this->string('identifier')->toString();
|
||||
|
||||
if ($this->identifierType() === 'phone' && ! preg_match('/^\+?[0-9]{7,15}$/', $identifier)) {
|
||||
$validator->errors()->add('identifier', 'The identifier must be a valid email address or phone number.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$column = $this->identifierType() === 'email' ? 'email' : 'phone';
|
||||
|
||||
if (User::where($column, $identifier)->exists()) {
|
||||
$validator->errors()->add('identifier', 'An account with this '.$column.' already exists.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class VerifyRegistrationCodeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => ['required', 'string'],
|
||||
'code' => ['required', 'string', 'size:6'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RegistrationCodeMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly string $code) {}
|
||||
|
||||
public function build(): self
|
||||
{
|
||||
return $this
|
||||
->subject(config('app.name').' - Verification Code')
|
||||
->view('identity::mail.registration-code');
|
||||
}
|
||||
}
|
||||
@@ -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