test borked
This commit is contained in:
parent
49b528a66b
commit
2418edccfd
29 changed files with 2036 additions and 121 deletions
75
app/Auth/HashEmailUserProvider.php
Executable file
75
app/Auth/HashEmailUserProvider.php
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
|
||||
class HashEmailUserProvider implements UserProvider
|
||||
{
|
||||
/**
|
||||
* Retrieve a user by their unique identifier.
|
||||
*/
|
||||
public function retrieveById($identifier): ?Authenticatable
|
||||
{
|
||||
return User::find($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by their unique identifier and "remember me" token.
|
||||
*/
|
||||
public function retrieveByToken($identifier, $token): ?Authenticatable
|
||||
{
|
||||
$user = $this->retrieveById($identifier);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rememberToken = $user->getRememberToken();
|
||||
|
||||
return $rememberToken && hash_equals($rememberToken, $token) ? $user : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the "remember me" token for the given user in storage.
|
||||
*/
|
||||
public function updateRememberToken(Authenticatable $user, $token): void
|
||||
{
|
||||
$user->setRememberToken($token);
|
||||
$user->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials): ?Authenticatable
|
||||
{
|
||||
if (empty($credentials['email'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::findByEmail($credentials['email']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*
|
||||
* For passwordless authentication, this always returns true.
|
||||
*/
|
||||
public function validateCredentials(Authenticatable $user, array $credentials): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehash the user's password if required and update the model.
|
||||
*
|
||||
* This is a no-op for passwordless authentication.
|
||||
*/
|
||||
public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false): void
|
||||
{
|
||||
// No-op: passwordless authentication doesn't use passwords
|
||||
}
|
||||
}
|
||||
129
app/Http/Controllers/Auth/MagicLinkController.php
Executable file
129
app/Http/Controllers/Auth/MagicLinkController.php
Executable file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Services\MagicLinkAuthService;
|
||||
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
|
||||
{
|
||||
public function __construct(
|
||||
protected MagicLinkAuthService $authService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show the login form.
|
||||
*/
|
||||
public function showLoginForm()
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a magic link to the user's email.
|
||||
*/
|
||||
public function sendLink(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$email = $request->email;
|
||||
$ip = $request->ip();
|
||||
$userAgent = $request->userAgent();
|
||||
|
||||
try {
|
||||
$token = $this->authService->sendMagicLink($email, $ip, $userAgent);
|
||||
|
||||
// Generate signed URL valid for 15 minutes
|
||||
$loginUrl = URL::temporarySignedRoute(
|
||||
'magic-link.verify',
|
||||
now()->addMinutes(15),
|
||||
['token' => $token->plain_token]
|
||||
);
|
||||
|
||||
// Queue the magic link email
|
||||
Mail::to($email)->queue(new MagicLoginLink($loginUrl, $token->plain_code, 15));
|
||||
|
||||
return back()->with('status', 'Check your email for a login link and code!');
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the code verification form.
|
||||
*/
|
||||
public function showCodeForm(Request $request)
|
||||
{
|
||||
return view('auth.verify-code');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the magic link token.
|
||||
*/
|
||||
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)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return redirect()->route('login')->with('error', 'Invalid or expired magic link.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the magic code.
|
||||
*/
|
||||
public function verifyCode(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'code' => 'required|digits:6',
|
||||
]);
|
||||
|
||||
$email = $request->email;
|
||||
$code = $request->code;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out.
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
54
app/Mail/MagicLoginLink.php
Executable file
54
app/Mail/MagicLoginLink.php
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MagicLoginLink extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $loginUrl,
|
||||
public string $code,
|
||||
public int $expiresInMinutes = 15
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Your Login Link',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.magic-login',
|
||||
text: 'emails.magic-login-text',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
138
app/Models/MagicLoginToken.php
Executable file
138
app/Models/MagicLoginToken.php
Executable file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MagicLoginToken extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'email_hash',
|
||||
'token_hash',
|
||||
'plain_token',
|
||||
'code_hash',
|
||||
'plain_code',
|
||||
'expires_at',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'used_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new magic login token for the given email.
|
||||
*/
|
||||
public static function generate(string $email, ?string $ip = null, ?string $ua = null): self
|
||||
{
|
||||
$emailHash = User::hashEmail($email);
|
||||
$wordToken = self::generateWordToken();
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
return self::create([
|
||||
'email_hash' => $emailHash,
|
||||
'token_hash' => Hash::make($wordToken),
|
||||
'plain_token' => $wordToken,
|
||||
'code_hash' => Hash::make($code),
|
||||
'plain_code' => $code,
|
||||
'expires_at' => now()->addMinutes(15),
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique 4-word token from the word list.
|
||||
*/
|
||||
public static function generateWordToken(): string
|
||||
{
|
||||
$words = explode("\n", trim(Storage::get('words.txt')));
|
||||
|
||||
do {
|
||||
$selectedWords = [];
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$selectedWords[] = $words[array_rand($words)];
|
||||
}
|
||||
$token = implode('-', $selectedWords);
|
||||
|
||||
// Check for uniqueness in database
|
||||
$exists = self::where('plain_token', $token)->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token is valid (not expired and not used).
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->expires_at->isFuture() && $this->used_at === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the token as used.
|
||||
*/
|
||||
public function markAsUsed(): void
|
||||
{
|
||||
$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.
|
||||
*/
|
||||
public function verifyCode(string $code): bool
|
||||
{
|
||||
return Hash::check($code, $this->code_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include valid tokens.
|
||||
*/
|
||||
public function scopeValid(Builder $query): void
|
||||
{
|
||||
$query->where('expires_at', '>', now())
|
||||
->whereNull('used_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include expired or used tokens.
|
||||
*/
|
||||
public function scopeExpiredOrUsed(Builder $query): void
|
||||
{
|
||||
$query->where(function (Builder $q) {
|
||||
$q->where('expires_at', '<=', now())
|
||||
->orWhereNotNull('used_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
|
|
@ -19,8 +20,7 @@ class User extends Authenticatable
|
|||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'email_hash',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -29,7 +29,7 @@ class User extends Authenticatable
|
|||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'email_hash',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
|
|
@ -42,7 +42,32 @@ class User extends Authenticatable
|
|||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an email address using HMAC-SHA256.
|
||||
*/
|
||||
public static function hashEmail(string $email): string
|
||||
{
|
||||
return hash_hmac('sha256', strtolower(trim($email)), config('app.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by their email address.
|
||||
*/
|
||||
public static function findByEmail(string $email): ?self
|
||||
{
|
||||
$emailHash = self::hashEmail($email);
|
||||
|
||||
return self::where('email_hash', $emailHash)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the magic login tokens for the user.
|
||||
*/
|
||||
public function magicLoginTokens(): HasMany
|
||||
{
|
||||
return $this->hasMany(MagicLoginToken::class, 'email_hash', 'email_hash');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Auth\HashEmailUserProvider;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
|
|
@ -19,6 +21,8 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Auth::provider('hash_email', function ($app, array $config) {
|
||||
return new HashEmailUserProvider();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
app/Services/MagicLinkAuthService.php
Executable file
117
app/Services/MagicLinkAuthService.php
Executable file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
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): MagicLoginToken
|
||||
{
|
||||
$key = 'magic-link:' . hash('sha256', strtolower(trim($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);
|
||||
|
||||
$user = User::findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
$user = 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('plain_token', $token)
|
||||
->first();
|
||||
|
||||
if (!$magicToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = User::where('email_hash', $magicToken->email_hash)->first();
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$magicToken->markAsUsed();
|
||||
|
||||
Auth::login($user, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a magic code and log the user in.
|
||||
*/
|
||||
public function verifyCode(string $email, string $code): bool
|
||||
{
|
||||
$emailHash = User::hashEmail($email);
|
||||
$key = 'magic-code:' . $emailHash;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue