Formulare mit Thymeleaf: Die Schulverwaltung legt Hinweise für den Flurmonitor an
https://bildung.social/@oerinformatik/
https://oer-informatik.de/flurmonitor-hinweise-formulare
Thymeleaf, Formular, th:object, th:field, Bean Validation, BindingResult, Post-Redirect-Get, Flash-Attribut, CSRF, Spring Security, Spring MVC, Scrum
tl/dr; (ca. 30 min Lesezeit): Bisher steht der Hinweis der Schulverwaltung fest im Template des Flurmonitors. In diesem Artikel bekommt er eine eigene Entity mit Repository und Service, und der Monitor zeigt nur Hinweise, die noch gelten. Die Verwaltung legt Hinweise über ein Formular an: Ein Formular-Objekt nimmt die Eingaben auf,
th:object,th:fieldundth:errorsverbinden es mit dem Template, Bean Validation prüft es mit@Valid, und dasBindingResultmeldet Fehler zurück an die Felder. Nach dem Speichern leitet der Controller um (Post-Redirect-Get), damit Neuladen keinen zweiten Hinweis anlegt. Eine zweite Filterkette von Spring Security sorgt für Anmeldung per Formular und CSRF-Schutz [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über den Flurmonitor mit Thymeleaf: Projektübersicht / Scrum-Projektstart / Thymeleaf einbinden / Templates / Ansichtsmodell / Integrationstest der Web-Schicht / Konfiguration und Formatierung / Aktualisierung / Hinweise mit Formularen / Robustheit und Betrieb / GUI-Test mit Selenium / Akzeptanztest / Change Request Keycloak
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 des Artikels Automatisch aktualisieren auf.
Als Formulare-Grundlagen kann ich nach Durchlesen des Artikels…
… eine neue Entity mit Repository und Service anlegen und ihre Daten im Template eines Monitors anzeigen.
… ein Formular-Objekt mit Bean-Validation-Annotationen (
@NotBlank,@Size,@NotNull,@FutureOrPresent) schreiben und die Meldungen inmessages.propertiesablegen.… ein Formular mit
th:action,th:object,th:fieldundth:errorsan dieses Formular-Objekt binden.… im Controller Eingaben mit
@Validprüfen, dasBindingResultauswerten und bei Fehlern dieselbe Seite mit den Eingaben zurückgeben.… den Ablauf Post-Redirect-Get beschreiben und mit
redirect:und Flash-Attributen umsetzen.
Darüber hinaus kann ich als Formulare-Expertenwissen…
… erklären, wogegen ein CSRF-Token schützt, warum es bei Formularen mit Sitzung nötig ist und warum die REST-API ohne Sitzung darauf verzichten kann.
… zwei Filterketten von Spring Security mit
securityMatcherund@Orderfür Verwaltungsseiten und API trennen.… begründen, warum bei Fehlern kein Redirect folgt und warum Meldungen nach einem Redirect ein Flash-Attribut brauchen.
… Formular, Validierung, CSRF-Schutz und Umleitung mit
@WebMvcTest,@WithMockUserundcsrf()testen.
Nach Tag 7 von Sprint 2 aktualisiert sich der Flurmonitor selbst. Eine Story steht noch fest im Sprint Backlog: MON-08, die Hinweise der Schulverwaltung. Seit dem ersten Template zeigt der Monitor einen gelben Kasten mit dem Satz „Am Freitag endet der Unterricht schon um 12:15 Uhr“, der fest im HTML steht. Damit Heike Brinkmann aus dem Sekretariat Hinweise selbst anlegen kann, braucht das Projekt zum ersten Mal ein Formular, das Daten an den Server schickt und dort speichert. Der Ablauf, um den sich dieser Artikel dreht, heißt Post-Redirect-Get: Der Browser schickt das Formular per POST, der Server speichert und antwortet mit einer Umleitung, und erst die folgende GET-Anfrage zeigt die Seite mit der Erfolgsmeldung.
Stand im Projekt: Sprint 2, Tag 8
MON-08 („Hinweise anzeigen“, 2 Story Points) ist die letzte feste Story im Sprint. Im Daily Scrum an Tag 8 fällt Oksana Melnyk auf, dass MON-08 allein wenig bringt: Ohne gespeicherte Hinweise gibt es nichts anzuzeigen, und der fest eingetragene Satz müsste weiter im Template gepflegt werden. MON-07 („Hinweise anlegen“, 5 Story Points) steht als Option am Ende des Sprint Backlogs. Weil nach Tag 7 noch drei Projekttage bleiben und die Velocity aus Sprint 1 bei 10 Story Points lag, fragt das Team Product Owner Thomas Becker, ob es MON-07 nach vorn ziehen darf. Er stimmt zu; das Sprint-Ziel („Monitor im Alltag“) bleibt dasselbe, und die Arbeitsvereinbarung „höchstens zwei Stories gleichzeitig“ ist mit MON-07 und MON-08 eingehalten.
Beim kurzen Backlog Refinement im Anschluss schärft Thomas Becker die Abnahmekriterien. Der Formularentwurf aus den HTML-Grundlagen fragt nur ein Datum ab („Anzeigen bis einschließlich“), keine Uhrzeit. Heike Brinkmann ist das recht, also ändert er AK-MON-07.1 und ergänzt zwei Kriterien:
AK-MON-07.1 (geändert): Angenommen, Heike Brinkmann ist an der Verwaltungsseite angemeldet. Wenn sie den Hinweis „Linie 61 fällt morgen aus“ mit Gültigkeit bis einschließlich morgen speichert, dann erscheint er beim nächsten Laden auf allen Monitoren.
AK-MON-07.3: Angenommen, niemand ist angemeldet. Wenn jemand die Verwaltungsseite aufruft, dann wird er zur Anmeldung geleitet.
AK-MON-07.4 (neu): Angenommen, Heike Brinkmann lässt den Text leer oder gibt ein Datum in der Vergangenheit an. Wenn sie speichert, dann wird nichts gespeichert, und das Formular zeigt ihre Eingaben mit einer Meldung am betroffenen Feld.
AK-MON-08.2 (neu): Angenommen, ein Hinweis gilt nur für den Haltepunkt Herford. Wenn ein Monitor lädt, der nur einen anderen Haltepunkt zeigt, dann erscheint der Hinweis dort nicht.
Die Developers zerlegen beide Stories und setzen die Nummern aus dem letzten Artikel fort:
| Nr. | Aufgabe im Sprint Backlog | Abschnitt |
|---|---|---|
| T36 | Entity Hinweis, Enum Wichtigkeit, HinweisRepository, Startdaten |
Entity |
| T37 | HinweisService: anlegen, löschen, gültige Hinweise je Monitor |
Monitor |
| T38 | Hinweise im Fragment aktuell statt fest im Template |
Monitor |
| T39 | Verwaltungsseite mit Formular-Objekt, th:object, th:field, Bean Validation |
Formular-Objekt und Template |
| T40 | Post-Redirect-Get mit Flash-Meldung, Löschen als POST | PRG |
| T41 | Zweite Filterkette: Anmeldeformular, Sitzung, CSRF-Schutz | Sicherheit |
| T42 | Tests: Verwaltung, Monitor mit Hinweisen, Repository | Testen |
Die Entity Hinweis
Ein Hinweis hat einen Text, ein Datum, bis zu dem er gilt, eine Wichtigkeit und optional einen Haltepunkt. Ohne Haltepunkt gilt er für alle Monitore. Das Klassendiagramm zeigt die neuen Klassen und wie sie zusammenhängen: Der Controller arbeitet mit einem eigenen Formular-Objekt und ruft den Service auf; nur der Service kennt das Repository und die Entity.
Die Entity folgt dem Muster von Angebot und Haltepunkt aus der Backend-Serie. Neu ist nur, dass die Beziehung zum Haltepunkt leer sein darf:
@Entity
public class Hinweis {
/** Höchstlänge des Textes, wie im HTML-Formular aus den Grundlagen (maxlength="140"). */
public static final int MAX_LAENGE = 140;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = MAX_LAENGE)
private String text;
/** optional: null heißt „alle Haltepunkte“. */
@ManyToOne(fetch = FetchType.LAZY)
private Haltepunkt haltepunkt;
@Column(nullable = false)
private LocalDate bis;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private Wichtigkeit wichtigkeit;
/** Gilt der Hinweis am Tag heute? Der letzte Tag zählt mit. */
public boolean giltAm(LocalDate heute) {
return !bis.isBefore(heute);
}
/** Gehört der Hinweis auf einen Monitor mit diesem Haltepunkt (null = Monitor zeigt alle Haltepunkte)? */
public boolean passtZu(Long haltepunktDesMonitors) {
return haltepunkt == null || haltepunktDesMonitors == null
|| haltepunkt.getId().equals(haltepunktDesMonitors);
}
// Konstruktoren, Getter und isWarnung() im Code-Stand
}Wichtigkeit ist ein Enum mit INFO und WARNUNG. @Enumerated(EnumType.STRING) speichert den Namen statt der Position; so bleibt die Datenbank lesbar, und ein neuer Wert in der Mitte des Enums verschiebt keine gespeicherten Daten. Die Tabelle legt Hibernate wie bisher aus der Entity an (ddl-auto=create-drop), data.sql füllt zwei Hinweise ein: den bekannten Satz zum Freitag für alle Monitore und eine Warnung für den Haltepunkt Herford.
Das Repository braucht zwei abgeleitete Abfragen. Spring Data bildet das SQL aus dem Methodennamen, @EntityGraph lädt den Haltepunkt in derselben Abfrage mit, weil das Template ihn anzeigt und open-in-view ausgeschaltet ist:
public interface HinweisRepository extends JpaRepository<Hinweis, Long> {
/** Hinweise, die am Tag tag noch gelten: zuerst Warnungen, dann nach Ablauf sortiert. */
@EntityGraph(attributePaths = "haltepunkt")
List<Hinweis> findByBisGreaterThanEqualOrderByWichtigkeitDescBisAsc(LocalDate tag);
/** Alle Hinweise für die Verwaltungsseite, die zuletzt ablaufenden zuerst. */
@EntityGraph(attributePaths = "haltepunkt")
List<Hinweis> findAllByOrderByBisDescIdDesc();
}Die Sortierung OrderByWichtigkeitDesc sortiert die gespeicherten Texte, nicht die Reihenfolge im Enum. Dass WARNUNG vor INFO kommt, liegt am Alphabet. Bei einer dritten Stufe müsste das Team das prüfen; ein Test gegen die Startdaten hält die Erwartung fest.
Hinweise auf dem Monitor
Der HinweisService kennt die Uhr der Anwendung (das Clock-Bean aus dem Ansichtsmodell-Artikel) und filtert die gültigen Hinweise nach dem Haltepunkt des Monitors:
public List<Hinweis> gueltigeFuer(Long haltepunktDesMonitors) {
LocalDate heute = LocalDate.now(uhr);
return hinweise.findByBisGreaterThanEqualOrderByWichtigkeitDescBisAsc(heute).stream()
.filter(hinweis -> hinweis.passtZu(haltepunktDesMonitors))
.toList();
}Der FlurmonitorController bekommt den Service als dritte Abhängigkeit über den Konstruktor und legt die Hinweise in der Methode fuellen(…) ins Model. Weil Seite und Fragment aktuell diese Methode gemeinsam nutzen, kommen die Hinweise mit jedem Nachladen alle 30 Sekunden mit:
private void fuellen(Model model, MonitorEinstellung einstellung, String quelle) {
model.addAttribute("ansicht", aufbereitung.ansicht(einstellung));
model.addAttribute("hinweise", hinweisService.gueltigeFuer(einstellung.haltepunkt())); // MON-08
model.addAttribute("quelle", quelle);
model.addAttribute("takt", einstellungen.taktInSekunden());
}Im Template ersetzt eine Schleife den festen Kasten. Er steht weiterhin vor den Tabellen, damit auch ein Screenreader den Hinweis zuerst vorliest. Eine Warnung ist nicht nur gelb, sondern beginnt mit dem Wort „Warnung:“ (MON-09: Information nie nur über Farbe):
<aside class="col-12" th:unless="${hinweise.isEmpty()}">
<div th:each="hinweis : ${hinweise}" class="alert hinweis-kasten mb-2"
th:classappend="${hinweis.warnung} ? 'alert-warning' : 'alert-info'">
<h2 class="h4 alert-heading" th:text="#{flurmonitor.hinweis.ueberschrift}">Hinweis der Schulverwaltung</h2>
<p class="mb-0"><strong th:if="${hinweis.warnung}" th:text="#{flurmonitor.hinweis.warnung}">Warnung:</strong>
<span th:text="${hinweis.text}">Am Freitag endet der Unterricht schon um 12:15 Uhr.</span>
<small class="d-block mt-1" th:text="#{flurmonitor.hinweis.bis(${#temporals.format(hinweis.bis,
#messages.msg('flurmonitor.format.bis'))})}">gilt bis Fr 20.11.</small></p>
</div>
</aside>${hinweis.warnung} ruft isWarnung() auf; für Boolean-Getter mit is gilt in Thymeleaf dieselbe Kurzform wie für get. th:text maskiert den Text, den die Verwaltung eingibt. Tippt jemand <script> in das Formular, steht auf dem Monitor genau diese Zeichenfolge, das Skript läuft nicht. th:utext würde den Text ungeprüft als HTML einsetzen und ist deshalb für Eingaben tabu.
Einen Cache wie bei den Angeboten bekommen die Hinweise nicht. Die Abfrage ist klein, und ein neuer Hinweis soll beim nächsten Nachladen erscheinen. Wer später doch cacht, muss den Namen in spring.cache.cache-names eintragen und beim Anlegen und Löschen mit @CacheEvict leeren, wie im letzten Artikel beschrieben.
Ein Formular-Objekt mit Bean Validation
Ein Formular schickt seine Felder als Name-Wert-Paare (text=…&bis=2026-11-17&wichtigkeit=WARNUNG). Spring MVC könnte jedes Feld einzeln mit @RequestParam entgegennehmen. Bei vier Feldern, Pflichtangaben und Fehlermeldungen wird das schnell unübersichtlich: Für jedes Feld wären eigene Prüfungen nötig, und bei einem Fehler müsste der Controller alle Eingaben wieder einzeln ins Model legen, damit das Formular sie erneut zeigt. Spring MVC bietet dafür das Formular-Objekt (engl. form backing bean): eine einfache Klasse mit einem Feld je Formularfeld. Spring füllt es beim Absenden über Data Binding (Felder werden über die Setter nach Namen befüllt und in den passenden Typ umgewandelt) und gibt es mit den Eingaben an das Template zurück, auch wenn sie ungültig sind.
Warum nicht gleich die Entity? Sie hätte dann Setter für alles, was im Formular steht, auch für Felder, die niemand von außen setzen darf (z. B. die id). Ein manipuliertes Formular mit einem zusätzlichen Feld id=1 würde dann einen fremden Hinweis überschreiben (Mass Assignment). Außerdem erwartet die Entity einen Haltepunkt, das Formular liefert nur dessen Nummer. Das Formular-Objekt trennt also Oberfläche und Datenmodell, so wie in der Backend-Serie die DTOs der REST-API.
public class HinweisFormular {
@NotBlank(message = "{hinweis.text.leer}")
@Size(max = Hinweis.MAX_LAENGE, message = "{hinweis.text.lang}")
private String text;
/** leer (null) = alle Haltepunkte. */
private Long haltepunkt;
@NotNull(message = "{hinweis.bis.leer}")
@FutureOrPresent(message = "{hinweis.bis.vergangen}")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) // <input type="date"> schickt 2026-11-20
private LocalDate bis;
@NotNull
private Wichtigkeit wichtigkeit = Wichtigkeit.INFO;
// Getter und Setter für alle Felder
}Die Annotationen stammen aus Bean Validation (Jakarta Validation, Referenzimplementierung Hibernate Validator), das der Starter spring-boot-starter-validation schon für die REST-API ins Projekt gebracht hat. Die Feldregeln stehen damit an einer Stelle und gelten für jeden Aufruf:
| Annotation | Bedeutung | hier für |
|---|---|---|
@NotBlank |
nicht null, nicht leer, nicht nur Leerzeichen |
Text |
@Size(max = 140) |
höchstens 140 Zeichen | Text |
@NotNull |
Pflichtangabe | Datum, Wichtigkeit |
@FutureOrPresent |
heute oder später | Datum |
@DateTimeFormat |
Spring-Annotation: in welchem Format der Text in ein Datum umgewandelt wird | Datum |
Steht die Meldung in geschweiften Klammern ({hinweis.text.leer}), sucht Spring Boot sie als Schlüssel in messages.properties. Die Texte liegen damit neben den übrigen Texten der Oberfläche und lassen sich übersetzen; {max} setzt den Wert aus der Annotation ein:
hinweis.text.leer=Bitte einen Hinweistext eingeben.
hinweis.text.lang=Der Hinweistext darf höchstens {max} Zeichen lang sein.
hinweis.bis.leer=Bitte ein Datum angeben, bis zu dem der Hinweis angezeigt wird.
hinweis.bis.vergangen=Das Datum liegt in der Vergangenheit.
# Umwandlungsfehler beim Data Binding (z. B. kein gültiges Datum): Code typeMismatch.<objekt>.<feld>
typeMismatch.formular.bis=Bitte ein gültiges Datum angeben.
Die letzte Zeile betrifft keinen Bean-Validation-Fehler, sondern einen Fehler beim Data Binding: Schickt ein Browser ohne Datumsauswahl „morgen“ statt eines Datums, scheitert schon die Umwandlung in LocalDate. Spring legt dann einen Fehler mit dem Code typeMismatch an und sucht den Text unter typeMismatch.<Objektname>.<Feld>.
Zwei Einschränkungen sind wichtig. Erstens prüft @FutureOrPresent gegen die Uhr der JVM, nicht gegen das Clock-Bean des Projekts; im Profil vorfuehrung mit fester Uhr kann das auseinanderlaufen. Für die Verwaltung im Alltag ist das richtig, für Tests mit festen Daten muss man es wissen. Zweitens ersetzen die Prüfungen im Browser (required, maxlength im HTML) die Prüfung auf dem Server nicht: Jede Anfrage lässt sich ohne Browser abschicken. Das Formular trägt deshalb novalidate, damit beim Ausprobieren die Meldungen des Servers zu sehen sind; im Betrieb könnte man es weglassen.
Das Formular im Template
Das Template verwaltung/hinweise.html entsteht aus dem statischen Formular der HTML-Grundlagen. Drei Attribute verbinden es mit dem Formular-Objekt:
th:object="${formular}"am<form>legt das Objekt fest, auf das sich die Felder darin beziehen.th:field="*{text}"an einem Eingabefeld setztid,nameund den aktuellen Wert. Der Sternchen-Ausdruck*{…}bezieht sich auf das Objekt austh:object,*{text}heißt also${formular.text}.th:errors="*{text}"gibt die Fehlermeldungen zu diesem Feld aus.${#fields.hasErrors('text')}fragt, ob es welche gibt, undth:errorclass="is-invalid"hängt die Bootstrap-Klasse für ein rot markiertes Feld an, wenn das Feld einen Fehler hat.
<form th:action="@{/verwaltung/hinweise}" th:object="${formular}" method="post" novalidate>
<div class="mb-3">
<label for="text" class="form-label" th:text="#{hinweis.text}">Hinweistext</label>
<textarea th:field="*{text}" class="form-control" th:errorclass="is-invalid" rows="3"
maxlength="140" required aria-describedby="text-fehler"></textarea>
<p id="text-fehler" class="invalid-feedback" th:if="${#fields.hasErrors('text')}" th:errors="*{text}">
Bitte einen Text eingeben.</p>
</div>
<div class="mb-3">
<label for="haltepunkt" class="form-label" th:text="#{hinweis.haltepunkt}">Gilt für Haltepunkt</label>
<select th:field="*{haltepunkt}" class="form-select">
<option value="" th:text="#{hinweis.alle}">alle Haltepunkte</option>
<option th:each="h : ${haltepunkte}" th:value="${h.id}" th:text="|${h.ort}, ${h.name}|">
Gütersloh, Hauptbahnhof, ZOB Steig 3</option>
</select>
</div>
<!-- Feld "bis" wie "text", mit type="date" -->
<fieldset class="mb-3">
<legend class="form-label fs-6" th:text="#{hinweis.wichtigkeit}">Wichtigkeit</legend>
<div class="form-check form-check-inline"
th:each="w : ${T(de.dbh.mitfahrlabor.domain.Wichtigkeit).values()}">
<input type="radio" class="form-check-input" th:field="*{wichtigkeit}" th:value="${w}">
<label class="form-check-label" th:for="${#ids.prev('wichtigkeit')}"
th:text="#{${'hinweis.wichtigkeit.' + w}}">Information</label>
</div>
</fieldset>
<button type="submit" class="btn btn-primary" th:text="#{verwaltung.speichern}">Hinweis speichern</button>
</form>Was th:field erzeugt, zeigt der Blick in den Seitenquelltext nach einem Fehler. Aus dem Textfeld wird <textarea id="text" name="text" …>, aus dem Datum <input type="date" id="bis" name="bis" value="2026-11-09" …>: Der Wert steht wieder im Feld, umgewandelt mit demselben Format wie beim Einlesen. Bei den Radiobuttons zählt th:field die IDs hoch (wichtigkeit1, wichtigkeit2) und setzt checked beim gewählten Wert; #ids.prev(…) liefert die ID des gerade erzeugten Buttons für das passende <label>. Die leere Option der Auswahlliste schickt einen leeren Text, aus dem Spring beim Umwandeln in Long den Wert null macht. Das passt zu „alle Haltepunkte“.
th:action statt action hat einen zweiten Zweck: Thymeleaf ergänzt bei POST-Formularen automatisch ein verstecktes Feld _csrf, sobald Spring Security den CSRF-Schutz für die Seite eingeschaltet hat. Was es damit auf sich hat, steht im Abschnitt Anmelden und CSRF-Schutz.
Das Bildschirmfoto zeigt die Seite, nachdem Heike Brinkmann ohne Text und mit gestrigem Datum gespeichert hat. Beide Felder sind markiert, die Meldungen stehen direkt darunter, die übrigen Eingaben (Warnung) sind erhalten:
Prüfen im Controller: @Valid und BindingResult
Der HinweisVerwaltungController ist ein @Controller unter /verwaltung/hinweise. Die GET-Methode legt ein leeres Formular-Objekt unter dem Namen formular ins Model, dazu die Liste der Hinweise und die Haltepunkte für die Auswahl. Die POST-Methode nimmt das Formular entgegen:
@PostMapping
public String anlegen(@Valid @ModelAttribute("formular") HinweisFormular formular, BindingResult pruefung,
Model model, Principal angemeldet, RedirectAttributes weiterleitung) {
if (pruefung.hasErrors()) {
fuellen(model, angemeldet);
return SEITE; // kein Redirect: Eingaben und Fehlermeldungen stehen nur in diesem Request
}
Hinweis neu = hinweisService.legeAn(formular.getText(), formular.getHaltepunkt(), formular.getBis(),
formular.getWichtigkeit());
weiterleitung.addFlashAttribute("meldung", "hinweis.gespeichert");
weiterleitung.addFlashAttribute("neueId", neu.getId());
return ZURUECK_ZUR_SEITE; // "redirect:/verwaltung/hinweise"
}@ModelAttribute("formular") sagt Spring, unter welchem Namen das Objekt im Model liegt; er muss zum th:object im Template passen. @Valid löst nach dem Data Binding die Bean Validation aus. Das BindingResult sammelt alle Fehler aus Umwandlung und Validierung. Es muss als Parameter direkt hinter dem geprüften Objekt stehen. Fehlt es dort, wirft Spring bei einem Fehler eine MethodArgumentNotValidException, und statt des Formulars erscheint eine Fehlerseite.
Bei Fehlern gibt die Methode einfach den View-Namen der Seite zurück. Das Formular-Objekt mit den Eingaben und das BindingResult liegen schon im Model; th:field und th:errors finden sie dort. Die übrigen Daten der Seite (Liste, Haltepunkte, Kontoname) legt die private Methode fuellen(…) wieder hinein, die auch die GET-Methode nutzt.
Fachliche Prüfungen, die keine Annotation abdeckt, landen ebenfalls im BindingResult: Mit pruefung.rejectValue("feld", "schluessel") hängt der Controller einen eigenen Fehler an ein Feld, etwa wenn ein Datensatz mit der eingegebenen Nummer nicht existiert. Das Template zeigt ihn dann genauso an wie die Fehler aus den Annotationen.
Post-Redirect-Get
Ohne Fehler ruft der Controller den Service auf und antwortet nicht mit einer Seite, sondern mit redirect:/verwaltung/hinweise. Spring macht daraus die HTTP-Antwort 302 Found mit dem Kopf Location: /verwaltung/hinweise. Der Browser folgt der Umleitung von sich aus mit einer GET-Anfrage. Dieses Muster heißt Post-Redirect-Get (PRG).
Warum der Umweg? Würde der Controller nach dem Speichern direkt die Seite zurückgeben, wäre die letzte Anfrage im Browser der POST. Drückt Heike Brinkmann dann F5 oder kommt sie mit „Zurück“ auf die Seite, fragt der Browser „Formular erneut senden?“, und wer „Ja“ klickt, legt den Hinweis ein zweites Mal an. Nach dem Redirect ist die letzte Anfrage dagegen ein GET; Neuladen liest nur die Liste noch einmal. Außerdem lässt sich die Adresse nach dem Speichern als Lesezeichen ablegen, und der Verlauf enthält keine POST-Einträge.
| Situation | Antwort des Controllers | Was Neuladen bewirkt |
|---|---|---|
| Formular ohne Fehler, direkt die Seite zurückgegeben | 200 OK mit HTML |
sendet den POST erneut: zweiter Hinweis |
| Formular ohne Fehler, Post-Redirect-Get | 302 Found, danach GET mit 200 OK |
wiederholt nur das GET |
| Formular mit Fehlern | 200 OK, Formular mit Eingaben und Meldungen |
sendet den ungültigen POST erneut, es wird wieder nichts gespeichert |
Bei Fehlern leitet der Controller bewusst nicht um. Ein Redirect beginnt einen neuen Request; das Formular-Objekt mit den Eingaben und das BindingResult wären verloren, und die Verwaltung stünde vor einem leeren Formular ohne Hinweis, was falsch war. Ein wiederholter ungültiger POST schadet nicht, weil er wieder an der Prüfung scheitert.
Die Erfolgsmeldung muss den Redirect überleben. Ein normales Model-Attribut tut das nicht: Spring MVC verwirft das Model, wenn ein Controller umleitet. Dafür gibt es Flash-Attribute. RedirectAttributes.addFlashAttribute(…) legt den Wert kurz in der Sitzung ab; Spring holt ihn bei der nächsten Anfrage heraus, legt ihn ins Model und löscht ihn sofort. Die Meldung erscheint also genau einmal. Das Template zeigt sie als Status:
<p th:if="${meldung}" class="alert alert-success" role="status" th:text="#{${meldung}}">Hinweis gespeichert.</p>Im Flash-Attribut steht nur der Schlüssel hinweis.gespeichert; der Text kommt aus messages.properties. addAttribute(…) (ohne „Flash“) würde den Wert dagegen als Parameter an die Adresse hängen (/verwaltung/hinweise?meldung=…), wo jeder ihn sieht und ändern kann. Das passt für Werte wie eine Seitennummer, nicht für Meldungen.
Auch das Löschen ist ein POST mit Umleitung: Jeder Eintrag der Liste hat ein kleines Formular mit dem Button „Löschen“ und th:action="@{/verwaltung/hinweise/{id}/loeschen(id=${hinweis.id})}". Ein Link mit GET wäre einfacher, aber GET-Anfragen sollen nichts ändern. Ein Browser, der Links vorab lädt, oder ein Werkzeug, das alle Links einer Seite prüft, würde sonst Hinweise löschen.
Anmelden und CSRF-Schutz
AK-MON-07.3 verlangt eine Anmeldung. Die Sicherheitskonfiguration aus der Backend-Serie meldet mit HTTP Basic und ohne Sitzung an und hat den CSRF-Schutz ausgeschaltet. Für die REST-API ist das passend; für Seiten, die ein Mensch im Browser bedient, nicht: HTTP Basic zeigt nur das graue Anmeldefenster des Browsers, eine Abmeldung gibt es nicht, und Formulare brauchen CSRF-Schutz.
CSRF (Cross-Site Request Forgery) nutzt aus, dass der Browser das Sitzungs-Cookie bei jeder Anfrage an die Anwendung mitschickt, egal von welcher Seite die Anfrage ausgeht. Ist Heike Brinkmann angemeldet und öffnet in einem anderen Tab eine präparierte Seite, kann diese unbemerkt ein Formular an /verwaltung/hinweise abschicken, und der Server hielte es für ihres. Der Schutz: Jedes Formular der Anwendung enthält ein geheimes, zufälliges Token, das zur Sitzung gehört. Spring Security lehnt jeden POST, PUT, PATCH oder DELETE ohne passendes Token mit 403 Forbidden ab. Eine fremde Seite kennt das Token nicht. Thymeleaf fügt es über th:action selbst ein:
<form action="/verwaltung/hinweise" method="post" novalidate><input type="hidden" name="_csrf" value="kW3x…"/>Die REST-API braucht das nicht, weil sie keine Sitzung und kein Cookie hat: Ohne Authorization-Kopf, den eine fremde Seite nicht setzen kann, kommt keine Anfrage durch.
Das Projekt bekommt deshalb eine zweite Filterkette. securityMatcher(…) legt fest, für welche Adressen sie gilt, @Order(1) lässt Spring Security sie zuerst prüfen. Alle anderen Anfragen landen wie bisher in der vorhandenen Kette, die jetzt @Order(2) trägt:
@Bean
@Order(1)
SecurityFilterChain verwaltungsfilter(HttpSecurity http) throws Exception {
http.securityMatcher("/verwaltung/**", "/login", "/logout")
.authorizeHttpRequests(regeln -> regeln
.requestMatchers("/verwaltung/**").hasRole("ORGA")
.anyRequest().permitAll())
.formLogin(anmeldung -> anmeldung.defaultSuccessUrl("/verwaltung/hinweise"))
.logout(abmeldung -> abmeldung.logoutSuccessUrl("/login?logout"));
return http.build();
}Ohne weitere Angaben gilt in dieser Kette der Standard von Spring Security: Sitzung nach der Anmeldung, CSRF-Schutz an, eine erzeugte Anmeldeseite unter /login. Wer ohne Anmeldung /verwaltung/hinweise aufruft, wird dorthin umgeleitet und nach der Anmeldung zurückgeschickt. Die Anmeldung prüft die vorhandene NutzerAnmeldung gegen die Tabelle nutzer. Heike Brinkmann bekommt dort ein Konto mit dem Pseudonym Sekretariat-HB und der Rolle ORGA, die bisher nur Andrea Wolf als Administratorin hat. Eine eigene Rolle für die Verwaltung wäre genauer; das Team notiert das für den Change Request mit Keycloak am Ende der Serie, der die Anmeldung der Verwaltung ohnehin ersetzt. Auch das Abmelden ist ein POST mit CSRF-Token, damit eine fremde Seite niemanden abmelden kann.
Formulare testen
Die Tests der Verwaltung sind @WebMvcTests wie in der Testreihe zur Web-Schicht: Controller, Template und Sicherheitskonfiguration laufen echt, der Service ist gemockt. @WithMockUser meldet für einen Test eine Person mit Rolle an, csrf() aus spring-security-test fügt der Anfrage ein gültiges Token hinzu:
@Test
@WithMockUser(roles = "ORGA")
void gueltigerHinweisWirdGespeichertUndUmgeleitet() { // Post-Redirect-Get
assertThat(mvc.post().uri("/verwaltung/hinweise").with(csrf())
.param("text", "Linie 61 fällt morgen aus.")
.param("haltepunkt", "")
.param("bis", MORGEN.toString())
.param("wichtigkeit", "WARNUNG"))
.hasStatus(HttpStatus.FOUND)
.hasRedirectedUrl("/verwaltung/hinweise")
.flash().containsEntry("meldung", "hinweis.gespeichert");
then(hinweisService).should().legeAn("Linie 61 fällt morgen aus.", null, MORGEN, Wichtigkeit.WARNUNG);
}
@Test
@WithMockUser(roles = "ORGA")
void ungueltigeEingabenZeigenDieSeiteMitMeldungen() { // AK-MON-07.4
assertThat(mvc.post().uri("/verwaltung/hinweise").with(csrf())
.param("text", " ")
.param("bis", "2020-01-01")
.param("wichtigkeit", "INFO"))
.hasStatusOk()
.hasViewName(HinweisVerwaltungController.SEITE)
.bodyText()
.contains("Bitte einen Hinweistext eingeben.", "Das Datum liegt in der Vergangenheit.",
"value=\"2020-01-01\"", "is-invalid");
then(hinweisService).should(never()).legeAn(any(), any(), any(), any());
}MORGEN ist LocalDate.now().plusDays(1), weil @FutureOrPresent gegen die echte Uhr prüft. Weitere Tests derselben Klasse decken ab: Ohne Anmeldung kommt 302 zur Anmeldeseite (AK-MON-07.3), mit Rolle NUTZER 403, ohne CSRF-Token 403 und kein Aufruf des Service, ein zu langer Text und ein Text statt Datum landen als Feldfehler im BindingResult (.model().extractingBindingResult("formular").hasFieldErrors("text", "bis")), und eine Flash-Meldung erscheint auf der Seite.
Für MON-08 prüft der FlurmonitorControllerTest mit gemocktem Service, dass eine Warnung als alert-warning mit dem Wort „Warnung:“ vor der ersten Tabelle steht (AK-MON-08.1) und dass auch das Fragment aktuell die Hinweise enthält. Ein HinweisServiceTest mit fester Uhr deckt die Regel für Haltepunkte ab (AK-MON-08.2), ein @DataJpaTest die Abfragen und ihre Sortierung gegen die Startdaten. Die vorhandenen @WebMvcTests des Flurmonitors brauchen ein @MockitoBean HinweisService, weil der Controller eine neue Abhängigkeit hat; ohne Vorgabe liefert der Mock eine leere Liste, und die bisherigen Erwartungen bleiben gültig.
Den Ablauf im Browser prüft Jonas Wolff zusätzlich von Hand: Anmelden, einen Hinweis speichern, mit F5 neu laden und in der Liste nachsehen, dass er nur einmal da ist. In Sprint 3 übernimmt das ein Selenium-Test.
Code-Stand
Den vollständigen Stand dieses Artikels findest Du im Ordner src/a08-hinweise-formulare des Backend-Repositorys. Neue Abhängigkeiten braucht er nicht: Bean Validation und Spring Security sind seit der Backend-Serie im Projekt. Gegenüber dem Stand a07 sind neu bzw. geändert:
| Datei | Änderung |
|---|---|
domain/Hinweis.java, domain/Wichtigkeit.java |
neu: Entity und Enum |
repository/HinweisRepository.java |
neu: zwei abgeleitete Abfragen mit @EntityGraph |
service/HinweisService.java |
neu: anlegen, löschen, alle, gültige je Monitor |
web/verwaltung/HinweisFormular.java |
neu: Formular-Objekt mit Bean Validation |
web/verwaltung/HinweisVerwaltungController.java |
neu: Seite, Anlegen mit Post-Redirect-Get, Löschen |
web/FlurmonitorController.java |
Hinweise im Model |
web/SicherheitsKonfiguration.java |
zweite Filterkette für /verwaltung/** mit Anmeldeformular und CSRF-Schutz |
templates/flurmonitor.html |
Hinweise aus dem Model statt fest im Template |
templates/verwaltung/hinweise.html |
neu: Verwaltungsseite |
messages.properties, messages_en.properties |
Texte der Hinweise, der Verwaltung und der Validierung |
data.sql |
Konto Sekretariat-HB (Passwort wie alle Demo-Konten), zwei Hinweise |
Tests unter test/… |
neu: HinweisVerwaltungControllerTest, HinweisServiceTest, HinweisRepositoryTest; Flurmonitor-Tests mit @MockitoBean HinweisService, Smoke-Test erwartet die Umleitung zur Anmeldung |
sprintplan.md, product-backlog.md (neben dem Projektordner) |
Sprint Backlog T36–T42, geänderte und neue Abnahmekriterien |
Der Startcode für die Übungen mit den Mitfahrgesuchen liegt wie bisher im Unterordner uebung-mitfahrgesuche. Der Platzhalter-Service für die Gesuche kann dort jetzt ein Gesuch zurückziehen.
Nächster Schritt: Robustheit und Sprint Review
Nach Tag 9 erfüllen MON-07 und MON-08 die Definition of Done: Alle Abnahmekriterien sind durch Tests belegt, Heike Brinkmann hat in der Sichtprüfung einen Hinweis angelegt und ihn auf dem Monitor im Foyer erscheinen sehen. Im Sprint sind damit 18 Story Points fertig, 13 feste und 5 aus der Option. Offen ist noch MON-10, die verständliche Störungsanzeige. Im Artikel Robustheit und Betrieb bekommt der Monitor eigene Fehlerseiten, Logging und Health-Checks, und das Team schließt Sprint 2 mit Review und Retrospektive ab.
In den Übungsaufgaben zu diesem Artikel ordnest Du den Ablauf von Post-Redirect-Get, baust ein Formular zum Zurückziehen eines Mitfahrgesuchs und findest heraus, warum Hinweise doppelt gespeichert werden.
Leitfragen
- Warum nimmt der Controller ein eigenes Formular-Objekt entgegen und nicht die Entity
Hinweis?
Das Formular-Objekt enthält genau die Felder des Formulars und nimmt auch ungültige Eingaben auf. Die Entity hätte dann Setter für Felder wie id, die niemand über das Formular setzen darf (Mass Assignment), und sie erwartet einen Haltepunkt statt einer Nummer. siehe Abschnitt „Formular-Objekt“
- Was bewirken
th:object,th:fieldundth:errors?
th:object legt am <form> das Formular-Objekt fest. th:field="*{feld}" setzt id, name und den aktuellen Wert des Feldes, bei Radiobuttons und Auswahllisten auch checked bzw. selected. th:errors="*{feld}" gibt die Fehlermeldungen zu diesem Feld aus. siehe Abschnitt „Das Formular im Template“
- Wo muss das
BindingResultstehen, und was passiert, wenn es fehlt?
Direkt hinter dem Parameter mit @Valid. Fehlt es, wirft Spring bei einem Validierungsfehler eine MethodArgumentNotValidException, und statt des Formulars mit Meldungen kommt eine Fehlerseite. siehe Abschnitt „Prüfen im Controller“
- Welches Problem löst Post-Redirect-Get?
Gibt der Controller nach dem Speichern direkt eine Seite zurück, ist die letzte Anfrage im Browser der POST. Neuladen oder „Zurück“ schickt ihn erneut, und der Datensatz wird doppelt gespeichert. Mit der Umleitung ist die letzte Anfrage ein GET, das gefahrlos wiederholt werden kann. siehe Abschnitt „Post-Redirect-Get“
- Warum leitet der Controller bei Fehlern nicht um?
Eine Umleitung beginnt einen neuen Request. Das Formular-Objekt mit den Eingaben und das BindingResult mit den Meldungen wären verloren. Ein wiederholter ungültiger POST schadet nicht, weil er erneut an der Prüfung scheitert. siehe Abschnitt „Post-Redirect-Get“
- Wie kommt die Meldung „Hinweis gespeichert“ über die Umleitung?
Als Flash-Attribut über RedirectAttributes.addFlashAttribute(…). Spring legt es kurz in der Sitzung ab, gibt es bei der nächsten Anfrage ins Model und löscht es danach. Ein normales Model-Attribut wird bei einer Umleitung verworfen. siehe Abschnitt „Post-Redirect-Get“
- Wogegen schützt das CSRF-Token, und warum braucht die REST-API es nicht?
Gegen Anfragen, die eine fremde Seite im Namen einer angemeldeten Person abschickt; der Browser sendet das Sitzungs-Cookie mit, das Token kennt die fremde Seite aber nicht. Die REST-API hat keine Sitzung und kein Cookie, sie verlangt bei jeder Anfrage einen Authorization-Kopf, den eine fremde Seite nicht setzen kann. siehe Abschnitt „Anmelden und CSRF-Schutz“
- Wie trennt die Sicherheitskonfiguration Verwaltung und API?
Mit zwei SecurityFilterChain-Beans. Die Kette für die Verwaltung gilt per securityMatcher nur für /verwaltung/**, /login und /logout und wird mit @Order(1) zuerst geprüft; sie nutzt Anmeldeformular, Sitzung und CSRF-Schutz. Alle übrigen Anfragen gehen an die bisherige Kette mit HTTP Basic ohne Sitzung. siehe Abschnitt „Anmelden und CSRF-Schutz“
Links und weitere Informationen
für den Einstieg: Ein Flurmonitor mit Thymeleaf: das Projekt im Überblick
als Grundlage: Automatisch aktualisieren, HTML-Grundlagen (Formulare) und Scrum
zum Szenario: Szenario Mitfahrlabor mit Personas und Flurmonitoren
als Nachschlagewerk: Tutorial: Thymeleaf + Spring, Kapitel Creating a Form und Validation and Error Messages
als Nachschlagewerk: Spring Framework Reference: Validation und Flash Attributes
als Nachschlagewerk: Spring Security Reference: Cross Site Request Forgery und Form Login
zum Weiterlesen: Jakarta Validation Specification und OWASP Cheat Sheet: Cross-Site Request Forgery Prevention
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: “Formulare mit Thymeleaf: Die Schulverwaltung legt Hinweise für den Flurmonitor an” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/flurmonitor-hinweise-formulare 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]
