AP7 Best Practices (Teil): Security-Header, AvatarService, Bean-Validation-Start

- SecurityConfig: Security-Header ergaenzt – CSP (self-hosted, pragmatisch mit
  'unsafe-inline' fuer die Template-Inline-Styles/-Skripte, Chart.js ist lokal),
  Referrer-Policy strict-origin-when-cross-origin, HSTS bleibt Spring-Default
  (nur ueber HTTPS = faktisch Prod).
- AvatarService extrahiert: Bild-Dekodierung/Skalierung/Datei-I/O + Pfad-Logik
  (vorher 3x im ProfileController dupliziert) an einer Stelle; Controller nutzt
  jetzt avatarService.store()/delete(). Verhalten unveraendert.
- Bean Validation aktiviert: ScoreSubmission mit @NotBlank/@Size/@Min/@Max,
  Consumer mit @Valid (ApiExceptionHandler behandelt MethodArgumentNotValid
  bereits). Ausbau auf weitere Request-DTOs + NeuralNet-DTOs, Spring-Boot-Patch
  und Cookie-CSRF stehen noch aus (brauchen Test-Lauf mit DB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFxcjtc9HNP4VECT6Ur1tE
This commit is contained in:
AliceDarkest
2026-07-21 00:25:54 +02:00
parent 501ecc0c1e
commit f8f068150e
5 changed files with 132 additions and 80 deletions
@@ -12,6 +12,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*; import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
@Configuration @Configuration
public class SecurityConfig { public class SecurityConfig {
@@ -43,6 +44,17 @@ public class SecurityConfig {
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. // Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/serviceclash/**", "/api/networker/**", "/api/neuralnet/**") .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/serviceclash/**", "/api/networker/**", "/api/neuralnet/**")
)
.headers(headers -> headers
// CSP: pragmatischer Start – alle Assets sind self-hosted (Chart.js lokal
// gevendort). 'unsafe-inline' bleibt vorerst nötig, weil Templates Inline-
// Styles und einzelne Inline-Skripte enthalten (später auf Nonces umstellbar).
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"))
.referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
// HSTS bleibt auf Spring-Default: wird nur über HTTPS gesendet, greift also
// faktisch nur in Produktion (Dev läuft über HTTP).
); );
return http.build(); return http.build();
@@ -4,6 +4,7 @@ import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ScoreResult; import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission; import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.service.GameScoreService; import com.example.mindflux.service.GameScoreService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -32,7 +33,7 @@ public class GameScoreController {
@PostMapping("/{game}/scores") @PostMapping("/{game}/scores")
public ResponseEntity<ScoreResult> submitScore(@PathVariable String game, public ResponseEntity<ScoreResult> submitScore(@PathVariable String game,
@RequestBody ScoreSubmission submission, @Valid @RequestBody ScoreSubmission submission,
Principal principal) { Principal principal) {
if (!KNOWN_GAMES.contains(game)) { if (!KNOWN_GAMES.contains(game)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -17,19 +17,10 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Principal; import java.security.Principal;
import java.util.UUID;
import java.util.List; import java.util.List;
import com.example.mindflux.dto.WeeklyActivity; import com.example.mindflux.dto.WeeklyActivity;
import com.example.mindflux.service.AvatarService;
@Controller @Controller
public class ProfileController { public class ProfileController {
@@ -45,7 +36,8 @@ public class ProfileController {
@Autowired @Autowired
private ProgressService progressService; private ProgressService progressService;
private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/profiles/"; @Autowired
private AvatarService avatarService;
@GetMapping("/profile") @GetMapping("/profile")
public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) { public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) {
@@ -67,24 +59,8 @@ public class ProfileController {
User existingUser = userService.findByUsername(username); User existingUser = userService.findByUsername(username);
if (existingUser != null) { if (existingUser != null) {
String profilePicture = existingUser.getProfilePicture(); avatarService.delete(existingUser.getProfilePicture());
if (profilePicture != null && !profilePicture.trim().isEmpty()) { existingUser.setProfilePicture(null); // auch wenn keine Datei existierte
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
if (Files.exists(picturePath)) {
Files.delete(picturePath);
log.info("Profilbild gelöscht: {}", picturePath.getFileName());
} else {
log.warn("Profilbild-Datei existiert nicht: {}", picturePath.toAbsolutePath());
}
} catch (IOException e) {
log.error("Fehler beim Löschen der Profilbild-Datei", e);
}
} else {
log.debug("Kein Profilbild gesetzt, nichts zu löschen.");
}
existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert
userService.save(existingUser); userService.save(existingUser);
} }
@@ -122,47 +98,9 @@ public class ProfileController {
if (existingUser != null) { if (existingUser != null) {
existingUser.setEmail(email); // Email aktualisieren existingUser.setEmail(email); // Email aktualisieren
if (file != null && !file.isEmpty()) { String newAvatar = avatarService.store(file, existingUser.getProfilePicture());
try { if (newAvatar != null) {
BufferedImage original = ImageIO.read(file.getInputStream()); existingUser.setProfilePicture(newAvatar);
if (original == null) {
// Keine lesbare Bilddatei -> Upload ignorieren, restliches Profil trotzdem speichern.
log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
} else {
// Auf max. Kantenlänge herunterskalieren (kein Hochskalieren).
final int MAX_DIM = 400;
int w = original.getWidth();
int h = original.getHeight();
double scale = Math.min(1.0, (double) MAX_DIM / Math.max(w, h));
int nw = Math.max(1, (int) Math.round(w * scale));
int nh = Math.max(1, (int) Math.round(h * scale));
BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setColor(Color.WHITE); // JPEG kennt kein Alpha -> Transparenz auf weißem Grund
g.fillRect(0, 0, nw, nh);
g.drawImage(original, 0, 0, nw, nh, null);
g.dispose();
// Altes Profilbild löschen (falls vorhanden)
if (existingUser.getProfilePicture() != null && !existingUser.getProfilePicture().isEmpty()) {
Path oldFilePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", existingUser.getProfilePicture());
Files.deleteIfExists(oldFilePath);
}
// Normalisiert als JPEG speichern
String fileName = UUID.randomUUID() + ".jpg";
Path uploadPath = Paths.get(UPLOAD_DIR);
Files.createDirectories(uploadPath);
ImageIO.write(scaled, "jpg", uploadPath.resolve(fileName).toFile());
existingUser.setProfilePicture(fileName);
}
} catch (IOException e) {
log.error("Fehler beim Speichern des Profilbilds", e);
}
} }
userService.save(existingUser); userService.save(existingUser);
@@ -203,15 +141,7 @@ public class ProfileController {
// Profilbild-Datei von der Platte entfernen (DB-Daten löscht der Service in einer Transaktion). // Profilbild-Datei von der Platte entfernen (DB-Daten löscht der Service in einer Transaktion).
User user = userService.findByUsername(username); User user = userService.findByUsername(username);
if (user != null) { if (user != null) {
String profilePicture = user.getProfilePicture(); avatarService.delete(user.getProfilePicture());
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
Files.deleteIfExists(picturePath);
} catch (IOException e) {
log.error("Fehler beim Löschen des Profilbilds", e);
}
}
} }
userService.deleteByUsername(username); userService.deleteByUsername(username);
@@ -1,14 +1,24 @@
package com.example.mindflux.dto; package com.example.mindflux.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** /**
* Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird * Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird
* bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten * bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten
* berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann. * berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann.
* Die Constraints fangen offensichtlich unsinnige Eingaben schon vor der Logik ab.
*/ */
public class ScoreSubmission { public class ScoreSubmission {
@NotBlank @Size(max = 100)
private String deck; private String deck;
@Min(0) @Max(1000)
private int pairs; private int pairs;
@Min(0) @Max(100000)
private int moves; private int moves;
@Min(0) @Max(86400)
private int durationSeconds; private int durationSeconds;
public String getDeck() { return deck; } public String getDeck() { return deck; }
@@ -0,0 +1,99 @@
package com.example.mindflux.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
import javax.imageio.ImageIO;
/**
* Kapselt die gesamte Profilbild-Logik (Dekodierung, Skalierung, Datei-I/O, Pfad-Auflösung),
* die zuvor mehrfach im ProfileController dupliziert war. Der Controller bleibt so schlank
* und die Bildverarbeitung ist an einer Stelle testbar/änderbar.
*/
@Service
public class AvatarService {
private static final Logger log = LoggerFactory.getLogger(AvatarService.class);
private static final int MAX_DIM = 400;
/** Basisverzeichnis der Profilbilder – eine einzige Quelle der Wahrheit für den Pfad. */
private Path baseDir() {
return Paths.get(System.getProperty("user.dir"), "uploads", "profiles");
}
/** Voller Pfad zu einem gespeicherten Profilbild. */
public Path resolve(String fileName) {
return baseDir().resolve(fileName);
}
/**
* Verarbeitet einen Bild-Upload: liest das Bild, skaliert auf max. Kantenlänge herunter,
* löscht ein evtl. vorhandenes altes Bild und speichert normalisiert als JPEG.
*
* @return der neue Dateiname, oder {@code null}, wenn kein/kein lesbares Bild geliefert wurde.
*/
public String store(MultipartFile file, String oldFileName) {
if (file == null || file.isEmpty()) {
return null;
}
try {
BufferedImage original = ImageIO.read(file.getInputStream());
if (original == null) {
log.warn("Profilbild-Upload ignoriert: keine lesbare Bilddatei.");
return null;
}
int w = original.getWidth();
int h = original.getHeight();
double scale = Math.min(1.0, (double) MAX_DIM / Math.max(w, h));
int nw = Math.max(1, (int) Math.round(w * scale));
int nh = Math.max(1, (int) Math.round(h * scale));
BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setColor(Color.WHITE); // JPEG kennt kein Alpha -> Transparenz auf weißem Grund
g.fillRect(0, 0, nw, nh);
g.drawImage(original, 0, 0, nw, nh, null);
g.dispose();
delete(oldFileName); // altes Bild aufräumen
String fileName = UUID.randomUUID() + ".jpg";
Path dir = baseDir();
Files.createDirectories(dir);
ImageIO.write(scaled, "jpg", dir.resolve(fileName).toFile());
return fileName;
} catch (IOException e) {
log.error("Fehler beim Speichern des Profilbilds", e);
return null;
}
}
/** Löscht ein Profilbild, falls Name gesetzt und Datei vorhanden. Fehler werden nur geloggt. */
public void delete(String fileName) {
if (fileName == null || fileName.trim().isEmpty()) {
return;
}
try {
Path path = resolve(fileName);
if (Files.deleteIfExists(path)) {
log.info("Profilbild gelöscht: {}", path.getFileName());
}
} catch (IOException e) {
log.error("Fehler beim Löschen der Profilbild-Datei", e);
}
}
}