• Время чтения ~6 мин
  • 29.06.2022

При работе с аутентификацией в 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-адрес как часть ключа кеша, чтобы ограничить, кто может получить доступ к этому ключу при возврате.

Итак, у нас есть три компонента, которые нужно использовать при отправке одноразового пароля, и есть несколько способов, которыми мы могли бы добиться их отправки красиво. Для этого урока я собираюсь создать еще одну команду, которая справится с этим за нас — с помощью помощника Laravel

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, который примет код одноразового пароля и позволит нам проверить его. Однако прежде чем мы это сделаем, нам нужно создать новую команду, которая позволит нам убедиться, что пользователь существует с этим адресом электронной почты.

Это действие внедряется в наш компонент 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,
                ],
            );
    }
}

Мы хотим убедиться, что мы сбрасываем одноразовый пароль для этого IP-адреса. если у нас есть неудачная попытка. Как только это будет сделано, пользователь будет аутентифицирован и перенаправлен, как если бы он вошел в систему со стандартным адресом электронной почты и паролем.

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}-otp",
                     ),
        );
    }
 
    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}-otp",
        );
    }
 
    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',
        );
    }
}

Это не то, что я бы назвал идеальным решением, но оно интересно, это точно. Было бы лучше отправить по электронной почте подписанный URL-адрес, содержащий некоторую информацию, вместо того, чтобы полностью полагаться на наш кеш.

Работали ли вы раньше с пользовательским потоком аутентификации? Какой метод авторизации в Laravel вы предпочитаете? Дайте нам знать в Твиттере!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Об авторе CrazyBoy49z
WORK EXPERIENCE
Контакты
Ukraine, Lutsk
+380979856297