-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
14dde84
commit f987b75
Showing
10 changed files
with
669 additions
and
85 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
33
database/migrations/0001_01_01_000013_create_login_links_table.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
}; |
Oops, something went wrong.