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,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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+28
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -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();
|
||||
});
|
||||
@@ -49,6 +49,7 @@ class User extends Authenticatable implements FilamentUser
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
];
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user