Thursday, April 25, 2024
HomePHPEasy one-time password authentication in Laravel

Easy one-time password authentication in Laravel


When coping with Authentication in Laravel, there are a number of choices out of the field. Nevertheless, typically you want one thing extra particular. This tutorial will have a look at how we will add a one-time password method to our authentication stream.

To start with, we might want to make some changes to our Consumer mannequin, as we not want a password to log in. We can even want to make sure that our title is nullable and pressure this to be up to date via an onboarding course of. This fashion, we will have one entry route for authentication – the important thing distinction is that now registered customers will probably be redirected via the onboarding course of.

Your customers’ migration ought to now appear to be the next:

1public perform up(): void

2{

3 Schema::create('customers', perform (Blueprint $desk): void {

4 $desk->id();

5 

6 $desk->string('title')->nullable();

7 $desk->string('e mail')->distinctive();

8 $desk->string('sort')->default(Sort::STAFF->worth);

9 

10 $desk->timestamps();

11 });

12}

We will replicate these modifications into our mannequin too. We not want a keep in mind token as we need to implement logging in every time. Additionally, customers validate their e mail simply by logging in utilizing a one-time password.

1ultimate class Consumer 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 perform workplaces(): HasMany

18 {

19 return $this->hasMany(

20 associated: Workplace::class,

21 foreignKey: 'user_id',

22 );

23 }

24 

25 public perform bookings(): HasMany

26 {

27 return $this->hasMany(

28 associated: Reserving::class,

29 foreignKey: 'user_id',

30 );

31 }

32}

Our mannequin is far cleaner, so we will begin how we need to generate our one-time password code. To start with, we’ll need to create a GeneratorContract that our implementation can use, and we will bind it to our container for resolving.

1declare(strict_types=1);

2 

3namespace InfrastructureAuthGenerators;

4 

5interface GeneratorContract

6{

7 public perform generate(): string;

8}

Now allow us to have a look at implementing a NumberGenerator for the one-time password, and we’ll go for a default of 6 characters.

1declare(strict_types=1);

2 

3namespace DomainsAuthGenerators;

4 

5use DomainsAuthExceptionsOneTimePasswordGenertionException;

6use InfrastructureAuthGeneratorsGeneratorContract;

7use Throwable;

8 

9ultimate class NumberGenerator implements GeneratorContract

10{

11 public perform generate(): string

12 {

13 strive {

14 $quantity = random_int(

15 min: 000_000,

16 max: 999_999,

17 );

18 } catch (Throwable $exception) {

19 throw new OneTimePasswordGenertionException(

20 message: 'Did not generate a random integer',

21 );

22 }

23 

24 return str_pad(

25 string: strval($quantity),

26 size: 6,

27 pad_string: '0',

28 pad_type: STR_PAD_LEFT,

29 );

30 }

31}

Lastly, we need to add this to a Service Supplier to bind the interface and implementation into Laravels’ container – permitting us to resolve this when required. If you cannot keep in mind how to do that, I wrote a helpful tutorial on Laravel Information about how I develop Laravel functions. This can stroll you thru this course of fairly properly.

1declare(strict_types=1);

2 

3namespace DomainsAuthProviders;

4 

5use DomainsAuthGeneratorsNumberGenerator;

6use IlluminateSupportServiceProvider;

7use InfrastructureAuthGeneratorsGeneratorContract;

8 

9ultimate class AuthServiceProvider extends ServiceProvider

10{

11 protected array $bindings = [

12 GeneratorContract::class => NumberGenerator::class,

13 ];

14}

Now that we all know that we will generate these codes, we will have a look at how we’ll implement this. To start with, we’ll need to refactor the Consumer Knowledge Object we created in our final tutorial known as Establishing your Knowledge Mannequin in Laravel.

1declare(strict_types=1);

2 

3namespace DomainsAuthDataObjects;

4 

5use DomainsAuthEnumsType;

6use JustSteveKingDataObjectsContractsDataObjectContract;

7 

8ultimate class Consumer implements DataObjectContract

9{

10 public perform __construct(

11 personal readonly string $e mail,

12 personal readonly Sort $sort,

13 ) {}

14 

15 public perform toArray(): array

16 {

17 return [

18 'email' => $this->email,

19 'type' => $this->type,

20 ];

21 }

22}

We will now deal with the motion of sending a one-time password and what steps must be taken really to ship the notification and keep in mind the person. To start out with, we have to run an motion/command that may generate a code, and ship this via to the person as a notification. To recollect this, we might want to add this code to our functions cache alongside the gadget’s IP deal with that requested this one-time password. This might trigger an issue in case you are utilizing a VPN and your IP switches between asking for a code and getting into the code – nevertheless a slight threat for now.

To start out with, we’ll create a command for every step. I prefer to create small single lessons that do every a part of a course of. To start with, allow us to make the command to generate the code – and as ordinary, we’ll construct a corresponding interface/contract to permit us to lean on the container.

1declare(strict_types=1);

2 

3namespace InfrastructureAuthCommands;

4 

5interface GenerateOneTimePasswordContract

6{

7 public perform deal with(): string;

8}

Then the implementation we want to use:

1declare(strict_types=1);

2 

3namespace DomainsAuthCommands;

4 

5use InfrastructureAuthCommandsGenerateOneTimePasswordContract;

6use InfrastructureAuthGeneratorsGeneratorContract;

7 

8ultimate class GenerateOneTimePassword implements GenerateOneTimePasswordContract

9{

10 public perform __construct(

11 personal readonly GeneratorContract $generator,

12 ) {}

13 

14 public perform deal with(): string

15 {

16 return $this->generator->generate();

17 }

18}

As you’ll be able to see, we’re leaning on the container at any alternative – in case we resolve to alter implementations of our one-time password from 6 numbers to three phrases, for instance.

As earlier than, make sure you bind this to your container within the Service Supplier for this area. Subsequent, we need to ship a notification. This time I’ll skip displaying the interface, as you’ll be able to guess what it seems to be like at this level.

1declare(strict_types=1);

2 

3namespace DomainsAuthCommands;

4 

5use AppNotificationsAuthOneTimePassword;

6use IlluminateSupportFacadesNotification;

7use InfrastructureAuthCommandsSendOneTimePasswordNotificationContract;

8 

9ultimate class SendOneTimePasswordNotification implements SendOneTimePasswordNotificationContract

10{

11 public perform deal with(string $code, string $e mail): 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 settle for the code and e mail and route a brand new e mail notification to the requester. Make sure you create the notification and return a mail message containing the code generated. Register this binding into your container, after which we will work on how we need to keep in mind the IP deal with with this info.

1declare(strict_types=1);

2 

3namespace DomainsAuthCommands;

4 

5use IlluminateSupportFacadesCache;

6use InfrastructureAuthCommandsRememberOneTimePasswordRequestContract;

7 

8ultimate class RememberOneTimePasswordRequest implements RememberOneTimePasswordRequestContract

9{

10 public perform deal with(string $ip, string $e mail, string $code): void

11 {

12 Cache::keep in mind(

13 key: "{$ip}-one-time-password",

14 ttl: (60 * 15), // quarter-hour,

15 callback: fn (): array => [

16 'email' => $email,

17 'code' => $code,

18 ],

19 );

20 }

21}

We settle for the IP deal with, e mail deal with, and one-time code so we will retailer this within the cache. We set this lifetime to fifteen minutes in order that codes don’t go stale, and a busy mail system ought to ship this completely inside this time. We use the IP deal with as a part of the cache key to restrict who can entry this key on returning.

So we’ve got three parts to make use of when sending a one-time password, and there are just a few methods wherein we may obtain sending these properly. For this tutorial, I’m going to create yet another command that may deal with this for us – utilizing Laravels’ faucet helper to make it fluent,

1declare(strict_types=1);

2 

3namespace DomainsAuthCommands;

4 

5use InfrastructureAuthCommandsGenerateOneTimePasswordContract;

6use InfrastructureAuthCommandsHandleAuthProcessContract;

7use InfrastructureAuthCommandsRememberOneTimePasswordRequestContract;

8use InfrastructureAuthCommandsSendOneTimePasswordNotificationContract;

9 

10ultimate class HandleAuthProcess implements HandleAuthProcessContract

11{

12 public perform __construct(

13 personal readonly GenerateOneTimePasswordContract $code,

14 personal readonly SendOneTimePasswordNotificationContract $notification,

15 personal readonly RememberOneTimePasswordRequestContract $keep in mind,

16 ) {}

17 

18 public perform deal with(string $ip, string $e mail)

19 {

20 faucet(

21 worth: $this->code->deal with(),

22 callback: perform (string $code) use ($ip, $e mail): void {

23 $this->notification->deal with(

24 code: $code,

25 e mail: $e mail

26 );

27 

28 $this->keep in mind->deal with(

29 ip: $ip,

30 e mail: $e mail,

31 code: $code,

32 );

33 },

34 );

35 }

36}

We use the faucet perform first to create a code that we cross via to a closure in order that we will ship the notification and keep in mind the main points provided that the code is generated. The one downside with this method is that it’s a synchronous motion, and we don’t want this to occur in the primary thread as it could be fairly blocking. As a substitute, we’ll transfer this to a background job – we will do that by turning our command into one thing that may be dispatched onto the queue.

1declare(strict_types=1);

2 

3namespace DomainsAuthCommands;

4 

5use IlluminateBusQueueable;

6use IlluminateContractsQueueShouldQueue;

7use IlluminateFoundationBusDispatchable;

8use IlluminateQueueInteractsWithQueue;

9use IlluminateQueueSerializesModels;

10use InfrastructureAuthCommandsGenerateOneTimePasswordContract;

11use InfrastructureAuthCommandsHandleAuthProcessContract;

12use InfrastructureAuthCommandsRememberOneTimePasswordRequestContract;

13use InfrastructureAuthCommandsSendOneTimePasswordNotificationContract;

14 

15ultimate class HandleAuthProcess implements HandleAuthProcessContract, ShouldQueue

16{

17 use Queueable;

18 use Dispatchable;

19 use SerializesModels;

20 use InteractsWithQueue;

21 

22 public perform __construct(

23 public readonly string $ip,

24 public readonly string $e mail,

25 ) {}

26 

27 public perform deal with(

28 GenerateOneTimePasswordContract $code,

29 SendOneTimePasswordNotificationContract $notification,

30 RememberOneTimePasswordRequestContract $keep in mind,

31 ): void {

32 faucet(

33 worth: $code->deal with(),

34 callback: perform (string $oneTimeCode) use ($notification, $keep in mind): void {

35 $notification->deal with(

36 code: $oneTimeCode,

37 e mail: $this->e mail

38 );

39 

40 $keep in mind->deal with(

41 ip: $this->ip,

42 e mail: $this->e mail,

43 code: $oneTimeCode,

44 );

45 },

46 );

47 }

48}

Now we will have a look at the front-end implementation. On this instance, I’ll use Laravel Livewire for the front-end, however the course of is analogous regardless of the know-how you utilize. All we have to do is settle for an e mail deal with from the person, route this via the dispatched job and redirect the person.

1declare(strict_types=1);

2 

3namespace AppHttpLivewireAuth;

4 

5use DomainsAuthCommandsHandleAuthProcess;

6use IlluminateContractsViewView as ViewContract;

7use IlluminateHttpRedirectResponse;

8use IlluminateSupportFacadesView;

9use LivewireComponent;

10use LivewireRedirector;

11 

12ultimate class RequestOneTimePassword extends Part

13{

14 public string $e mail;

15 

16 public perform submit(): Redirector|RedirectResponse

17 {

18 $this->validate();

19 

20 dispatch(new HandleAuthProcess(

21 ip: strval(request()->ip()),

22 e mail: $this->e mail,

23 ));

24 

25 return redirect()->route(

26 route: 'auth:one-time-password',

27 );

28 }

29 

30 public perform guidelines(): array

31 {

32 return [

33 'email' => [

34 'required',

35 'email',

36 'max:255',

37 ],

38 ];

39 }

40 

41 public perform render(): ViewContract

42 {

43 return View::make(

44 view: 'livewire.auth.request-one-time-password',

45 );

46 }

47}

Our element will take the e-mail and ship a notification. In actuality, at this level, I’d add a trait to my Livewire element to implement strict fee limiting. This trait would appear to be the next:

1declare(strict_types=1);

2 

3namespace AppHttpLivewireConcerns;

4 

5use AppExceptionsTooManyRequestsException;

6use IlluminateSupportFacadesRateLimiter;

7 

8trait WithRateLimiting

9{

10 protected perform clearRateLimiter(null|string $methodology = null): void

11 {

12 if (! $methodology) {

13 $methodology = debug_backtrace()[1]['function'];

14 }

15 

16 RateLimiter::clear(

17 key: $this->getRateLimitKey(

18 methodology: $methodology,

19 ),

20 );

21 }

22 

23 protected perform getRateLimitKey(null|string $methodology = null): string

24 {

25 if (! $methodology) {

26 $methodology = debug_backtrace()[1]['function'];

27 }

28 

29 return strval(static::class . '|' . $methodology . '|' . request()->ip());

30 }

31 

32 protected perform hitRateLimiter(null|string $methodology = null, int $decaySeonds = 60): void

33 {

34 if (! $methodology) {

35 $methodology = debug_backtrace()[1]['function'];

36 }

37 

38 RateLimiter::hit(

39 key: $this->getRateLimitKey(

40 methodology: $methodology,

41 ),

42 decaySeconds: $decaySeonds,

43 );

44 }

45 

46 protected perform rateLimit(int $maxAttempts, int $decaySeconds = 60, null|string $methodology = null): void

47 {

48 if (! $methodology) {

49 $methodology = debug_backtrace()[1]['function'];

50 }

51 

52 $key = $this->getRateLimitKey(

53 methodology: $methodology,

54 );

55 

56 if (RateLimiter::tooManyAttempts(key: $key, maxAttempts: $maxAttempts)) {

57 throw new TooManyRequestsException(

58 element: static::class,

59 methodology: $methodology,

60 ip: strval(request()->ip()),

61 secondsUntilAvailable: RateLimiter::availableIn(

62 key: $key,

63 )

64 );

65 }

66 

67 $this->hitRateLimiter(

68 methodology: $methodology,

69 decaySeonds: $decaySeconds,

70 );

71 }

72}

This can be a helpful little trait to maintain in case you use Livewire, and need to add fee limiting to your parts.

Subsequent, on the one-time password view, we’d use a further livewire element that may settle for the one-time password code and permit us to validate it. Earlier than we do this, although, we have to create a brand new command that may allow us to make sure a person exists with this e mail deal with.

1declare(strict_types=1);

2 

3namespace DomainsAuthCommands;

4 

5use AppModelsUser;

6use IlluminateDatabaseEloquentModel;

7use InfrastructureAuthCommandsEnsureUserExistsContract;

8 

9ultimate class EnsureUserExists implements EnsureUserExistsContract

10{

11 public perform deal with(string $e mail): Consumer|Mannequin

12 {

13 return Consumer::question()

14 ->firstOrCreate(

15 attributes: [

16 'email' => $email,

17 ],

18 );

19 }

20}

This motion is injected into our Livewire element, permitting us to authenticate to the app’s dashboard or the onboarding step, relying on whether or not it’s a new person. We will inform if it’s a new person as a result of it will not have a reputation, solely an e mail deal with.

1declare(strict_types=1);

2 

3namespace AppHttpLivewireAuth;

4 

5use AppHttpLivewireConcernsWithRateLimiting;

6use IlluminateContractsViewView as ViewContract;

7use IlluminateHttpRedirectResponse;

8use IlluminateSupportFacadesAuth;

9use IlluminateSupportFacadesCache;

10use IlluminateSupportFacadesView;

11use InfrastructureAuthCommandsEnsureUserExistsContract;

12use LivewireComponent;

13use LivewireRedirector;

14 

15ultimate class OneTimePasswordForm extends Part

16{

17 use WithRateLimiting;

18 

19 public string $e mail;

20 

21 public null|string $otp = null;

22 

23 public string $ip;

24 

25 public perform mount(): void

26 {

27 $this->ip = strval(request()->ip());

28 }

29 

30 public perform 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}-otp",

38 ),

39 );

40 }

41 

42 protected perform handleOneTimePasswordAttempt(

43 EnsureUserExistsContract $command,

44 blended $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{e mail: 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->deal with(

67 e mail: $this->e mail,

68 )->getKey()),

69 );

70 

71 return redirect()->route(

72 route: 'app:dashboard:present',

73 );

74 }

75 

76 protected perform forgetOtp(): void

77 {

78 Cache::overlook(

79 key: "{$this->ip}-otp",

80 );

81 }

82 

83 public perform guidelines(): 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 perform render(): ViewContract

100 {

101 return View::make(

102 view: 'livewire.auth.one-time-password-form',

103 );

104 }

105}

We need to be certain that we reset the one-time password for this IP deal with if we’ve got a failed try. As soon as that is finished, the person is authenticated and redirected as in the event that they logged in with a normal e mail deal with and password method.

This is not what I’d name an ideal resolution, however it’s an attention-grabbing one, that’s for positive. An enchancment could be to e mail a signed URL containing a few of the info as an alternative of leaning on our cache fully.

Have you ever labored with a customized authentication stream earlier than? What’s your most popular methodology for auth in Laravel? Tell us on Twitter!

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments