Laravel Fortify:实现双因素身份验证 (2FA) 时,要确保用户不会因自身操作而被锁定。
Fortify 和双因素身份验证的问题
Laravel Fortify 非常棒。它帮你处理了大部分繁重的身份验证工作,但与 Laravel Breeze(它会在你的应用程序中发布控制器和视图)或 Laravel Jetstream(它实际上在后台使用了 Fortify)不同,它允许你自行创建视图,因此你不必使用 Tailwind CSS 或 Inertia.js。
它所处理的功能之一,也是我非常感兴趣的,是使用基于时间的一次性密码 (TOTP) 的双因素身份验证 (2FA):它会要求您在Google Authenticator或Duo等应用程序中扫描二维码,然后每 30 秒为您提供一个新的 6 位密码,您必须输入该密码才能登录。
这确实很棒,因为对于任何需要良好安全性的应用程序来说,这都是一项必不可少的功能。如果让我从头开始实现,我真不知道该从何下手。但目前它有一个主要的局限性:如果严格按照文档操作,用户在尝试启用该功能时很可能会被锁定在应用程序之外。这个问题在这里有详细描述,但归根结底,Fortify 不会要求用户输入验证码来确认他们已成功安装应用程序并扫描了二维码。因此,如果用户启用了双因素身份验证 (2FA),但之后未能将您的网站添加到 Google Authenticator(或者他们的电脑崩溃了等等),他们将永远无法再次登录您的网站。
值得庆幸的是,Laravel 非常灵活,只需稍加努力,我们就可以实现自己的确认系统,同时还能利用 Fortify 提供的所有好处。
解决方案
我们将实施的解决方案非常常见:一旦用户激活了 2FA,我们不会简单地启用它,显示一次二维码就完事,而是会显示二维码,要求他们输入使用 Google Authenticator 生成的 TOTP 以确认他们已正确完成所有操作,然后才激活 2FA。
您可以在此 GitHub 存储库中找到我们将要执行的操作的示例(请记住,这只是一个示例,在实际应用程序中,您可能会采取一些不同的做法)。
在开始之前,我假设您已经按照官方文档安装了 Fortify 并实现了双因素身份验证。
那么,让我们开始吧!
步骤 1:添加一个新的“two_factor_confirmed”字段
Fortify 提供了一个迁移脚本,会在表中添加 ` two_factor_secretand`two_factor_recovery_codes列users。每当用户激活双因素身份验证(通过向 `/user/two-factor-authentication` 端点发送 POST 请求)时,这些字段都会被填充,Laravel 正是通过这些信息来判断用户尝试登录时是否需要输入 TOTP。
由于我们需要用户确认他们已正确设置所有内容,因此我们需要一个新列来存储此确认信息。为此,我们将创建一个新的迁移来创建该列,`up()` 方法如下所示:
Schema::table('users', function (Blueprint $table) {
$table->boolean('two_factor_confirmed')
->after('two_factor_recovery_codes')
->default(false);
});
现在我们可能有一个视图,它会显示一个启用双因素身份验证的按钮,或者在用户启用双因素身份验证后返回页面时显示二维码。它很可能类似于这样的视图:
<!-- 2FA enabled, we display the QR code : -->
@if(auth()->user()->two_factor_secret)
{!! auth()->user()->twoFactorQrCodeSvg() !!}
<!-- 2FA not enabled, we show an 'enable' button : -->
@else
<form action="/user/two-factor-authentication" method="post">
@csrf
<button type="submit">Activate 2FA</button>
</form>
@endif
此时,我们应该能够修改two_factor_confirmed数据库中的列,这将改变我们在页面上显示的内容。
不过,仍然存在两个主要问题:
- 我们没有办法更新新专栏。
- Laravel 本身并不关心这一点,只要该
two_factor_secret列被填充,它就会继续要求用户输入 TOTP。
步骤 2:当用户确认双因素身份验证时,更新该列。
首先,我们需要一个路由,用于向该路由发送 TOTP POST 请求,以确认用户能够获取 TOTP。因此,让我们将其添加到 routes/web.php 文件中,并添加相应的 TwoFactorAuthController。
Route::post('/2fa-confirm', [TwoFactorAuthController::class, 'confirm'])->name('two-factor.confirm');
现在我们可以在控制器中直接处理验证和更新,但我认为让 User 模型来做更有意义,这样confirm()我们控制器的方法就只会告诉用户检查code参数中提供的 TOTP,然后重定向回去(如果代码无效,则会报错)。
/app/Http/Controllers/TwoFactorAuthController:
public function confirm(Request $request)
{
$confirmed = $request->user()->confirmTwoFactorAuth($request->code);
if (!$confirmed) {
return back()->withErrors('Invalid Two Factor Authentication code');
}
return back();
}
最后,我们confirmTwoFactorAuth()在 User 模型中添加该方法。
public function confirmTwoFactorAuth($code)
{
$codeIsValid = app(TwoFactorAuthenticationProvider::class)
->verify(decrypt($this->two_factor_secret), $code);
if ($codeIsValid) {
$this->two_factor_confirmed = true;
$this->save();
return true;
}
return false;
}
现在我们可以将主视图更新为以下两种之一:
- 如果未启用双因素认证,则显示“启用双因素认证”按钮。
- 如果已启用但尚未确认,则显示二维码和一个表单,该表单会向我们刚刚创建的路由发送 POST 请求。
- 显示一个按钮,点击该按钮即可通过发送 DELETE 请求来禁用双因素身份验证。
/resources/views/home.blade.php:
<!-- 2FA confirmed, we show a 'disable' button to disable it : -->
@if(auth()->user()->two_factor_confirmed)
<form action="/user/two-factor-authentication" method="post">
@csrf
@method('delete')
<button type="submit">Disable 2FA</button>
</form>
<!-- 2FA enabled but not yet confirmed, we show the QRcode and ask for confirmation : -->
@elseif(auth()->user()->two_factor_secret)
<p>Validate 2FA by scanning the floowing QRcode and entering the TOTP</p>
{!! auth()->user()->twoFactorQrCodeSvg() !!}
<form action="{{route('two-factor.confirm')}}" method="post">
@csrf
<input name="code" required/>
<button type="submit">Validate 2FA</button>
</form>
</div>
<!-- 2FA not enabled at all, we show an 'enable' button : -->
@else
<form action="/user/two-factor-authentication" method="post">
@csrf
<button type="submit">Activate 2FA</button>
</form>
@endif
它应该能正常工作,所以现在唯一的问题是 Fortify 不知道这一two_factor_confirmed列的存在,并且会一直要求输入 TOTP。
步骤 3:在要求用户输入 TOTP 之前,检查用户是否已确认启用双因素身份验证 (2FA)。
我们可以看到 Fortify 是如何通过其RedirectIfTwoFactorAuthenticatable 函数来判断是否需要请求 TOTP 的,该函数在 handle 方法中有以下代码:
if (optional($user)->two_factor_secret &&
in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
return $this->twoFactorChallengeResponse($request, $user);
}
看起来如果$user->two_factor_secret该值不为空,用户就会被重定向。所以解决方法很简单:我们只需要把它改成if (optional($user)->two_factor_confirmed这样就行了,对吧?
是的,但是这个文件位于 Fortify 本身(在 vendor 目录中),所以我们肯定不会直接修改它。
最好的办法是创建我们自己的版本,RedirectIfTwoFactorAuthenticatable并稍微修改它的行为。所以,让我们创建一个RedirectIfTwoFactorConfirmed继承自原类的类。
app/Actions/Fortify/RedirectIfTwoFactorConfirmed:
namespace App\Actions\Fortify;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
class RedirectIfTwoFactorConfirmed extends RedirectIfTwoFactorAuthenticatable
{
public function handle($request, $next)
{
$user = $this->validateCredentials($request);
if (optional($user)->two_factor_confirmed &&
in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
return $this->twoFactorChallengeResponse($request, $user);
}
return $next($request);
}
}
看起来不错,现在为了让 Fortify 知道它需要使用我们的 Action,我们需要通过在/app/Providers/FortifyServiceProvider中添加以下内容来自定义身份验证管道:
Fortify::authenticateThrough(function(){
return array_filter([
config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,
Features::enabled(Features::twoFactorAuthentication()) ? RedirectIfTwoFactorConfirmed::class : null,
AttemptToAuthenticate::class,
PrepareAuthenticatedSession::class,
]);
});
它与默认管道完全相同,只是我们RedirectIfTwoFactorAuthenticatable用RedirectIfTwoFactorConfirmed.
就这样,我们的用户现在需要确认他们能够获得有效的 TOTP,然后才能真正启用 2FA,而且他们不会把自己锁在我们的应用程序之外!
或者他们真的可以吗?
我们的工作流程仍然存在一个问题:当我们点击“禁用双因素身份验证”按钮并向 Fortify 控制器发送 DELETE 请求时会发生什么?Fortify 会将 two_factor_secret 字段设置为null,但现在我们修改了身份验证管道,改为检查 two_factor_confirmed,这就引入了一个新问题:即使用户已禁用双因素身份验证,我们仍然会要求输入 TOTP。
步骤 4:禁用双因素身份验证时,将“two_factor_confirmed”设置为 false。
如果我们回到 Fortify 的源代码,可以看到它在DisableTwoFactorAuthentication操作中将 two_factor_secret 和 two_factor_recovery_codes 都设置为 null:
$user->forceFill([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
])->save();
所以我们只需要在那里添加一列,但我们绝对不想修改供应商目录。而且这次也没有任何方法可以让我们修改相关的管道。
幸运的是,我们可以依靠服务容器来帮助我们注入我们自己的此操作版本。
我们来看看它在 Fortify 中是如何使用的。所有操作都在TwoFactorAuthenticationController中完成(也就是处理 DELETE 请求的那个控制器):
public function destroy(Request $request, DisableTwoFactorAuthentication $disable)
{
$disable($request->user());
return $request->wantsJson()
? new JsonResponse('', 200)
: back()->with('status', 'two-factor-authentication-disabled');
}
这就是我们的操作,看起来它并没有被硬编码到函数内部,而是通过依赖注入来解析并立即调用。这真是个好消息,因为这意味着我们只需要告诉服务容器“每当有人请求禁用双因素身份验证时,就使用我们自己的版本”。
首先,让我们创建自己的版本(非常重要的一点是,它必须扩展原始版本,否则服务容器将无法解析它):
/app/Actions/Fortify/禁用双因素身份验证
namespace App\Actions\Fortify;
class DisableTwoFactorAuthentication extends \Laravel\Fortify\Actions\DisableTwoFactorAuthentication
{
public function __invoke($user)
{
$user->forceFill([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed' => 0,
])->save();
}
}
然后通过在app/Providers/FortifyServiceProvider中添加以下代码,告诉服务容器将此新类绑定到父类:
$this->app->bind(Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class, function(){
return new App\Actions\Fortify\DisableTwoFactorAuthentication();
});
好了!我们终于可以实现一个功能完善的双因素身份验证系统,不会再出现用户自己被锁定的情况了。
总结一下
看着上面一大段文字和代码,我意识到信息量很大,但其实并没有那么复杂,我们所做的是:
- 使用迁移添加一个新的“已确认”列。
- 添加新的路由和控制器,将该列的值设置为 true
- 扩展RedirectIfTwoFactorAuthenticatable操作,使其检查该列而不是 'two_factor_secret' 列。
- 覆盖身份验证管道以使用我们自己的操作
- 扩展DisableTwoFactorAuthentication操作,使其包含我们自定义的禁用“已确认”列的操作。
- 使用服务容器将我们自己的操作绑定到DisableTwoFactorAuthentication。
有什么问题吗?或者有什么你想做但没做的事情?请在评论区留言!
文章来源:https://dev.to/nicolus/laravel-fortify-implement-2fa-in-a-way-that-won-t-let-users-lock-themselves-out-2ejk