*/ 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); $token = Str::random(64); $code = 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, 'expires_at' => now()->addMinutes(15), 'ip_address' => $ip, 'user_agent' => $ua, ]); } /** * 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'); }); } }