• Czas czytania ~11 min
  • 29.06.2022

W przypadku uwierzytelniania w Laravelu istnieje kilka opcji po wyjęciu z pudełka. Czasami jednak potrzebujesz czegoś bardziej konkretnego. W tym samouczku przyjrzymy się, jak możemy dodać jednorazowe hasło do naszego procesu uwierzytelniania.

Na początek będziemy musieli wprowadzić pewne zmiany w naszym modelu użytkownika, ponieważ nie już potrzebujesz hasła, aby się zalogować. Będziemy również musieli upewnić się, że nasza nazwa nie może mieć wartości null, i wymusić jej aktualizację w ramach procesu wdrażania. W ten sposób będziemy mogli mieć jedną ścieżkę wejścia do uwierzytelniania — kluczowa różnica polega na tym, że teraz zarejestrowani użytkownicy będą przekierowywani przez proces onboardingu.

Migracja Twoich użytkowników powinna teraz wyglądać tak co następuje:

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();
    });
}

Możemy również uwzględnić te zmiany w naszym modelu. Nie potrzebujemy już tokena zapamiętywania, ponieważ chcemy wymusić każdorazowe logowanie. Ponadto użytkownicy weryfikują swój adres e-mail, logując się przy użyciu hasła jednorazowego.

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

Nasz model jest znacznie czystszy, więc możemy zacząć zastanawiać się, w jaki sposób chcemy wygenerować nasze hasło jednorazowe kod. Na początek będziemy chcieli utworzyć GeneratorContract, z którego może korzystać nasza implementacja, i powiązać go z naszym kontenerem w celu rozwiązania.

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

Teraz przyjrzyjmy się implementacji NumberGenerator dla hasło jednorazowe, a my ustawimy domyślnie 6 znaków.

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

Na koniec chcemy dodać to do dostawcy usług, aby powiązać interfejs i implementację z kontenerem Laravels - co pozwoli nam rozwiązać ten problem, gdy jest to wymagane. Jeśli nie pamiętasz, jak to zrobić, napisałem przydatny samouczek na Laravel News o jak rozwijam aplikacje Laravel. To całkiem ładnie przeprowadzi Cię przez ten proces.

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,
    ];
}

Teraz, gdy wiemy, że możemy wygenerować te kody, możemy przyjrzeć się, jak to zaimplementujemy. Na początek będziemy chcieli dokonać refaktoryzacji obiektu danych użytkownika, który utworzyliśmy w naszym ostatnim samouczku zatytułowanym Konfigurowanie modelu danych w 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,
        ];
    }
}

Możemy teraz skupić się na akcji wysyłania hasło jednorazowe i jakie kroki należy podjąć, aby wysłać powiadomienie i zapamiętać użytkownika. Na początek musimy uruchomić akcję/polecenie, które wygeneruje kod i wyśle ​​go do użytkownika jako powiadomienie. Aby to zapamiętać, będziemy musieli dodać ten kod do pamięci podręcznej naszych aplikacji wraz z adresem IP urządzenia, które zażądało tego jednorazowego hasła. Może to spowodować problem, jeśli korzystasz z VPN, a Twój adres IP przełącza się między pytaniem o kod a jego wprowadzaniem – jednak na razie niewielkie ryzyko.

Na początek utworzymy polecenie dla każdego kroku. Lubię tworzyć małe, pojedyncze klasy, które wykonują każdą część procesu. Na początek wydajmy polecenie wygenerowania kodu - i jak zwykle zbudujemy odpowiedni interfejs/kontrakt, który pozwoli nam oprzeć się na kontenerze.

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

Następnie implementujemy chcesz użyć:

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();
    }
}

Jak widać, opieramy się na kontenerze przy każdej okazji - w przypadku gdy zdecydujemy się zmienić implementacje naszego jednorazowego hasła z 6 cyfr na 3 słowa, na przykład.

Tak jak poprzednio, upewnij się, że powiązałeś to ze swoim kontenerem w dostawcy usług dla tej domeny. Następnie chcemy wysłać powiadomienie. Tym razem pominę pokazywanie interfejsu, ponieważ można się domyślić, jak wygląda w tym momencie.

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

To polecenie zaakceptuje kod i wyśle ​​e-mailem oraz przekieruje nowe powiadomienie e-mail do żądającego. Upewnij się, że tworzysz powiadomienie i zwracasz wiadomość e-mail zawierającą wygenerowany kod. Zarejestruj to powiązanie w swoim kontenerze, a następnie będziemy mogli pracować nad tym, jak chcemy zapamiętać adres IP z tymi informacjami.

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,
            ],
        );
    }
}

Akceptujemy adres IP, adres e-mail i kod jednorazowy więc możemy to przechowywać w pamięci podręcznej. Ustawiliśmy ten czas życia na 15 minut, aby kody się nie starzeje, a obciążony system pocztowy powinien dostarczyć to doskonale w tym czasie. Używamy adresu IP jako części klucza pamięci podręcznej, aby ograniczyć dostęp do tego klucza po powrocie.

Mamy więc trzy elementy do wykorzystania podczas wysyłania hasła jednorazowego, a są kilka sposobów, dzięki którym możemy je ładnie osiągnąć. W tym samouczku zamierzam utworzyć jeszcze jedno polecenie, które obsłuży to za nas - używając pomocnika tap Laravels, aby było płynnie,

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

Najpierw używamy funkcji tap, aby utworzyć kod, przez który przechodzimy do zamknięcia, abyśmy mogli wysłać powiadomienie i zapamiętać szczegóły tylko w przypadku wygenerowania kodu. Jedynym problemem związanym z tym podejściem jest to, że jest to akcja synchroniczna i nie chcemy, aby działo się to w głównym wątku, ponieważ byłoby to dość blokujące. Zamiast tego przeniesiemy to do zadania w tle — możemy to zrobić, zmieniając nasze polecenie w coś, co można wysłać do kolejki.

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

Teraz możemy przyjrzeć się implementacji front-endowej . W tym przykładzie użyję Laravel Livewire jako front-end, ale proces jest podobny bez względu na używaną technologię. Wszystko, co musimy zrobić, to zaakceptować adres e-mail od użytkownika, przekierować go przez wysłane zadanie i przekierować użytkownika.

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

Nasz komponent odbierze e-mail i wyśle ​​powiadomienie. W rzeczywistości w tym momencie dodałbym cechę do mojego komponentu Livewire, aby wymusić ścisłe ograniczenie szybkości. Ta cecha wyglądałaby następująco:

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

Jest to przydatna mała cecha do zachowania, jeśli używasz Livewire i chcesz dodać ograniczenie szybkości do swoich komponentów.

Następnie, w widoku hasła jednorazowego, użyjemy dodatkowego komponentu livewire, który zaakceptuje kod hasła jednorazowego i pozwoli nam go zweryfikować. Jednak zanim to zrobimy, musimy utworzyć nowe polecenie, które pozwoli nam upewnić się, że użytkownik z tym adresem e-mail istnieje.

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,
                ],
            );
    }
}

To działanie jest wstrzykiwane do naszego komponentu Livewire, co pozwala nam uwierzytelnić się na pulpicie nawigacyjnym aplikacji lub na etapie onboardingu, w zależności od tego, czy jest to nowy użytkownik. Możemy stwierdzić, czy jest to nowy użytkownik, ponieważ nie będzie miał nazwy, tylko adres e-mail.

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

Chcemy się upewnić, że zresetowaliśmy hasło jednorazowe dla tego adresu IP jeśli mamy nieudaną próbę. Po wykonaniu tej czynności użytkownik jest uwierzytelniany i przekierowywany tak, jakby zalogował się za pomocą standardowego adresu e-mail i hasła.

To nie jest idealne rozwiązanie, ale jest ciekawy, to na pewno. Ulepszeniem byłoby wysłanie e-mailem podpisanego adresu URL zawierającego niektóre informacje, zamiast całkowitego korzystania z naszej pamięci podręcznej.

Czy pracowałeś już wcześniej z niestandardowym przepływem uwierzytelniania? Jaka jest Twoja preferowana metoda autoryzacji w Laravel? Daj nam znać na Twitterze!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

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...

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297