1. authenticated() — Anmeldung, nicht Zugriff
Wenn Sie bereits an URL-Regeln der Art „/api/drafts/** ist für alle Authentifizierten verfügbar“ gewöhnt sind, tappt man leicht in eine Falle: es scheint, dass, sobald ein Benutzer eingeloggt ist und die Rolle USER besitzt, er sich „in der privaten Zone“ befinde und mit Entwürfen arbeiten könne. Aber die private Zone ist keine „allgemeine Küche“, sondern eine Sammlung persönlicher Räume. Und ein Entwurf ist meistens persönlich.
Stellen wir uns einen typischen Endpunkt vor:
GET /api/drafts/42 — einen Entwurf anhand seiner ID abrufen.
Auf der Ebene der Request-Regeln setzen wir normalerweise so etwas wie: „Wenn du authentifiziert bist, darfst du eintreten.“ Und das ist richtig: Anonymous hat hier nichts zu suchen. Aber dann taucht die Hauptfrage auf: Welchem authentifizierten Benutzer ist es erlaubt, diesen konkreten Entwurf zu sehen?
Das Problem ist, dass die Rolle USER die Frage beantwortet: „Welche allgemeine Zugriffsebene hast du?“ (grobkörnig). Ownership beantwortet die Frage: „Gehört dir dieses konkrete Objekt?“ (Objektebene). Diese Fragen konkurrieren nicht, sie liegen einfach auf unterschiedlichen Ebenen.
Um dies zu veranschaulichen, ist es praktisch, eine einfache Tabelle im Kopf zu haben:
| Mechanismus | Beantwortet die Frage | Beispiel | Was es NICHT löst |
|---|---|---|---|
| hasRole("USER") | „Bist du überhaupt ein Benutzer?“ | Eintritt in die private Zone | wessen genau der Entwurf |
| hasAuthority("draft:read:own") | „Darfst du deine eigenen Entwürfe lesen?“ | Recht auf die Art der Aktion | ob diese draftId „eigen“ ist |
| Owner-Check | „Ist dieser Entwurf deiner?“ | Vergleich von authorId und currentUserId | ersetzt keine Rollen/Authorities |
Und hier ist eine wichtige, fast philosophische These: Die Rolle betrifft, wer du im System bist, Ownership betrifft, welche Objekte im System dir gehören. Das sind unterschiedliche Koordinaten.
2. Ownership-Modell: actor, resource, action
Wenn wir „eigen / fremd“ sagen, ist es sinnvoll, aufzuhören in Kategorien wie „Endpunkt offen/geschlossen“ zu denken und stattdessen wie eine Security-Engine zu denken (oder wie ein Wachmann in einem Geschäftszentrum, dem Anweisungen und Kaffee gegeben wurden). Jede Zugriffsentscheidung ist eine Prüfung der Kombination: wer tut → was tut → womit tut. Und Ownership lebt fast immer im dritten Teil – „womit“.
In einer für Backend-Entwickler bequemen Terminologie haben wir drei Entitäten:
Actor (Subjekt) — der aktuelle Benutzer, den wir aus SecurityContext erhalten haben (z. B. principal.getUserId()).
Resource (Objekt) — eine Domänenentität: ContentItem, UserProfile, StoredFile.
Action (Aktion) — eine Operation: read/update/delete/submit/publish.
Die Owner-only-Regel wird ganz alltäglich formuliert: „Die Aktion ist erlaubt, wenn der Actor der Owner der Ressource ist.“ Technisch bedeutet das: „Die Ressource hat ein Owner-Feld (authorId, userId, ownerUserId), und wir vergleichen es mit der vertrauenswürdigen Kennung des aktuellen Benutzers.“
Hier ist ein Schema, das hilft, nicht zu verwechseln, wo genau die Prüfung leben sollte:
flowchart TD
A[HTTP request] --> B[SecurityFilterChain]
B --> C[Controller]
C --> D[Service: business action]
D --> E[Load resource from DB]
E --> F{"ownerId == currentUserId?"}
F -->|yes| G[Perform action]
F -->|no| H["AccessDeniedException -> 403"]
Beachten Sie einen wichtigen Punkt: Ownership wird nicht „in der Luft“ geprüft. Man kann es nicht ehrlich prüfen, ohne mindestens zwei Dinge zu haben: eine vertrauenswürdige Kennung des aktuellen Benutzers und das Owner-Feld der Ressource. Daher ist Ownership fast immer die Service-Ebene (oder eine Schicht neben der Geschäftsaktion) und nicht nur ein hübscher Matcher in SecurityFilterChain.
3. Owner-Check im Service-Layer
Manchmal scheint es, dass, wenn wir bereits @PreAuthorize aktiviert haben, man „eine Annotation schreiben kann – und die Sicherheit ist fertig“. Leider (oder zum Glück für Ihr zukünftiges Gehalt) funktioniert die reale Welt nicht so. Die Ownership-Prüfung wird fast immer zu sehr einfachem Code: Laden des Objekts, Vergleich von ownerId, wenn nicht übereinstimmend – verweigert.
Beginnen wir mit einem kleinen Modell-Fragment. Unser Entwurf hat einen Autor:
package com.example.securecontent.content;
public class ContentItem {
private Long id;
private Long authorId; // ID des Eigentümers (Autors) — genau diese vergleichen wir mit currentUserId
public Long getId() { return id; }
public Long getAuthorId() { return authorId; }
}
Ja, das ist nicht die gesamte Entity-Klasse, aber uns ist jetzt wichtig genau die Idee: Die Ressource hat einen Eigentümer.
Jetzt der Controller. Wir vertrauen dem Client nicht bezüglich authorId und bitten ihn nicht zu übermitteln „Ich bin genau der Benutzer 123, ehrlich-ehrlich“. Wir holen den aktuellen Benutzer aus dem Security-Kontext und übergeben eine vertrauenswürdige Kennung an den Service:
package com.example.securecontent.content;
import com.example.securecontent.security.AppUserPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DraftController {
private final DraftService draftService;
DraftController(DraftService draftService) {
this.draftService = draftService;
}
@GetMapping("/api/drafts/{id}")
ContentItem getDraft(@PathVariable Long id,
@org.springframework.security.core.annotation.AuthenticationPrincipal
AppUserPrincipal principal) {
// principal — vertrauenswürdige Quelle für userId: kommt aus SecurityContext, nicht vom Client
return draftService.getOwnDraft(id, principal.getUserId()); // trusted userId
}
}
Der Service – der Ort, an dem wir die Entscheidung „eigen/fremd“ treffen, denn hier befindet sich die Geschäftsaktion. Zuerst machen wir eine naiv-transparente Variante (zwei Zeilen, die die Sicherheit real machen):
package com.example.securecontent.content;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
@Service
class DraftService {
private final ContentRepository contentRepository;
DraftService(ContentRepository contentRepository) {
this.contentRepository = contentRepository;
}
ContentItem getOwnDraft(Long draftId, Long currentUserId) {
// Zuerst laden wir die Ressource, weil Ownership ohne ownerId der Ressource nicht geprüft wird
ContentItem draft = contentRepository.findById(draftId).orElseThrow();
// Wichtig: vergleiche genau die Identifikatoren, nicht die 'Benutzerrolle'
if (!draft.getAuthorId().equals(currentUserId)) {
// Dies ist bewusst 403 (forbidden) für den authentifizierten Benutzer
throw new AccessDeniedException("Fremder Entwurf");
}
return draft;
}
}
Wenn draftId nicht existiert, sollte der normale Not-found-Path des Projekts greifen. AccessDeniedException wird hier genau für den Fall benötigt, wenn das Objekt gefunden wurde, aber es fremd ist.
Hier gibt es zwei superwichtige praktische Nuancen, die oft Anfänger verwirren.
Erstens: Long muss mit equals() verglichen werden, nicht mit ==. == vergleicht Referenzen, und der Fehler wird „manchmal funktioniert es, manchmal nicht“. Ein solcher Fehler ist ein idealer Kandidat für einen nächtlichen Albtraum.
Zweitens: AccessDeniedException ist nicht nur eine Ausnahme „für die Schönheit“. Da wir bereits einen AccessDeniedHandler für die JSON-API haben, wird diese Ausnahme zu einem korrekten 403 mit unserem JSON-Fehlervertrag, nicht zu „oh, schon wieder eine HTML-Seite“.
Wenn man die Auswahl auf Datenbankebene zusätzlich einschränken möchte, kann man eine Repository-Methode haben, die sofort ein Objekt im Owner-gebundenen Kontext suchen kann. Das ist nützlich als unterstützendes Muster, ersetzt aber nicht unsere Antwortpolitik:
package com.example.securecontent.content;
import java.util.Optional;
interface ContentRepository {
Optional<ContentItem> findById(Long id);
// Variante "bereits mit Owner-gebundener Filterung": liefert nur Ergebnis, wenn Eigentümer übereinstimmt
Optional<ContentItem> findByIdAndAuthorId(Long id, Long authorId);
}
Aber hier ist es wichtig, die kanonische Antwortpolitik nicht zu verlieren. In der Baseline dieses Projekts vermischen wir not found und foreign nicht: wenn das Objekt nicht existiert, ist es 404, wenn das Objekt existiert, aber fremd ist, ist es 403. Daher bleibt für den Owner-Check die explizite Variante oben die Hauptvariante: Objekt laden, Owner vergleichen, bei Nichtübereinstimmung AccessDeniedException werfen. Und findByIdAndAuthorId(...) ist praktisch als zusätzliches Schutzmuster, nicht als Ersatz für diese Politik.
4. Self-API: Zone /api/me/**
Wenn Sie eine API zum ersten Mal entwerfen, greift die Hand oft nach dem „klassischen REST“: /api/users/{id}/profile, /api/users/{id}/drafts. Und das ist normal, es ist gewohnt. Aber für Self-Szenarien (Operationen „in meinem Namen“) schafft dieses Design eine zusätzliche Angriffsfläche: Der Client beginnt, Ihnen id zu senden, und Sie müssen jedes Mal beweisen, dass dieses id tatsächlich sein gehört.
Im Schulungsprojekt halten wir bewusst eine separate Zone /api/me/**, damit Self-Szenarien „natürlich“ sind: Der Server weiß ohnehin, wer gekommen ist. Und dann wird Ownership nicht als „stimmt path userId mit current userId überein“ geprüft, sondern als „wir akzeptieren userId überhaupt nicht von außen“.
Vergleichen Sie die beiden Ansätze. Die für Self-Operationen fragwürdige Variante (nicht unbedingt „immer schlecht“, aber risikoreicher):
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ProfileController {
@PatchMapping("/api/users/{userId}/profile")
void updateProfile(@PathVariable Long userId,
UpdateProfileRequest request) {
// Anti-Pattern für Self: Client-gesteuerte userId
// Wenn man vergisst, userId == currentUserId zu prüfen, verschenkt man die Bearbeitung eines fremden Profils.
}
}
Self-freundliche Variante, die viel einfacher korrekt zu schützen ist:
package com.example.securecontent.profile;
import com.example.securecontent.security.AppUserPrincipal;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeProfileController {
private final ProfileService profileService;
MeProfileController(ProfileService profileService) {
this.profileService = profileService;
}
@PatchMapping("/api/me/profile")
void updateMyProfile(@AuthenticationPrincipal AppUserPrincipal principal,
@RequestBody UpdateProfileRequest request) {
// In dieser API kann der Client KEINE fremde userId übergeben: sie wird immer aus dem SecurityContext geholt
profileService.updateOwnProfile(principal.getUserId(), request); // owner by design
}
}
Hier ist Ownership in die API-Form selbst eingebaut: Der Client hat einfach keinen Platz, wo er eine „fremde id unterschieben“ könnte. Das hebt die Zugriffskontrolle nicht auf (der Endpunkt muss immer noch authenticated() sein), reduziert aber die Wahrscheinlichkeit einer logischen Schwachstelle „ich habe dem Client aus Versehen vertraut“.
5. Ownership + Methodensicherheit ohne Überlänge
Nach Tag 19 haben wir ein mächtiges Werkzeug bekommen – Methodensicherheit. Und da kommt die Versuchung: „Lass uns Ownership vollständig in @PreAuthorize ausdrücken.“ Manchmal wird das tatsächlich so gemacht, aber für Anfänger ist es wichtig, einen bodenständigeren, stabileren Gedanken zu behalten: Methodensicherheit und Owner-Check sind Schichten, die man bequem kombinieren kann, anstatt sie um jeden Preis in eine einzige SpEL-Zeile zu packen.
Ein typischer gesunder Baseline sieht so aus: Die Annotation schützt die Methode nach Rolle/Authority (also nach allgemeinem Recht auf die Aktion), und innerhalb der Methode bleibt eine kleine Prüfung des Eigentümers des konkreten Objekts. Dann wird der Code von Menschen gelesen, nicht nur vom Compiler.
Beispiel für das Lesen eines Entwurfs:
package com.example.securecontent.content;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class DraftService {
private final ContentRepository repo;
DraftService(ContentRepository repo) {
this.repo = repo;
}
@PreAuthorize("hasAuthority('draft:read:own')") // Recht auf die Art der Aktion (eigene Entwürfe lesen)
ContentItem getOwnDraft(Long draftId, Long currentUserId) {
// Owner-Check kann nicht durchgeführt werden, bevor die Ressource geladen ist (authorId benötigt)
ContentItem draft = repo.findById(draftId).orElseThrow();
if (!draft.getAuthorId().equals(currentUserId)) {
// authority != ownership: Recht vorhanden, aber das Objekt könnte fremd sein
throw new AccessDeniedException("Fremder Entwurf");
}
return draft;
}
}
In menschlicher Sprache liest sich das so: „Im Allgemeinen können nur diejenigen ihre eigenen Entwürfe lesen, die die Authority draft:read:own haben, und selbst wenn sie vorhanden ist – der konkrete Entwurf muss anhand des Autors geprüft werden.“
Wenn Sie die Methode noch „sprechender“ machen möchten, können Sie die Ownership-Prüfung in eine separate kleine Komponente auslagern. Das ist nützlich, wenn die Prüfung für update/delete/submit wiederholt wird und Sie nicht if (!equals) in fünf Methoden kopieren möchten. Wir bauen kein ACL und Policy-Engine, wir machen einfach einen kleinen Helfer.
package com.example.securecontent.content;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Component;
@Component
class DraftOwnership {
void assertOwner(ContentItem draft, Long currentUserId) {
// Einheitlicher Prüfstandard: ein Ort – geringere Wahrscheinlichkeit, eine 'ähnliche, aber andere' Prüfung zu machen
if (!draft.getAuthorId().equals(currentUserId)) {
throw new AccessDeniedException("Fremder Entwurf");
}
}
}
Und im Service wird es sauberer:
package com.example.securecontent.content;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class DraftService {
private final ContentRepository repo;
private final DraftOwnership ownership;
DraftService(ContentRepository repo, DraftOwnership ownership) {
this.repo = repo;
this.ownership = ownership;
}
@PreAuthorize("hasAuthority('draft:update:own')") // Recht auf Aktualisierung eigener Entwürfe
void updateOwnDraft(Long draftId, Long currentUserId, UpdateDraftRequest req) {
ContentItem draft = repo.findById(draftId).orElseThrow();
ownership.assertOwner(draft, currentUserId); // Owner-Check – Teil der Geschäftsaktion
// draft.update(req) – bedingt, Geschäftslogik
}
}
Beachten Sie eine wichtige „methodische Ehrlichkeit“: Die Authority heißt draft:update:own, aber sie macht den Entwurf nicht automatisch „own“. Sie sagt nur: „Der Benutzer hat das Recht, seine eigenen Entwürfe zu aktualisieren.“ Ob „eigen oder nicht eigen“ wird durch den Ownership-Check geklärt.
6. Editor/Admin und Ownership: verschiedene Zweige
An dieser Stelle kommt es oft zu Verwirrungen der Art „alle Prüfungen in einen Topf“: Der Entwickler versucht, in einer Methode sowohl den Eigentümer, als auch den Editor und den Admin zu berücksichtigen, und „für alle Fälle noch eine Katze“. Das Ergebnis ist eine Bedingung über drei Bildschirme, und niemand ist sicher, dass sie richtig ist. In einem realen Projekt ist das ein direkter Weg zu versehentlich offenen Daten.
Wir behalten das Kursmodell bei: Owner-only – ein separater Zugriffstyp, und Editor/Admin sind privilegierte Zweige. Ein Entwurf kann „nicht meiner“ sein, aber der Editor kann das Recht haben, ihn in der Moderationswarteschlange zu lesen, und der Admin kann ihn für Untersuchungen lesen. Das macht den Entwurf nicht zu „meinem“, es macht die Aktion „durch Privileg erlaubt“.
Praktisch drückt sich das so aus: In Owner-Methoden prüfen wir den Owner, und für privilegierte Operationen machen wir separate Methoden und separate Rechte. Zum Beispiel das Lesen eines Entwurfs durch den Eigentümer:
@PreAuthorize("hasAuthority('draft:read:own')")
ContentItem getOwnDraft(Long draftId, Long currentUserId) { ... }
Und das Lesen eines Entwurfs zur Moderation (anderer Use Case, anderes Recht, anderer Endpunkt):
package com.example.securecontent.content;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class EditorDraftService {
private final ContentRepository repo;
EditorDraftService(ContentRepository repo) {
this.repo = repo;
}
@PreAuthorize("hasAuthority('draft:review')") // Moderationsprivileg, nicht 'Ownership'
ContentItem getDraftForReview(Long draftId) {
return repo.findById(draftId).orElseThrow(); // Owner-Check nicht nötig: dies ist keine Owner-Methode
}
}
Psychologisch ist es wichtig zu akzeptieren: Wenn Sie einen Editor/Admin-Bypass in eine Owner-Methode eingebaut haben, haben Sie bereits zwei Welten vermischt. Manchmal ist das in einem kleinen Projekt akzeptabel, aber als Lern-Baseline ist es besser, die Zweige getrennt zu halten, weil es so leichter zu lesen und zu testen ist.
Ownership und Statuscodes 401/403
Wenn Sie die Regel „eigen / fremd“ implementieren, tauchen ständig Fragen zu 401 und 403 auf. Und das ist gut: das bedeutet, dass in Ihrem Kopf die richtige Verbindung zwischen Mechanik und API-Vertrag entsteht. Die Logik hier ist einfach, aber es ist nützlich, sie laut auszusprechen, um sie nicht zu verwechseln.
Wenn die Anfrage von einem anonymen Benutzer (nicht authentifiziert) kommt, der aber versucht, in die private Zone zu gelangen, dann liegt das Problem im Fehlen der Authentifizierung. Das ist 401. Ihr AuthenticationEntryPoint (den wir für die JSON-API konfiguriert haben) wird die korrekte Antwort liefern.
Wenn die Anfrage von einem authentifizierten Benutzer kommt (er ist „jemand“), aber dieser „jemand“ versucht, eine Aktion auf einem fremden Objekt auszuführen, dann liegt das Problem im Fehlen des Zugriffsrechts auf die Ressource. Das ist 403. Der natürlichste Weg, dies innerhalb des Service zu „signalisieren“, ist AccessDeniedException.
Und das ist ein weiterer Vorteil der Platzierung des Owner-Checks im Service-Layer: Genau dort versteht man den Kontext, und genau dort kann man entscheiden, dass es sich um Forbidden handelt und nicht um „einfach nicht gefunden“. Auf Controllerebene beginnen ohne Kenntnis der Geschäftsbedeutung oft krumme Lösungen wie „wir geben 404 zurück, damit niemand es errät“. Das ist eine separate Strategie (manchmal akzeptabel), aber im Rahmen des Kurses halten wir das Verhalten einfach und beobachtbar: Fremd ist 403.
7. Typische Fehler beim Owner-Only-Zugriff
Wenn Menschen zum ersten Mal Ownership implementieren, treten sie normalerweise nicht auf „komplexe kryptografische Rechen“ sondern auf die alltäglichsten – und genau deshalb sind sie so gefährlich: Das Gehirn sagt „das ist doch alles offensichtlich“, und einen Monat später finden Sie eine Schwachstelle der Stufe „Benutzer ändert fremdes Profil“. Unten folgt eine Reihe von Fehlern, die man lernen sollte, am Geruch zu erkennen.
Fehler Nr. 1: userId für Self-Operationen aus der Anfrage nehmen (Pfad/Query/Body).
Das ist ein Klassiker des Genres: Der Endpunkt akzeptiert userId, und Sie aktualisieren „ehrlich“ das Profil anhand dieser ID. So beginnt das System, den Worten des Clients darüber zu glauben, wer er ist. In Self-Szenarien sollte die vertrauenswürdige Kennung aus SecurityContext kommen, nicht aus JSON.
Fehler Nr. 2: Sich auf authenticated() beschränken und denken, dass Ownership „irgendwie von selbst“ funktioniert.
authenticated() schließt den Endpunkt zwar gegen Anonymous ab, unterscheidet aber keinesfalls zwischen „mein“ und „fremd“. Daher wird der Endpunkt /api/drafts/{id}, der für alle Authentifizierten ohne zusätzliche Prüfung geöffnet ist, fast zwangsläufig zu „jeden Entwurf anhand der ID lesen“.
Fehler Nr. 3: Das Recht draft:read:own mit der Tatsache des Besitzes eines konkreten Entwurfs verwechseln.
Authority ist ein String, der sagt „dem Benutzer ist die Aktionsart erlaubt“, aber sie weiß nicht, wessen konkretes Objekt gerade angefragt wird. Wenn Sie authorId nicht mit currentUserId verglichen haben, haben Sie keinen Ownership-Check durchgeführt, selbst wenn Sie die Authority sehr überzeugend genannt haben.
Fehler Nr. 4: Den Owner-Check nur im Controller machen und den Service „sauber“ lassen.
Solange Sie einen Controller und einen Endpunkt haben, funktioniert das „scheinbar“. Aber sobald ein zweiter Einstieg in den Service auftaucht (anderer Endpunkt, Batch-Operation, Integration), wird der Service zum Loch: Er führt die Geschäftsaktion ohne Prüfung aus, weil Sie die Sicherheit in die HTTP-Schicht ausgelagert haben. In der Folge kann ein neuer Controller versehentlich den Zugriff auf fremde Objekte öffnen.
Fehler Nr. 5: Long mit == vergleichen und „Geister“-Zugriffsfehler bekommen.
Das ist ein besonders fieser Fehler, weil er sich nicht immer auf dem lokalen Rechner zeigt. Heute hat == funktioniert, morgen nicht, und übermorgen beginnt Ihr Teamleiter zu vermuten, dass Sie „manchmal den falschen Benutzer“ haben. Für Object-IDs verwenden Sie immer equals().
GO TO FULL VERSION