*/ protected $fillable = [ 'email_hash', 'token_hash', 'code_hash', '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. * Plain token and code are returned in the DTO but never persisted. */ public static function generate(string $email, ?string $ip = null, ?string $ua = null): MagicTokenResult { $plainToken = Str::random(64); $plainCode = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); $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); } /** * 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 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'); }); } }