Password Less Authentication with Laravel Signed Routes

Building a Password Less Authentication System with Laravel Signed Routes

I've seen many moderns apps are offering passwordless authentication in their platform. A lot of social media, email platforms are also offering the login with their API system by providing limited data necessary for user registration.

A lot of modern webs, mobile apps using social login to give a great user experience while using their platforms.

Today in this blog post, I'm explaining the process of customizing to use own passwordless authentication system with the Laravel framework.

Let's start together.

This article will utilize features from Laravel 5.6 version.

laravel new passwordless-auth

cd passwordless-auth

php artisan make:auth

After publishing the default Laravel auth scaffoldings, we now need to remove unnecessary files listed below.

ResetPasswordController.php
ForgotPasswordController.php

passwords/email.blade.php
passwords/reset.blade.php

The register, login pages come up with password fields, as we're building passwordless auth, so we have to tweak on those files.

login.blade.php
register.blade.php

Note: It is better to give some instructions on each pages describing how our passwordless authentication system works.

Now, adding auth routes needed for this integration.

We're not going to use the default routes which ship with the framework for the auth system.

We'll care the naming convention of routes, but we have to avoid unnecessary ones coming with the framework which are not necessary for this integration.

Route::get('login', 'Auth\[email protected]')->name('login');
Route::post('login/attempt', 'Auth\[email protected]')->name('login.attempt');
Route::get('login/{token}/validate', 'Auth\[email protected]')
    ->name('login.token.validate')
    ->middleware('signed');
Route::post('logout', 'Auth\[email protected]')->name('logout');
Route::get('register', 'Auth\[email protected]')->name('register');
Route::post('register', 'Auth\[email protected]');

Let's start with registering a user without a password to log in.

Before writing any codes for login, register process, let me give you some idea on what things need to update.

Signed Route Middleware
The middleware prevents the user to misuse the expired URLs by checking the signature validity.

    protected $routeMiddleware = [
        ...
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
    ];‚Äč

The new feature signed route was introduced to the framework with version 5.6.

Handle InvalidSignatureException
We need to handle the exception for invalid signature, if a user tries to login with the expired signed URL show message to them to generate new URL.

    use Illuminate\Routing\Exceptions\InvalidSignatureException;

    public function render($request, Exception $exception)
    {
        if (is_a($exception, InvalidSignatureException::class)) {
            return response()->view('_signature-expired');
        }

        return parent::render($request, $exception);
    }
@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header">
                        <h2>{{ __('Error') }}</h2>
                    </div>
                    <div class="card-body">
                        <div class="alert alert-danger text-center text-muted">
                            The signature seems to be expired, please try generating a new one and try again.
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

Remove Password Fields
We're not using the password field anymore, so remove from the migration file and User model.

After all, these make sure you run the migration to generate database tables.
Register

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class RegisterController extends Controller
{
    use RegistersUsers;

    /**
     * @var string
     */
    protected $redirectTo = '/home';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest');
    }

    /**
     * Get a validator for an incoming registration request.
     *
     * @param  array  $data
     * @return \Illuminate\Contracts\Validation\Validator
     */
    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
        ]);
    }

    /**
     * Create a new user instance after a valid registration.
     *
     * @param  array  $data
     * @return \App\User
     */
    protected function create(array $data)
    {
        return User::create([
            'name' => $data['name'],
            'email' => $data['email'],
        ]);
    }

    /**
     * Handle a registration request for the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function register(Request $request)
    {
        $this->validator($request->all())->validate();

        event(new Registered($user = $this->create($request->all())));

        return redirect()->route('login')
            ->with(['success' => 'Success! your account is registered.']);
    }
}

The main updates in this file are, remove password form validation, create methods.

Another major update is to prevent a user from logging in automatically into the application after registration inside the register method.

Tip: I've seen BuySell Ads, a popular advertising platform using this kind of system as only admin adds the user.

Login

I've added a new trait for the Login process to keep the overrides a little bit cleaner.

Previously with the framework's default auth, there is a trait called AuthenticatesUsers.

Now, with this integration, we will be importing new trait which itself imports the existing AuthenticatesUsers.

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Auth\Traits;
use App\Http\Controllers\Controller;

class LoginController extends Controller
{
    use Traits\PasswordLessAuth;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = '/home';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }
}

Also, the Login Throttling feature will still work.

<?php

namespace App\Http\Controllers\Auth\Traits;

use App\LoginAttempt;
use App\Notifications\NewLoginAttempt;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

trait PasswordLessAuth
{
    use AuthenticatesUsers;

    /**
     * Validate the user login request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return void
     */
    protected function validateLogin(Request $request)
    {
        $messages = ['exists' => trans('auth.exists')];

        $this->validate($request, [
            $this->username() => 'required|email|exists:users',
        ], $messages);
    }

    /**
     * Handle a login attempt request to the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function attempt(Request $request)
    {
        $this->incrementLoginAttempts($request);

        if ($this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

            return $this->sendLockoutResponse($request);
        }

        $this->validateLogin($request);

        if ($this->createLoginAttempt($request)) {
            return $this->sendAttemptResponse($request);
        }

        return $this->sendFailedLoginResponse($request);
    }

    /**
     * Handle a login request to the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function login($token, Request $request)
    {
        if ($this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

            return $this->sendLockoutResponse($request);
        }

        if ($this->attemptLogin($token, $request)) {
            return $this->sendLoginResponse($request);
        }

        $this->incrementLoginAttempts($request);

        return $this->sendFailedLoginResponse($request);
    }

    /**
     * Attempt to log the user into the application.
     *
     * @param string $token
     * @param \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function attemptLogin($token, Request $request)
    {
        $user = LoginAttempt::userFromToken($token);

        if (is_object($user)) {
            return $this->guard()->login($user);
        }
    }

    /**
     * Attempt to log the user into the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \App\LoginAttempt
     */
    protected function createLoginAttempt(Request $request)
    {
        $authorize = LoginAttempt::create([
            'email' => $request->input($this->username()),
            'token' => str_random(40) . time(),
        ]);

        $authorize->notify(new NewLoginAttempt($authorize));

        return $authorize;
    }

    /**
     * @param $request
     */
    public function sendAttemptResponse($request)
    {
        return \View::make('auth._link-sent');
    }
}

To customize the validation message for the user exists rule, open up resources/lang/en/auth.php and update like below.

<?php

return [

    ...
    'exists' => 'The provided email address does not match our records.'
];

Let me give some run down about the login feature and new methods added to the PasswordLessAuth.php trait.

  • The method validateLogin() now, only validates email field and if that exists in the database.
  • The new attempt() method is responsible for sending an email with a signed URL after validating the request.
  • The new createLoginAttempt(), is being called via attempt() method, which creates a new login attempt which will be validated later while user clicks on the link sent via email.
  • After creating a login attempt, we display a message to the user with sendAttemptResponse() method.

Let's create a model, migration and notification class for the login attempt process.

<?php

namespace App;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;

class LoginAttempt extends Model
{
    use Notifiable;

    /**
     * @var string
     */
    protected $table = 'login_attempts';

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'email', 'token',
    ];

    /**
     * @return mixed
     */
    public function user()
    {
        return $this->hasOne(User::class, 'email', 'email');
    }

    /**
     * @param $token
     */
    public static function userFromToken($token)
    {
        $query = self::where('token', $token)
            ->where('created_at', '>', Carbon::parse('-15 minutes'))
            ->first();

        return $query->user ?? null;
    }
}

Add, those fields by creating a new migration file like below.

    Schema::create('login_attempts', function (Blueprint $table) {
        $table->increments('id');
        $table->string('email')->index();
        $table->string('token')->index();
        $table->timestamps();
    });

To send the login link in an email, we have a new notification class.

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\URL;

class NewLoginAttempt extends Notification
{
    use Queueable;

    /**
     * Create a new notification instance.
     *
     * @return void
     */
    public function __construct($attempt)
    {
        $this->attempt = $attempt;
    }

    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
        return ['mail'];
    }

    /**
     * Get the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return \Illuminate\Notifications\Messages\MailMessage
     */
    public function toMail($notifiable)
    {
        return with(new MailMessage)
            ->from(env('ADMIN_MAIL_ADDRESS'))
            ->subject('Login Your Account')
            ->greeting("Hello {$this->attempt->user->name}!")
            ->line('Please click the button below to get access to the application, which will be valid only for 15 minutes.')
            ->action('Login to your account', URL::temporarySignedRoute('login.token.validate', now()->addMinutes(15), [$this->attempt->token]))
            ->line('Thank you for using our application!');
    }

    /**
     * Get the array representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

After a user makes a valid attempt to login into the application, we display a message to the user with a view file like below.

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header">
                        <h2>{{ __('Success') }}</h2>
                    </div>
                    <div class="card-body">
                        <div class="alert text-center text-muted">
                            Please check your email for a login link, which will be valid for next 15 minutes only.
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <a class="btn btn-link" href="{{ route('login') }}">
                                    {{ __('Get Another Link') }}
                                </a>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

Conclusion

The full source code now freely available on github, you are free to modify as per your application logic.

Also Read: Laravel 5.6 Login, Register, Activation with Username or Email Support

Thanks for reading this article up to the end, please don't forget to give your feedback in the comment section below.

Happy Coding!