test borked

This commit is contained in:
Dan Baker 2026-02-22 17:49:23 +00:00
parent 49b528a66b
commit 2418edccfd
29 changed files with 2036 additions and 121 deletions

View 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
}
}

View 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
View 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
View 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');
});
}
}

View file

@ -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');
}
}

View file

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

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