Simple one-time password authentication in Laravel

Tutorials

September 23rd, 2022

Simple one-time password authentication in Laravel

When dealing with Authentication in Laravel, there are several options out of the box. However, sometimes you need something more specific. This tutorial will look at how we can add a one-time password approach to our authentication flow.

To begin with, we will need to make some adjustments to our User model, as we no longer need a password to log in. We will also need to ensure that our name is nullable and force this to be updated through an onboarding process. This way, we will be able to have one entry route for authentication - the key difference is that now registered users will be redirected through the onboarding process.

Your users' migration should now look like the following:

1public function up(): void
2{
3 Schema::create('users', function (Blueprint $table): void {
4 $table->id();
5 
6 $table->string('name')->nullable();
7 $table->string('email')->unique();
8 $table->string('type')->default(Type::STAFF->value);
9 
10 $table->timestamps();
11 });
12}

We can reflect these changes into our model too. We no longer need a remember token as we want to enforce logging in each time. Also, users validate their email just by logging in using a one-time password.

1final class User extends Authenticatable
2{
3 use HasApiTokens;
4 use HasFactory;
5 use Notifiable;
6 
7 protected $fillable = [
8 'name',
9 'email',
10 'type',
11 ];
12 
13 protected $casts = [
14 'type' => Type::class,
15 ];
16 
17 public function offices(): HasMany
18 {
19 return $this->hasMany(
20 related: Office::class,
21 foreignKey: 'user_id',
22 );
23 }
24 
25 public function bookings(): HasMany
26 {
27 return $this->hasMany(
28 related: Booking::class,
29 foreignKey: 'user_id',
30 );
31 }
32}

Our model is much cleaner, so we can start looking at how we want to generate our one-time password code. To begin with, we will want to create a GeneratorContract that our implementation can use, and we can bind it to our container for resolving.

1declare(strict_types=1);
2 
3namespace Infrastructure\Auth\Generators;
4 
5interface GeneratorContract
6{
7 public function generate(): string;
8}

Now let us look at implementing a NumberGenerator for the one-time password, and we will go for a default of 6 characters.

1declare(strict_types=1);
2 
3namespace Domains\Auth\Generators;
4 
5use Domains\Auth\Exceptions\OneTimePasswordGenertionException;
6use Infrastructure\Auth\Generators\GeneratorContract;
7use Throwable;
8 
9final class NumberGenerator implements GeneratorContract
10{
11 public function generate(): string
12 {
13 try {
14 $number = random_int(
15 min: 000_000,
16 max: 999_999,
17 );
18 } catch (Throwable $exception) {
19 throw new OneTimePasswordGenertionException(
20 message: 'Failed to generate a random integer',
21 );
22 }
23 
24 return str_pad(
25 string: strval($number),
26 length: 6,
27 pad_string: '0',
28 pad_type: STR_PAD_LEFT,
29 );
30 }
31}

Finally, we want to add this to a Service Provider to bind the interface and implementation into Laravels' container - allowing us to resolve this when required. If you can't remember how to do this, I wrote a handy tutorial on Laravel News about how I develop Laravel applications. This will walk you through this process quite nicely.

1declare(strict_types=1);
2 
3namespace Domains\Auth\Providers;
4 
5use Domains\Auth\Generators\NumberGenerator;
6use Illuminate\Support\ServiceProvider;
7use Infrastructure\Auth\Generators\GeneratorContract;
8 
9final class AuthServiceProvider extends ServiceProvider
10{
11 protected array $bindings = [
12 GeneratorContract::class => NumberGenerator::class,
13 ];
14}

Now that we know that we can generate these codes, we can look at how we will implement this. To begin with, we will want to refactor the User Data Object we created in our last tutorial called Setting up your Data Model in Laravel.

1declare(strict_types=1);
2 
3namespace Domains\Auth\DataObjects;
4 
5use Domains\Auth\Enums\Type;
6use JustSteveKing\DataObjects\Contracts\DataObjectContract;
7 
8final class User implements DataObjectContract
9{
10 public function __construct(
11 private readonly string $email,
12 private readonly Type $type,
13 ) {}
14 
15 public function toArray(): array
16 {
17 return [
18 'email' => $this->email,
19 'type' => $this->type,
20 ];
21 }
22}

We can now focus on the action of sending a one-time password and what steps need to be taken actually to send the notification and remember the user. To start with, we need to run an action/command that will generate a code, and send this through to the user as a notification. To remember this, we will need to add this code to our applications cache alongside the device's IP address that requested this one-time password. This could cause a problem if you are using a VPN and your IP switches between asking for a code and entering the code - however a slight risk for now.

To start with, we will create a command for each step. I like to create small single classes that do each part of a process. To begin with, let us make the command to generate the code - and as usual, we will build a corresponding interface/contract to allow us to lean on the container.

1declare(strict_types=1);
2 
3namespace Infrastructure\Auth\Commands;
4 
5interface GenerateOneTimePasswordContract
6{
7 public function handle(): string;
8}

Then the implementation we wish to use:

1declare(strict_types=1);
2 
3namespace Domains\Auth\Commands;
4 
5use Infrastructure\Auth\Commands\GenerateOneTimePasswordContract;
6use Infrastructure\Auth\Generators\GeneratorContract;
7 
8final class GenerateOneTimePassword implements GenerateOneTimePasswordContract
9{
10 public function __construct(
11 private readonly GeneratorContract $generator,
12 ) {}
13 
14 public function handle(): string
15 {
16 return $this->generator->generate();
17 }
18}

As you can see, we are leaning on the container at any opportunity - in case we decide to change implementations of our one-time password from 6 numbers to 3 words, for example.

As before, ensure you bind this to your container in the Service Provider for this domain. Next, we want to send a notification. This time I will skip showing the interface, as you can guess what it looks like at this point.

1declare(strict_types=1);
2 
3namespace Domains\Auth\Commands;
4 
5use App\Notifications\Auth\OneTimePassword;
6use Illuminate\Support\Facades\Notification;
7use Infrastructure\Auth\Commands\SendOneTimePasswordNotificationContract;
8 
9final class SendOneTimePasswordNotification implements SendOneTimePasswordNotificationContract
10{
11 public function handle(string $code, string $email): void
12 {
13 Notification::route(
14 channel: 'mail',
15 route: [$email],
16 )->notify(
17 notification: new OneTimePassword(
18 code: $code,
19 ),
20 );
21 }
22}

This command will accept the code and email and route a new email notification to the requester. Ensure you create the notification and return a mail message containing the code generated. Register this binding into your container, and then we can work on how we want to remember the IP address with this information.

1declare(strict_types=1);
2 
3namespace Domains\Auth\Commands;
4 
5use Illuminate\Support\Facades\Cache;
6use Infrastructure\Auth\Commands\RememberOneTimePasswordRequestContract;
7 
8final class RememberOneTimePasswordRequest implements RememberOneTimePasswordRequestContract
9{
10 public function handle(string $ip, string $email, string $code): void
11 {
12 Cache::remember(
13 key: "{$ip}-one-time-password",
14 ttl: (60 * 15), // 15 minutes,
15 callback: fn (): array => [
16 'email' => $email,
17 'code' => $code,
18 ],
19 );
20 }
21}

We accept the IP address, email address, and one-time code so we can store this in the cache. We set this lifetime to 15 minutes so that codes do not go stale, and a busy mail system should deliver this perfectly within this time. We use the IP address as part of the cache key to limit who can access this key on returning.

So we have three components to use when sending a one-time password, and there are a few ways in which we could achieve sending these nicely. For this tutorial, I am going to create one more command that will handle this for us - using Laravels' tap helper to make it fluent,

1declare(strict_types=1);
2 
3namespace Domains\Auth\Commands;
4 
5use Infrastructure\Auth\Commands\GenerateOneTimePasswordContract;
6use Infrastructure\Auth\Commands\HandleAuthProcessContract;
7use Infrastructure\Auth\Commands\RememberOneTimePasswordRequestContract;
8use Infrastructure\Auth\Commands\SendOneTimePasswordNotificationContract;
9 
10final class HandleAuthProcess implements HandleAuthProcessContract
11{
12 public function __construct(
13 private readonly GenerateOneTimePasswordContract $code,
14 private readonly SendOneTimePasswordNotificationContract $notification,
15 private readonly RememberOneTimePasswordRequestContract $remember,
16 ) {}
17 
18 public function handle(string $ip, string $email)
19 {
20 tap(
21 value: $this->code->handle(),
22 callback: function (string $code) use ($ip, $email): void {
23 $this->notification->handle(
24 code: $code,
25 email: $email
26 );
27 
28 $this->remember->handle(
29 ip: $ip,
30 email: $email,
31 code: $code,
32 );
33 },
34 );
35 }
36}

We use the tap function first to create a code that we pass through to a closure so that we can send the notification and remember the details only if the code is generated. The only problem with this approach is that it is a synchronous action, and we do not want this to happen in the main thread as it would be quite blocking. Instead, we will move this to a background job - we can do this by turning our command into something that can be dispatched onto the queue.

1declare(strict_types=1);
2 
3namespace Domains\Auth\Commands;
4 
5use Illuminate\Bus\Queueable;
6use Illuminate\Contracts\Queue\ShouldQueue;
7use Illuminate\Foundation\Bus\Dispatchable;
8use Illuminate\Queue\InteractsWithQueue;
9use Illuminate\Queue\SerializesModels;
10use Infrastructure\Auth\Commands\GenerateOneTimePasswordContract;
11use Infrastructure\Auth\Commands\HandleAuthProcessContract;
12use Infrastructure\Auth\Commands\RememberOneTimePasswordRequestContract;
13use Infrastructure\Auth\Commands\SendOneTimePasswordNotificationContract;
14 
15final class HandleAuthProcess implements HandleAuthProcessContract, ShouldQueue
16{
17 use Queueable;
18 use Dispatchable;
19 use SerializesModels;
20 use InteractsWithQueue;
21 
22 public function __construct(
23 public readonly string $ip,
24 public readonly string $email,
25 ) {}
26 
27 public function handle(
28 GenerateOneTimePasswordContract $code,
29 SendOneTimePasswordNotificationContract $notification,
30 RememberOneTimePasswordRequestContract $remember,
31 ): void {
32 tap(
33 value: $code->handle(),
34 callback: function (string $oneTimeCode) use ($notification, $remember): void {
35 $notification->handle(
36 code: $oneTimeCode,
37 email: $this->email
38 );
39 
40 $remember->handle(
41 ip: $this->ip,
42 email: $this->email,
43 code: $oneTimeCode,
44 );
45 },
46 );
47 }
48}

Now we can look at the front-end implementation. In this example, I will use Laravel Livewire for the front-end, but the process is similar no matter the technology you use. All we need to do is accept an email address from the user, route this through the dispatched job and redirect the user.

1declare(strict_types=1);
2 
3namespace App\Http\Livewire\Auth;
4 
5use Domains\Auth\Commands\HandleAuthProcess;
6use Illuminate\Contracts\View\View as ViewContract;
7use Illuminate\Http\RedirectResponse;
8use Illuminate\Support\Facades\View;
9use Livewire\Component;
10use Livewire\Redirector;
11 
12final class RequestOneTimePassword extends Component
13{
14 public string $email;
15 
16 public function submit(): Redirector|RedirectResponse
17 {
18 $this->validate();
19 
20 dispatch(new HandleAuthProcess(
21 ip: strval(request()->ip()),
22 email: $this->email,
23 ));
24 
25 return redirect()->route(
26 route: 'auth:one-time-password',
27 );
28 }
29 
30 public function rules(): array
31 {
32 return [
33 'email' => [
34 'required',
35 'email',
36 'max:255',
37 ],
38 ];
39 }
40 
41 public function render(): ViewContract
42 {
43 return View::make(
44 view: 'livewire.auth.request-one-time-password',
45 );
46 }
47}

Our component will take the email and send a notification. In reality, at this point, I would add a trait to my Livewire component to enforce strict rate limiting. This trait would look like the following:

1declare(strict_types=1);
2 
3namespace App\Http\Livewire\Concerns;
4 
5use App\Exceptions\TooManyRequestsException;
6use Illuminate\Support\Facades\RateLimiter;
7 
8trait WithRateLimiting
9{
10 protected function clearRateLimiter(null|string $method = null): void
11 {
12 if (! $method) {
13 $method = debug_backtrace()[1]['function'];
14 }
15 
16 RateLimiter::clear(
17 key: $this->getRateLimitKey(
18 method: $method,
19 ),
20 );
21 }
22 
23 protected function getRateLimitKey(null|string $method = null): string
24 {
25 if (! $method) {
26 $method = debug_backtrace()[1]['function'];
27 }
28 
29 return strval(static::class . '|' . $method . '|' . request()->ip());
30 }
31 
32 protected function hitRateLimiter(null|string $method = null, int $decaySeonds = 60): void
33 {
34 if (! $method) {
35 $method = debug_backtrace()[1]['function'];
36 }
37 
38 RateLimiter::hit(
39 key: $this->getRateLimitKey(
40 method: $method,
41 ),
42 decaySeconds: $decaySeonds,
43 );
44 }
45 
46 protected function rateLimit(int $maxAttempts, int $decaySeconds = 60, null|string $method = null): void
47 {
48 if (! $method) {
49 $method = debug_backtrace()[1]['function'];
50 }
51 
52 $key = $this->getRateLimitKey(
53 method: $method,
54 );
55 
56 if (RateLimiter::tooManyAttempts(key: $key, maxAttempts: $maxAttempts)) {
57 throw new TooManyRequestsException(
58 component: static::class,
59 method: $method,
60 ip: strval(request()->ip()),
61 secondsUntilAvailable: RateLimiter::availableIn(
62 key: $key,
63 )
64 );
65 }
66 
67 $this->hitRateLimiter(
68 method: $method,
69 decaySeonds: $decaySeconds,
70 );
71 }
72}

This is a handy little trait to keep if you use Livewire, and want to add rate limiting to your components.

Next, on the one-time password view, we would use an additional livewire component that will accept the one-time password code and allow us to validate it. Before we do that, though, we need to create a new command that will enable us to ensure a user exists with this email address.

1declare(strict_types=1);
2 
3namespace Domains\Auth\Commands;
4 
5use App\Models\User;
6use Illuminate\Database\Eloquent\Model;
7use Infrastructure\Auth\Commands\EnsureUserExistsContract;
8 
9final class EnsureUserExists implements EnsureUserExistsContract
10{
11 public function handle(string $email): User|Model
12 {
13 return User::query()
14 ->firstOrCreate(
15 attributes: [
16 'email' => $email,
17 ],
18 );
19 }
20}

This action is injected into our Livewire component, allowing us to authenticate to the app's dashboard or the onboarding step, depending on whether it is a new user. We can tell if it is a new user because it won't have a name, only an email address.

1declare(strict_types=1);
2 
3namespace App\Http\Livewire\Auth;
4 
5use App\Http\Livewire\Concerns\WithRateLimiting;
6use Illuminate\Contracts\View\View as ViewContract;
7use Illuminate\Http\RedirectResponse;
8use Illuminate\Support\Facades\Auth;
9use Illuminate\Support\Facades\Cache;
10use Illuminate\Support\Facades\View;
11use Infrastructure\Auth\Commands\EnsureUserExistsContract;
12use Livewire\Component;
13use Livewire\Redirector;
14 
15final class OneTimePasswordForm extends Component
16{
17 use WithRateLimiting;
18 
19 public string $email;
20 
21 public null|string $otp = null;
22 
23 public string $ip;
24 
25 public function mount(): void
26 {
27 $this->ip = strval(request()->ip());
28 }
29 
30 public function login(EnsureUserExistsContract $command): Redirector|RedirectResponse
31 {
32 $this->validate();
33 
34 return $this->handleOneTimePasswordAttempt(
35 command: $command,
36 code: Cache::get(
37 key: "{$this->ip}-one-time-password",
38 ),
39 );
40 }
41 
42 protected function handleOneTimePasswordAttempt(
43 EnsureUserExistsContract $command,
44 mixed $code = null,
45 ): Redirector|RedirectResponse {
46 if (null === $code) {
47 $this->forgetOtp();
48 
49 return new RedirectResponse(
50 url: route('auth:login'),
51 );
52 }
53 
54 /**
55 * @var array{email: string, otp: string} $code
56 */
57 if ($this->otp !== $code['otp']) {
58 $this->forgetOtp();
59 
60 return new RedirectResponse(
61 url: route('auth:login'),
62 );
63 }
64 
65 Auth::loginUsingId(
66 id: intval($command->handle(
67 email: $this->email,
68 )->getKey()),
69 );
70 
71 return redirect()->route(
72 route: 'app:dashboard:show',
73 );
74 }
75 
76 protected function forgetOtp(): void
77 {
78 Cache::forget(
79 key: "{$this->ip}-one-time-password",
80 );
81 }
82 
83 public function rules(): array
84 {
85 return [
86 'email' => [
87 'required',
88 'string',
89 'email',
90 ],
91 'otp' => [
92 'required',
93 'string',
94 'min:6',
95 ]
96 ];
97 }
98 
99 public function render(): ViewContract
100 {
101 return View::make(
102 view: 'livewire.auth.one-time-password-form',
103 );
104 }
105}

We want to ensure that we reset the one-time password for this IP address if we have a failed attempt. Once this is done, the user is authenticated and redirected as if they logged in with a standard email address and password approach.

This isn't what I would call a perfect solution, but it is an interesting one, that is for sure. An improvement would be to email a signed URL containing some of the information instead of leaning on our cache completely.

Have you worked with a custom authentication flow before? What is your preferred method for auth in Laravel? Let us know on Twitter!