Skip to content
全部文档

多因素认证

简介

Filament 中的用户默认可以使用邮箱和密码登录。不过,你可以启用多因素认证(MFA),为用户账户增加一层额外安全保护。

启用多因素认证后,用户必须完成额外步骤,才能通过认证并访问应用。

多因素认证挑战页多因素认证挑战页

Filament 内置两种可开箱即用的多因素认证方式:

  • [验证器应用认证](#app-authentication) 使用兼容 Google Authenticator 的应用(如 Google Authenticator、Authy 或 Microsoft Authenticator)生成基于时间的一次性密码(TOTP)来验证用户。
  • [邮箱认证](#email-authentication) 向用户邮箱发送一次性验证码,用户必须输入该验证码以验证身份。

在 Filament 中,用户从 个人资料页 设置多因素认证。若使用 Filament 的个人资料页功能,设置多因素认证会自动在个人资料页添加正确的 UI 元素:

php
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->profile();
}
个人资料页上的多因素认证选项个人资料页上的多因素认证选项

验证器应用认证

要在面板中启用验证器应用认证,必须先在 users 表(或该面板中用于「可认证」Eloquent 模型的表)中添加新列。该列需要存储用于生成和验证基于时间的一次性密码的密钥。它可以是迁移中的普通 text() 列:

php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::table('users', function (Blueprint $table) {
    $table->text('app_authentication_secret')->nullable();
});

User 模型中,应实现 HasAppAuthentication 接口并使用 InteractsWithAppAuthentication trait,它提供与密钥及其他集成信息交互所需的方法:

php
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthentication;
use Filament\Models\Contracts\FilamentUser;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements FilamentUser, HasAppAuthentication, MustVerifyEmail
{
    use InteractsWithAppAuthentication;
    
    // ...
}

TIP

Filament 提供了默认实现以便快速简单上手,但你也可以自行实现所需方法,自定义列名,或将密钥存储在完全独立的表中。

最后,应在面板中激活验证器应用认证功能。为此,在 配置 中使用 multiFactorAuthentication() 方法,并向其传入 AppAuthentication 实例:

php
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            AppAuthentication::make(),
        ]);
}

设置验证器应用恢复码

若用户无法访问其双因素认证应用,将无法登录你的应用。为防止这种情况,可以生成一组恢复码,用户在无法访问双因素认证应用时可用其登录。

app_authentication_secret 列类似,应在 users 表(或该面板中用于「可认证」Eloquent 模型的表)中添加新列。该列需要存储恢复码。它可以是迁移中的普通 text() 列:

php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::table('users', function (Blueprint $table) {
    $table->text('app_authentication_recovery_codes')->nullable();
});

接下来,应在 User 模型上实现 HasAppAuthenticationRecovery 接口,并使用 InteractsWithAppAuthenticationRecovery trait,它为 Filament 提供与恢复码交互所需的方法:

php
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthenticationRecovery;
use Filament\Models\Contracts\FilamentUser;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, MustVerifyEmail
{
    use InteractsWithAppAuthentication;
    use InteractsWithAppAuthenticationRecovery;
    
    // ...
}

TIP

Filament 提供了默认实现以便快速简单上手,但你也可以自行实现所需方法,自定义列名,或将恢复码存储在完全独立的表中。

最后,应在面板中激活验证器应用认证的恢复码功能。为此,在 配置multiFactorAuthentication() 方法中,对 AppAuthentication 实例调用 recoverable() 方法:

php
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            AppAuthentication::make()
                ->recoverable(),
        ]);
}

更改生成的恢复码数量

默认情况下,Filament 为每个用户生成 8 个恢复码。若要更改,可以在 配置multiFactorAuthentication() 方法中,对 AppAuthentication 实例使用 recoveryCodeCount() 方法:

php
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            AppAuthentication::make()
                ->recoverable()
                ->recoveryCodeCount(10),
        ]);
}

阻止用户重新生成恢复码

默认情况下,用户可以访问个人资料页重新生成恢复码。若要阻止此行为,可以在 配置multiFactorAuthentication() 方法中,对 AppAuthentication 实例使用 regenerableRecoveryCodes(false) 方法:

php
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            AppAuthentication::make()
                ->recoverable()
                ->regenerableRecoveryCodes(false),
        ]);
}

更改验证器应用验证码过期时间

验证器应用验证码使用基于时间的一次性密码(TOTP)算法签发,这意味着它们仅在生成时间前后的短暂时间内有效。该时间以时间「窗口」定义。默认情况下,Filament 使用过期窗口 8,即在生成时间两侧各 4 分钟的有效期(共 8 分钟)。

若要更改窗口,例如仅在生成后 2 分钟内有效,可以在 AppAuthentication 实例上使用 codeWindow() 方法,设为 4

php
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            AppAuthentication::make()
                ->codeWindow(4),
        ]);
}

自定义验证器应用认证品牌名称

每个验证器应用认证集成都有一个在认证应用中显示的「品牌名称」。默认是你的应用名称。若要更改,可以在 配置multiFactorAuthentication() 方法中,对 AppAuthentication 实例使用 brandName() 方法:

php
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            AppAuthentication::make()
                ->brandName('Filament Demo'),
        ]);
}

邮箱认证

邮箱认证会向用户邮箱发送一次性验证码,用户必须输入该验证码以验证身份。

要在面板中启用邮箱认证,必须先在 users 表(或该面板中用于「可认证」Eloquent 模型的表)中添加新列。该列需要存储一个布尔值,表示是否已启用邮箱认证:

php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::table('users', function (Blueprint $table) {
    $table->boolean('has_email_authentication')->default(false);
});

接下来,应在 User 模型上实现 HasEmailAuthentication 接口,并使用 InteractsWithEmailAuthentication trait,它为 Filament 提供与「是否启用邮箱认证」列交互所需的方法:

php
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
use Filament\Auth\MultiFactor\Email\Concerns\InteractsWithEmailAuthentication;
use Filament\Models\Contracts\FilamentUser;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements FilamentUser, HasEmailAuthentication, MustVerifyEmail
{
    use InteractsWithEmailAuthentication;
    
    // ...
}

TIP

Filament 提供了默认实现以便快速简单上手,但你也可以自行实现所需方法,自定义列名,或将值存储在完全独立的表中。

最后,应在面板中激活邮箱认证功能。为此,在 配置 中使用 multiFactorAuthentication() 方法,并向其传入 EmailAuthentication 实例:

php
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            EmailAuthentication::make(),
        ]);
}

更改邮箱验证码过期时间

邮箱验证码的有效期为 4 分钟,之后会过期。

若要更改过期时间,例如仅在生成后 2 分钟内有效,可以在 EmailAuthentication 实例上使用 codeExpiryMinutes() 方法,设为 2

php
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            EmailAuthentication::make()
                ->codeExpiryMinutes(2),
        ]);
}

要求多因素认证

默认情况下,用户不必设置多因素认证。你可以通过在 配置multiFactorAuthentication() 方法中传入 isRequired: true 参数,要求用户配置它:

php
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            AppAuthentication::make(),
        ], isRequired: true);
}

启用后,用户登录后若尚未设置多因素认证,会被提示进行设置。

创建自定义多因素认证提供者

你可以通过创建实现 MultiFactorAuthenticationProvider 接口的对象来添加另一种多因素认证方式。该提供者告诉 Filament 如何识别该方法、判断用户是否已启用、如何管理,以及如何验证其登录挑战。

以下各节以短信认证提供者为例。该提供者将验证码的生成、存储、发送和验证委托给应用中的 SmsAuthenticationService。这样提供者可以专注于将你的认证方式与 Filament 集成:

php
<?php

namespace App\Filament\Auth\MultiFactor;

use App\Services\SmsAuthenticationService;
use Filament\Auth\MultiFactor\Contracts\MultiFactorAuthenticationProvider;

class SmsAuthentication implements MultiFactorAuthenticationProvider
{
    public function __construct(
        protected SmsAuthenticationService $service,
    ) {}

    public static function make(): static
    {
        return app(static::class);
    }

    // ...
}

该服务应使用密码学安全的随机源生成验证码,仅存储每个验证码的哈希,将验证码限定到签发对象用户,使验证码过期并消费,并对发送和验证尝试进行速率限制。它可以使用 Laravel 支持的任何 短信通知渠道 发送验证码。

标识提供者

getId() 方法必须返回在面板的多因素认证提供者中唯一且稳定的标识符。Filament 用它来识别提供者并限定其表单状态。getLoginFormLabel() 方法返回用户启用多种多因素认证方式时显示的选项:

php
// ...

public function getId(): string
{
    return 'sms';
}

public function getLoginFormLabel(): string
{
    return 'SMS';
}

// ...

检查提供者是否已启用

isEnabled() 方法决定用户是否应由该提供者进行挑战。例如,你可以在 User 模型上存储 has_sms_authentication 布尔值和 phone_number

php
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;

// ...

public function isEnabled(Authenticatable $user): bool
{
    if (! ($user instanceof User)) {
        return false;
    }

    return filled($user->phone_number) && ((bool) $user->has_sms_authentication);
}

// ...

Filament 准备登录挑战时,传给 isEnabled() 的用户尚未认证,因此应始终使用该方法的 $user 参数,而不是当前已认证用户。

渲染管理 schema

getManagementSchemaComponents() 方法返回用于管理该提供者的 schema 组件操作。Filament 会在用户的个人资料页上渲染它们;当要求多因素认证时,也会在必填的多因素认证设置页上渲染:

php
use App\Filament\Auth\MultiFactor\Actions\DisableSmsAuthenticationAction;
use App\Filament\Auth\MultiFactor\Actions\SetUpSmsAuthenticationAction;
use Filament\Schemas\Components\Actions;

// ...

public function getManagementSchemaComponents(): array
{
    return [
        Actions::make([
            SetUpSmsAuthenticationAction::make($this->service),
            DisableSmsAuthenticationAction::make($this->service),
        ]),
    ];
}

// ...

本例中,设置和禁用操作应发送短信验证码、显示 OneTimeCodeInput、使用服务验证验证码,然后持久化新的启用状态。将这些工作流放在独立的操作类中,可避免提供者变得难以阅读。若你的集成在其他地方管理注册,管理 schema 也可以包含一个链接到该页面的操作。

渲染挑战表单

getChallengeFormComponents() 方法返回在用户密码验证通过后显示的字段。Filament 仅在组件通过验证后完成认证,因此短信验证码字段会使用该服务拒绝无效挑战:

php
use Closure;
use Filament\Forms\Components\OneTimeCodeInput;
use Illuminate\Contracts\Auth\Authenticatable;
use SensitiveParameter;

// ...

public function getChallengeFormComponents(Authenticatable $user): array
{
    return [
        OneTimeCodeInput::make('code')
            ->label('SMS code')
            ->required()
            ->rule(fn (): Closure => function (string $attribute, #[SensitiveParameter] mixed $value, Closure $fail) use ($user): void {
                if (is_string($value) && $this->service->verifyCode($user, $value)) {
                    return;
                }

                $fail('The SMS code is invalid or has expired.');
            }),
    ];
}

// ...

验证操作应消费有效验证码,使其无法再次成功使用。

在挑战前运行逻辑

短信提供者需要在显示挑战之前发送验证码。要在该时机运行逻辑,还需实现 HasBeforeChallengeHook 接口并添加 beforeChallenge() 方法:

php
use Filament\Auth\MultiFactor\Contracts\HasBeforeChallengeHook;
use Illuminate\Contracts\Auth\Authenticatable;

class SmsAuthentication implements HasBeforeChallengeHook, MultiFactorAuthenticationProvider
{
    // ...

    public function beforeChallenge(Authenticatable $user): void
    {
        $this->service->sendCode($user);
    }

    // ...
}

若用户在已启用的提供者之间切换,beforeChallenge() 方法可能被多次调用。该服务应对验证码发送进行速率限制,并在尚不能发送新验证码时避免使现有验证码失效。

DANGER

请以一致的格式(如 E.164)存储电话号码,并在用户电话号码变更时禁用短信认证,使其必须验证新号码。你还应为无法访问手机的用户提供安全的账户恢复流程。短信认证容易受到 SIM 卡交换等风险影响,因此可考虑提供验证器应用认证或安全密钥作为更强的替代方案。

注册提供者

最后,使用面板的 multiFactorAuthentication() 方法注册该提供者:

php
use App\Filament\Auth\MultiFactor\SmsAuthentication;
use Filament\Panel;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->multiFactorAuthentication([
            SmsAuthentication::make(),
        ]);
}

在登录页之外挑战用户

登录页展示的多因素挑战也可以单独使用,以便在已登录用户执行敏感操作之前,要求其验证已配置的因素。

MultiFactorChallenge 类为用户构建挑战。其 schema 组件携带用于验证用户输入验证码的验证规则。

INFO

以下示例假定 $user 是已登录用户,并且是 Authenticatable 的实例。你的 Livewire 组件还必须 设置为使用 schema,并使用 安全文档 中所述的 RestrictsFileUploadsToSchemaComponents trait。

检查用户是否可以被挑战

在展示挑战之前,应使用 hasEnabledProviders() 检查用户至少有一个已启用的提供者:

php
use Filament\Auth\MultiFactor\MultiFactorChallenge;

$multiFactorChallenge = MultiFactorChallenge::make();

abort_unless($multiFactorChallenge->hasEnabledProviders($user), 403);

务必在验证挑战之前立即重复此检查。当没有启用任何提供者时,getSchemaComponents() 会返回空 schema,而验证空 schema 会成功。你的应用必须将该状态视为挑战失败。

你可以使用 getEnabledProviders() 获取所有已启用的提供者实例,或使用 getFirstEnabledProvider() 获取第一个。当没有已启用的提供者时,getFirstEnabledProvider() 返回 null

构建挑战 schema

使用 getSchemaComponents() 获取每个已启用提供者的选择器和挑战字段:

php
use Filament\Auth\MultiFactor\MultiFactorChallenge;

$schema
    ->components(MultiFactorChallenge::make()->getSchemaComponents($user))
    ->statePath('multiFactorData');

当启用多个提供者时,生成的提供者选择器会控制显示哪个提供者的字段。若需要将选择器和字段分开放置,请改用 getProviderPickerSchemaComponent()getChallengeSchemaComponents()。这两个组件必须属于同一个根 schema,以便选择器能找到所选提供者的字段。

像其他 Livewire schema 一样渲染并提交该 schema。

在挑战前运行逻辑

某些提供者需要在展示挑战之前执行工作,例如向用户发送验证码邮件。在填充并展示 schema 之前使用 beforeChallenge()

php
$multiFactorChallenge->beforeChallenge($user);

$this->multiFactorChallengeForm->fill();

这会为第一个已启用的提供者运行钩子。使用生成的提供者选择器时,用户切换提供者时会运行相应的钩子。

对挑战尝试进行速率限制

应对挑战进行速率限制,以防止用户的第二因素被暴力破解。在每次验证尝试前检查 isRateLimited(),然后在验证之前立即调用 hitRateLimiter()

php
abort_if($multiFactorChallenge->isRateLimited($user), 429);

$multiFactorChallenge->hitRateLimiter($user);

速率限制器与登录页的挑战共享,并限定到认证守卫和用户。你可以使用 getMaxRateLimiterAttempts() 获取最大尝试次数,使用 getRateLimiterAvailableInSeconds() 确定距离下次可尝试还需多久。

验证挑战

在 schema 上调用 getState() 以验证所选提供者的字段。在此之前,立即检查用户仍有已启用的提供者,并记录一次受速率限制的尝试:

php
abort_unless($multiFactorChallenge->hasEnabledProviders($user), 403);
abort_if($multiFactorChallenge->isRateLimited($user), 429);

$multiFactorChallenge->hitRateLimiter($user);

$this->multiFactorChallengeForm->getState();

DANGER

验证挑战不会认证任何人,也不会授权受保护的操作。它只证明已登录用户持有当前注册到其账户的因素。验证成功后,应重新加载任何安全敏感状态,并在执行受保护操作之前立即重新授权。

关于多因素认证的安全说明

在 Filament 中,多因素认证过程发生在用户真正认证进入应用之前。这确保没有用户能在未通过多因素认证步骤的情况下认证并访问应用。你不必记得为任何已认证路由添加中间件,以确保用户完成了多因素认证步骤。

不过,若 Laravel 应用的其他部分也会认证用户,请注意:若他们已在其他地方认证然后访问面板,则不会被要求进行多因素认证,除非 要求多因素认证 且他们尚未设置。

并发恢复码提交

当用户使用恢复码登录时,Filament 的 verifyRecoveryCode() 方法会将读-验证-写序列包裹在按用户的 Cache::lock 中,以及带有对该用户行 lockForUpdate() 行锁的数据库事务中。无论底层数据库驱动如何,缓存锁都会跨 PHP worker 序列化并发提交,因此两个并行的登录请求不能同时消费同一恢复码,也不能从过期快照中复活刚被消费的恢复码——即使存储是非 SQL 存储、不同的数据库连接,或不支持 SELECT ... FOR UPDATE 的驱动(如 SQLite)。

WARNING

缓存锁依赖于共享锁存储。Filament 默认的 file 缓存存储,以及 redismemcacheddatabasedynamodb,都会在同一台机器上的 PHP-FPM worker 之间提供共享锁(对于网络后端存储,则跨机器)。array 存储是按进程的,不会跨 worker 序列化——它仅用于测试。

若覆盖 getAppAuthenticationRecoveryCodes() / saveAppAuthenticationRecoveryCodes(),缓存锁仍会包裹完整的读-验证-写序列,因此你的覆盖受到保护。你的覆盖只需负责使存储写入本身是原子的——例如单次 Eloquent update(),或你所选存储上的等效原子原语。