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:
Nyan Lin Paing
2026-08-30 14:53:18 +07:00
parent b6934e1fb5
commit 914b7f97f3
14 changed files with 677 additions and 0 deletions
@@ -0,0 +1,57 @@
<?php
namespace Modules\Identity\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Modules\Identity\Models\RegistrationVerification;
/**
* @extends Factory<RegistrationVerification>
*/
class RegistrationVerificationFactory extends Factory
{
protected $model = RegistrationVerification::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'identifier' => fake()->unique()->safeEmail(),
'type' => 'email',
'code' => Hash::make('123456'),
'attempts' => 0,
'verified_at' => null,
'verification_token' => null,
'consumed_at' => null,
'expires_at' => now()->addMinutes(10),
];
}
public function phone(): self
{
return $this->state(fn (array $attributes) => [
'identifier' => fake()->numerify('+959#########'),
'type' => 'phone',
]);
}
public function verified(): self
{
return $this->state(fn (array $attributes) => [
'verified_at' => now(),
'verification_token' => str()->random(64),
]);
}
public function expired(): self
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->subMinute(),
]);
}
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone')->nullable()->unique()->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('phone');
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('registration_verifications', function (Blueprint $table) {
$table->id();
$table->string('identifier')->unique();
$table->string('type');
$table->string('code');
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamp('verified_at')->nullable();
$table->string('verification_token')->nullable()->unique();
$table->timestamp('consumed_at')->nullable();
$table->timestamp('expires_at');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('registration_verifications');
}
};
@@ -0,0 +1,14 @@
<x-mail::message>
# Verification Code
Use the code below to confirm your account:
<x-mail::panel>
{{ $code }}
</x-mail::panel>
This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
@@ -1,8 +1,17 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Identity\Http\Controllers\RegistrationController;
use Modules\Identity\Http\Controllers\TokenController;
Route::prefix('api/v1')->middleware(['api', 'throttle:api-auth'])->group(function () {
Route::post('/auth/token', [TokenController::class, 'store'])->name('identity.auth.token');
Route::post('/auth/registration/request-code', [RegistrationController::class, 'requestCode'])
->middleware('throttle:api-otp')
->name('identity.auth.registration.request-code');
Route::post('/auth/registration/verify-code', [RegistrationController::class, 'verifyCode'])
->name('identity.auth.registration.verify-code');
Route::post('/auth/register', [RegistrationController::class, 'store'])
->name('identity.auth.register');
});
@@ -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.',
);
}
}
@@ -0,0 +1,188 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Modules\Identity\Enums\TokenAbility;
use Modules\Identity\Mail\RegistrationCodeMail;
use Modules\Identity\Models\RegistrationVerification;
beforeEach(function () {
config([
'services.sms.enabled' => true,
'services.sms.sms_poh.server' => 'https://sms.example.test/send',
'services.sms.sms_poh.token' => 'test-token',
'services.sms.sms_poh.sender' => 'App',
]);
});
test('requesting a code for a new email sends a mail and creates a pending verification', function () {
Mail::fake();
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'new@example.com'])
->assertSuccessful();
Mail::assertSent(RegistrationCodeMail::class);
$verification = RegistrationVerification::where('identifier', 'new@example.com')->sole();
expect($verification->type)->toBe('email')
->and($verification->verified_at)->toBeNull();
});
test('requesting a code for a new phone number sends an sms', function () {
Http::fake(['sms.example.test/*' => Http::response('OK', 200)]);
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => '+959123456789'])
->assertSuccessful();
Http::assertSent(fn ($request) => $request->url() === 'https://sms.example.test/send'
&& $request['to'] === '+959123456789');
$verification = RegistrationVerification::where('identifier', '+959123456789')->sole();
expect($verification->type)->toBe('phone');
});
test('requesting a code rejects an already registered email', function () {
Mail::fake();
User::factory()->create(['email' => 'taken@example.com']);
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'taken@example.com'])
->assertUnprocessable()
->assertJsonValidationErrors('identifier');
Mail::assertNothingSent();
});
test('requesting a code rejects an already registered phone', function () {
User::factory()->create(['phone' => '+959123456789']);
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => '+959123456789'])
->assertUnprocessable()
->assertJsonValidationErrors('identifier');
});
test('verifying with the correct code returns a verification token', function () {
Mail::fake();
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'new@example.com']);
$verification = RegistrationVerification::where('identifier', 'new@example.com')->sole();
// The plaintext code isn't returned by the API by design, so reach
// into the model the same way the real code was generated to recover
// it for the test — simplest is to reissue with a known code via the
// factory instead of parsing outbound mail content.
$verification->forceFill(['code' => Hash::make('654321')])->save();
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => 'new@example.com',
'code' => '654321',
])
->assertSuccessful()
->assertJsonStructure(['verification_token']);
expect($verification->fresh()->verified_at)->not->toBeNull();
});
test('verifying with the wrong code fails and increments attempts', function () {
$verification = RegistrationVerification::factory()->create();
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => $verification->identifier,
'code' => '000000',
])->assertUnprocessable();
expect($verification->fresh()->attempts)->toBe(1);
});
test('verifying locks out after too many wrong attempts', function () {
$verification = RegistrationVerification::factory()->create(['attempts' => 5]);
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => $verification->identifier,
'code' => '000000',
])->assertUnprocessable();
});
test('verifying an expired code fails', function () {
$verification = RegistrationVerification::factory()->expired()->create();
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => $verification->identifier,
'code' => '000000',
])->assertUnprocessable();
});
test('registering with a valid verification token creates a user and returns a token', function () {
$verification = RegistrationVerification::factory()->verified()->create(['identifier' => 'new@example.com']);
$response = $this->postJson('/api/v1/auth/register', [
'verification_token' => $verification->verification_token,
'name' => 'Jane Doe',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
]);
$response->assertSuccessful()->assertJsonStructure(['token']);
$user = User::where('email', 'new@example.com')->sole();
expect($user->name)->toBe('Jane Doe')
->and($verification->fresh()->consumed_at)->not->toBeNull();
$accessToken = $user->tokens()->sole();
expect($accessToken->abilities)->toEqualCanonicalizing(TokenAbility::customerAbilities());
});
test('registering fails when the verification token was already consumed', function () {
$verification = RegistrationVerification::factory()->verified()->create([
'identifier' => 'new@example.com',
'consumed_at' => now(),
]);
$this->postJson('/api/v1/auth/register', [
'verification_token' => $verification->verification_token,
'name' => 'Jane Doe',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
])->assertUnprocessable();
});
test('registering fails with an unknown verification token', function () {
$this->postJson('/api/v1/auth/register', [
'verification_token' => 'not-a-real-token',
'name' => 'Jane Doe',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
])->assertUnprocessable();
});
test('the full request-code, verify-code, register flow works end to end', function () {
Mail::fake();
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'flow@example.com'])
->assertSuccessful();
$verification = RegistrationVerification::where('identifier', 'flow@example.com')->sole();
$verification->forceFill(['code' => Hash::make('111222')])->save();
$verifyResponse = $this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => 'flow@example.com',
'code' => '111222',
])->assertSuccessful();
$registerResponse = $this->postJson('/api/v1/auth/register', [
'verification_token' => $verifyResponse->json('verification_token'),
'name' => 'Flow User',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
])->assertSuccessful();
$token = $registerResponse->json('token');
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/v1/companies')
->assertSuccessful();
});