Let’s Kill the Password! Magic Login Links to the Rescue!
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.
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.
Changing the Login Link
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:
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!