diff --git a/app-modules/identity/database/factories/RegistrationVerificationFactory.php b/app-modules/identity/database/factories/RegistrationVerificationFactory.php new file mode 100644 index 0000000..2d862e8 --- /dev/null +++ b/app-modules/identity/database/factories/RegistrationVerificationFactory.php @@ -0,0 +1,57 @@ + + */ +class RegistrationVerificationFactory extends Factory +{ + protected $model = RegistrationVerification::class; + + /** + * Define the model's default state. + * + * @return array + */ + 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(), + ]); + } +} diff --git a/app-modules/identity/database/migrations/2026_08_26_000000_add_phone_to_users_table.php b/app-modules/identity/database/migrations/2026_08_26_000000_add_phone_to_users_table.php new file mode 100644 index 0000000..00610c4 --- /dev/null +++ b/app-modules/identity/database/migrations/2026_08_26_000000_add_phone_to_users_table.php @@ -0,0 +1,28 @@ +string('phone')->nullable()->unique()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('phone'); + }); + } +}; diff --git a/app-modules/identity/database/migrations/2026_08_26_000001_create_registration_verifications_table.php b/app-modules/identity/database/migrations/2026_08_26_000001_create_registration_verifications_table.php new file mode 100644 index 0000000..18df4ce --- /dev/null +++ b/app-modules/identity/database/migrations/2026_08_26_000001_create_registration_verifications_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/app-modules/identity/resources/views/mail/registration-code.blade.php b/app-modules/identity/resources/views/mail/registration-code.blade.php new file mode 100644 index 0000000..633d732 --- /dev/null +++ b/app-modules/identity/resources/views/mail/registration-code.blade.php @@ -0,0 +1,14 @@ + +# Verification Code + +Use the code below to confirm your account: + + +{{ $code }} + + +This code expires in 10 minutes. If you didn't request this, you can safely ignore this email. + +Thanks,
+{{ config('app.name') }} +
diff --git a/app-modules/identity/routes/identity-routes.php b/app-modules/identity/routes/identity-routes.php index 20cee1a..c6d505c 100644 --- a/app-modules/identity/routes/identity-routes.php +++ b/app-modules/identity/routes/identity-routes.php @@ -1,8 +1,17 @@ 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'); }); diff --git a/app-modules/identity/src/Http/Controllers/RegistrationController.php b/app-modules/identity/src/Http/Controllers/RegistrationController.php new file mode 100644 index 0000000..574ccc4 --- /dev/null +++ b/app-modules/identity/src/Http/Controllers/RegistrationController.php @@ -0,0 +1,76 @@ +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, + ]; + } +} diff --git a/app-modules/identity/src/Http/Requests/RegisterRequest.php b/app-modules/identity/src/Http/Requests/RegisterRequest.php new file mode 100644 index 0000000..596ae5b --- /dev/null +++ b/app-modules/identity/src/Http/Requests/RegisterRequest.php @@ -0,0 +1,27 @@ +> + */ + 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'], + ]; + } +} diff --git a/app-modules/identity/src/Http/Requests/RequestRegistrationCodeRequest.php b/app-modules/identity/src/Http/Requests/RequestRegistrationCodeRequest.php new file mode 100644 index 0000000..f8e0989 --- /dev/null +++ b/app-modules/identity/src/Http/Requests/RequestRegistrationCodeRequest.php @@ -0,0 +1,53 @@ +> + */ + 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.'); + } + }); + } +} diff --git a/app-modules/identity/src/Http/Requests/VerifyRegistrationCodeRequest.php b/app-modules/identity/src/Http/Requests/VerifyRegistrationCodeRequest.php new file mode 100644 index 0000000..bc8863c --- /dev/null +++ b/app-modules/identity/src/Http/Requests/VerifyRegistrationCodeRequest.php @@ -0,0 +1,24 @@ +> + */ + public function rules(): array + { + return [ + 'identifier' => ['required', 'string'], + 'code' => ['required', 'string', 'size:6'], + ]; + } +} diff --git a/app-modules/identity/src/Mail/RegistrationCodeMail.php b/app-modules/identity/src/Mail/RegistrationCodeMail.php new file mode 100644 index 0000000..c216adc --- /dev/null +++ b/app-modules/identity/src/Mail/RegistrationCodeMail.php @@ -0,0 +1,21 @@ +subject(config('app.name').' - Verification Code') + ->view('identity::mail.registration-code'); + } +} diff --git a/app-modules/identity/src/Models/RegistrationVerification.php b/app-modules/identity/src/Models/RegistrationVerification.php new file mode 100644 index 0000000..228cd48 --- /dev/null +++ b/app-modules/identity/src/Models/RegistrationVerification.php @@ -0,0 +1,135 @@ + */ + 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 + */ + protected $fillable = [ + 'identifier', + 'type', + 'code', + 'attempts', + 'verified_at', + 'verification_token', + 'consumed_at', + 'expires_at', + ]; + + /** + * @return array + */ + 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.', + ); + } +} diff --git a/app-modules/identity/tests/Feature/RegistrationTest.php b/app-modules/identity/tests/Feature/RegistrationTest.php new file mode 100644 index 0000000..f0fe413 --- /dev/null +++ b/app-modules/identity/tests/Feature/RegistrationTest.php @@ -0,0 +1,188 @@ + 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(); +}); diff --git a/app/Models/User.php b/app/Models/User.php index 704534a..33c7058 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,6 +49,7 @@ class User extends Authenticatable implements FilamentUser protected $fillable = [ 'name', 'email', + 'phone', 'password', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index afbd578..93aa197 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -56,6 +56,15 @@ class AppServiceProvider extends ServiceProvider return Limit::perMinute(10)->by($request->ip()); }); + // Tighter than api-auth: layered on top of it for the + // registration-code request endpoint specifically, keyed by the + // identifier being verified (falling back to IP) so one target + // can't be bombed with codes even from rotating IPs, and one IP + // can't spray codes across many identifiers. + RateLimiter::for('api-otp', function (Request $request) { + return Limit::perMinutes(10, 3)->by($request->string('identifier')->toString() ?: $request->ip()); + }); + RateLimiter::for('api-webhooks', function (Request $request) { return Limit::perMinute(30)->by($request->ip()); });