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