First round of refinements on the login system...

There's a lot more to do on the to-do list
This commit is contained in:
Dan Baker 2026-02-22 20:02:09 +00:00
parent 82ed2e3ce2
commit 1b241aeddb
7 changed files with 390 additions and 219 deletions

View file

@ -0,0 +1,14 @@
<?php
namespace App\DTOs;
use App\Models\MagicLoginToken;
readonly class MagicTokenResult
{
public function __construct(
public MagicLoginToken $token,
public string $plainToken,
public string $plainCode,
) {}
}

View file

@ -9,7 +9,6 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\ValidationException;
class MagicLinkController extends Controller
{
@ -34,35 +33,29 @@ class MagicLinkController extends Controller
'email' => 'required|email',
]);
$email = $request->email;
$ip = $request->ip();
$userAgent = $request->userAgent();
$result = $this->authService->sendMagicLink(
$request->email,
$request->ip(),
$request->userAgent()
);
try {
$token = $this->authService->sendMagicLink($email, $ip, $userAgent);
$loginUrl = URL::temporarySignedRoute(
'magic-link.verify',
now()->addMinutes(15),
['token' => $result->plainToken]
);
// Generate signed URL valid for 15 minutes
$loginUrl = URL::temporarySignedRoute(
'magic-link.verify',
now()->addMinutes(15),
['token' => $token->plain_token]
);
Mail::to($request->email)->queue(new MagicLoginLink($loginUrl, $result->plainCode, 15));
// Queue the magic link email
Mail::to($email)->queue(new MagicLoginLink($loginUrl, $token->plain_code, 15));
return redirect()->route('verify-code')
->with('status', 'Check your email for your login code!')
->with('email', $email);
} catch (ValidationException $e) {
throw $e;
}
return redirect()->route('verify-code')
->with('status', 'Check your email for your login code!')
->with('email', $request->email);
}
/**
* Show the code verification form.
*/
public function showCodeForm(Request $request)
public function showCodeForm()
{
return view('auth.verify-code');
}
@ -72,14 +65,11 @@ class MagicLinkController extends Controller
*/
public function verifyLink(Request $request)
{
// Validate the signed URL
if (!$request->hasValidSignature()) {
return redirect()->route('login')->with('error', 'Invalid or expired magic link.');
}
$token = $request->token;
if ($this->authService->verifyMagicLink($token)) {
if ($this->authService->verifyMagicLink($request->query('token'))) {
$request->session()->regenerate();
return redirect()->route('dashboard');
@ -98,22 +88,15 @@ class MagicLinkController extends Controller
'code' => 'required|digits:6',
]);
$email = $request->email;
$code = $request->code;
if ($this->authService->verifyCode($request->email, $request->code)) {
$request->session()->regenerate();
try {
if ($this->authService->verifyCode($email, $code)) {
$request->session()->regenerate();
return redirect()->route('dashboard');
}
return back()->withErrors([
'code' => 'Invalid or expired code.',
]);
} catch (ValidationException $e) {
throw $e;
return redirect()->route('dashboard');
}
return back()->withErrors([
'code' => 'Invalid or expired code.',
]);
}
/**

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\DTOs\MagicTokenResult;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -20,9 +21,7 @@ class MagicLoginToken extends Model
protected $fillable = [
'email_hash',
'token_hash',
'plain_token',
'code_hash',
'plain_code',
'expires_at',
'ip_address',
'user_agent',
@ -44,23 +43,23 @@ class MagicLoginToken extends Model
/**
* Generate a new magic login token for the given email.
* Plain token and code are returned in the DTO but never persisted.
*/
public static function generate(string $email, ?string $ip = null, ?string $ua = null): self
public static function generate(string $email, ?string $ip = null, ?string $ua = null): MagicTokenResult
{
$emailHash = User::hashEmail($email);
$token = Str::random(64);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$plainToken = Str::random(64);
$plainCode = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
return self::create([
'email_hash' => $emailHash,
'token_hash' => Hash::make($token),
'plain_token' => $token,
'code_hash' => Hash::make($code),
'plain_code' => $code,
$model = self::create([
'email_hash' => User::hashEmail($email),
'token_hash' => hash('sha256', $plainToken),
'code_hash' => Hash::make($plainCode),
'expires_at' => now()->addMinutes(15),
'ip_address' => $ip,
'user_agent' => $ua,
]);
return new MagicTokenResult($model, $plainToken, $plainCode);
}
/**
@ -79,14 +78,6 @@ class MagicLoginToken extends Model
$this->update(['used_at' => now()]);
}
/**
* Verify the provided token matches the plain token.
*/
public function verifyToken(string $token): bool
{
return $this->plain_token === $token;
}
/**
* Verify the provided code matches the hashed code.
*/

View file

@ -2,6 +2,7 @@
namespace App\Services;
use App\DTOs\MagicTokenResult;
use App\Models\MagicLoginToken;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
@ -15,9 +16,9 @@ class MagicLinkAuthService
*
* @throws ValidationException
*/
public function sendMagicLink(string $email, ?string $ip, ?string $ua): MagicLoginToken
public function sendMagicLink(string $email, ?string $ip, ?string $ua): MagicTokenResult
{
$key = 'magic-link:' . hash('sha256', strtolower(trim($email)));
$key = 'magic-link:' . User::hashEmail($email);
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
@ -31,10 +32,8 @@ class MagicLinkAuthService
RateLimiter::hit($key, 3600);
$user = User::findByEmail($email);
if (!$user) {
$user = User::create([
if (!User::findByEmail($email)) {
User::create([
'email_hash' => User::hashEmail($email),
]);
}
@ -48,7 +47,7 @@ class MagicLinkAuthService
public function verifyMagicLink(string $token): bool
{
$magicToken = MagicLoginToken::valid()
->where('plain_token', $token)
->where('token_hash', hash('sha256', $token))
->first();
if (!$magicToken) {
@ -63,6 +62,11 @@ class MagicLinkAuthService
$magicToken->markAsUsed();
if (is_null($user->email_verified_at)) {
$user->email_verified_at = now();
$user->save();
}
Auth::login($user, true);
return true;
@ -70,6 +74,8 @@ class MagicLinkAuthService
/**
* Verify a magic code and log the user in.
*
* @throws ValidationException
*/
public function verifyCode(string $email, string $code): bool
{
@ -77,7 +83,13 @@ class MagicLinkAuthService
$key = 'magic-code:' . $emailHash;
if (RateLimiter::tooManyAttempts($key, 5)) {
return false;
$seconds = RateLimiter::availableIn($key);
throw ValidationException::withMessages([
'code' => [
"Too many attempts. Please try again in {$seconds} seconds.",
],
]);
}
$magicToken = MagicLoginToken::valid()
@ -100,6 +112,11 @@ class MagicLinkAuthService
RateLimiter::clear($key);
if (is_null($user->email_verified_at)) {
$user->email_verified_at = now();
$user->save();
}
Auth::login($user, true);
return true;