Laravel中简单的一次性密码认证

291 阅读8分钟

在Laravel中处理认证问题时, 有几个选项是开箱即用的.然而, 有时你需要更具体的东西.本教程将探讨如何在我们的认证流程中添加一个一次性密码的方法。

首先, 我们需要对我们的用户模型做一些调整, 因为我们不再需要密码来登录了.我们还需要确保我们的名字是可以作废的,并通过入职流程强制更新。这样,我们将能够有一个认证的入口路线--关键的区别是,现在注册用户将通过入职流程被重定向。

你的用户的迁移现在应该是以下的样子。

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 News上写了一个关于我如何开发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,而不是完全依靠我们的缓存。