Recently I was working on a project where one of our major pain points was users' passwords. Users were added to the application by administrators, so they didn't have passwords when they were first added, and forcing them to set and remember passwords was a big hitch on the project's usability.
So, we decided to try out a Medium/Slack-inspired password-less login. If you've never had the chance to work with this, the login system works like this: enter your email address on the login page, get emailed a login link, click the link, and now you're logged in. Access to your email address proves your identity without the need for a password.
Let's build one together.
First we create our Laravel app and scaffold the authentication system:
laravel new medium-logincd medium-loginphp artisan make:auth
We now have a series of new authentication-related files, including the login and registration pages. Let's start by tweaking those files.
The login and registration pages are pretty good, but we need to drop the password fields from each.
Open up the login page at resources/views/auth/login.blade.php
and delete the entire password
form group (label, input, and wrapping <div>
). Save and close.
Open up the registration page at resources/views/auth/register.blade.php
and delete the password
and password-reset
form groups there too. Save and close.
Later you'll probably want to give some instructions on both pages describing how our authentication will work, and drop the links to password resets, but for right now this should be good enough.
Now, we need to update the route that the login and registration forms are pointing to. Let's head over to the AuthController
and see what we have.
First, we'll notice the validator
method, which returns a validator that expects a password
field. This is the validator for the account registration process, so let's get rid of the password there.
The function should end up looking like this:
// app/http/Controllers/Auth/AuthController.phpprotected function validator(array $data){ return Validator::make($data, [ 'name' => 'required|max:255', 'email' => 'required|email|max:255|unique:users', ]);}
And we'll do the same thing for the create
method, which is also used for registration:
// app/http/Controllers/Auth/AuthController.phpprotected function create(array $data){ return User::create([ 'name' => $data['name'], 'email' => $data['email'], ]);}
login
routeBut you can see that there are no methods here for logging in users. These are hidden in the AuthenticatesAndRegistersUsers
trait, which just gives you the AuthenticatesUsers
trait and the RegistersUsers
trait. You can go to the AuthenticatesUsers
trait and find, finally, that the method that logs users in is named login
.
Everything that's happening there is predicated around a passworded login, though, so let's override that method in its entirety.
The goal of our new method will be be to trigger an email to the user prompting them to verify their login. Let's go to the AuthController
and add a login
method to override the one in AuthenticatesUsers
.
// app/http/Controllers/Auth/AuthController.phppublic function login(Request $request){ // validate that this is a real email address // send off a login email // show the users a view saying "check your email"}
First, let's validate their email address. That's pretty easy:
$this->validate($request, ['email' => 'required|email|exists:users']);
Next, we need to send off an email prompting them to log in. This will take a bit more work.
If you're familiar with the shape of the password_reset
database structure, we'll be creating something very similar. Every time someone tries to log in, we'll need to add an entry to a table that captures their email address, a unique token we just created (and will send in the email as a part of the URL), and the created date for expiration purposes.
In the end we'll use an entry in this table to generate (and verify) a URL like this: myapp.com/auth/email-authenticate/09ajfpoib23li4ub123p984h1234
. We'll expire this login after a certain time, and associate that URL with a particular user, so we need to track email
, token
, and created_at
for each entry in that table.
So, let's create a migration for it:
php artisan make:migration create_email_logins_table --create=email_logins
And let's add a few fields in there:
Schema::create('email_logins', function (Blueprint $table) { $table->string('email')->index(); $table->string('token')->index(); $table->timestamps();});
Note: If you wanted, you could use a foreign key
id
column instead of the
Now, let's create the model.
php artisan make:model EmailLogin
Edit the file (app/EmailLogin.php
) and make it simple for us to create an instance with the right properties:
class EmailLogin extends Model{ public $fillable = ['email', 'token'];}
And then we'll want to associate each with a user, and since in this particular example we're tracking user by email
, not id
, we have to manually link each table's email
column:
class EmailLogin extends Model{ public $fillable = ['email', 'token']; public function user() { return $this->hasOne(\App\User::class, 'email', 'email'); }}
Now we're ready to create the email. We're going to want to send an email to the end user with a URL that contains the unique token we generated earlier.
First, let's figure out how we're going to handle the creation and storage of those tokens. We need to create an instance of EmailLogin
, so let's start there:
public function login(){ $this->validate($request, ['email' => 'required|email|exists:users']); $emailLogin = EmailLogin::createForEmail($request->input('email'));}
Let's add that method to EmailLogin
:
class EmailLogin extends Model{ ... public static function createForEmail($email) { return self::create([ 'email' => $email, 'token' => str_random(20) ]); }}
We're now generating a random token and creating an instance of of the EmailToken
class with it, and then returning it back.
Now, we need to use that EmailToken
to build a URL that we can send to our user in the email.
public function login(){ $this->validate($request, ['email' => 'required|email|exists:users']); $emailLogin = EmailLogin::createForEmail($request->input('email')); $url = route('auth.email-authenticate', [ 'token' => $emailLogin->token ]);}
Let's create the route for it:
// app/Http/routes.phpRoute::get('auth/email-authenticate/{token}', [ 'as' => 'auth.email-authenticate', 'uses' => 'Auth\AuthController@authenticateEmail']);
... and set up a controller method to fulfill that route:
class AuthController{ ... public function authenticateEmail($token) { $emailLogin = EmailLogin::validFromToken($token); Auth::login($emailLogin->user); return redirect('home'); }}
... and let's make that method validFromToken
work:
class EmailLogin{ ... public static function validFromToken($token) { return self::where('token', $token) ->where('created_at', '>', Carbon::parse('-15 minutes')) ->firstOrFail(); }
We now have an incoming route that, given a valid token that hasn't expired, logs the user in and redirects them to home
. Let's send this email.
Let's add the "send mail" call to our controller method:
public function login(){ ... Mail::send('auth.emails.email-login', ['url' => $url], function ($m) use ($request) { $m->from('noreply@myapp.com', 'MyApp'); $m->to($request->input('email'))->subject('MyApp Login'); });
... and create that email template:
<!-- resources/views/auth/emails/email-login.blade.php -->Log in to MyApp here: <a href="{{ $url }}">{{ $url }}</a>
You can create a view any way you want, but in general you just need to say "Hey, we send you an email, go check for it." That's all.
return 'Login email sent. Go check your email.';
So, let's take a look at our system. We have a new login
method on our AuthController
:
public function login(Request $request){ $this->validate($request, ['email' => 'required|exists:users']); $emailLogin = EmailLogin::createForEmail($request->input('email')); $url = route('auth.email-authenticate', [ 'token' => $emailLogin->token ]); Mail::send('auth.emails.email-login', ['url' => $url], function ($m) use ($request) { $m->from('noreply@myapp.com', 'MyApp'); $m->to($request->input('email'))->subject('MyApp login'); }); return 'Login email sent. Go check your email.';}
We've created a few new views. We updated old views to drop the password fields. We created a new route at /auth/email-authenticate
. And we've created an EmailLogin
migration and class to support all of these needs.
And done! Put all of these pieces together and you have a fully-functional, passwordless login system.
When your users sign up, they only need to provide their email address. When they log in, they only need to provide their email address. No more forgotten passwords. Boom.
We appreciate your interest.
We will get right back to you.