Change Request: Anmeldung mit Spring Security nachrüsten und alle Testebenen als Regressionstest nutzen

https://bildung.social/@oerinformatik/

https://oer-informatik.de/spring-boot-change-request-authentifizierung

Change Request, Änderungsantrag, Auswirkungsanalyse, Regressionstest, Spring Security, HTTP Basic, Authentifizierung, Autorisierung, Rollen, securitySchemes

tl/dr; (ca. 28 min Lesezeit): Das Mitfahr-Backend ist abgenommen, da kommt ein Änderungsantrag: Statt die Nutzer-ID im Body mitzuschicken, sollen sich alle anmelden. Der Artikel zeigt, wie eine Auswirkungsanalyse die betroffenen Teile findet, wie der API-Vertrag securitySchemes bekommt und wie Spring Security mit HTTP Basic, Passwort-Hashes und Rollen die Anmeldung übernimmt. Danach laufen alle Tests aller Ebenen als Regressionstest; sie zeigen, was die Änderung kaputt macht, und ein Systemtest zeigt, dass er zu wenig prüft. Ausblick: Keycloak und OAuth2 [AI-Assisted]. (Zuletzt geändert am 11.11.2026)

Dieser Artikel ist Teil einer Artikelreihe über ein REST-Backend mit Spring Boot: Überblick und Fahrplan / 01 Anforderungen und API-Vertrag / 02 Projekt anlegen / 03 Codegenerierung / 04 Fachlogik und Komponententest / 05 Build-Qualität und CI / 06 Persistenz mit JPA / 07 Repository-Integrationstest / 08 Service und Dependency Injection / 09 REST-API und Fehlerbehandlung / 10 Controller-Integrationstest / 11 System- und Abnahmetest / 12 Change Request Authentifizierung / Übungsaufgaben zu 12

Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.

Dieser Artikel baut auf der Kann-Liste der Artikel Anforderungen und API-Vertrag, Controller-Integrationstest und System- und Abnahmetest auf.

Als Grundlagen zu Änderungen und Anmeldung kann ich nach Durchlesen des Artikels…

  • … einen Änderungsantrag (Change Request) beschreiben und seinen Weg durch das V-Modell nennen.

  • … in einer Auswirkungsanalyse die Artefakte und Tests finden, die eine Änderung betrifft.

  • … Authentifizierung (Wer bist Du?) und Autorisierung (Was darfst Du?) unterscheiden und die Statuscodes 401 und 403 zuordnen.

  • … im OpenAPI-Vertrag ein securitySchemes für HTTP Basic anlegen und einzelne Operationen davon ausnehmen.

  • … erklären, warum Passwörter nur als Hash (z. B. BCrypt) gespeichert werden.

  • … alle Tests nach einer Änderung als Regressionstest ausführen und die Fehlschläge den Ebenen zuordnen.

Darüber hinaus kann ich als Expertenwissen zu Spring Security…

  • … eine SecurityFilterChain mit öffentlichen Pfaden, Rollenregeln und HTTP Basic ohne Sitzung konfigurieren.

  • … Benutzer und Rollen per UserDetailsService aus der Datenbank laden.

  • … prüfen, ob ein Datensatz der angemeldeten Person gehört, und dafür 403 mit Problem Details liefern.

  • … Controller-Tests mit @WithMockUser und Systemtests mit Basic-Header schreiben.

  • … CORS mit Anmeldedaten (allowCredentials) einrichten und die Grenzen von HTTP Basic gegenüber OAuth2/OIDC benennen.

Nach Artikel 11 ist das Mitfahrlabor-Backend abgenommen: 109 schnelle Tests, 31 System- und Abnahmetests, alle grün. Eine Lücke war von Anfang an bekannt und im Vertrag dokumentiert: Wer ein Angebot anlegt, schickt seine fahrerId selbst mit. Jede*r könnte also im Namen einer anderen Person Fahrten anbieten, Plätze anfragen oder fremde Buchungen bestätigen. Für einen Prototyp war das in Ordnung; vor dem Einsatz an der Schule nicht mehr.

Dieser Artikel ist anders als die vorigen: Er baut keine neue Schicht, sondern ändert ein fertiges System. Das ist der Alltag in der Softwareentwicklung, und hier zeigt sich, was die Tests der vorigen Artikel wert sind.

Fahrplan der Artikelreihe im V-Modell; der Änderungsantrag aus Artikel 12 durchläuft alle Ebenen erneut (mit KI erstellt)

Ausgangspunkt ist das Projekt aus Artikel 11. Das Ergebnis findest Du im Ordner src/12-change-request-authentifizierung des Repositorys.

Der Änderungsantrag

Ein Änderungsantrag (Change Request, kurz CR) beschreibt eine gewünschte Änderung an einem abgenommenen Stand. Er hält fest, was sich ändern soll, warum, und woran man später erkennt, dass die Änderung umgesetzt ist. Für das Mitfahrlabor stellt die Schulleitung nach einem Gespräch mit dem Datenschutz diesen Antrag:

Änderungsantrag CR-01 für das Mitfahrlabor-Backend
Änderungsantrag
Nummer CR-01
Titel Anmeldung statt Nutzer-ID im Body
Beschreibung Jede Anfrage außer Haltepunkten und Flurmonitor braucht eine Anmeldung mit Pseudonym und Passwort. Die Nutzer-ID ergibt sich aus der Anmeldung. Nur wer fährt, darf das eigene Angebot ändern und Anfragen bestätigen oder ablehnen; stornieren darf nur, wer mitfährt. Das Organisationsteam (Rolle ORGA) darf jedes Angebot absagen, aber selbst nichts anbieten oder buchen.
Begründung Bisher kann jede Person im Namen anderer handeln (Datenschutz, Missbrauch).
Abnahmekriterien neue User Story US-10 mit AK-10.1 bis AK-10.5 (siehe unten)
Priorität hoch, vor dem Einsatz an der Schule
Entscheidung angenommen nach Auswirkungsanalyse

Die Abnahmekriterien schreibt das Team wie in Artikel 01 als neue User Story:

US-10 Mit Anmeldung nutzen: Als Nutzer*in möchte ich mich anmelden, damit niemand in meinem Namen Fahrten anbietet oder Plätze bucht.

  • AK-10.1: Ohne Anmeldung werden Angebote und Buchungen abgewiesen; Haltepunkte und Flurmonitor bleiben ohne Anmeldung lesbar.
  • AK-10.2: Ein falsches Passwort oder ein unbekanntes Pseudonym wird abgewiesen.
  • AK-10.3: Wer ein Angebot anlegt, fährt selbst; eine fahrerId im Body zählt nicht.
  • AK-10.4: Ein fremdes Angebot kann niemand ändern, außer dem Organisationsteam, das jedes Angebot absagen darf.
  • AK-10.5: Nur wer fährt, bestätigt oder lehnt ab; nur wer mitfährt, storniert. Das Organisationsteam bietet nichts an.

Im V-Modell durchläuft ein Änderungsantrag die linke Seite noch einmal von oben nach unten (Anforderungen, Vertrag, Entwurf, Code) und die rechte Seite von unten nach oben (alle Testebenen bis zur erneuten Abnahme). Mehr zum Modell im Artikel V-Modell.

Auswirkungsanalyse: Was hängt an der Nutzer-ID?

Bevor jemand Code ändert, klärt die Auswirkungsanalyse (Impact Analysis), welche Teile betroffen sind und wie groß der Aufwand ist. Grundlage ist die Verfolgbarkeit, die die Reihe aufgebaut hat: Jede User Story hat Abnahmekriterien und einen Abnahmetest, jede Operation steht im Vertrag, jede Schicht hat ihre Tests.

Schema der Auswirkungsanalyse: Der Änderungsantrag CR-01 betrifft user-stories.md (neue US-10) und openapi.yaml 2.0.0 (securitySchemes, 401/403, ohne fahrerId und mitfahrerId); daraus folgen eine neue Sicherheitskonfiguration, geänderte Controller und Mapper, geänderte Services mit Berechtigungsprüfung und ein Nutzer mit Passwort und Rolle; als Regression laufen Komponententests, @WebMvcTest, Systemtest und Abnahmetest erneut (mit KI erstellt)
Auswirkungsanalyse für CR-01: betroffene Artefakte und wer die Änderung bemerkt
Artefakt Änderung Wer bemerkt es?
user-stories.md neue US-10 mit fünf Kriterien Abnahmetest
openapi.yaml securitySchemes, Antworten 401 und 403, fahrerId und mitfahrerId entfallen in Anfragen, Version 2.0.0 Systemtest gegen den Vertrag, Compiler (erzeugte Schnittstellen)
domain.Nutzer, data.sql Passwort-Hash und Rolle Repository-Test, Systemtest
neu: SicherheitsKonfiguration, NutzerAnmeldung, AktuelleAnmeldung Anmeldung und Regeln Controller-Test, Systemtest
AngebotService, BuchungService Prüfung, ob das Angebot oder die Buchung der angemeldeten Person gehört Komponententest
Controller, Mapper Nutzer-ID aus der Anmeldung statt aus dem Body Compiler, Controller-Test
CorsKonfiguration Anmeldedaten aus dem Browser erlauben CORS-Systemtest
.http-Dateien Authorization-Header Hand-Test

Eine Entscheidung fällt schon hier: Fallen fahrerId und mitfahrerId aus den Anfragen weg, ist das eine inkompatible Änderung (Breaking Change). Ein Frontend, das sich an Version 1 hält, schickt keine Anmeldung mehr mit und bekommt 401. Nach Semantic Versioning steigt die Vertragsversion deshalb von 1.0.0 auf 2.0.0. Weil noch kein Frontend im Einsatz ist, bleibt der Pfad /v1; bei Nutzer*innen im Betrieb liefe die alte Version eine Zeit lang parallel unter /v1, die neue unter /v2.

Zwei Fragen: Wer bist Du, und was darfst Du?

Bei Anmeldungen sind zwei Begriffe zu trennen, die im Englischen fast gleich klingen:

  • Authentifizierung (Authentication) prüft, wer anfragt, hier mit Pseudonym und Passwort. Scheitert sie, antwortet die API mit 401 Unauthorized (der Name ist historisch irreführend: gemeint ist „nicht angemeldet“) und dem Header WWW-Authenticate, der das Verfahren nennt.
  • Autorisierung (Authorization) prüft, ob die angemeldete Person diese Anfrage stellen darf. Scheitert sie, antwortet die API mit 403 Forbidden.

Das Mitfahrlabor nutzt HTTP Basic (RFC 7617): Der Client schickt bei jeder Anfrage den Header Authorization: Basic <Base64 von pseudonym:passwort>. Base64 ist eine Kodierung, keine Verschlüsselung; wer den Header mitliest, kennt das Passwort. HTTP Basic gehört deshalb immer hinter HTTPS. Mehr zu Verfahren und Passwörtern in den Artikeln Authentifizierung und Passwörter und MFA.

Der Vertrag bekommt securitySchemes

Contract-first heißt: Der Vertrag ändert sich zuerst. OpenAPI beschreibt Anmeldeverfahren unter components/securitySchemes und legt mit security fest, wo sie gelten. Auf oberster Ebene gilt die Anmeldung für alle Operationen; security: [] an einer Operation nimmt sie davon aus:

security:
  - basicAuth: []

paths:
  /haltepunkte:
    get:
      tags: [haltepunkte]
      operationId: listeHaltepunkte
      security: []          # öffentlich, ebenso holeHaltepunkt und listeMonitorEintraege
# ...
components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
      description: Pseudonym und Passwort; nur über HTTPS einsetzen (außer lokal).
  responses:
    NichtAngemeldet:
      description: Die Anfrage enthält keine oder falsche Anmeldedaten.
      headers:
        WWW-Authenticate:
          description: Anmeldeverfahren, z. B. Basic realm="Realm"
          schema:
            type: string
    KeineBerechtigung:
      description: Angemeldet, aber nicht berechtigt (falsche Rolle oder fremdes Angebot bzw. fremde Buchung).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'

Jede geschützte Operation bekommt die Antwort '401', die schreibenden zusätzlich '403'. Im Schema AngebotNeu entfällt fahrerId; die Antwort Angebot behält sie als eigenes Feld („ergibt sich beim Anlegen aus der Anmeldung“). Das Schema BuchungNeu mit mitfahrerId entfällt ganz, und POST /angebote/{angebotId}/buchungen hat keinen Body mehr.

Der Generator aus Artikel 03 erzeugt die Schnittstellen neu. Schon der erste Build zeigt einen Teil der Auswirkungen: frageBuchungAn(Long angebotId) hat einen Parameter weniger, und AngebotNeu hat einen anderen Konstruktor. Der Compiler meldet in den Tests 14 Stellen, zum Beispiel:

AngebotMapperTest.java:53: error: no suitable constructor found for AngebotNeu(long,long,Richtung,LocalDate,String,int,double)
        var neu = new AngebotNeu(1L, 1L, Richtung.ZUR_SCHULE, LocalDate.of(2026, 11, 19), "07:00", 2, 1.86);
                  ^
    constructor AngebotNeu.AngebotNeu(Long,Richtung,LocalDate,String,Integer,Double) is not applicable

Das ist der angenehmste Fall einer Auswirkung: Contract-first mit erzeugtem Code macht sie zum Compilerfehler.

Spring Security einbinden

Spring Security ist ein eigenes Projekt der Spring-Familie; Spring Boot 4.1.2 bringt Version 7.1.2 mit. Der Starter genügt:

In build.gradle.kts (wie vom Initializr für „Spring Security“):

dependencies {
    // ... bisherige Abhängigkeiten
    implementation("org.springframework.boot:spring-boot-starter-security")
    testImplementation("org.springframework.boot:spring-boot-starter-security-test")
}

Schon ohne eigene Konfiguration ändert der Starter alles: Jeder Pfad verlangt eine Anmeldung, und Spring Boot legt einen einzigen Benutzer user mit zufälligem Passwort an, das beim Start im Log steht:

Using generated security password: fd071921-72bd-4ad9-a407-fa31b7bc195b

Das ist ein sicherer Standard, aber nicht das, was CR-01 verlangt. Drei Bausteine ersetzen ihn: Nutzer aus der Datenbank, Regeln für die Pfade und die Prüfung des Eigentums im Service.

Wer bist Du? Nutzer und Passwörter aus der Datenbank

Die Entity Nutzer bekommt zwei Felder: den Passwort-Hash und die Rolle (enum Rolle { NUTZER, ORGA }, gespeichert als Text). Das Pseudonym ist der Benutzername. In data.sql stehen nur Hashes, nie Passwörter:

-- Passwort aller Demo-Konten: mitfahren (nur für die lokale Entwicklung!); gespeichert ist nur der BCrypt-Hash.
INSERT INTO nutzer (pseudonym, vorname, nachname, geburtsdatum, einwilligung_eltern, fuehrerschein, passwort, rolle) VALUES
('Funkenflug',   'Ben',    'Richter', '2008-01-17', NULL,         TRUE,
  '{bcrypt}$2b$10$VTVbC8tWRURl/i1RMuHZouYe4gqTiUroLgqtAYRbjPYlCNSPgzAk6', 'NUTZER'),  -- 1
-- ...
('Admin-AW',     'Andrea', 'Wolf',    '1981-05-05', NULL,         TRUE,
  '{bcrypt}$2b$10$Ax/Vdqk4Lmcd8G.wlBpaxejxKCdwP8ny7FPO2daKlOPDq7Dxym7EC', 'ORGA');  -- 10

BCrypt ist eine Hashfunktion, die absichtlich langsam ist (Kostenfaktor 10 = 2¹⁰ Runden) und für jedes Passwort ein zufälliges Salz erzeugt. Deshalb sehen die Hashes aller zehn Konten verschieden aus, obwohl das Passwort gleich ist, und ein Angreifer mit einer gestohlenen Tabelle muss jedes Passwort einzeln durchprobieren. Das Präfix {bcrypt} wertet der DelegatingPasswordEncoder aus: Er erkennt daran das Verfahren und könnte später auf ein anderes umstellen, ohne alte Hashes ungültig zu machen. Mehr zu Hashfunktionen im Artikel Kryptografie.

Spring Security fragt einen UserDetailsService nach dem Konto zum Benutzernamen. Die eigene Umsetzung liest es über ein neues findByPseudonym aus dem NutzerRepository:

@Service
public class NutzerAnmeldung implements UserDetailsService {

    private final NutzerRepository nutzerRepository;

    public NutzerAnmeldung(NutzerRepository nutzerRepository) {
        this.nutzerRepository = nutzerRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public AngemeldeterNutzer loadUserByUsername(String pseudonym) {
        return nutzerRepository.findByPseudonym(pseudonym)
                .filter(nutzer -> nutzer.getPasswort() != null)
                .map(AngemeldeterNutzer::new)
                .orElseThrow(() -> new UsernameNotFoundException("Kein Konto für " + pseudonym));
    }
}

AngemeldeterNutzer extends User (aus Spring Security) übergibt Pseudonym, Hash und die Rolle als Berechtigung ROLE_NUTZER bzw. ROLE_ORGA; zusätzlich merkt er sich die Nutzer-ID. Den Vergleich des Passworts mit dem Hash übernimmt Spring Security.

Was darfst Du? Regeln in der SecurityFilterChain

Spring Security arbeitet als Kette von Servlet-Filtern vor dem DispatcherServlet: Eine abgewiesene Anfrage erreicht keinen Controller. Welche Filter die Kette hat und welche Regeln gelten, legt eine Bean vom Typ SecurityFilterChain fest:

@Configuration
public class SicherheitsKonfiguration {

    @Bean
    SecurityFilterChain sicherheitsfilter(HttpSecurity http,
            @Qualifier("handlerExceptionResolver") HandlerExceptionResolver fehlerUebersetzung) throws Exception {
        http.authorizeHttpRequests(regeln -> regeln
                .requestMatchers(HttpMethod.GET, "/v1/haltepunkte/**", "/v1/monitor/**").permitAll()
                .requestMatchers(HttpMethod.POST, "/v1/angebote", "/v1/angebote/*/buchungen").hasRole("NUTZER")
                .requestMatchers("/error", "/h2-console/**").permitAll()
                .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .cors(Customizer.withDefaults())
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .headers(h -> h.frameOptions(f -> f.sameOrigin()))
                .exceptionHandling(e -> e.accessDeniedHandler(
                        (anfrage, antwort, fehler) -> fehlerUebersetzung.resolveException(anfrage, antwort, null,
                                fehler)));
        return http.build();
    }

    /** Erkennt das Verfahren am Präfix des Hashs, z. B. {bcrypt}; neue Passwörter bekommen BCrypt. */
    @Bean
    PasswordEncoder passwortPruefung() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

Die Regeln gelten in der Reihenfolge, in der sie stehen; die erste passende entscheidet. Deshalb steht anyRequest() am Schluss. hasRole("NUTZER") prüft die Berechtigung ROLE_NUTZER; das Präfix ergänzt Spring Security selbst. Die übrigen Zeilen:

Einstellungen der SecurityFilterChain
Einstellung Bedeutung für das Backend
httpBasic(...) Anmeldung per Authorization-Header; ohne gültige Anmeldung 401 mit WWW-Authenticate: Basic realm="Realm"
cors(...) CORS vor der Anmeldung prüfen, damit die Vorabanfrage des Browsers ohne Anmeldedaten durchkommt
csrf(...disable) CSRF-Schutz aus: Er schützt Sitzungen per Cookie, die Browser automatisch mitschicken. Die API hat keine Sitzung; jede Anfrage trägt die Anmeldung selbst
STATELESS keine Sitzung (JSESSIONID) anlegen: jede Anfrage wird für sich angemeldet
frameOptions(sameOrigin) nur für die H2-Konsole aus Artikel 06, die in einem Frame läuft
accessDeniedHandler(...) 403 der Rollenregeln an Spring MVC weitergeben, damit die ProblemBehandlung Problem Details daraus macht

Zusammen ergeben die Regeln diese Zugriffsmatrix:

Zugriffsregeln nach CR-01; „eigenes“ prüft der Service
Anfrage ohne Anmeldung Rolle NUTZER Rolle ORGA
GET Haltepunkte, Flurmonitor 200 200 200
GET Angebote, Buchungen 401 200 200
POST Angebot, POST Buchung 401 201 (für sich selbst) 403
PATCH Angebot 401 nur eigenes, sonst 403 jedes
PATCH Buchung 401 Fahrer*in: bestätigen, ablehnen; Mitfahrer*in: stornieren; sonst 403 403

Wem gehört das Angebot? Die Prüfung im Service

Die Regeln in der Filterkette kennen nur Pfade und Rollen. Ob Angebot 8 Ben gehört, weiß erst die Datenbank. Diese Prüfung gehört deshalb in den Service, wo die Fachregeln stehen. Der Service bekommt die Anmeldung als einfaches Record, ohne Abhängigkeit von Spring Security:

public record Anmeldung(Long nutzerId, Rolle rolle) {

    public boolean istOrga() {
        return rolle == Rolle.ORGA;
    }
}
@Transactional
public Angebot aendere(Long angebotId, Angebotsaenderung aenderung, Anmeldung anmeldung) {
    Angebot angebot = angebotRepository.findById(angebotId)
            .orElseThrow(() -> new NichtGefundenException("Angebot", angebotId));
    if (!angebot.getFahrer().getId().equals(anmeldung.nutzerId()) && !anmeldung.istOrga()) {
        throw new KeineBerechtigungException("Nur die Fahrerin oder der Fahrer darf das Angebot "
                + angebotId + " ändern.");
    }
    // ... Kostenregel, ändern, absagen wie in Artikel 09
}

BuchungService.aendereStatus prüft entsprechend: Für STORNIERT muss die angemeldete Person mitfahren, für BESTAETIGT und ABGELEHNT muss sie fahren. Die neue KeineBerechtigungException erbt von MitfahrlaborException; die ProblemBehandlung aus Artikel 09 bekommt einen Handler für 403 mit dem type keine-berechtigung.

Woher kennt der Controller die Anmeldung? Die erzeugten Schnittstellen legen die Parameter fest, ein zusätzliches @AuthenticationPrincipal passt nicht hinein. Eine kleine Komponente liest die Anmeldung aus dem SecurityContext, den Spring Security für jede Anfrage füllt:

@Component
public class AktuelleAnmeldung {

    public Anmeldung anmeldung() {
        Authentication anmeldung = SecurityContextHolder.getContext().getAuthentication();
        if (anmeldung != null && anmeldung.getPrincipal() instanceof AngemeldeterNutzer nutzer) {
            return new Anmeldung(nutzer.getNutzerId(), nutzer.getRolle());
        }
        throw new IllegalStateException("Anfrage ohne Anmeldung am geschützten Endpunkt");
    }
}

Im AngebotController ersetzt sie die fahrerId aus dem Body: AngebotMapper.zuNeuemAngebot(neu, aktuelleAnmeldung.anmeldung().nutzerId()). Der Weg einer Anfrage durch alle Prüfungen:

UML-Sequenzdiagramm: Das Frontend schickt PATCH /v1/angebote/8 mit Basic-Header an die SecurityFilterChain; diese lädt über NutzerAnmeldung Ben mit Hash und ROLE_NUTZER, vergleicht das Passwort und prüft die Regel; bei fehlender oder falscher Anmeldung antwortet sie mit 401; sonst ruft sie den AngebotController, der AngebotService.aendere mit der Anmeldung aufruft; fährt Ben das Angebot selbst oder hat die Rolle ORGA, kommt 200, bei einem fremden Angebot wirft der Service KeineBerechtigungException und die Antwort ist 403 mit Problem Details (mit KI erstellt)

Ausprobieren

Nach dem Start mit bootRun bzw. spring-boot:run (H2 und Startdaten wie bisher) zeigt curl beide Seiten. -u setzt den Basic-Header:

curl.exe -i http://localhost:8080/v1/angebote/4
curl.exe -i -u Ostwind:mitfahren http://localhost:8080/v1/angebote/4
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Type: application/json

{"timestamp":"2026-11-10T02:16:38.922Z","status":401,"error":"Unauthorized","path":"/v1/angebote/4"}
{"haltepunktId":2,"richtung":"zur-schule","datum":"2026-10-20","abfahrt":"06:50","freiePlaetze":3,"kostenbeitragEur":1.7,"id":4,"status":"offen","fahrerId":7}

Den Body der 401-Antwort schreibt die Fehlerseite von Spring Boot; der Vertrag beschreibt für 401 nur den Header. Spannender sind die Regeln aus CR-01. Ben schickt absichtlich "fahrerId": 9 mit, und Marie versucht, Bens neues Angebot abzusagen:

# JSON über die Standardeingabe übergeben (@-), damit PowerShell die Anführungszeichen nicht verändert
'{"fahrerId": 9, "haltepunktId": 1, "richtung": "zur-schule", "datum": "2026-11-19", "abfahrt": "07:00", "freiePlaetze": 2, "kostenbeitragEur": 1.80}' |
  curl.exe -i -u Funkenflug:mitfahren -X POST http://localhost:8080/v1/angebote -H "Content-Type: application/json" --data-binary "@-"
'{"status": "abgesagt"}' |
  curl.exe -i -u Ostwind:mitfahren -X PATCH http://localhost:8080/v1/angebote/8 -H "Content-Type: application/json" --data-binary "@-"
HTTP/1.1 201
Location: /v1/angebote/8

{"haltepunktId":1,"richtung":"zur-schule","datum":"2026-11-19","abfahrt":"07:00","freiePlaetze":2,"kostenbeitragEur":1.8,"id":8,"status":"offen","fahrerId":1}

HTTP/1.1 403
Content-Type: application/problem+json

{"detail":"Nur die Fahrerin oder der Fahrer darf das Angebot 8 ändern.","instance":"/v1/angebote/8","status":403,"title":"Keine Berechtigung","type":"https://mitfahrlabor.example/probleme/keine-berechtigung"}

Die fahrerId im Body gibt es im Modell nicht mehr; Jackson ignoriert unbekannte Felder, und Fahrer ist Ben (ID 1). Die .http-Dateien tragen jetzt je Anfrage einen Header wie Authorization: Basic Funkenflug mitfahren; IntelliJ und die REST-Client-Erweiterung von VS Code kodieren Pseudonym und Passwort selbst.

Regression: Was sagen die alten Tests?

Ein Regressionstest führt nach einer Änderung die vorhandenen Tests erneut aus, um ungewollte Auswirkungen zu finden. Nach der Umstellung des Anwendungscodes, aber vor dem Anpassen der Tests, antwortet jede Ebene anders:

Regressionstest nach CR-01: Ergebnisse vor dem Anpassen der Tests
Ebene Ergebnis Bedeutung
Komponententest (Service, Mapper) Compilerfehler: required: Long,Angebotsaenderung,Anmeldung / found: long,Angebotsaenderung neue Signaturen, Tests müssen sagen, wer handelt
Integrationstest Web (@WebMvcTest) Angebot und Buchung: Kontext startet nicht (No qualifying bean of type 'de.dbh.mitfahrlabor.web.AktuelleAnmeldung'); Haltepunkte und Monitor: expected: 200 but was: 401 Slice kennt die neue Komponente nicht und nutzt die Standard-Sicherheit statt der eigenen Regeln
Integrationstest Persistenz grün Tabelle nutzer hat neue Spalten, die Tests legen Nutzer ohne Passwort an
Abnahmetest 20 von 24 rot: Status expected:<201 CREATED> but was:<401 UNAUTHORIZED> alle Kriterien mit Angeboten und Buchungen; grün bleiben nur Haltepunkte und Monitor
Systemtest Vertrag grün siehe unten
CORS-Systemtest 1 von 3 rot (401 beim POST)

Die rote Mehrheit ist erwünscht: Die Tests zeigen genau die Stellen, an denen sich das Verhalten absichtlich geändert hat. Gefährlich ist die grüne Zeile. Der VertragSystemTest aus Artikel 11 prüft nur, ob der Statuscode für die Operation dokumentiert ist und die Antwort zum Schema passt. Seit CR-01 ist 401 überall dokumentiert, also besteht jede Antwort „nicht angemeldet“ die Prüfung. Der Test lief grün und prüfte nichts mehr von dem, was er prüfen sollte. Die Abhilfe: Jeder Aufruf nennt den erwarteten Status.

static void pruefe(HttpMethod methode, String pfad, int erwartet, EntityExchangeResult<byte[]> ergebnis) {
    assertThat(ergebnis.getStatus().value()).as("Status bei %s %s", methode, pfad).isEqualTo(erwartet);
    pruefe(methode, pfad, ergebnis);
}

Die Tests je Ebene anpassen

Komponententests: Die Service-Tests bekommen Anmeldungen als Konstanten (new Anmeldung(1L, Rolle.NUTZER) für Ben) und vier neue Fälle: fremdes Angebot ändern, ORGA sagt ab, Mitfahrerin bestätigt selbst, Fahrer storniert.

Controller-Tests: @WebMvcTest lädt die eigene @Configuration nicht von selbst; ohne sie gelten die Standardregeln (alles 401). @Import holt sie dazu. @WithMockUser aus spring-security-test setzt eine angemeldete Person, ohne Passwort und Datenbank; @WithAnonymousUser an einem einzelnen Test hebt das wieder auf. Die Komponente AktuelleAnmeldung ist ein Mock:

@WebMvcTest(AngebotController.class)
@Import({EnumConverterConfiguration.class, SicherheitsKonfiguration.class}) // Konverter und Sicherheitsregeln
@WithMockUser(username = "Funkenflug", roles = "NUTZER") // gilt für jeden Test, der nichts anderes sagt
class AngebotControllerTest {

    @MockitoBean
    private AktuelleAnmeldung aktuelleAnmeldung;

    @BeforeEach
    void benIstAngemeldet() {
        when(aktuelleAnmeldung.anmeldung()).thenReturn(new Anmeldung(1L, Rolle.NUTZER));
    }

    @Test
    @WithAnonymousUser
    void ohneAnmeldungErgibt401MitBasicAufforderung() {
        assertThat(mvc.post().uri("/v1/angebote").contentType(MediaType.APPLICATION_JSON).content(NEUES_ANGEBOT))
                .hasStatus(HttpStatus.UNAUTHORIZED)
                .hasHeader("WWW-Authenticate", "Basic realm=\"Realm\", charset=\"UTF-8\"");
        verifyNoInteractions(angebotService);
    }

    @Test
    @WithMockUser(username = "Admin-AW", roles = "ORGA")
    void orgaDarfKeinAngebotAnlegen() {
        assertThat(mvc.post().uri("/v1/angebote").contentType(MediaType.APPLICATION_JSON).content(NEUES_ANGEBOT))
                .hasStatus(HttpStatus.FORBIDDEN)
                .hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
                .bodyJson().extractingPath("$.type")
                .isEqualTo("https://mitfahrlabor.example/probleme/keine-berechtigung");
        verifyNoInteractions(angebotService);
    }
    // ... bisherige Tests ohne fahrerId im JSON
}

Der Test zu WWW-Authenticate hat beim ersten Lauf übrigens selbst etwas gelernt: Spring Security 7 hängt charset="UTF-8" an, die Erwartung Basic realm="Realm" war zu knapp.

System- und Abnahmetests: SystemtestBasis bekommt die Pseudonyme als Konstanten und eine Methode, die einen angemeldeten Client baut. Die Hilfsmethoden nehmen statt der ID das Pseudonym der handelnden Person:

/** Client, der jede Anfrage mit HTTP Basic als die genannte Person sendet. */
protected RestTestClient als(String pseudonym) {
    return client.mutate().defaultHeaders(kopf -> kopf.setBasicAuth(pseudonym, PASSWORT)).build();
}
@Test
@DisplayName("AK-10.4 Fremdes Angebot ändern ergibt 403, das Organisationsteam darf absagen")
void ak1004() {
    long id = bieteAn(BEN, 1, MORGEN, "07:50", 2, "1.80");
    String absagen = "{\"status\": \"abgesagt\"}";

    new RestTestClientAntwort(als(MARIE).patch().uri("/v1/angebote/{id}", id)
            .contentType(MediaType.APPLICATION_JSON).body(absagen).exchange())
            .erwarteProblem(403, "keine-berechtigung");
    als(ORGA).patch().uri("/v1/angebote/{id}", id).contentType(MediaType.APPLICATION_JSON).body(absagen)
            .exchange().expectStatus().isOk();
    assertThat(angebot(id).getStatus()).hasToString("abgesagt");
}

Auch die alten Kriterien werden genauer: AK-02.3 („Oksana ohne Führerschein“) meldet sich jetzt wirklich als Oksana an, statt eine fremde ID in den Body zu schreiben.

Am Ende laufen beide Builds grün:

.\gradlew.bat check
test:        121 Tests (vorher 109), 0 Fehler
systemTest:   36 Tests (AbnahmeTest 29, VertragSystemTest 4, CorsSystemTest 3; vorher 31), 0 Fehler
JaCoCo:      95 % Anweisungen (1710/1792), Zweige 103/111; Gate 0.85 erfüllt

Die Pipeline aus den Artikeln 05 bis 11 bleibt unverändert: Die neuen Tests laufen in den vorhandenen Jobs test und systemtest mit. Die Rückgabe an den Auftraggeber ist ein neues Abnahmeprotokoll wie in Artikel 11, nun mit Grundlage US-01 bis US-10, Vertrag 2.0.0 und 29 von 29 erfüllten Kriterien.

CORS mit Anmeldedaten

Ein Browser-Frontend schickt den Authorization-Header nur mit, wenn das Backend Anmeldedaten über Herkunftsgrenzen ausdrücklich erlaubt. In der CorsKonfiguration aus Artikel 11 kommt eine Zeile dazu:

registry.addMapping("/v1/**")
        .allowedOrigins(erlaubteUrspruenge)
        .allowedMethods("GET", "POST", "PATCH")
        .exposedHeaders(HttpHeaders.LOCATION)
        .allowCredentials(true); // Browser darf Anmeldedaten mitsenden; dann kein * als Ursprung

Mit Anmeldedaten verbietet die CORS-Regel den Platzhalter * als Herkunft. Wer mitfahrlabor.cors.erlaubte-urspruenge=* setzt, bekommt bei der ersten Vorabanfrage:

java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.

Die Vorabanfrage (OPTIONS) selbst trägt nie Anmeldedaten. Weil cors(...) in der Filterkette vor der Anmeldung greift, antwortet das Backend trotzdem mit 200 und den freigegebenen Werten, darunter Access-Control-Allow-Headers: authorization, content-type und Access-Control-Allow-Credentials: true. Das prüft der CorsSystemTest.

Grenzen von HTTP Basic und Ausblick

HTTP Basic ist einfach, im Test bequem und für eine Lernanwendung ausreichend. Für den Betrieb hat es Grenzen:

  • Das Passwort geht bei jeder Anfrage über die Leitung und wird bei jeder Anfrage mit BCrypt geprüft; ohne HTTPS liest es jede*r mit.
  • Ein Frontend muss das Passwort im Speicher halten. Abmelden, Ablaufzeiten oder eine zweite Anmeldestufe (MFA) gibt es nicht.
  • Jede Anwendung der Schule hätte eigene Passwörter.

Die Frontend-Reihen lösen das mit Keycloak als Identity Provider: Die Anmeldung (mit Schulkonto, MFA, Abmelden) übernimmt Keycloak nach OpenID Connect, das Frontend bekommt ein zeitlich begrenztes Token, und das Backend prüft nach OAuth2 nur noch dessen Signatur und Rollen (Spring Security „Resource Server“ mit JWT). Im Vertrag wird aus type: http dann type: oauth2 bzw. openIdConnect; die Regeln der SecurityFilterChain, die Prüfung im Service und die meisten Tests bleiben, weil sie nur Rollen und die Nutzer-ID brauchen.

Offen bleibt in diesem Stand der Lesezugriff: Jede angemeldete Person darf jede Buchung abrufen, auch fremde. Ob das gewollt ist, wäre ein eigener Änderungsantrag.

Nächster Artikel

Damit ist die Reihe vollständig: Das Mitfahrlabor-Backend ist vom Vertrag bis zur Abnahme durch alle Ebenen des V-Modells gegangen und hat eine erste Änderung überstanden. Weiter geht es in den Frontend-Reihen (Thymeleaf, JavaScript/TypeScript, JavaFX), die diese API nutzen und die Anmeldung auf Keycloak umstellen; den Fahrplan zeigt die Übersichtsseite der Reihe. Zum Üben setzen die Übungsaufgaben den Änderungsantrag für die Mitfahrgesuche um.

Leitfragen

  1. Was enthält ein Änderungsantrag, und welchen Weg nimmt er durch das V-Modell?

Nummer, Titel, Beschreibung der Änderung, Begründung, Abnahmekriterien, Priorität und Entscheidung. Nach der Auswirkungsanalyse und der Entscheidung durchläuft er die linke Seite erneut (Anforderungen, Vertrag, Entwurf, Code) und danach alle Testebenen bis zur erneuten Abnahme. siehe Abschnitt „Der Änderungsantrag“

  1. Wozu dient eine Auswirkungsanalyse, und was macht sie in diesem Backend einfach?

Sie klärt vor der Umsetzung, welche Artefakte und Tests betroffen sind und wie groß der Aufwand ist, und deckt inkompatible Änderungen auf. Einfach wird sie durch Verfolgbarkeit: User Stories mit Abnahmekriterien und Abnahmetests, ein Vertrag mit erzeugtem Code und Tests je Schicht. siehe Abschnitt „Auswirkungsanalyse“

  1. Wann antwortet die API mit 401, wann mit 403?

401, wenn die Authentifizierung fehlt oder scheitert (kein Header, falsches Passwort, unbekanntes Pseudonym), mit WWW-Authenticate: Basic. 403, wenn die Person angemeldet ist, aber die Anfrage nicht stellen darf: falsche Rolle (ORGA legt ein Angebot an) oder fremder Datensatz (Marie ändert Bens Angebot). siehe Abschnitt „Zwei Fragen“

  1. Wie beschreibt der OpenAPI-Vertrag die Anmeldung, und wie bleiben einzelne Operationen öffentlich?

Unter components/securitySchemes steht basicAuth mit type: http und scheme: basic; security: [basicAuth: []] auf oberster Ebene gilt für alle Operationen, security: [] an einer Operation nimmt sie aus. Dazu kommen die Antworten 401 und 403. siehe Abschnitt „Der Vertrag bekommt securitySchemes“

  1. Warum speichert das Backend nur BCrypt-Hashes, und wozu dient das Präfix {bcrypt}?

Ein Hash lässt sich nicht zurückrechnen; BCrypt ist absichtlich langsam und salzt jedes Passwort, sodass gestohlene Tabellen nur mit viel Aufwand je Passwort angreifbar sind. Das Präfix nennt dem DelegatingPasswordEncoder das Verfahren, damit später ein anderes Verfahren dazukommen kann. siehe Abschnitt „Wer bist Du?“

  1. Warum prüft die SecurityFilterChain die Rolle, der Service aber, ob das Angebot der angemeldeten Person gehört?

Die Filterkette sieht nur Methode, Pfad und Rollen und arbeitet vor dem Controller. Wem Angebot 8 gehört, steht erst in der Datenbank; diese Regel ist eine Fachregel und gehört wie die anderen in den Service, wo sie sich auch im Komponententest prüfen lässt. siehe Abschnitt „Wem gehört das Angebot?“

  1. Warum blieb der VertragSystemTest nach der Änderung grün, und was wurde geändert?

Er prüfte nur, ob der Status dokumentiert ist und der Body zum Schema passt. Nach CR-01 ist 401 überall dokumentiert, also bestand jede Antwort „nicht angemeldet“. Jetzt nennt jeder Aufruf den erwarteten Status, und die Anfragen werden als passende Person angemeldet. siehe Abschnitt „Regression“

  1. Was brauchen Controller-Tests mit @WebMvcTest, seit Spring Security im Projekt ist?

@Import(SicherheitsKonfiguration.class), sonst gelten die Standardregeln; @WithMockUser (bzw. @WithAnonymousUser) für die angemeldete Person; einen Mock für neue Komponenten wie AktuelleAnmeldung, die der Ausschnitt nicht lädt. siehe Abschnitt „Die Tests je Ebene anpassen“


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Change Request: Anmeldung mit Spring Security nachrüsten und alle Testebenen als Regressionstest nutzen” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/spring-boot-change-request-authentifizierung veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: