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:
parent
82ed2e3ce2
commit
1b241aeddb
7 changed files with 390 additions and 219 deletions
14
app/DTOs/MagicTokenResult.php
Normal file
14
app/DTOs/MagicTokenResult.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue