scan.fyi/app/Services/MagicLinkAuthService.php
ritual 1b241aeddb First round of refinements on the login system...
There's a lot more to do on the to-do list
2026-02-22 20:02:09 +00:00

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();
}
}