编程

Laravel 无密码授权登录

785 2023-04-10 17:11:00

有些情况下,我们不希望用户通过密码访问。有时,我们希望通过发送魔术链接到用户邮箱,让他们点击链接授权访问。

本教程中,我将向你介绍一个可以自己实现的处理过程。这一工作流的重点是,创建一个签名 URL,让我们可以发送特定的 URL 到用户邮箱中,使得只有该用户能够访问此 URL。

我们需要先从 migration、model 和模型工厂中删除 password 字段。由于不需要该字段,我们需要确保将其移除,因为该字段默认不是 nullable 字段。此处理过程很简单,此处不予展示。同时,我们也可以删除密码重置表,因为密码字段没了,也就无所谓重置了。

接下来,我们该关注的是路由。我们创建一个简单的视图路由作为登录路由,本例中,我们将使用 Livewire 构建。路由如下:

Route::middleware(['guest'])->group(static function (): void {
    Route::view('login', 'app.auth.login')->name('login');
});

此处使用 guest 中间件,如果用户已登录则强制重定向。本例就不展示 UI 部分了。我们一起看看用于登录表单的 Livewire 组件该如何编码:

final class LoginForm extends Component
{
    public string $email = '';
 
    public string $status = '';
 
    public function submit(SendLoginLink $action): void
    {
        $this->validate();
 
        $action->handle(
            email: $this->email,
        );
 
        $this->status = 'An email has been sent for you to log in.';
    }
 
    public function rules(): array
    {
        return [
            'email' => [
                'required',
                'email',
                Rule::exists(
                    table: 'users',
                    column: 'email',
                ),
            ]
        ];
    }
 
    public function render(): View
    {
        return view('livewire.auth.login-form');
    }
}

该组件有两个属性。Email 用于获取表单输入;然后是 status,借由该字段我们无需依赖于请求 session。另外还有一个方法返回验证规则。我个人偏好于在 Livewire 组件定义验证规则。submit 方法是该组件的重点,这是我处理表单组件时的命名规范。我们使用 Laravel 容器将一个 action 类注入到该容器,使之共享创建和发送签名URL的逻辑。此处我们要做的就是,将用户输入的邮箱传给 action,并设置 status 提醒邮件已发送。

现在,我们来看看我们要用的 action 类。

final class SendLoginLink
{
    public function handle(string $email): void
    {
        Mail::to(
            users: $email,
        )->send(
            mailable: new LoginLink(
                url: URL::temporarySignedRoute(
                    name: 'login:store',
                    parameters: [
                        'email' => $email,
                    ],
                    expiration: 3600,
                ),
            )
        );
    }
}

该 action 只负责发送邮件。如果需要我们可以将其配置成队列 - 不过,如果我们在创建 API,当我们使用的 action 需要快速处理的话,最好还是使用队列。我们有一个 mailable 的类,叫做 LoginLink, 用来传入我们要使用的 URL。URL 是通过传入路由名以及签名参数生成。

final class LoginLink extends Mailable
{
    use Queueable, SerializesModels;
 
    public function __construct(
        public readonly string $url,
    ) {}
 
    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Your Magic Link is here!',
        );
    }
 
    public function content(): Content
    {
        return new Content(
            markdown: 'emails.auth.login-link',
            with: [
                'url' => $this->url,
            ],
        );
    }
 
    public function attachments(): array
    {
        return [];
    }
}

mailable 类相对简单,和标准的 mailable 相比没有太大延迟。我们为该 URL 传入一个字符串。然后,我们将其传入到 markdown 视图中:

<x-mail::message>
# Login Link
 
Use the link below to log into the {{ config('app.name') }} application.
 
<x-mail::button :url="$url">
Login
</x-mail::button>
 
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

该用户将收到这封邮件并点击链接, 并引导到签名 URL 页面。接下来,我们注册这一路由:

Route::middleware(['guest'])->group(static function (): void {
    Route::view('login', 'app.auth.login')->name('login');
    Route::get(
        'login/{email}',
        LoginController::class,
    )->middleware('signed')->name('login:store');
});

我们为该路由分配一个控制器,并确保添加了 signed 中间件。接下来,我们看看如何在控制器中处理签名 URL。

final class LoginController
{
    public function __invoke(Request $request, string $email): RedirectResponse
    {
        if (! $request->hasValidSignature()) {
            abort(Response::HTTP_UNAUTHORIZED);
        }
 
        /**
         * @var User $user
         */
        $user = User::query()->where('email', $email)->firstOrFail();
 
        Auth::login($user);
 
        return new RedirectResponse(
            url: route('dashboard:show'),
        );
    }
}

第一步,先确保该 URL 是有效的签名,如果不是,则抛出未授权响应。如果签名有效,我们可以查询用户并进行授权。最后,我们返回重定向到 dashboard。

现在用户已经成功登录,我们的旅程也完成了。不过,我们还需要再查看一下注册路由。再次添加该路由,此路由同样是视图路由。

Route::middleware(['guest'])->group(static function (): void {
    Route::view('login', 'app.auth.login')->name('login');
    Route::get(
        'login/{email}',
        LoginController::class,
    )->middleware('signed')->name('login:store');
 
    Route::view('register', 'app.auth.register')->name('register');
});

我们还是使用 Livewire 组件来处理注册表单。

final class RegisterForm extends Component
{
    public string $name = '';
 
    public string $email = '';
 
    public string $status = '';
 
    public function submit(CreateNewUser $user, SendLoginLink $action): void
    {
        $this->validate();
 
        $user = $user->handle(
            name: $this->name,
            email: $this->email,
        );
 
        if (! $user) {
            throw ValidationException::withMessages(
                messages: [
                    'email' => 'Something went wrong, please try again later.',
                ],
            );
        }
 
        $action->handle(
            email: $this->email,
        );
 
        $this->status = 'An email has been sent for you to log in.';
    }
 
    public function rules(): array
    {
        return [
            'name' => [
                'required',
                'string',
                'min:2',
                'max:55',
            ],
            'email' => [
                'required',
                'email',
            ]
        ];
    }
 
    public function render(): View
    {
        return view('livewire.auth.register-form');
    }
}

我们获取用户名,邮箱地址,并且还是使用 status 属性替代 request session。然后我们还是用 rule 方法来返回请求的验证规则。最后是 submit 方法,这一次我们注入了两个 action。

CreateNewUser 是我们基于用户输入的信息来创建并返回新用户的 action。如果此处失败,我们抛出验证异常。然后我们复用 SendLoginLink 来减少代码重复。

final class CreateNewUser
{
    public function handle(string $name, string $email): Builder|Model
    {
        return User::query()->create([
            'name' => $name,
            'email' => $email,
        ]);
    }
}

这是无密码授权的一种实现方法。

Github