diff --git a/app/Http/Controllers/User/LoginLinkController.php b/app/Http/Controllers/User/LoginLinkController.php new file mode 100644 index 0000000..1ce0b06 --- /dev/null +++ b/app/Http/Controllers/User/LoginLinkController.php @@ -0,0 +1,81 @@ + $validated */ + $validated = $request->validate([ + 'email' => ['required', 'email', 'exists:users,email'], + ]); + + $email = $validated['email']; + $key = self::RATE_LIMIT_PREFIX.$email; + + // Rate limit check - 1 attempt per minute + if (RateLimiter::tooManyAttempts($key, self::RATE_LIMIT_ATTEMPTS)) { + $seconds = RateLimiter::availableIn($key); + + session()->flash('error', __('Please wait :seconds seconds before requesting another magic link.', ['seconds' => $seconds])); + + return redirect()->back(); + } + + $user = User::query()->where('email', $email)->firstOrFail(); + + // Increment the rate limiter + RateLimiter::increment($key); + + $magicLink = LoginLink::query()->create([ + 'user_id' => $user->id, + 'token' => Str::random(64), + 'expires_at' => now()->addMinutes(self::EXPIRATION_TIME), + ]); + + defer(fn () => $user->notify(new LoginLinkMail($magicLink)), 'login-link-notification'); + + session()->flash('success', __('Magic link sent to your email!')); + + return redirect()->back(); + } + + /** + * Login with a magic link. + */ + public function login(string $token): RedirectResponse + { + $magicLink = LoginLink::query()->where('token', $token) + ->whereNull('used_at') + ->where('expires_at', '>', now()) + ->firstOrFail(); + + $magicLink->update(['used_at' => now()]); + Auth::login($magicLink->user, true); + + return redirect()->route('dashboard'); + } +} diff --git a/app/Models/LoginLink.php b/app/Models/LoginLink.php new file mode 100644 index 0000000..a850b22 --- /dev/null +++ b/app/Models/LoginLink.php @@ -0,0 +1,79 @@ +|LoginLink newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink query() + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink whereExpiresAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink whereToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink whereUsedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|LoginLink whereUserId($value) + * + * @mixin \Eloquent + */ +final class LoginLink extends Model +{ + /** @use HasFactory */ + use HasFactory; + + use MassPrunable; + + protected $guarded = []; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'expires_at' => 'datetime', + 'used_at' => 'datetime', + ]; + + /** + * Get the user that the magic link belongs to. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the prunable model query. + * This will delete all magic links that were created more than a week ago. + * + * @return Builder + */ + public function prunable(): Builder + { + return self::query()->where('expires_at', '<=', Date::now())->toBase(); + } +} diff --git a/app/Notifications/LoginLinkMail.php b/app/Notifications/LoginLinkMail.php new file mode 100644 index 0000000..2efcfc9 --- /dev/null +++ b/app/Notifications/LoginLinkMail.php @@ -0,0 +1,41 @@ +magicLinkUrl = URL::signedRoute('login-link.login', ['token' => $this->magicLink->token]); + } + + /** + * @return array + */ + public function via(): array + { + return ['mail']; + } + + public function toMail(): MailMessage + { + return (new MailMessage) + ->subject('Your Magic Login Link') + ->line('Click the button below to log in.') + ->action('Log In', $this->magicLinkUrl) + ->line('This link will expire in 15 minutes.') + ->line('If you did not request this link, no action is needed.'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 992f318..b27a6ca 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use EchoLabs\Prism\Prism; use Carbon\CarbonImmutable; use Knuckles\Scribe\Scribe; +use Illuminate\Http\Request; use Laravel\Sanctum\Sanctum; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\App; @@ -18,6 +19,8 @@ use EchoLabs\Prism\Text\PendingRequest; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\ServiceProvider; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; use EchoLabs\Prism\Enums\Provider as PrismProvider; use Knuckles\Camel\Extraction\ExtractedEndpointData; use Symfony\Component\HttpFoundation\Request as HttpFoundationRequest; @@ -55,6 +58,7 @@ public function boot(): void $this->configureVite(); $this->configurePrisms(); $this->configureScribeDocumentation(); + $this->configureRateLimiting(); } /** @@ -156,4 +160,14 @@ private function configurePrisms(): void ->withMaxTokens(250) ); } + + /** + * Configure the application's rate limiting. + * + * @see https://laravel.com/docs/11.x/routing#rate-limiting + */ + private function configureRateLimiting(): void + { + RateLimiter::for('login-link', fn (Request $request) => $request->email ? Limit::perHour(5)->by($request->email) : Limit::perHour(5)->by($request->ip())); + } } diff --git a/database/factories/LoginLinkFactory.php b/database/factories/LoginLinkFactory.php new file mode 100644 index 0000000..a557490 --- /dev/null +++ b/database/factories/LoginLinkFactory.php @@ -0,0 +1,30 @@ + + */ +final class LoginLinkFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array, mixed> + */ + public function definition(): array + { + return [ + 'user_id' => User::factory()->create()->id, + 'token' => fake()->uuid(), + 'expires_at' => fake()->dateTimeBetween('5 minutes', '15 minutes'), + 'used_at' => null, + ]; + } +} diff --git a/database/migrations/0001_01_01_000013_create_login_links_table.php b/database/migrations/0001_01_01_000013_create_login_links_table.php new file mode 100644 index 0000000..825f0c0 --- /dev/null +++ b/database/migrations/0001_01_01_000013_create_login_links_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('token', 64)->unique(); + $table->timestamp('expires_at'); + $table->timestamp('used_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('login_links'); + } +}; diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index 123fedb..12b4f7a 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -7,14 +7,15 @@ import Checkbox from '@/Components/shadcn/ui/checkbox/Checkbox.vue' import Input from '@/Components/shadcn/ui/input/Input.vue' import Label from '@/Components/shadcn/ui/label/Label.vue' import Sonner from '@/Components/shadcn/ui/sonner/Sonner.vue' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/shadcn/ui/tabs' import SocialLoginButton from '@/Components/SocialLoginButton.vue' import { useSeoMetaTags } from '@/Composables/useSeoMetaTags.js' -import { cn } from '@/lib/utils' import { Link, useForm, usePage } from '@inertiajs/vue3' -import { inject, onMounted } from 'vue' +import { useLocalStorage } from '@vueuse/core' +import { computed, inject, onMounted } from 'vue' import { toast } from 'vue-sonner' -defineProps({ +const props = defineProps({ canResetPassword: Boolean, status: String, availableOauthProviders: Object, @@ -22,43 +23,85 @@ defineProps({ const page = usePage() const route = inject('route') +const activeTab = useLocalStorage('login-active-tab', 'password') -useSeoMetaTags({ - title: 'Log in', -}) - -const form = useForm({ +// Form state +const passwordForm = useForm({ email: 'test@example.com', password: 'password', remember: false, }) -function handleSubmit() { - form.transform(data => ({ - ...data, - remember: form.remember ? 'on' : '', - })).post(route('login'), { - onFinish: () => form.reset('password'), + +const loginLinkForm = useForm({ + email: '', +}) + +// Computed +const hasOauthProviders = computed(() => + Object.keys(props.availableOauthProviders || {}).length > 0, +) + +const isProcessing = computed(() => + passwordForm.processing || loginLinkForm.processing, +) + +// Methods +function handlePasswordLogin() { + passwordForm + .transform(data => ({ + ...data, + remember: data.remember ? 'on' : '', + })) + .post(route('login'), { + onFinish: () => passwordForm.reset('password'), + }) +} + +function handleLoginLink() { + loginLinkForm.post(route('login-link.store'), { + onSuccess: () => { + loginLinkForm.reset() + if (page.props.flash.success) { + toast.success(page.props.flash.success) + } + }, + onError: () => { + if (page.props.flash.error) { + toast.error(page.props.flash.error) + } + }, }) } +// Lifecycle onMounted(() => { if (page.props.flash.error) { toast.error(page.props.flash.error) } + + if (page.props.flash.success) { + toast.success(page.props.flash.success) + } +}) + +// SEO +useSeoMetaTags({ + title: 'Log in', })