Laravel 中简单的的一次性密码身份验证

发布时间:作者:

Simple one-time password authentication in Laravel image

在处理 Laravel 中的身份验证时,有几个开箱即用的选项。但是,有时您需要更具体的东西。本教程将介绍如何在身份验证流程中添加一次性密码方法。

首先,我们需要对 User 模型进行一些调整,因为我们不再需要密码登录。我们还需要确保我们的姓名是可空值,并强制通过入职流程更新它。这样,我们就可以有一个用于身份验证的入口路由 - 主要区别在于现在注册用户将被重定向到入职流程。

您的用户迁移现在应该如下所示

public function up(): void
{
Schema::create('users', function (Blueprint $table): void {
$table->id();
 
$table->string('name')->nullable();
$table->string('email')->unique();
$table->string('type')->default(Type::STAFF->value);
 
$table->timestamps();
});
}

我们也可以将这些更改反映到我们的模型中。我们不再需要记住令牌,因为我们希望每次都强制登录。此外,用户只需使用一次性密码登录即可验证其电子邮件。

final class User extends Authenticatable
{
use HasApiTokens;
use HasFactory;
use Notifiable;
 
protected $fillable = [
'name',
'email',
'type',
];
 
protected $casts = [
'type' => Type::class,
];
 
public function offices(): HasMany
{
return $this->hasMany(
related: Office::class,
foreignKey: 'user_id',
);
}
 
public function bookings(): HasMany
{
return $this->hasMany(
related: Booking::class,
foreignKey: 'user_id',
);
}
}

我们的模型更加简洁,因此我们可以开始研究如何生成一次性密码代码。首先,我们希望创建一个 `GeneratorContract`,我们的实现可以使用它,并且我们可以将其绑定到我们的容器中以供解析。

declare(strict_types=1);
 
namespace Infrastructure\Auth\Generators;
 
interface GeneratorContract
{
public function generate(): string;
}

现在让我们看看如何实现一次性密码的 `NumberGenerator`,默认情况下我们将使用 6 个字符。

declare(strict_types=1);
 
namespace Domains\Auth\Generators;
 
use Domains\Auth\Exceptions\OneTimePasswordGenertionException;
use Infrastructure\Auth\Generators\GeneratorContract;
use Throwable;
 
final class NumberGenerator implements GeneratorContract
{
public function generate(): string
{
try {
$number = random_int(
min: 000_000,
max: 999_999,
);
} catch (Throwable $exception) {
throw new OneTimePasswordGenertionException(
message: 'Failed to generate a random integer',
);
}
 
return str_pad(
string: strval($number),
length: 6,
pad_string: '0',
pad_type: STR_PAD_LEFT,
);
}
}

最后,我们希望将其添加到服务提供者中,以将接口和实现绑定到 Laravels 的容器中 - 允许我们在需要时解析它。如果您不记得如何执行此操作,我在 Laravel 新闻上写了一个关于 如何开发 Laravel 应用程序 的实用教程。这将很好地引导您完成这个过程。

declare(strict_types=1);
 
namespace Domains\Auth\Providers;
 
use Domains\Auth\Generators\NumberGenerator;
use Illuminate\Support\ServiceProvider;
use Infrastructure\Auth\Generators\GeneratorContract;
 
final class AuthServiceProvider extends ServiceProvider
{
protected array $bindings = [
GeneratorContract::class => NumberGenerator::class,
];
}

现在我们知道可以生成这些代码,我们可以研究如何实现它。首先,我们希望重构我们在上一篇名为 在 Laravel 中设置数据模型 的教程中创建的用户数据对象。

declare(strict_types=1);
 
namespace Domains\Auth\DataObjects;
 
use Domains\Auth\Enums\Type;
use JustSteveKing\DataObjects\Contracts\DataObjectContract;
 
final class User implements DataObjectContract
{
public function __construct(
private readonly string $email,
private readonly Type $type,
) {}
 
public function toArray(): array
{
return [
'email' => $this->email,
'type' => $this->type,
];
}
}

现在我们可以专注于发送一次性密码的操作以及实际发送通知和记住用户的步骤。首先,我们需要运行一个动作/命令来生成一个代码,并将此代码作为通知发送给用户。为了记住这一点,我们需要将此代码添加到我们的应用程序缓存中,以及请求此一次性密码的设备的 IP 地址。如果您使用 VPN 并且您的 IP 在请求代码和输入代码之间切换,这可能会导致问题 - 但目前而言这是一个轻微的风险。

首先,我们将为每个步骤创建一个命令。我喜欢创建小的单类来执行每个过程的一部分。首先,让我们创建生成代码的命令 - 像往常一样,我们将构建一个相应的接口/契约,使我们能够依赖容器。

declare(strict_types=1);
 
namespace Infrastructure\Auth\Commands;
 
interface GenerateOneTimePasswordContract
{
public function handle(): string;
}

然后是我们希望使用的实现

declare(strict_types=1);
 
namespace Domains\Auth\Commands;
 
use Infrastructure\Auth\Commands\GenerateOneTimePasswordContract;
use Infrastructure\Auth\Generators\GeneratorContract;
 
final class GenerateOneTimePassword implements GenerateOneTimePasswordContract
{
public function __construct(
private readonly GeneratorContract $generator,
) {}
 
public function handle(): string
{
return $this->generator->generate();
}
}

如您所见,我们随时依赖容器 - 如果我们决定将一次性密码的实现从 6 个数字更改为 3 个单词,例如。

如前所述,请确保您在该域的服务提供者中将此绑定到您的容器中。接下来,我们希望发送通知。这次我将跳过显示接口,因为您可以在此时猜到它是什么样子。

declare(strict_types=1);
 
namespace Domains\Auth\Commands;
 
use App\Notifications\Auth\OneTimePassword;
use Illuminate\Support\Facades\Notification;
use Infrastructure\Auth\Commands\SendOneTimePasswordNotificationContract;
 
final class SendOneTimePasswordNotification implements SendOneTimePasswordNotificationContract
{
public function handle(string $code, string $email): void
{
Notification::route(
channel: 'mail',
route: [$email],
)->notify(
notification: new OneTimePassword(
code: $code,
),
);
}
}

此命令将接受代码和电子邮件,并将新的电子邮件通知路由到请求者。请确保创建通知并返回包含所生成代码的邮件消息。将此绑定注册到您的容器中,然后我们可以研究如何将 IP 地址与这些信息一起记住。

declare(strict_types=1);
 
namespace Domains\Auth\Commands;
 
use Illuminate\Support\Facades\Cache;
use Infrastructure\Auth\Commands\RememberOneTimePasswordRequestContract;
 
final class RememberOneTimePasswordRequest implements RememberOneTimePasswordRequestContract
{
public function handle(string $ip, string $email, string $code): void
{
Cache::remember(
key: "{$ip}-one-time-password",
ttl: (60 * 15), // 15 minutes,
callback: fn (): array => [
'email' => $email,
'code' => $code,
],
);
}
}

我们接受 IP 地址、电子邮件地址和一次性代码,以便我们可以将其存储在缓存中。我们将此生命周期设置为 15 分钟,以便代码不会过时,并且繁忙的邮件系统应该在此时间内完美地传递此代码。我们使用 IP 地址作为缓存键的一部分,以限制谁可以在返回时访问此键。

因此,我们在发送一次性密码时有三个组件可以使用,并且有几种方法可以实现很好地发送这些组件。在本教程中,我将创建一个额外的命令来为我们处理它 - 使用 Laravels 的 `tap` 帮助器使其更流畅,

declare(strict_types=1);
 
namespace Domains\Auth\Commands;
 
use Infrastructure\Auth\Commands\GenerateOneTimePasswordContract;
use Infrastructure\Auth\Commands\HandleAuthProcessContract;
use Infrastructure\Auth\Commands\RememberOneTimePasswordRequestContract;
use Infrastructure\Auth\Commands\SendOneTimePasswordNotificationContract;
 
final class HandleAuthProcess implements HandleAuthProcessContract
{
public function __construct(
private readonly GenerateOneTimePasswordContract $code,
private readonly SendOneTimePasswordNotificationContract $notification,
private readonly RememberOneTimePasswordRequestContract $remember,
) {}
 
public function handle(string $ip, string $email)
{
tap(
value: $this->code->handle(),
callback: function (string $code) use ($ip, $email): void {
$this->notification->handle(
code: $code,
email: $email
);
 
$this->remember->handle(
ip: $ip,
email: $email,
code: $code,
);
},
);
}
}

我们首先使用 tap 函数创建一个代码,并将代码传递给闭包,以便只有在生成代码时才发送通知并记住详细信息。这种方法的唯一问题是它是一个同步操作,我们不希望它在主线程中发生,因为它会导致阻塞。相反,我们将将其移动到后台作业 - 我们可以通过将我们的命令转换为可以分派到队列的东西来做到这一点。

declare(strict_types=1);
 
namespace Domains\Auth\Commands;
 
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Infrastructure\Auth\Commands\GenerateOneTimePasswordContract;
use Infrastructure\Auth\Commands\HandleAuthProcessContract;
use Infrastructure\Auth\Commands\RememberOneTimePasswordRequestContract;
use Infrastructure\Auth\Commands\SendOneTimePasswordNotificationContract;
 
final class HandleAuthProcess implements HandleAuthProcessContract, ShouldQueue
{
use Queueable;
use Dispatchable;
use SerializesModels;
use InteractsWithQueue;
 
public function __construct(
public readonly string $ip,
public readonly string $email,
) {}
 
public function handle(
GenerateOneTimePasswordContract $code,
SendOneTimePasswordNotificationContract $notification,
RememberOneTimePasswordRequestContract $remember,
): void {
tap(
value: $code->handle(),
callback: function (string $oneTimeCode) use ($notification, $remember): void {
$notification->handle(
code: $oneTimeCode,
email: $this->email
);
 
$remember->handle(
ip: $this->ip,
email: $this->email,
code: $oneTimeCode,
);
},
);
}
}

现在我们可以看看前端实现。在本例中,我将使用 Laravel Livewire 作为前端,但无论您使用哪种技术,该过程都类似。我们需要做的就是从用户那里接受一个电子邮件地址,将其通过已分派的作业路由并重定向用户。

declare(strict_types=1);
 
namespace App\Http\Livewire\Auth;
 
use Domains\Auth\Commands\HandleAuthProcess;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\View;
use Livewire\Component;
use Livewire\Redirector;
 
final class RequestOneTimePassword extends Component
{
public string $email;
 
public function submit(): Redirector|RedirectResponse
{
$this->validate();
 
dispatch(new HandleAuthProcess(
ip: strval(request()->ip()),
email: $this->email,
));
 
return redirect()->route(
route: 'auth:one-time-password',
);
}
 
public function rules(): array
{
return [
'email' => [
'required',
'email',
'max:255',
],
];
}
 
public function render(): ViewContract
{
return View::make(
view: 'livewire.auth.request-one-time-password',
);
}
}

我们的组件将获取电子邮件并发送通知。实际上,在这一点上,我会在我的 Livewire 组件中添加一个特征来强制执行严格的速率限制。这个特征看起来像这样

declare(strict_types=1);
 
namespace App\Http\Livewire\Concerns;
 
use App\Exceptions\TooManyRequestsException;
use Illuminate\Support\Facades\RateLimiter;
 
trait WithRateLimiting
{
protected function clearRateLimiter(null|string $method = null): void
{
if (! $method) {
$method = debug_backtrace()[1]['function'];
}
 
RateLimiter::clear(
key: $this->getRateLimitKey(
method: $method,
),
);
}
 
protected function getRateLimitKey(null|string $method = null): string
{
if (! $method) {
$method = debug_backtrace()[1]['function'];
}
 
return strval(static::class . '|' . $method . '|' . request()->ip());
}
 
protected function hitRateLimiter(null|string $method = null, int $decaySeonds = 60): void
{
if (! $method) {
$method = debug_backtrace()[1]['function'];
}
 
RateLimiter::hit(
key: $this->getRateLimitKey(
method: $method,
),
decaySeconds: $decaySeonds,
);
}
 
protected function rateLimit(int $maxAttempts, int $decaySeconds = 60, null|string $method = null): void
{
if (! $method) {
$method = debug_backtrace()[1]['function'];
}
 
$key = $this->getRateLimitKey(
method: $method,
);
 
if (RateLimiter::tooManyAttempts(key: $key, maxAttempts: $maxAttempts)) {
throw new TooManyRequestsException(
component: static::class,
method: $method,
ip: strval(request()->ip()),
secondsUntilAvailable: RateLimiter::availableIn(
key: $key,
)
);
}
 
$this->hitRateLimiter(
method: $method,
decaySeonds: $decaySeconds,
);
}
}

如果您使用 Livewire 并希望向您的组件添加速率限制,这是一个方便的小特征。

接下来,在一次性密码视图上,我们将使用一个额外的 livewire 组件,该组件将接受一次性密码代码并允许我们验证它。但是,在我们这样做之前,我们需要创建一个新的命令,使我们能够确保存在一个具有此电子邮件地址的用户。

declare(strict_types=1);
 
namespace Domains\Auth\Commands;
 
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Infrastructure\Auth\Commands\EnsureUserExistsContract;
 
final class EnsureUserExists implements EnsureUserExistsContract
{
public function handle(string $email): User|Model
{
return User::query()
->firstOrCreate(
attributes: [
'email' => $email,
],
);
}
}

此操作被注入到我们的 Livewire 组件中,允许我们根据是否是新用户进行身份验证到应用程序的仪表板或入职步骤。我们可以通过它是否没有姓名,只有电子邮件地址来判断它是否是新用户。

declare(strict_types=1);
 
namespace App\Http\Livewire\Auth;
 
use App\Http\Livewire\Concerns\WithRateLimiting;
use Illuminate\Contracts\View\View as ViewContract;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\View;
use Infrastructure\Auth\Commands\EnsureUserExistsContract;
use Livewire\Component;
use Livewire\Redirector;
 
final class OneTimePasswordForm extends Component
{
use WithRateLimiting;
 
public string $email;
 
public null|string $otp = null;
 
public string $ip;
 
public function mount(): void
{
$this->ip = strval(request()->ip());
}
 
public function login(EnsureUserExistsContract $command): Redirector|RedirectResponse
{
$this->validate();
 
return $this->handleOneTimePasswordAttempt(
command: $command,
code: Cache::get(
key: "{$this->ip}-one-time-password",
),
);
}
 
protected function handleOneTimePasswordAttempt(
EnsureUserExistsContract $command,
mixed $code = null,
): Redirector|RedirectResponse {
if (null === $code) {
$this->forgetOtp();
 
return new RedirectResponse(
url: route('auth:login'),
);
}
 
/**
* @var array{email: string, otp: string} $code
*/
if ($this->otp !== $code['otp']) {
$this->forgetOtp();
 
return new RedirectResponse(
url: route('auth:login'),
);
}
 
Auth::loginUsingId(
id: intval($command->handle(
email: $this->email,
)->getKey()),
);
 
return redirect()->route(
route: 'app:dashboard:show',
);
}
 
protected function forgetOtp(): void
{
Cache::forget(
key: "{$this->ip}-one-time-password",
);
}
 
public function rules(): array
{
return [
'email' => [
'required',
'string',
'email',
],
'otp' => [
'required',
'string',
'min:6',
]
];
}
 
public function render(): ViewContract
{
return View::make(
view: 'livewire.auth.one-time-password-form',
);
}
}

我们希望确保如果我们尝试失败,我们会重置此 IP 地址的一次性密码。完成后,用户将被验证并重定向,就像他们使用标准电子邮件地址和密码方法登录一样。

这不能说是一个完美的解决方案,但它确实很有趣。一个改进是发送包含一些信息的已签名 URL,而不是完全依赖我们的缓存。

您以前使用过自定义身份验证流程吗?您在 Laravel 中的首选身份验证方法是什么?请在 Twitter 上告诉我们!

Steve McDougall photo

Laravel 新闻的 技术作家,Treblle 的 开发者倡导者。API 专家,资深 PHP/Laravel 工程师。 YouTube 直播主

Cube

Laravel 新闻快讯

加入 40,000 多名其他开发者,永不错过新的提示、教程等。

Laravel Forge logo

Laravel Forge

轻松创建和管理您的服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动补全和本地和生产环境的即时反馈进行调试。

Tinkerwell
No Compromises logo

绝不妥协

Joel 和 Aaron,来自“绝不妥协”播客的两名经验丰富的开发人员,现在可供您为您的 Laravel 项目聘用。 ⬧ 固定价格 $7500/月。 ⬧ 无需冗长的销售流程。 ⬧ 无需签订合同。 ⬧ 100% 退款保证。

绝不妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,以确保您的 Web 应用程序取得成功。

Kirschbaum
Shift logo

Shift

正在运行旧版本的 Laravel?即时自动化的 Laravel 升级和代码现代化,让您的应用程序保持新鲜。

Shift
Bacancy logo

Bacancy

只需 $2500/月,即可为您的项目配备经验丰富的 Laravel 开发人员,拥有 4-6 年的经验。获得 160 小时的专业知识和 15 天的无风险试用。立即安排电话会议!

Bacancy
Lucky Media logo

Lucky Media

现在就幸运起来 - Laravel 开发的理想之选,拥有超过十年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel 电子商务

Laravel 的电子商务。一个开源软件包,将现代无头电子商务功能的强大功能引入 Laravel。

Lunar: Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 职位板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit:Laravel SaaS 启动套件

SaaSykit 是一个 Laravel SaaS 启动套件,包含运行现代 SaaS 所需的所有功能。付款、精美结账、管理面板、用户仪表板、身份验证、预制组件、统计信息、博客、文档等等。

SaaSykit:Laravel SaaS 启动套件
Rector logo

Rector

您无缝升级 Laravel、降低成本和加速创新以实现成功公司的合作伙伴

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解灵活的现代数据库 MongoDB 如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的无依赖项输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

将 Swagger UI 添加到您的 Laravel 应用程序

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel Prompts 构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包为您的 Laravel 应用程序添加评论

阅读文章