使用Laravel的神奇登录链接

229 阅读9分钟

如果你曾经使用过像Vercel或Medium这样的网站,你很可能曾经经历过无密码登录。

流程通常是这样的:输入你的电子邮件->提交表格->电子邮件被发送给你->你点击里面的链接->你就登录了。

对每个人来说,这是一个相当方便的流程。用户不必记住网站任意规则集的密码,而网站管理员(人们还在使用这个词吗?)不必担心密码泄露或他们的加密是否足够好。

在这篇文章中, 我们将探讨如何使用一个标准的Laravel安装来实现这个流程.

我们将假设你对Laravel的MVC结构有一定的了解, 并且你的环境已经设置好了composerphp.

请注意, 本文中的代码锁可能不包括整个文件, 以求简洁.

环境设置

让我们从创建一个新的Laravel 8应用程序开始。

$ composer create-project laravel/laravel magic-links

然后我们需要cd ,进入我们的项目,确保输入我们的数据库凭证。请确保事先创建数据库.

在我的例子中, 我使用的是PostgreSQL, 我通过TablePlus完成所有的配置.打开.env 文件。

# .env
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=magic_link
DB_USERNAME=postgres
DB_PASSWORD=postgres

现在,我们的数据库已经配置好了,但是还没有运行迁移!我们要做的是,把我们的数据库配置好。让我们看一下Laravel在database/migrations/2014_10_12_000000_create_users_table.php 中为我们创建的默认用户迁移。

你会看到,默认的用户表包含了一列密码。由于我们要做的是无密码认证, 我们可以把它去掉:

public function up()
{
  Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable();
    $table->rememberToken();
    $table->timestamps();
  });
}

删除这一行后,继续保存文件。在我们清理东西的时候,让我们继续删除密码重置表的迁移,因为它对我们没有用处。

$ rm database/migrations/2014_10_12_100000_create_password_resets_table.php

我们的初始数据库模式已经准备好了,所以让我们运行我们的迁移。

$ php artisan migrate

app/Models/User.php 让我们也从用户模型的$fillable 数组中删除password 属性,因为它不再存在了。

protected $fillable = [
  'name',
  'email',
];

我们还要配置我们的邮件驱动,这样我们就可以预览我们的登录邮件了。我喜欢使用Mailtrap,它是一个免费的SMTP捕获器(你可以发送邮件到任何地址,它们只会显示在Mailtrap中,而不会传递给实际的用户),但你可以使用任何你喜欢的。

如果你不想设置任何东西,你可以使用log ,邮件将以原始文本形式显示在storage/logs/laravel.log

回到之前的那个.env 文件中。

# .env
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=redacted
MAIL_PASSWORD=redacted
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=hello@example.com

我们现在准备好了,可以开始建设了

我们的方法

在本文的开头,我们谈到了从用户的角度看流程是什么样的,但从技术角度看,这是如何工作的呢?

好吧,给定一个用户,我们需要能够给他们发送一个独特的链接,当他们点击它时,就能登录到他们自己的账户。

这告诉我们,我们可能需要生成某种独特的令牌,将其与试图登录的用户联系起来,建立一个路由,查看该令牌并确定其是否有效,然后将用户登录。我们还希望只允许这些令牌使用一次,而且一旦生成,只在一定时间内有效。

由于我们需要跟踪令牌是否已经被使用过,我们要把它们存储在数据库中。这也将方便我们跟踪哪个令牌属于哪个用户,以及令牌是否被使用过,是否已经过期。

创建一个测试用户

我们在这篇文章中只关注登录流程。创建一个注册页面将由你来决定,尽管它将遵循所有相同的步骤。

正因为如此,我们需要在数据库中建立一个用户来测试登录。让我们用tinker创建一个。

$ php artisan tinker
> User::create(['name' => 'Jane Doe', 'email' => 'test@example.com'])

登录路线

我们将首先创建一个控制器,AuthController ,用来处理登录、验证和注销功能。

$ php artisan make:controller AuthController

现在让我们在我们应用程序的routes/web.php 文件中注册登录路由。在欢迎路线下面,让我们定义一个路线组,它将使用guest 中间件保护我们的验证路线,阻止已经登录的人查看它们。

在这个组中,我们将创建两个路由。一个用于显示登录页面,另一个用于处理表单的提交。我们还将给它们命名,以便我们以后可以轻松地引用它们。

Route::group(['middleware' => ['guest']], function() {
  Route::get('login', [AuthController::class, 'showLogin'])->name('login.show');
  Route::post('login', [AuthController::class, 'login'])->name('login');
});

现在路由已经注册,但我们需要创建响应这些路由的动作。让我们在我们创建的控制器中创建这些方法app/Http/Controllers/AuthController.php

现在,我们将让我们的登录页面返回一个位于auth.login (我们将在接下来创建)的视图,并创建一个占位符login ,一旦我们建立了我们的表单,我们将回到这个方法。

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class AuthController extends Controller
{
  public function showLogin()
  {
    return view('auth.login');
  }

  public function login(Request $request)
  {
    // TODO
  }
}

我们将使用Laravel的模板系统Blade和TailwindCSS作为我们的视图。

由于本文的主要重点是在后台逻辑上, 我们不打算详细讨论样式设计。我不想花时间去设置一个适当的CSS配置,所以我们将使用这个TailwindCSS JIT CDN,我们可以将它放入我们的布局中,它将处理拉取正确的样式。

当你第一次加载页面时,你可能会注意到一个闪光的样式。这是因为这些样式在页面加载后才会存在。在生产环境中,你不希望出现这种情况,但为了本教程的目的,这是好的。

让我们从创建一个通用的布局开始,我们可以在所有的页面中使用。这个文件将存放在resources/views/layouts/app.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{ $title }}</title>
</head>
<body>
  @yield('content')
  <script src="https://unpkg.com/tailwindcss-jit-cdn"></script>
</body>
</html>

在此我要指出几件事

  • 页面标题将由一个$title 变量设置,当我们从布局中扩展时,我们将把它传递给布局。
  • @yield('content') Blade指令--当我们从这个布局中扩展时,我们将使用一个名为 "内容 "的部分来放置我们的页面特定内容。
  • 我们正在使用TailwindCSS JIT CDN脚本来处理我们的样式。

现在我们有了布局,我们可以在resources/views/auth/login.blade.php 中创建注册页面。

@extends('layouts.app', ['title' => 'Login'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      <h1 class="text-xl font-semibold">Login</h1>
      <form action="{{ route('login') }}" method="post" class="space-y-4">
        @csrf
        <div class="space-y-1">
          <label for="email" class="block">Email</label>
          <input type="email" name="email" id="email" class="block w-full border-gray-400 rounded-md px-4 py-2" />
          @error('email')
            <p class="text-sm text-red-600">{{ $message }}</p>
          @enderror
        </div>
        <button class="rounded-md px-4 py-2 bg-indigo-600 text-white">Login</button>
      </form>
    </div>
  </div>
@endsection

这里有一些事情要做,让我们指出一些东西。

  • 我们首先扩展我们先前创建的布局,并给它一个 "登录 "的标题,这将是我们文档标签的标题。
  • 我们声明一个叫做content (还记得前面的@yield 吗?)的部分,并把我们的页面内容放在里面,这将被渲染到布局中。
  • 应用一些基本的容器和样式,将表单放在屏幕中间。
  • 表单的动作指向一个名为route('login') 的路由,如果我们记得routes/web.php 文件,这就是我们在控制器中给登录POST 请求的名字。
  • 我们使用@csrf 指令包含了隐藏的CSRF字段(在此阅读更多内容)。
  • 我们使用@error 指令,有条件地显示Laravel提供的任何验证错误。

如果你加载页面,它应该看起来像这样。

Screenshot of Laravel web page with simple login box

很基本, 我们只是要求用户的电子邮件.如果我们现在提交表单,你只会看到一个空白的白屏,因为我们之前定义的login 方法是空的。让我们在我们的AuthController 中实现login 方法,向他们发送一个链接来完成登录。

流程将是这样的:验证表单数据 -> 发送登录链接 -> 在页面上向用户显示一条信息,告诉他们检查他们的电子邮件。

// app/Http/Controllers/AuthController.php
// near other use statements
use App\Models\User;

// inside class
public function login(Request $request)
{
  $data = $request->validate([
    'email' => ['required', 'email', 'exists:users,email'],
  ]);
  User::whereEmail($data['email'])->first()->sendLoginLink();
  session()->flash('success', true);
  return redirect()->back();
}

我们在这里要做几件事。

  • 验证表单数据--说电子邮件是必须的,应该是有效的电子邮件,并存在于我们的数据库中
  • 我们通过提供的电子邮件找到用户,并调用一个我们需要实现的函数sendLoginLink
  • 我们向会话闪现一个值,表明请求成功了,然后将用户返回到登录页面。

上面的步骤中有几个未完成的任务,所以我们现在需要实现这些。

我们将从更新我们的登录视图开始,以检查成功布尔值,隐藏我们的表单,如果有的话,向用户显示一个消息。回到resources/views/auth/login.blade.php

@extends('layouts.app', ['title' => 'Login'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      @if(!session()->has('success'))
        <h1 class="text-xl font-semibold">Login</h1>
        <form action="{{ route('login') }}" method="post" class="space-y-4">
          @csrf
          <div class="space-y-1">
            <label for="email" class="block">Email</label>
            <input type="email" name="email" id="email" class="block w-full border-gray-400 rounded-md px-4 py-2" />
            @error('email')
              <p class="text-sm text-red-600">{{ $message }}</p>
            @enderror
          </div>
          <button class="rounded-md px-4 py-2 bg-indigo-600 text-white">Login</button>
        </form>
      @else
        <p>Please click the link sent to your email to finish logging in.</p>
      @endif
    </div>
  </div>
@endsection

在这里,我们简单地将表单包裹在一个条件中。

它在说。

  • 我们刚刚成功提交了一个表单吗?
    • 不--显示注册表单而不是
    • 是的--让用户知道他们的账户已经创建,并让他们查看电子邮件中的链接。

现在,如果你再次提交这个表单,你会看到一个错误,说我们需要在User 模型上实现这个sendLoginLink 函数。我喜欢把这样的逻辑存储在模型本身,这样我们就可以在以后的应用程序中重复使用它。

打开app/Models/User.php ,创建一个空方法来填补它的位置。

public function sendLoginLink()
{
  // TODO
}

现在再次提交表单,确保你看到如下的成功信息。

Screenshot of Laravel web page that reads "please click the link sent to your email to finish logging in"

当然,你还不会收到电子邮件,但现在我们可以继续进行这一步了。

实现sendLoginLink 功能

反思我们上面讨论的令牌的方法,我们现在需要做的是:。

  1. 生成一个独特的令牌并将其附加到用户身上
  2. 向用户发送一封电子邮件,其中包含一个验证该令牌的页面链接。

我们将把这些保存在一个叫做login_tokens 的表中。让我们创建模型和迁移 (-m)。

$ php artisan make:model -m LoginToken

对于迁移,我们需要。

  • 为我们正在生成的url提供一个唯一的token
  • 一个关联,将其与请求用户联系起来
  • 一个说明令牌何时到期的日期
  • 一个标志,告诉我们该令牌是否已经被使用。我们将使用一个时间戳字段,因为这一列中没有一个值会告诉我们它是否被使用过,而且它是一个时间戳也让我们知道它是什么时候被使用的--双赢

打开已经生成的迁移,添加必要的列。

Schema::create('login_tokens', function (Blueprint $table) {
  $table->id();
  $table->unsignedBigInteger('user_id');
  $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
  $table->string('token')->unique();
  $table->timestamp('consumed_at')->nullable();
  $table->timestamp('expires_at');
  $table->timestamps();
});

确保事后运行迁移。

$ php artisan migrate

接下来更新我们的新app/Models/LoginToken 模型,以说明一些事情。

  • 将我们的$guarded 属性设置为一个空数组,这意味着我们没有限制哪些列可以被填充
  • 创建一个$dates 属性,当我们在php代码中引用expires_atconsumed_at 字段时,它将把这些字段转换成Carbon\Carbon 实例,以方便以后的工作。
  • 我们的user() 方法,让我们引用与token相关的用户。
class LoginToken extends Model
{
  use HasFactory;

  protected $guarded = [];
  protected $dates = [
    'expires_at', 'consumed_at',
  ];

  public function user()
  {
    return $this->belongsTo(User::class);
  }
}

User 模型上放置逆向关联也是一个好主意。

// inside app/Models/User.php
public function loginTokens()
{
  return $this->hasMany(LoginToken::class);
}

现在我们已经建立了模型,我们可以做我们的sendLoginLink() 函数的第一步,即创建令牌。

回到app/Models/User.php ,我们将使用我们刚刚创建的新的loginTokens() 关联为用户创建令牌,并使用Laravel的Str 帮助器给它一个随机的字符串,到期时间为15分钟后。

因为我们把expires_atconsumed_at 设置为LoginToken 模型上的日期,我们可以简单地传递一个流畅的日期,它将被适当地转换。在我们将令牌插入数据库之前,我们还将对令牌进行哈希处理,这样,如果这个表被破坏,没有人可以看到原始令牌值。

我们使用的哈希值是可重复的,这样我们就可以在以后需要的时候再次查询它。

use Illuminate\Support\Str;

public function sendLoginLink()
{
    $plaintext = Str::random(32);
    $token = $this->loginTokens()->create([
      'token' => hash('sha256', $plaintext),
      'expires_at' => now()->addMinutes(15),
    ]);
    // todo send email
}

现在我们有了一个令牌,我们可以向用户发送一封电子邮件,其中包含一个链接,在网址中包含(纯文本)令牌,这将验证他们的会话。令牌需要在URL中出现,这样我们就可以查到是哪个用户的。

我们不希望只使用LoginToken 的ID,因为那样的话,用户有可能会逐一去找一个有效的URL。我们将在后面讨论另一种防止这种情况的方法。

首先,创建代表电子邮件的mailer类。

$ php artisan make:mail MagicLoginLink

打开在app/Mail/MagicLoginLink.php 生成的邮件程序,并输入以下内容。

<?php
namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;

class MagicLoginLink extends Mailable
{
  use Queueable, SerializesModels;

  public $plaintextToken;
  public $expiresAt;

  public function __construct($plaintextToken, $expiresAt)
  {
    $this->plaintextToken = $plaintextToken;
    $this->expiresAt = $expiresAt;
  }

  public function build()
  {
    return $this->subject(
      config('app.name') . ' Login Verification'
    )->markdown('emails.magic-login-link', [
      'url' => URL::temporarySignedRoute('verify-login', $this->expiresAt, [
        'token' => $this->plaintextToken,
      ]),
    ]);
  }
}

下面是正在发生的事情--邮件将接收明文令牌和到期日,并将其存储在公共属性中。这将允许我们以后在build() 方法中使用它,当它被组成时。

build() 方法中,我们将设置邮件的主题,并告诉它在resources/views/emails/magic-login-link.blade.php 中寻找一个markdown格式的视图。Laravel为markdown邮件提供了一些默认的样式,我们将在稍后利用这些样式。

我们还将一个url 变量传递给视图,这个变量将成为用户点击的链接。

这个url 属性是一个临时的签名网址。它接收一个命名的路由、一个过期日期(我们希望它是我们的令牌的过期日期)和任何参数(在这种情况下,token 是我们生成的未哈希的随机字符串)。一个有签名的URL可以确保URL没有被修改过, 它是用一个只有Laravel知道的秘密对URL进行散列。

尽管我们将在我们的verify-login 路由中添加检查,以确保我们的令牌仍然有效(基于expires_atconsumed_at 属性),签署URL在框架层面上给了我们额外的安全,因为没有人能够用随机的令牌强行进入verify-login 路由,看看他们是否可以找到一个可以登录的令牌。

现在我们需要在resources/views/emails/magic-login-link.blade.php 实现那个标记视图。你可能想知道为什么扩展名为.blade.php 。这是因为即使我们在这个文件中写的是markdown,我们也可以使用里面的Blade指令来构建可重复使用的组件,我们可以在邮件中使用。

Laravel为我们提供了开箱即用的预设组件,可以马上开始使用。我们使用的是mail::message ,它给我们提供了一个布局和一个通过mail::button 的呼叫行动。

@component('mail::message')
  Hello, to finish logging in please click the link below
  @component('mail::button', ['url' => $url])
    Click to login
  @endcomponent
@endcomponent

现在我们已经建立了电子邮件的内容, 我们可以通过实际发送电子邮件来完成sendLoginLink() 方法.我们将使用Laravel提供的Mail façade来指定我们要发送的用户邮箱, 并且邮件的内容应该由我们刚刚完成设置的MagicLoginLink 类来构建.

我们还使用queue() ,而不是send() ,这样邮件就会在后台发送,而不是在当前的请求中发送。如果你想让它立即发生,请确保你的队列驱动设置得当,或者你使用sync 驱动(这是默认的)。

回到app/Models/User.php

use Illuminate\Support\Facades\Mail;
use App\Mail\MagicLoginLink;

public function sendLoginLink()
{
  $plaintext = Str::random(32);
  $token = $this->loginTokens()->create([
    'token' => hash('sha256', $plaintext),
    'expires_at' => now()->addMinutes(15),
  ]);
  Mail::to($this->email)->queue(new MagicLoginLink($plaintext, $token->expires_at));
}

如果你提交我们的登录表格,你现在会看到一封看起来像这样的电子邮件。

Screenshot of a Laravel app that reads "Hello to finish signing in click the link below"

验证路线

如果你试图点击该链接,你可能会收到一个404错误。这是因为在我们的电子邮件中,我们向用户发送了一个链接到verify-login 命名的路由,但我们还没有创建这个链接

routes/web.php 内的路由组中注册该路由。

Route::group(['middleware' => ['guest']], function() {
  Route::get('login', [AuthController::class, 'showLogin'])->name('login.show');
  Route::post('login', [AuthController::class, 'login'])->name('login');
  Route::get('verify-login/{token}', [AuthController::class, 'verifyLogin'])->name('verify-login');
});

然后我们将在我们的AuthController 类内通过verifyLogin 方法创建实现。

public function verifyLogin(Request $request, $token)
{
  $token = \App\Models\LoginToken::whereToken(hash('sha256', $token))->firstOrFail();
  abort_unless($request->hasValidSignature() && $token->isValid(), 401);
  $token->consume();
  Auth::login($token->user);
  return redirect('/');
}

在这里,我们正在做以下工作。

      • 通过散列明文值找到令牌,并将其与我们数据库中的散列版本进行比较(如果没有找到,则抛出404--通过firstOrFail())
      • 如果令牌无效,或者签名的URL无效,则以401状态代码中止请求(如果你想显示一个视图或其他东西让用户知道更多信息,你可以在这里花样翻新,但为了这个教程,我们将只是杀死请求)。
      • 将令牌标记为已使用,这样它就不能再被使用了
      • 登录与该令牌相关的用户
      • 将他们重定向到主页

我们在令牌上调用了几个实际上还不存在的方法,所以我们来创建它们。

      • isValid() ,如果令牌还没有被消耗(consumed_at === null ),如果还没有过期(expires_at <= now ),则为真。
      • 我们将提取过期和消耗,检查到他们自己的函数,使其更易读
      • consume() 将会把consumed_at 属性设置为当前的时间戳。

我喜欢把这个逻辑直接封装在模型上,以便于阅读和重复使用。打开app/Models/LoginToken.php

public function isValid()
{
  return !$this->isExpired() && !$this->isConsumed();
}

public function isExpired()
{
  return $this->expires_at->isBefore(now());
}

public function isConsumed()
{
  return $this->consumed_at !== null;
}

public function consume()
{
  $this->consumed_at = now();
  $this->save();
}

如果你现在从你的电子邮件中点击那个登录链接,你应该被重定向到/ 路径!

你还会注意到,如果你再次点击该链接,你将会看到错误的屏幕,因为它现在是无效的。

最后的润色

现在我们的认证流程已经开始工作了,让我们保护我们的根路径,使其只能被那些已经登录的人查看,并添加一个注销的方法,这样我们就可以再次进行该流程。

首先,编辑app/web.php 中的默认根路径,添加auth 中间件。

Route::get('/', function () {
    return view('welcome');
})->middleware('auth');

让我们也调整一下默认的欢迎视图,以显示一些关于我们登录用户的信息,并提供一个退出链接。将resources/views/welcome.blade.php 的内容替换为以下内容。

@extends('layouts.app', ['title' => 'Home'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      <h1>Logged in as {{ Auth::user()->name }}</h1>
      <a href="{{ route('logout') }}" class="text-indigo-600 inline-block underline mt-4">Logout</a>
    </div>
  </div>
@endsection

最后是注销路线,它将忘记我们的会话,并使我们回到登录屏幕。再次打开routes/web.php ,把这个路由添加到文件的底部。

Route::get('logout', [AuthController::class, 'logout'])->name('logout');

最后,我们需要在我们的AuthController 中实现注销动作。

public function logout()
{
  Auth::logout();
  return redirect(route('login'));
}

现在你的主页应该是这样的,而且只有登录的人才能看到。

Screenshot of a Laravel web app that reads "logged in as Ozzie - logout"

总结

就这样结束了!我们覆盖了很多地方,但你会注意到我们写的整体代码对于这样一个功能来说是相当低的。我希望你在这个过程中能学到一两个技巧。

完整的源代码可以在这里查看.

The postMagic login links with Laravelappeared first onLogRocket Blog.