• Reading time ~ 6 min
  • 29.06.2022
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',
        );
    }
}
declare(strict_types=1);
 
namespace Infrastructure\Auth\Generators;
 
interface GeneratorContract
{
    public function generate(): string;
}
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,
        );
    }
}
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,
    ];
}
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,
        ];
    }
}
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();
    }
}
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,
            ),
        );
    }
}
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,
            ],
        );
    }
}
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,
                );
            },
        );
    }
}
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,
                );
            },
        );
    }
}
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',
        );
    }
}
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,
        );
    }
}
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,
                ],
            );
    }
}
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',
        );
    }
}

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

ABOUT

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

About author CrazyBoy49z
WORK EXPERIENCE
Contact
Ukraine, Lutsk
+380979856297