Startseite / Artikel / Beenden Sie das Lecken von Datenbankfeldern: Eine Datenzugriffsschicht für Next.js-Anwendungen

Beenden Sie das Lecken von Datenbankfeldern: Eine Datenzugriffsschicht für Next.js-Anwendungen

Erfahren Sie, wie eine Datenzugriffsschicht in Next.js Server Components die Authentifizierungsprüfungen und das Feldfiltern zentralisiert, sodass sensible Spalten niemals versehentlich beim Browser ankommen.

1597 Wörter

Serverkomponenten ermöglichen es Ihnen, direkt von einer Komponente aus die Datenbank abzufragen. Der Haken: Jedes Objekt, das Sie an eine Client-Komponente weitergeben, wird serialisiert und an den Browser gesendet – einschließlich Felder, die Sie eigentlich nicht anzeigen wollten. Eine Datenzugriffsschicht (DAL) befindet sich zwischen den Komponenten und der Datenbank, sodass Authentifizierung, Autorisierung sowie Feldfilterung an einem Ort stattfinden. Hier erfahren Sie, wie man eine solche Schicht strukturiert, was dazu gehört und wann sich die zusätzlichen Dateien lohnen.

Was eine Datenzugriffsschicht ist

Ein DAL ist ein spezieller Ordner, der jede Datenbankabfrage enthält. Komponenten rufen niemals direkt Prisma auf; sie rufen stattdessen Funktionen wie getProfile auf, die entscheiden, was der Aufrufer sehen darf und welche Felder zurückgegeben werden. Man kann es sich als Kontrollpunkt auf dem Weg zur Benutzeroberfläche vorstellen: Es überprüft, wer nach Daten fragt, und entfernt alles, was der Bildschirm nicht benötigt. Die Next.js-Dokumente zur Datensicherheit empfehlen diesen Ansatz für neue Projekte.

In einer typischen Struktur werden die Authentifizierungs-Hilfsfunktionen neben den Abfragemodulen für jedes Domänenbereich platziert:

src/
  data/
    auth.ts     # Authentication helpers
    user.ts     # User queries
    posts.ts    # Post queries

Die Regel, die das funktionieren lässt, ist einfach: Nichts außerhalb von data/ importiert den Datenbankklienten.

Wie Rohabfragen Daten durchsickern lassen

Eine direkte Abfrage gibt die gesamte Zeile zurück. Im folgenden Beispiel lädt die Komponente einen Benutzer und gibt das Ergebnis direkt an eine Karte weiter:

// Without DAL - directly in a component
const user = await prisma.user.findUnique({ where: { id } })
return <ProfileCard user={user} />

Dieses Objekt enthält jede Spalte: das Passworthash, die E-Mail-Adresse, die Telefonnummer sowie interne IDs. Wenn ProfileCard eine Client-Komponente ist, wird das gesamte Objekt in den Seiteninhalt serialisiert, und jeder, der die Netzwerkeinstellungen geöffnet hat, kann es lesen – auch dann, wenn die Karte nur den Namen anzeigt.

Die Lösung besteht darin, anstelle des rohen Datensatzes ein speziell dafür konstruiertes Objekt zurückzugeben. Diese DAL-Funktion wählt die drei Felder aus, die tatsächlich in der Profilansicht verwendet werden:

// data/user.ts
export async function getProfile(id: string) {
  const user = await prisma.user.findUnique({ where: { id } })
  return {
    name: user.name,
    avatar: user.avatar,
    bio: user.bio
  }
}

Die Komponente ruft getProfile(id) auf und erhält ein sicheres Objekt; das Passworthash bleibt somit auf dem Server. Man könnte auch mithilfe von Prismas select im Abfragedefekt filtern, und man sollte mit dem Fall umgehen, dass findUnique null zurückgibt, da sonst user.name zu einem Fehler führen würde.

Warum die Komponentenspezifische Sicherheit versagt

Das größere Risiko bei einer wachsenden Codebasis besteht darin, dass jede Komponente ihre eigenen Zugriffsregeln implementiert und diese auseinanderdriften. Stellen Sie sich zwei Entwickler für eine Anwendung vor. Der erste erstellt die Profilseite mithilfe einer DAL-Funktion, die ursprünglich zur Durchführung von Authentifizierungsprüfungen gedacht war (der Codeausschnitt wiederholt die oben genannte gefilterte Funktion und enthält noch keine Prüfung; die geschützte Version folgt im nächsten Abschnitt):

// data/user.ts
export async function getProfile(id: string) {
  const user = await prisma.user.findUnique({ where: { id } })
  return {
    name: user.name,
    avatar: user.avatar,
    bio: user.bio
  }
}

Der zweite Entwickler erstellt eine Einstellungsseite und ruft Prisma direkt auf, liest die Benutzer-ID aus der URL ab und überprüft nie, wer eingeloggt ist:

// pages/settings/page.tsx - Developer B wrote this
export default async function SettingsPage({ searchParams }) {
  // No auth check!
  const user = await prisma.user.findUnique({
    where: { id: searchParams.id }
  })

  // Returns everything, including sensitive fields
  return <Settings user={user} />
}

Es gibt jetzt zwei Fehler: Es fehlt die Authentifizierung, sodass jeder, der den id-Parameter ändert, die Einstellungen einer anderen Person laden kann, und das vollständige Datensatz inklusive sensibler Felder wird in die Benutzeroberfläche übertragen. (Trotz des Kommentars zu pages/ handelt es sich dabei um eine App Router-Seite unter app/). Da die Sicherheitslogik über verschiedene Seiten verteilt ist, reicht bereits eine vergessene Überprüfung aus, weshalb bei einer Prüfung jedes Komponenten berücksichtigt werden muss, das auf die Datenbank zugreift.

Authentifizierung und Filterung an einem Ort zusammenführen

Mit einer DAL durchlaufen alle Aufrufer dieselben Überprüfungen. getCurrentUser liest die Session und gibt den Benutzer oder null zurück; requireAuth leitet bei fehlender Anmeldung zur Anmeldeseite um. Beide Funktionen sind in Reacts cache eingebettet, sodass wiederholte Aufrufe innerhalb derselben Anfrage das erste Ergebnis wiederverwenden:

// data/auth.ts
import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await getSession()
  if (!session) return null
  return session.user
})

export const requireAuth = cache(async () => {
  const user = await getCurrentUser()
  if (!user) redirect('/login')
  return user
})

Die Importe wurden weggelassen: getSession stammt aus Ihrer Authentifizierungsbibliothek und redirect aus next/navigation.

getProfile erfordert nun einen angemeldeten Benutzer und gibt die E-Mail nur dem Eigentümer des Profils preis:

// data/user.ts
import 'server-only'
import { requireAuth } from './auth'

export async function getProfile(id: string) {
  const viewer = await requireAuth()
  const user = await prisma.user.findUnique({ where: { id } })

  return {
    name: user.name,
    avatar: user.avatar,
    email: viewer.id === user.id ? user.email : null
  }
}

Jeder Aufrufer erhält Authentifizierung und Filterung kostenlos; der zweite Entwickler kann eine in der Funktion integrierte Überprüfung nicht überspringen. Beachten Sie, dass requireAuth nur auf die Frage „Ist jemand angemeldet?“ antwortet. Ob dieser Benutzer diesen Beitrag bearbeiten darf, ist eine Autorisierungsfrage und gehört ebenfalls in die DAL. Für eine ausführlichere Erklärung zu dieser Trennung siehe wo Authentifizierung und Autorisierung jeweils hingehören.

Die Seiten werden dadurch schlanker. Sie holen die Daten über die DAL und rendern sie:

// Any component - simple and secure
export default async function ProfilePage({ params }) {
  const profile = await getProfile(params.id)
  return <Profile profile={profile} />
}

Der Komponente enthält keinen Sicherheitscode, und eine Überprüfung bedeutet das Durchgehen von data/ anstelle von Hunderten von Komponenten. In neueren Next.js-Versionen ist params ein Promise, daher muss man zuerst mit await darauf warten.

Parallelige Abfrage aus DAL-Funktionen

Dashboards führen oft unabhängige Abfragen nacheinander aus:

// Sequential fetching - slow
export default async function Dashboard() {
  const user = await prisma.user.findUnique({ where: { id } })
  const posts = await prisma.post.findMany({ where: { authorId: id } })
  const stats = await prisma.stats.findFirst({ where: { userId: id } })

  return <DashboardUI user={user} posts={posts} stats={stats} />
}

Jedes await wartet auf das vorherige, sodass drei 100-millisekündige Abfragen insgesamt etwa 300 Millisekunden dauern. Da sie unabhängig sind, startet diese DAL-Funktion sie mit Promise.all gleichzeitig und ordnet die Ergebnisse den erforderlichen Feldern zu:

// data/dashboard.ts
export async function getDashboardData(userId: string) {
  const [user, posts, stats] = await Promise.all([
    prisma.user.findUnique({ where: { id: userId } }),
    prisma.post.findMany({ where: { authorId: userId } }),
    prisma.stats.findFirst({ where: { userId } })
  ])

  return {
    user: { name: user.name, avatar: user.avatar },
    posts: posts.map(p => ({ id: p.id, title: p.title })),
    stats: { views: stats.views, followers: stats.followers }
  }
}

Die Gesamtzeit sinkt auf die des langsamsten Abfrages, etwa 100 ms. Der Vorteil stammt von Promise.all und nicht direkt vom DAL selbst, doch eine Datenfunktion sorgt dafür, dass das Muster konsistent bleibt und das Filtern neben dem Abrufen erfolgt. Abhängige Abfragen müssen weiterhin warten. Ebenso führt die Verwendung von cache() durch requireAuth dazu, dass viele Komponenten authentifizierte DAL-Funktionen aufrufen können, während die Sitzung pro Anfrage nur einmal gelesen wird.

Schutz der Schicht durch server-only

Jedes DAL-Datei sollte mit dieser Import-Anweisung beginnen:

import 'server-only'

Das server-only-Paket verhindert den Build, wenn ein Modul, das es importiert, in Client-Code landet. Somit führt ein versehentlich in eine Client-Komponente importiertes DAL-Funktion zu einem klaren Fehler bereits vor der Produktion. Diese Konvention wird durch Tools durchgesetzt.

Was gehört in das DAL

Sorgen Sie dafür, dass jede DAL-Funktion sich auf vier Aufgaben konzentriert:

  • Authentifizierung: Überprüfen Sie, ob ein angemeldeter Benutzer vorhanden ist, wobei dabei die im Cache gespeicherten Hilfsfunktionen genutzt werden, damit die Überprüfung pro Anfrage nur einmal durchgeführt wird.
  • Berechtigung: Überprüfen Sie, ob dieser Benutzer Zugriff auf dieses spezielle Datensatz hat – beispielsweise ob er ein Profil ansehen oder einen Beitrag bearbeiten kann.
  • Filtern: Geben Sie nur die Felder zurück, die auf dem Bildschirm angezeigt werden. Wenn Sie unsicher sind, ob ein Feld benötigt wird, lassen Sie es aus.
  • Geheime Informationen: Nur die DAL sollte sensible Konfigurationen wie DATABASE_URL lesen, damit Verbindungsdaten nicht im Komponentencodex landen.

Server Actions sollten dieselben Funktionen verwenden, damit Schreibvorgänge denselben Überprüfungen unterliegen wie Lesevorgänge; siehe Autorisierung innerhalb jeder Server Action.

Wann ein DAL sinnvoll ist

Verwenden Sie eines für alles, was mit Benutzerdaten, Authentifizierung oder sensiblen Informationen umgeht; in einer Produktivanwendung mit Konten ist es die Grundvoraussetzung, kein Zusatz. Vermeiden Sie es nur bei vorübergehenden Prototypen oder statischen Webseiten ohne Benutzerdaten – selbst dort lohnt es sich, explizite Objekte zurückzugeben.

Kernpunkte

  • Alles, was eine Client Component erreicht, gelangt auch zum Browser, daher sollten niemals rohe Datenbankzeilen über diese Grenze übertragen werden.
  • Leiten Sie jede Anfrage durch ein data/-Verzeichnis, das für Authentifizierung, Autorisierung und Feldauswahl zuständig ist.
  • Umhüllen Sie Suchanfragen in der Session mit Reacts cache, sodass viele DAL-Aufrufe nur einen Session-Lesevorgang pro Anfrage erfordern.
  • Verwenden Sie Promise.all innerhalb von DAL-Funktionen für unabhängige Abfragen.
  • Importieren Sie server-only in jeder DAL-Datei, damit ein fehlerhafter Import den Build verhindert anstelle einer Sicherheitsprüfung.
  • Server Components erleichtern den Zugriff auf die Datenbank; eine Data Access Layer sorgt dafür, dass diese Erleichterung nicht heimlich zu einem Datenleck führt.

    Weitere Literatur