Skip to content

Commit

Permalink
feat: add magic login link
Browse files Browse the repository at this point in the history
  • Loading branch information
pushpak1300 committed Jan 19, 2025
1 parent 14dde84 commit f987b75
Show file tree
Hide file tree
Showing 10 changed files with 669 additions and 85 deletions.
81 changes: 81 additions & 0 deletions app/Http/Controllers/User/LoginLinkController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\User;

use App\Models\User;
use App\Models\LoginLink;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Notifications\LoginLinkMail;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\RateLimiter;

use function Illuminate\Support\defer;

final class LoginLinkController extends Controller
{
private const RATE_LIMIT_PREFIX = 'login-link:';
private const RATE_LIMIT_ATTEMPTS = 1;
private const EXPIRATION_TIME = 15;

/**
* Create a new magic link.
*/
public function store(Request $request): RedirectResponse
{
/** @var array<string, string> $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');
}
}
79 changes: 79 additions & 0 deletions app/Models/LoginLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Model;
use Database\Factories\LoginLinkFactory;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;

/**
* @property int $id
* @property int $user_id
* @property string $token
* @property CarbonImmutable $expires_at
* @property CarbonImmutable|null $used_at
* @property CarbonImmutable|null $created_at
* @property CarbonImmutable|null $updated_at
* @property-read User $user
*
* @method static \Database\Factories\LoginLinkFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink whereExpiresAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink whereToken($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink whereUsedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|LoginLink whereUserId($value)
*
* @mixin \Eloquent
*/
final class LoginLink extends Model
{
/** @use HasFactory<LoginLinkFactory> */
use HasFactory;

use MassPrunable;

protected $guarded = [];

/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];

/**
* Get the user that the magic link belongs to.
*
* @return BelongsTo<User, covariant $this>
*/
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();
}
}
41 changes: 41 additions & 0 deletions app/Notifications/LoginLinkMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Notifications;

use App\Models\LoginLink;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\URL;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;

final class LoginLinkMail extends Notification
{
use Queueable;

private readonly string $magicLinkUrl;

public function __construct(private readonly LoginLink $magicLink)
{
$this->magicLinkUrl = URL::signedRoute('login-link.login', ['token' => $this->magicLink->token]);
}

/**
* @return array<int, string>
*/
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.');
}
}
14 changes: 14 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -55,6 +58,7 @@ public function boot(): void
$this->configureVite();
$this->configurePrisms();
$this->configureScribeDocumentation();
$this->configureRateLimiting();
}

/**
Expand Down Expand Up @@ -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()));
}
}
30 changes: 30 additions & 0 deletions database/factories/LoginLinkFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\User;
use App\Models\LoginLink;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<LoginLink>
*/
final class LoginLinkFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<model-property<LoginLink>, 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,
];
}
}
33 changes: 33 additions & 0 deletions database/migrations/0001_01_01_000013_create_login_links_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('login_links', function (Blueprint $table): void {
$table->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');
}
};
Loading

0 comments on commit f987b75

Please sign in to comment.