Let’s Kill the Password! Magic Login Links to the Rescue!

Christopher Vundi
Share

This article was peer reviewed by Younes Rafie and Wern Ancheta. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!


Authentication is something that has evolved over the years. We have seen it change from email – password combination to social authentication, and finally password-less authentication. Actually, more like an “email only” authentication. In the case of a password-less login, the app assumes that you will get the login link from your inbox if the email provided is indeed yours.

Vector illustration of an open padlock on yellow background

The general flow in a password-less login system is as follows:

  • Users visit the login page
  • They type in their email address and confirm
  • A link is sent to their email
  • Upon clicking the link, they are redirected back to the app and logged in
  • The link is disabled

This comes in handy when you can’t remember your password for an app, but you do remember the email you signed up with. Even Slack employs this technique.

In this tutorial, we are going to implement such a system in a Laravel app. The complete code can be found here.

Creating the App

Let’s start by generating a new Laravel app. I am using Laravel 5.2 in this tutorial:

composer create-project laravel/laravel passwordless-laravel 5.2.*

If you have an existing Laravel project with users and passwords, worry not – we won’t be interfering with the normal auth flow, just creating a layer on top of what is already there. Users will still have the option of logging in with passwords.

Database Setup

Next, we have to set up our MySQL database before running any migrations.

Open your .env file in the root directory and pass in the hostname, username, and database name:

[...]
DB_CONNECTION=mysql
DB_HOST=localhost
DB_DATABASE=passwordless-app
DB_USERNAME=username
DB_PASSWORD=
[...]

If you’re using our Homestead Improved box, the database / username / password combination is homestead, homestead, secret.

Scaffolding Auth

One great thing that Laravel introduced in version 5.2 is the ability to add a pre-made authentication layer with just a single command. Let’s do that:

php artisan make:auth

This command scaffolds everything we need for authentication i.e the Views, Controllers, and Routes.

Migrations

If we look inside database/migrations, we notice that the generated Laravel app came with migrations for creating the users table and password_resets table.

We won’t alter anything since we still want our app to have the normal auth flow.

To create the tables, run:

php artisan migrate

We can now serve the app and users should be able to sign up and log in using the links in the nav.

Next, we want to change the login link to redirect users to a custom login view where users will be submitting their email addresses without a password.

Navigate to resources/views/layouts/app.blade.php. That’s where we find the nav partial. Change the line with the login link (right below the conditional to check if the user is logged out) to this:

resources/views/layouts/app.blade.php

[...]
@if (Auth::guest())
<li><a href="{{ url('/login/magiclink') }}">Login</a></li>
<li><a href="{{ url('/register') }}">Register</a></li>
[...]

When a user tries to access a protected route when not logged in, they should be taken to our new custom login view instead of the normal one. This behavior is specified in the authenticate middleware. We’ll have to tweak that:

app/Http/Middleware/Authenticate.php

class Authenticate
{
[...]
public function handle($request, Closure $next, $guard = null)
{
    if (Auth::guard($guard)->guest()) {
        if ($request->ajax() || $request->wantsJson()) {
            return response('Unauthorized.', 401);
        } else {
            return redirect()->guest('login/magiclink');
        }
    }

    return $next($request);
}
[...]

Notice inside the else block we’ve changed the redirect to point to login/magiclink instead of the normal login.

Creating the Magic Login Controller, View, and Routes

Our next step is to create the MagicLoginController inside our Auth folder:

php artisan make:controller Auth\\MagicLoginController

Then the route to display our custom login page:

app/Http/routes.php

[...]
Route::get('/login/magiclink', 'Auth\MagicLoginController@show');

Let’s update our MagicLoginController to include a show action:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
    [...]
    public function show()
    {
        return view('auth.magic.login');
    }
    [...]
}

For the new login view, we are going to borrow the normal login view but remove the password field. We’ll also change the form’s post URL to point to \login\magiclink.

Let’s create a magic folder inside the views/auth folder to hold this new view:

mkdir resources/views/auth/magic
touch resources/views/auth/magic/login.blade.php

Let’s update our newly created view to this:

resources/views/auth/magic/login.blade.php

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <div class="panel panel-default">
                <div class="panel-heading">Login</div>
                <div class="panel-body">
                    <form class="form-horizontal" role="form" method="POST" action="{{ url('/login/magiclink') }}">
                        {{ csrf_field() }}

                        <div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
                            <label for="email" class="col-md-4 control-label">E-Mail Address</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control" name="email" value="{{ old('email') }}" required autofocus>

                                @if ($errors->has('email'))
                                    <span class="help-block">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group">
                            <div class="col-md-6 col-md-offset-4">
                                <div class="checkbox">
                                    <label>
                                        <input type="checkbox" name="remember"> Remember Me
                                    </label>
                                </div>
                            </div>
                        </div>

                        <div class="form-group">
                            <div class="col-md-8 col-md-offset-4">
                                <button type="submit" class="btn btn-primary">
                                    Send magic link
                                </button>

                                <a href="{{ url('/login') }}" class="btn btn-link">Login with password instead</a>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

We will leave an option to log in with a password since users may still opt for the password login. So if users click on login from the nav, they’ll be taken to a login view that looks like this:

Example of view with login form

Generating Tokens and Associating Them with Users

Our next step is to generate tokens and associate them with users. This happens when one submits their email in order to log in.

Let’s start by creating a route to handle the posting action of the login form:

app/Http/routes.php

[...]
Route::post('/login/magiclink', 'Auth\MagicLoginController@sendToken');

Then, we add a controller method called sendToken inside the MagicLoginController. This method will validate the email address, associate a token with a user, send off a login email and flash a message notifying the user to check their email:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
    [...]
    /**
     * Validate that the email has a valid format and exists in the users table
     * in the email column
     */
    public function sendToken(Request $request)
    {
        $this->validate($request, [
            'email' => 'required|email|max:255|exists:users,email'
        ]);
    //will add methods to send off a login email and a flash message later
    }
    [...]
}

Now that we have a valid email address, we can send off a login email to the user. But before the email is sent, we have to generate a token for the user trying to log in. I don’t want to have all my method’s in the MagicLoginController and thus we’ll create a users-token model to handle some of these methods.

php artisan make:model UserToken -m

This command will make us both the model and the migration. We need to tweak the migration a bit and add user_id and token columns. Open the newly generated migration file and change the up method to this:

database/migrations/{timestamp}_create_user_tokens_table.php

[...]
public function up()
    {
        Schema::create('user_tokens', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('user_id');
            $table->string('token');
            $table->timestamps();
        });
    }
[...]

Then run the migrate Artisan command:

php artisan migrate

In the UserToken model, we need to add the user_id and token as part of the mass assignable attributes. We should also define the relationship this model has with the User model and vice-versa:

App/UserToken.php

[...]
class UserToken extends Model
{
    protected $fillable = ['user_id', 'token'];

    /**
     * A token belongs to a registered user.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Then inside App/User.php specify that a User can only have one token associated with them:

App/User.php

class User extends Model
{
    [...]
    /**
     * A user has only one token.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function token()
    {
        return $this->hasOne(UserToken::class);
    }
}

Let’s now generate the token. First, we need to retrieve a user object by their email before creating the token. Create a method in the User model called getUserByEmail to handle this functionality:

App/User.php

class User extends Model
{   
    [...]
    protected static function getUserByEmail($value)
    {
        $user = self::where('email', $value)->first();
        return $user;
    }
    [...]
}

We have to pull in the namespaces forUser and UserToken classes into our MagicLoginController in order to be able to call the methods in these classes from our controller:

app/Http/Controllers/Auth/MagicLoginController.php

[...]
use App\User;
use App\UserToken;
[...]
class MagicLoginController extends Controller
{
    [...]
    public function sendToken(Request $request)
    {
        //after validation
        [...]
        $user = User::getUserByEmail($request->get('email'));

        if (!user) {
            return redirect('/login/magiclink')->with('error', 'User not foud. PLease sign up');
        }

        UserToken::create([
            'user_id' => $user->id,
            'token'   => str_random(50)
        ]);
    }
    [...]
}

In the code block above, we are retrieving a user object based on the submitted email. Before getting to this point note we had to validate the presence of the submitted email address in the users table. But in the case where someone bypassed the validation and submitted an email that didn’t exist within our records, we will flash a message asking them to sign up.

Once we have the user object, we generate a token for them.

Emailing the Token

We can now email the generated token to the user in the form of a URL. First, we’ll have to require the Mail Facade in our model to help us with the email sending functionality.

In this tutorial, however, we won’t be sending any real emails. Just confirming that the app can send an email in the logs. To do this, navigate to your .env file and under the mail section set MAIL_DRIVER=log. Also, we won’t be creating email views; just sending a raw email from our UserToken class.

Let’s create a method in our UserToken model called sendEmail to handle this functionality. The URL which is a combination of the token, email address and remember me value will be generated inside this method:

app/UserToken.php

[...]
use Illuminate\Support\Facades\Mail;
[...]
class UserToken extends Model
{
[...]
    public static function sendMail($request)
    {
        //grab user by the submitted email
        $user = User::getUserByEmail($request->get('email'));

        if(!$user) {
            return redirect('/login/magiclink')->with('error', 'User not foud. PLease sign up');
        }

        $url = url('/login/magiclink/' . $user->token->token . '?' . http_build_query([
            'remember' => $request->get('remember'),
            'email' => $request->get('email'),
        ]));

        Mail::raw(
            "<a href='{$url}'>{$url}</a>",
            function ($message) use ($user) {
                $message->to($user->email)
                        ->subject('Click the magic link to login');
            }
        );
    }
[...]
}

While generating the URL, we’ll use PHP’s http_build_query function to help us make a query from the array of options passed. In our case it’s email and remember me value. After generating URL, we then mail it to the user.

Time to update our MagicLoginController and call the sendEmail method:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
    [...]
    public function sendToken(Request $request)
    {
        $this->validate($request, [
            'email' => 'required|email|max:255|exists:users,email'
        ]);
        UserToken::storeToken($request);

        UserToken::sendMail($request);

        return back()->with('success', 'We\'ve sent you a magic link! The link expires in 5 minutes');
    }
    [...]
}

We are also going to implement some basic flash messaging for notifications. In your resources/views/layouts/app.blade.php insert this block right above your content since flash messages show up at the top before any other content:

resources/views/layouts/app.blade.php

[...]
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            @include ('layouts.partials._notifications')
        </div>
    </div>
</div>
@yield('content')
[...]

Then create the notifications partial:

resources/views/layouts/partials/_notifications.blade.php

@if (session('success'))
    <div class="alert alert-success">
        {{ session('success') }}
    </div>
@endif

@if (session('error'))
    <div class="alert alert-danger">
        {{ session('error') }}
    </div>
@endif

In the partial, we have used the session helper to help us with different notification colors based on the session status i.e. success or error.

At this point, we are able to send emails. We can try it out by logging in with a valid email address, then navigating to the laravel.log file. We should be able to see the email containing the URL at the bottom of the logs.

Next, we want to validate the token and log the user in. We don’t want cases where a token that was sent out 3 days ago can still be used to log in.

Token Validation and Authentication

Now that we have the URL, let’s create a route and controller action to handle what happens when one clicks on the URL from their email:

app/Http/routes.php

[...]
Route::get('/login/magiclink/{token}', 'Auth\MagicLoginController@authenticate');

Let’s create the authenticate action in the MagicLoginController. It’s inside this method that we will authenticate the user. We are going to pull in the token into the authenticate method through Route Model Binding. We will then grab the user from the token. Note that we have to pull in the Auth facade in the controller to make it possible to use Auth methods:

app/Http/Controllers/Auth/MagicLoginController.php

[...]
use Auth;
[...]
class MagicLoginController extends Controller
{
    [...]
    public function authenticate(Request $request, UserToken $token)
        {
            Auth::login($token->user, $request->remember);
            $token->delete();
            return redirect('home');
        }
    [...]
}

Then in the UserToken class set the route key name that we expect. In our case, it’s the token:

App/UserToken.php

[...]
public function getRouteKeyName()
{
    return 'token';
}
[...]

And there we have it. Users can now log in. Note that after logging the user in, we delete the token since we don’t want to fill the user_tokens table with used tokens.

Our next step is checking if the token is still valid. For this app, we are going to make the magic link expire after 5 minutes. We will require the Carbon library to help us check the time difference between the token creation time and the current time.

In ourUserToken model, we are going to create two methods: isExpired and belongsToEmail to check the validity of the token. Note, the belongsToEmail validation is just an extra precaution making sure the token indeed belongs to that email address:

App/UserToken.php

[...]
use Carbon\Carbon;
[...]
class UserToken extends Model
{
    [...]
    //Make sure that 5 minutes have not elapsed since the token was created
    public function isExpired()
    {
        return $this->created_at->diffInMinutes(Carbon::now()) > 5;
    }

    //Make sure the token indeed belongs to the user with that email address
    public function belongsToUser($email)
    {
        $user = User::getUserByEmail($email);

        if(!$user || $user->token == null) {
            //if no record was found or record found does not have a token
            return false;
        }

        //if record found has a token that matches what was sent in the email
        return ($this->token === $user->token->token);
    }
    [...]
}

Let’s call the methods on the token instance in the authenticate method in the MagicLoginController:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
    [...]
    public function authenticate(Request $request, UserToken $token)
        {
            if ($token->isExpired()) {
                $token->delete();
                return redirect('/login/magiclink')->with('error', 'That magic link has expired.');
            }

            if (!$token->belongsToUser($request->email)) {
                $token->delete();
                return redirect('/login/magiclink')->with('error', 'Invalid magic link.');
            }

            Auth::login($token->user, $request->get('remember'));
            $token->delete();
            return redirect('home');

        }
    [...]
}

Conclusion

We have successfully added password-less login on top of the normal auth flow. Some may argue this takes longer than the normal password login, but so does using a password manager.

Passwordless systems wouldn’t work everywhere though, if you have short session timeout periods or expect users to log in frequently it could become frustrating. Fortunately, that affects very few sites.

Don’t you think it’s time you gave users an alternative way to log in your next project?

Please leave your comments and questions below, and remember to share this post with your friends and colleagues if you liked it!