134 lines
3.3 KiB
PHP
Executable file
134 lines
3.3 KiB
PHP
Executable file
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\DTOs\MagicTokenResult;
|
|
use App\Models\MagicLoginToken;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class MagicLinkAuthService
|
|
{
|
|
/**
|
|
* Send a magic link to the given email address.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
public function sendMagicLink(string $email, ?string $ip, ?string $ua): MagicTokenResult
|
|
{
|
|
$key = 'magic-link:' . User::hashEmail($email);
|
|
|
|
if (RateLimiter::tooManyAttempts($key, 5)) {
|
|
$seconds = RateLimiter::availableIn($key);
|
|
|
|
throw ValidationException::withMessages([
|
|
'email' => [
|
|
"Too many magic link requests. Please try again in {$seconds} seconds.",
|
|
],
|
|
]);
|
|
}
|
|
|
|
RateLimiter::hit($key, 3600);
|
|
|
|
if (!User::findByEmail($email)) {
|
|
User::create([
|
|
'email_hash' => User::hashEmail($email),
|
|
]);
|
|
}
|
|
|
|
return MagicLoginToken::generate($email, $ip, $ua);
|
|
}
|
|
|
|
/**
|
|
* Verify a magic link token and log the user in.
|
|
*/
|
|
public function verifyMagicLink(string $token): bool
|
|
{
|
|
$magicToken = MagicLoginToken::valid()
|
|
->where('token_hash', hash('sha256', $token))
|
|
->first();
|
|
|
|
if (!$magicToken) {
|
|
return false;
|
|
}
|
|
|
|
$user = User::where('email_hash', $magicToken->email_hash)->first();
|
|
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
|
|
$magicToken->markAsUsed();
|
|
|
|
if (is_null($user->email_verified_at)) {
|
|
$user->email_verified_at = now();
|
|
$user->save();
|
|
}
|
|
|
|
Auth::login($user, true);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Verify a magic code and log the user in.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
public function verifyCode(string $email, string $code): bool
|
|
{
|
|
$emailHash = User::hashEmail($email);
|
|
$key = 'magic-code:' . $emailHash;
|
|
|
|
if (RateLimiter::tooManyAttempts($key, 5)) {
|
|
$seconds = RateLimiter::availableIn($key);
|
|
|
|
throw ValidationException::withMessages([
|
|
'code' => [
|
|
"Too many attempts. Please try again in {$seconds} seconds.",
|
|
],
|
|
]);
|
|
}
|
|
|
|
$magicToken = MagicLoginToken::valid()
|
|
->where('email_hash', $emailHash)
|
|
->latest()
|
|
->first();
|
|
|
|
if (!$magicToken || !$magicToken->verifyCode($code)) {
|
|
RateLimiter::hit($key, 300);
|
|
return false;
|
|
}
|
|
|
|
$user = User::where('email_hash', $emailHash)->first();
|
|
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
|
|
$magicToken->markAsUsed();
|
|
|
|
RateLimiter::clear($key);
|
|
|
|
if (is_null($user->email_verified_at)) {
|
|
$user->email_verified_at = now();
|
|
$user->save();
|
|
}
|
|
|
|
Auth::login($user, true);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clean up expired and used tokens older than 7 days.
|
|
*/
|
|
public function cleanupExpiredTokens(): int
|
|
{
|
|
return MagicLoginToken::expiredOrUsed()
|
|
->where('created_at', '<', now()->subDays(7))
|
|
->delete();
|
|
}
|
|
}
|