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.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user