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:
@@ -12,6 +12,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.*;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
@@ -43,6 +44,17 @@ public class SecurityConfig {
|
||||
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
|
||||
// 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/**")
|
||||
)
|
||||
.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();
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.example.mindflux.dto.GameStats;
|
||||
import com.example.mindflux.dto.ScoreResult;
|
||||
import com.example.mindflux.dto.ScoreSubmission;
|
||||
import com.example.mindflux.service.GameScoreService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -32,7 +33,7 @@ public class GameScoreController {
|
||||
|
||||
@PostMapping("/{game}/scores")
|
||||
public ResponseEntity<ScoreResult> submitScore(@PathVariable String game,
|
||||
@RequestBody ScoreSubmission submission,
|
||||
@Valid @RequestBody ScoreSubmission submission,
|
||||
Principal principal) {
|
||||
if (!KNOWN_GAMES.contains(game)) {
|
||||
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.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.util.UUID;
|
||||
import java.util.List;
|
||||
import com.example.mindflux.dto.WeeklyActivity;
|
||||
import com.example.mindflux.service.AvatarService;
|
||||
|
||||
@Controller
|
||||
public class ProfileController {
|
||||
@@ -45,7 +36,8 @@ public class ProfileController {
|
||||
@Autowired
|
||||
private ProgressService progressService;
|
||||
|
||||
private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/profiles/";
|
||||
@Autowired
|
||||
private AvatarService avatarService;
|
||||
|
||||
@GetMapping("/profile")
|
||||
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);
|
||||
|
||||
if (existingUser != null) {
|
||||
String profilePicture = existingUser.getProfilePicture();
|
||||
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
|
||||
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
|
||||
avatarService.delete(existingUser.getProfilePicture());
|
||||
existingUser.setProfilePicture(null); // auch wenn keine Datei existierte
|
||||
userService.save(existingUser);
|
||||
}
|
||||
|
||||
@@ -122,47 +98,9 @@ public class ProfileController {
|
||||
if (existingUser != null) {
|
||||
existingUser.setEmail(email); // Email aktualisieren
|
||||
|
||||
if (file != null && !file.isEmpty()) {
|
||||
try {
|
||||
BufferedImage original = ImageIO.read(file.getInputStream());
|
||||
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);
|
||||
}
|
||||
String newAvatar = avatarService.store(file, existingUser.getProfilePicture());
|
||||
if (newAvatar != null) {
|
||||
existingUser.setProfilePicture(newAvatar);
|
||||
}
|
||||
|
||||
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).
|
||||
User user = userService.findByUsername(username);
|
||||
if (user != null) {
|
||||
String profilePicture = 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);
|
||||
}
|
||||
}
|
||||
avatarService.delete(user.getProfilePicture());
|
||||
}
|
||||
|
||||
userService.deleteByUsername(username);
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
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
|
||||
* bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten
|
||||
* berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann.
|
||||
* Die Constraints fangen offensichtlich unsinnige Eingaben schon vor der Logik ab.
|
||||
*/
|
||||
public class ScoreSubmission {
|
||||
@NotBlank @Size(max = 100)
|
||||
private String deck;
|
||||
@Min(0) @Max(1000)
|
||||
private int pairs;
|
||||
@Min(0) @Max(100000)
|
||||
private int moves;
|
||||
@Min(0) @Max(86400)
|
||||
private int durationSeconds;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user