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!