如果你曾经使用过像Vercel或Medium这样的网站,你很可能曾经经历过无密码登录。
流程通常是这样的:输入你的电子邮件->提交表格->电子邮件被发送给你->你点击里面的链接->你就登录了。
对每个人来说,这是一个相当方便的流程。用户不必记住网站任意规则集的密码,而网站管理员(人们还在使用这个词吗?)不必担心密码泄露或他们的加密是否足够好。
在这篇文章中, 我们将探讨如何使用一个标准的Laravel安装来实现这个流程.
我们将假设你对Laravel的MVC结构有一定的了解, 并且你的环境已经设置好了composer 和php.
请注意, 本文中的代码锁可能不包括整个文件, 以求简洁.
环境设置
让我们从创建一个新的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提供的任何验证错误。
如果你加载页面,它应该看起来像这样。
很基本, 我们只是要求用户的电子邮件.如果我们现在提交表单,你只会看到一个空白的白屏,因为我们之前定义的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
}
现在再次提交表单,确保你看到如下的成功信息。
当然,你还不会收到电子邮件,但现在我们可以继续进行这一步了。
实现sendLoginLink 功能
反思我们上面讨论的令牌的方法,我们现在需要做的是:。
- 生成一个独特的令牌并将其附加到用户身上
- 向用户发送一封电子邮件,其中包含一个验证该令牌的页面链接。
我们将把这些保存在一个叫做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_at和consumed_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_at 和consumed_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_at 和consumed_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));
}
如果你提交我们的登录表格,你现在会看到一封看起来像这样的电子邮件。
验证路线
如果你试图点击该链接,你可能会收到一个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状态代码中止请求(如果你想显示一个视图或其他东西让用户知道更多信息,你可以在这里花样翻新,但为了这个教程,我们将只是杀死请求)。
- 将令牌标记为已使用,这样它就不能再被使用了
- 登录与该令牌相关的用户
- 将他们重定向到主页
- 通过散列明文值找到令牌,并将其与我们数据库中的散列版本进行比较(如果没有找到,则抛出404--通过
-
我们在令牌上调用了几个实际上还不存在的方法,所以我们来创建它们。
-
-
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'));
}
现在你的主页应该是这样的,而且只有登录的人才能看到。
总结
就这样结束了!我们覆盖了很多地方,但你会注意到我们写的整体代码对于这样一个功能来说是相当低的。我希望你在这个过程中能学到一两个技巧。
The postMagic login links with Laravelappeared first onLogRocket Blog.