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