This commit is contained in:
@@ -6,6 +6,16 @@ Versionierung [semantisch](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased] – 2026-07-14 · SysnaptiK Neural Rebrand
|
## [Unreleased] – 2026-07-14 · SysnaptiK Neural Rebrand
|
||||||
|
|
||||||
|
### Konto & Sicherheit
|
||||||
|
- **E-Mail-Bestätigung:** Neue Accounts erhalten einen einmaligen, 24 Stunden
|
||||||
|
gültigen Bestätigungslink und werden erst nach erfolgreicher Verifikation zum
|
||||||
|
Login freigeschaltet. Bestehende Accounts bleiben ohne Unterbrechung bestätigt.
|
||||||
|
- **Passwort-Reset per E-Mail:** Über „Passwort vergessen?“ können bestätigte
|
||||||
|
Nutzerinnen und Nutzer einen zeitlich begrenzten Reset-Link anfordern und ihr
|
||||||
|
Passwort selbst neu vergeben. Token werden ausschließlich gehasht gespeichert.
|
||||||
|
- **Admin-Reset vereinheitlicht:** Manuelle Passwort-Resets im Adminbereich
|
||||||
|
verwenden jetzt verdeckte Eingabefelder und dieselbe Mindestlänge von acht Zeichen.
|
||||||
|
|
||||||
### Marke & Plattform
|
### Marke & Plattform
|
||||||
- **Neural Lightmode & Kontrast:** Die helle Plattformvariante ist wieder ein
|
- **Neural Lightmode & Kontrast:** Die helle Plattformvariante ist wieder ein
|
||||||
vollständiges Mint-/Blau-Neural-Theme, inklusive Sidebar, Karten und Login.
|
vollständiges Mint-/Blau-Neural-Theme, inklusive Sidebar, Karten und Login.
|
||||||
|
|||||||
@@ -60,6 +60,12 @@
|
|||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SMTP-Versand für Bestätigungs- und Passwort-Reset-Mails -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Health/Monitoring (Container-Healthcheck auf /actuator/health) -->
|
<!-- Health/Monitoring (Container-Healthcheck auf /actuator/health) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class SecurityConfig {
|
|||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
|
.requestMatchers("/", "/login", "/register", "/forgot-password", "/reset-password", "/verify-email", "/verification/resend", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
|
||||||
.requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
|
.requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
|
||||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/users/**").hasRole("ADMIN")
|
.requestMatchers("/api/users/**").hasRole("ADMIN")
|
||||||
@@ -63,6 +63,7 @@ public class SecurityConfig {
|
|||||||
.withUsername(user.getUsername())
|
.withUsername(user.getUsername())
|
||||||
.password(user.getPassword())
|
.password(user.getPassword())
|
||||||
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
|
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
|
||||||
|
.disabled(!user.isEmailVerified())
|
||||||
.build();
|
.build();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.example.mindflux.controller;
|
||||||
|
|
||||||
|
import com.example.mindflux.service.AccountMailService;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class AccountRecoveryController {
|
||||||
|
|
||||||
|
private final AccountMailService accountMailService;
|
||||||
|
|
||||||
|
public AccountRecoveryController(AccountMailService accountMailService) {
|
||||||
|
this.accountMailService = accountMailService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/forgot-password")
|
||||||
|
public String forgotPassword() {
|
||||||
|
return "forgot-password";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forgot-password")
|
||||||
|
public String requestPasswordReset(@RequestParam String email) {
|
||||||
|
try {
|
||||||
|
accountMailService.requestPasswordReset(email);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Die Antwort bleibt absichtlich identisch: keine E-Mail- oder Account-Informationen preisgeben.
|
||||||
|
}
|
||||||
|
// Einheitliche Antwort, damit nicht erkennbar ist, welche Adressen registriert sind.
|
||||||
|
return "redirect:/forgot-password?sent";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/verification/resend")
|
||||||
|
public String resendVerification(@RequestParam String email) {
|
||||||
|
try {
|
||||||
|
accountMailService.resendVerification(email);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Auch bei einem SMTP-Fehler keine Information über einen möglichen Account verraten.
|
||||||
|
}
|
||||||
|
return "redirect:/?verificationSent";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/verify-email")
|
||||||
|
public String verifyEmail(@RequestParam(required = false) String token) {
|
||||||
|
return accountMailService.verifyEmail(token)
|
||||||
|
? "redirect:/?emailVerified"
|
||||||
|
: "redirect:/?verificationInvalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/reset-password")
|
||||||
|
public String showResetPassword(@RequestParam(required = false) String token, Model model) {
|
||||||
|
if (!accountMailService.isResetTokenValid(token)) {
|
||||||
|
return "redirect:/?resetInvalid";
|
||||||
|
}
|
||||||
|
model.addAttribute("token", token);
|
||||||
|
return "reset-password";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reset-password")
|
||||||
|
public String resetPassword(@RequestParam String token,
|
||||||
|
@RequestParam String password,
|
||||||
|
@RequestParam String confirmPassword) {
|
||||||
|
if (password.length() < 8) {
|
||||||
|
return "redirect:/reset-password?token=" + token + "&error=policy";
|
||||||
|
}
|
||||||
|
if (!password.equals(confirmPassword)) {
|
||||||
|
return "redirect:/reset-password?token=" + token + "&error=mismatch";
|
||||||
|
}
|
||||||
|
return accountMailService.resetPassword(token, password, confirmPassword)
|
||||||
|
? "redirect:/?passwordReset"
|
||||||
|
: "redirect:/?resetInvalid";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,12 +64,13 @@ public class AdminUserController {
|
|||||||
/** Direktes Zurücksetzen des Passworts eines Nutzers (Admin-Recovery, ohne E-Mail). */
|
/** Direktes Zurücksetzen des Passworts eines Nutzers (Admin-Recovery, ohne E-Mail). */
|
||||||
@PostMapping("/users/{id}/reset-password")
|
@PostMapping("/users/{id}/reset-password")
|
||||||
public String resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
|
public String resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
|
||||||
if (newPassword != null && !newPassword.isBlank()) {
|
if (newPassword == null || newPassword.trim().length() < 8) {
|
||||||
|
return "redirect:/admin/users?passwordPolicy";
|
||||||
|
}
|
||||||
User user = userService.findById(id)
|
User user = userService.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
|
||||||
user.setPassword(passwordEncoder.encode(newPassword.trim()));
|
user.setPassword(passwordEncoder.encode(newPassword.trim()));
|
||||||
userService.save(user);
|
userService.save(user);
|
||||||
}
|
|
||||||
return "redirect:/admin/users";
|
return "redirect:/admin/users";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.mindflux.controller;
|
package com.example.mindflux.controller;
|
||||||
|
|
||||||
import com.example.mindflux.service.RegistrationResult;
|
import com.example.mindflux.service.RegistrationResult;
|
||||||
|
import com.example.mindflux.service.AccountMailService;
|
||||||
import com.example.mindflux.service.UserService;
|
import com.example.mindflux.service.UserService;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -11,9 +12,11 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
public class RegisterController {
|
public class RegisterController {
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final AccountMailService accountMailService;
|
||||||
|
|
||||||
public RegisterController(UserService userService) {
|
public RegisterController(UserService userService, AccountMailService accountMailService) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.accountMailService = accountMailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/register")
|
@GetMapping("/register")
|
||||||
@@ -29,10 +32,18 @@ public class RegisterController {
|
|||||||
@RequestParam(required = false) String confirmPassword) {
|
@RequestParam(required = false) String confirmPassword) {
|
||||||
RegistrationResult result = userService.register(username, email, password, confirmPassword);
|
RegistrationResult result = userService.register(username, email, password, confirmPassword);
|
||||||
return switch (result) {
|
return switch (result) {
|
||||||
case SUCCESS -> "redirect:/?registerSuccess";
|
case SUCCESS -> {
|
||||||
|
try {
|
||||||
|
accountMailService.sendVerificationFor(userService.findByUsernameIgnoreCase(username.trim()));
|
||||||
|
yield "redirect:/?registerSuccess";
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
yield "redirect:/?registerMailError";
|
||||||
|
}
|
||||||
|
}
|
||||||
case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
|
case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
|
||||||
case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
|
case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
|
||||||
case EMAIL_INVALID -> "redirect:/?emailInvalid";
|
case EMAIL_INVALID -> "redirect:/?emailInvalid";
|
||||||
|
case EMAIL_TAKEN -> "redirect:/?emailTaken";
|
||||||
case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
|
case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.mindflux.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "account_email_tokens")
|
||||||
|
public class AccountEmailToken {
|
||||||
|
public enum Purpose { EMAIL_VERIFICATION, PASSWORD_RESET }
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private Purpose purpose;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "consumed_at")
|
||||||
|
private Instant consumedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public User getUser() { return user; }
|
||||||
|
public void setUser(User user) { this.user = user; }
|
||||||
|
public String getTokenHash() { return tokenHash; }
|
||||||
|
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
|
||||||
|
public Purpose getPurpose() { return purpose; }
|
||||||
|
public void setPurpose(Purpose purpose) { this.purpose = purpose; }
|
||||||
|
public Instant getExpiresAt() { return expiresAt; }
|
||||||
|
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
|
||||||
|
public Instant getConsumedAt() { return consumedAt; }
|
||||||
|
public void setConsumedAt(Instant consumedAt) { this.consumedAt = consumedAt; }
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ public class User {
|
|||||||
private String email;
|
private String email;
|
||||||
private String role;
|
private String role;
|
||||||
|
|
||||||
|
@Column(name = "email_verified", nullable = false)
|
||||||
|
private boolean emailVerified = true;
|
||||||
|
|
||||||
@Column(name = "profile_picture")
|
@Column(name = "profile_picture")
|
||||||
private String profilePicture;
|
private String profilePicture;
|
||||||
|
|
||||||
@@ -47,6 +50,9 @@ public class User {
|
|||||||
public String getRole() { return role; }
|
public String getRole() { return role; }
|
||||||
public void setRole(String r) { this.role = r; }
|
public void setRole(String r) { this.role = r; }
|
||||||
|
|
||||||
|
public boolean isEmailVerified() { return emailVerified; }
|
||||||
|
public void setEmailVerified(boolean emailVerified) { this.emailVerified = emailVerified; }
|
||||||
|
|
||||||
public String getProfilePicture() { return profilePicture; }
|
public String getProfilePicture() { return profilePicture; }
|
||||||
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
|
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.example.mindflux.repository;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.AccountEmailToken;
|
||||||
|
import com.example.mindflux.model.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface AccountEmailTokenRepository extends JpaRepository<AccountEmailToken, Long> {
|
||||||
|
Optional<AccountEmailToken> findByTokenHashAndPurposeAndConsumedAtIsNull(
|
||||||
|
String tokenHash, AccountEmailToken.Purpose purpose);
|
||||||
|
|
||||||
|
void deleteByUserAndPurpose(User user, AccountEmailToken.Purpose purpose);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.example.mindflux.repository;
|
|||||||
|
|
||||||
import com.example.mindflux.model.User;
|
import com.example.mindflux.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
@@ -12,6 +13,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
// Case-insensitive Suche: Login und Registrierung sollen unabhängig von Groß-/Kleinschreibung greifen
|
// Case-insensitive Suche: Login und Registrierung sollen unabhängig von Groß-/Kleinschreibung greifen
|
||||||
Optional<User> findByUsernameIgnoreCase(String username);
|
Optional<User> findByUsernameIgnoreCase(String username);
|
||||||
|
|
||||||
|
List<User> findAllByEmailIgnoreCase(String email);
|
||||||
|
|
||||||
// Benutzer anhand des Benutzernamens löschen
|
// Benutzer anhand des Benutzernamens löschen
|
||||||
void deleteByUsername(String username);
|
void deleteByUsername(String username);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.example.mindflux.service;
|
||||||
|
|
||||||
|
import com.example.mindflux.model.AccountEmailToken;
|
||||||
|
import com.example.mindflux.model.User;
|
||||||
|
import com.example.mindflux.repository.AccountEmailTokenRepository;
|
||||||
|
import com.example.mindflux.repository.UserRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AccountMailService {
|
||||||
|
|
||||||
|
private static final Duration TOKEN_LIFETIME = Duration.ofHours(24);
|
||||||
|
private final AccountEmailTokenRepository tokenRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
private final String publicUrl;
|
||||||
|
private final String fromAddress;
|
||||||
|
|
||||||
|
public AccountMailService(AccountEmailTokenRepository tokenRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
JavaMailSender mailSender,
|
||||||
|
BCryptPasswordEncoder passwordEncoder,
|
||||||
|
@Value("${mindflux.public-url}") String publicUrl,
|
||||||
|
@Value("${mindflux.mail.from}") String fromAddress) {
|
||||||
|
this.tokenRepository = tokenRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.mailSender = mailSender;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.publicUrl = publicUrl.replaceAll("/+$", "");
|
||||||
|
this.fromAddress = fromAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void sendVerificationFor(User user) {
|
||||||
|
if (user == null || user.isEmailVerified()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String token = issueToken(user, AccountEmailToken.Purpose.EMAIL_VERIFICATION);
|
||||||
|
send(user.getEmail(), "Bestätige deinen Mindflux-Account",
|
||||||
|
"Hallo " + user.getUsername() + ",\n\n"
|
||||||
|
+ "bitte bestätige deine E-Mail-Adresse, um deinen Account freizuschalten:\n"
|
||||||
|
+ publicUrl + "/verify-email?token=" + token + "\n\n"
|
||||||
|
+ "Der Link ist 24 Stunden gültig und kann nur einmal verwendet werden.\n\n"
|
||||||
|
+ "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resendVerification(String email) {
|
||||||
|
uniqueUserForEmail(email).filter(user -> !user.isEmailVerified()).ifPresent(this::sendVerificationFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void requestPasswordReset(String email) {
|
||||||
|
uniqueUserForEmail(email).filter(User::isEmailVerified).ifPresent(user -> {
|
||||||
|
String token = issueToken(user, AccountEmailToken.Purpose.PASSWORD_RESET);
|
||||||
|
send(user.getEmail(), "Passwort für deinen Mindflux-Account zurücksetzen",
|
||||||
|
"Hallo " + user.getUsername() + ",\n\n"
|
||||||
|
+ "über diesen Link kannst du ein neues Passwort vergeben:\n"
|
||||||
|
+ publicUrl + "/reset-password?token=" + token + "\n\n"
|
||||||
|
+ "Der Link ist 24 Stunden gültig und kann nur einmal verwendet werden.\n\n"
|
||||||
|
+ "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean verifyEmail(String rawToken) {
|
||||||
|
Optional<AccountEmailToken> token = validToken(rawToken, AccountEmailToken.Purpose.EMAIL_VERIFICATION);
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AccountEmailToken value = token.get();
|
||||||
|
User user = value.getUser();
|
||||||
|
user.setEmailVerified(true);
|
||||||
|
value.setConsumedAt(Instant.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
tokenRepository.save(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean resetPassword(String rawToken, String password, String confirmPassword) {
|
||||||
|
if (password == null || password.length() < 8 || !password.equals(confirmPassword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Optional<AccountEmailToken> token = validToken(rawToken, AccountEmailToken.Purpose.PASSWORD_RESET);
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AccountEmailToken value = token.get();
|
||||||
|
User user = value.getUser();
|
||||||
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
|
value.setConsumedAt(Instant.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
tokenRepository.save(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isResetTokenValid(String rawToken) {
|
||||||
|
return validToken(rawToken, AccountEmailToken.Purpose.PASSWORD_RESET).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<User> uniqueUserForEmail(String email) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
List<User> users = userRepository.findAllByEmailIgnoreCase(email.trim());
|
||||||
|
return users.size() == 1 ? Optional.of(users.getFirst()) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String issueToken(User user, AccountEmailToken.Purpose purpose) {
|
||||||
|
tokenRepository.deleteByUserAndPurpose(user, purpose);
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
secureRandom.nextBytes(bytes);
|
||||||
|
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
AccountEmailToken token = new AccountEmailToken();
|
||||||
|
token.setUser(user);
|
||||||
|
token.setPurpose(purpose);
|
||||||
|
token.setTokenHash(hash(rawToken));
|
||||||
|
token.setExpiresAt(Instant.now().plus(TOKEN_LIFETIME));
|
||||||
|
tokenRepository.save(token);
|
||||||
|
return rawToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<AccountEmailToken> validToken(String rawToken, AccountEmailToken.Purpose purpose) {
|
||||||
|
if (rawToken == null || rawToken.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return tokenRepository.findByTokenHashAndPurposeAndConsumedAtIsNull(hash(rawToken), purpose)
|
||||||
|
.filter(token -> token.getExpiresAt().isAfter(Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(String recipient, String subject, String body) {
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(fromAddress);
|
||||||
|
message.setTo(recipient);
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setText(body);
|
||||||
|
mailSender.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hash(String value) {
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return java.util.HexFormat.of().formatHex(digest);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 ist nicht verfügbar", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ public enum RegistrationResult {
|
|||||||
USERNAME_INVALID,
|
USERNAME_INVALID,
|
||||||
USERNAME_TAKEN,
|
USERNAME_TAKEN,
|
||||||
EMAIL_INVALID,
|
EMAIL_INVALID,
|
||||||
|
EMAIL_TAKEN,
|
||||||
PASSWORD_MISMATCH,
|
PASSWORD_MISMATCH,
|
||||||
PASSWORD_TOO_SHORT
|
PASSWORD_TOO_SHORT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ public class UserServiceImpl implements UserService {
|
|||||||
if (userRepository.findByUsernameIgnoreCase(name).isPresent()) {
|
if (userRepository.findByUsernameIgnoreCase(name).isPresent()) {
|
||||||
return RegistrationResult.USERNAME_TAKEN;
|
return RegistrationResult.USERNAME_TAKEN;
|
||||||
}
|
}
|
||||||
|
if (!userRepository.findAllByEmailIgnoreCase(email.trim()).isEmpty()) {
|
||||||
|
return RegistrationResult.EMAIL_TAKEN;
|
||||||
|
}
|
||||||
User user = new User(name, passwordEncoder.encode(password), email.trim(), "USER");
|
User user = new User(name, passwordEncoder.encode(password), email.trim(), "USER");
|
||||||
|
user.setEmailVerified(false);
|
||||||
try {
|
try {
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
} catch (DataIntegrityViolationException e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ spring.output.ansi.enabled=ALWAYS
|
|||||||
|
|
||||||
# Öffentliche Basis-URL, z. B. für künftig erzeugte absolute Links.
|
# Öffentliche Basis-URL, z. B. für künftig erzeugte absolute Links.
|
||||||
mindflux.public-url=${MINDFLUX_PUBLIC_URL:http://localhost:8282}
|
mindflux.public-url=${MINDFLUX_PUBLIC_URL:http://localhost:8282}
|
||||||
|
mindflux.mail.from=${MAIL_USERNAME:noreply@sysnaptik.de}
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# E-Mail-Versand
|
||||||
|
# =======================
|
||||||
|
spring.mail.host=${MAIL_HOST:localhost}
|
||||||
|
spring.mail.port=${MAIL_PORT:587}
|
||||||
|
spring.mail.username=${MAIL_USERNAME:}
|
||||||
|
spring.mail.password=${MAIL_PASSWORD:}
|
||||||
|
spring.mail.properties.mail.smtp.auth=true
|
||||||
|
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||||
|
spring.mail.properties.mail.smtp.starttls.required=true
|
||||||
|
spring.mail.properties.mail.smtp.connectiontimeout=5000
|
||||||
|
spring.mail.properties.mail.smtp.timeout=5000
|
||||||
|
spring.mail.properties.mail.smtp.writetimeout=5000
|
||||||
|
|
||||||
# =======================
|
# =======================
|
||||||
# Datei-Uploads (Profilbilder)
|
# Datei-Uploads (Profilbilder)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE account_email_tokens (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||||
|
purpose VARCHAR(32) NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
consumed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT account_email_tokens_purpose_check
|
||||||
|
CHECK (purpose IN ('EMAIL_VERIFICATION', 'PASSWORD_RESET'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_account_email_tokens_lookup
|
||||||
|
ON account_email_tokens (token_hash, purpose);
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div th:if="${param.passwordPolicy}" class="mf-alert err">Das neue Passwort muss mindestens 8 Zeichen lang sein.</div>
|
||||||
<div class="mf-panel">
|
<div class="mf-panel">
|
||||||
<table class="mf-table">
|
<table class="mf-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -48,8 +49,8 @@
|
|||||||
<form th:action="@{'/admin/users/' + ${user.id} + '/reset-password'}" method="post"
|
<form th:action="@{'/admin/users/' + ${user.id} + '/reset-password'}" method="post"
|
||||||
style="display:flex; gap:.35rem; align-items:center;"
|
style="display:flex; gap:.35rem; align-items:center;"
|
||||||
onsubmit="return confirm('Passwort für diesen Nutzer wirklich neu setzen?');">
|
onsubmit="return confirm('Passwort für diesen Nutzer wirklich neu setzen?');">
|
||||||
<input class="mf-input" type="text" name="newPassword" placeholder="Neues Passwort"
|
<input class="mf-input" type="password" name="newPassword" placeholder="Neues Passwort"
|
||||||
required minlength="4" style="width:150px; padding:.45rem .6rem;">
|
required minlength="8" style="width:150px; padding:.45rem .6rem;">
|
||||||
<button type="submit" class="mf-btn">PW zurücksetzen</button>
|
<button type="submit" class="mf-btn">PW zurücksetzen</button>
|
||||||
</form>
|
</form>
|
||||||
<form th:action="@{'/admin/users/delete/' + ${user.id}}" method="post" style="display:inline;"
|
<form th:action="@{'/admin/users/delete/' + ${user.id}}" method="post" style="display:inline;"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Passwort zurücksetzen · Mindflux</title>
|
||||||
|
<link rel="stylesheet" href="/css/theme.css?v=3">
|
||||||
|
<script src="/js/theme.js?v=4"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="mf-theme-float"><button class="mf-theme"></button></div>
|
||||||
|
<main class="mf-container" style="max-width:34rem; padding-top:5rem">
|
||||||
|
<section class="mf-panel">
|
||||||
|
<div class="mf-eyebrow">Account-Zugang</div>
|
||||||
|
<h1>Passwort zurücksetzen</h1>
|
||||||
|
<p>Gib deine E-Mail-Adresse ein. Falls ein bestätigter Account dazugehört, senden wir dir einen Link zum Setzen eines neuen Passworts.</p>
|
||||||
|
<div th:if="${param.sent}" class="mf-alert ok">Falls ein passender Account existiert, wurde eine E-Mail versendet.</div>
|
||||||
|
<form th:action="@{/forgot-password}" method="post">
|
||||||
|
<div class="mf-field">
|
||||||
|
<label class="mf-label" for="email">E-Mail-Adresse</label>
|
||||||
|
<input id="email" class="mf-input" type="email" name="email" required autocomplete="email">
|
||||||
|
</div>
|
||||||
|
<button class="mf-btn" type="submit">Reset-Link senden</button>
|
||||||
|
</form>
|
||||||
|
<p style="margin-top:1rem"><a href="/">Zur Anmeldung</a></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -29,7 +29,12 @@
|
|||||||
|
|
||||||
<div th:if="${param.error}" class="mf-alert err">Benutzername oder Passwort sind falsch.</div>
|
<div th:if="${param.error}" class="mf-alert err">Benutzername oder Passwort sind falsch.</div>
|
||||||
<div th:if="${param.logout}" class="mf-alert ok">Du hast dich erfolgreich abgemeldet.</div>
|
<div th:if="${param.logout}" class="mf-alert ok">Du hast dich erfolgreich abgemeldet.</div>
|
||||||
<div th:if="${param.registerSuccess}" class="mf-alert ok">Registrierung erfolgreich! Du kannst dich jetzt einloggen.</div>
|
<div th:if="${param.registerSuccess}" class="mf-alert ok">Fast geschafft: Wir haben dir einen Bestätigungslink per E-Mail gesendet.</div>
|
||||||
|
<div th:if="${param.emailVerified}" class="mf-alert ok">Deine E-Mail-Adresse wurde bestätigt. Du kannst dich jetzt anmelden.</div>
|
||||||
|
<div th:if="${param.passwordReset}" class="mf-alert ok">Dein Passwort wurde geändert. Du kannst dich jetzt anmelden.</div>
|
||||||
|
<div th:if="${param.verificationSent}" class="mf-alert ok">Falls ein unbestätigter Account zu dieser Adresse existiert, wurde ein neuer Link versendet.</div>
|
||||||
|
<div th:if="${param.verificationInvalid}" class="mf-alert err">Dieser Bestätigungslink ist ungültig oder abgelaufen.</div>
|
||||||
|
<div th:if="${param.resetInvalid}" class="mf-alert err">Dieser Passwort-Link ist ungültig oder abgelaufen.</div>
|
||||||
|
|
||||||
<form th:action="@{/login}" method="post">
|
<form th:action="@{/login}" method="post">
|
||||||
<div class="mf-field">
|
<div class="mf-field">
|
||||||
@@ -43,6 +48,8 @@
|
|||||||
<button class="neon-btn" type="submit">Login</button>
|
<button class="neon-btn" type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="mobile-switch"><a href="/forgot-password">Passwort vergessen?</a></p>
|
||||||
|
|
||||||
<p class="mobile-switch">Noch keinen Account? <a data-goto="signup">Jetzt registrieren</a></p>
|
<p class="mobile-switch">Noch keinen Account? <a data-goto="signup">Jetzt registrieren</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +68,8 @@
|
|||||||
<div th:if="${param.passwordMismatch}" class="mf-alert err">Die Passwörter stimmen nicht überein. Bitte erneut eingeben.</div>
|
<div th:if="${param.passwordMismatch}" class="mf-alert err">Die Passwörter stimmen nicht überein. Bitte erneut eingeben.</div>
|
||||||
<div th:if="${param.passwordPolicy}" class="mf-alert err">Das Passwort muss mindestens 8 Zeichen lang sein.</div>
|
<div th:if="${param.passwordPolicy}" class="mf-alert err">Das Passwort muss mindestens 8 Zeichen lang sein.</div>
|
||||||
<div th:if="${param.emailInvalid}" class="mf-alert err">Bitte gib eine gültige E-Mail-Adresse an.</div>
|
<div th:if="${param.emailInvalid}" class="mf-alert err">Bitte gib eine gültige E-Mail-Adresse an.</div>
|
||||||
|
<div th:if="${param.emailTaken}" class="mf-alert err">Diese E-Mail-Adresse wird bereits verwendet.</div>
|
||||||
|
<div th:if="${param.registerMailError}" class="mf-alert err">Der Account wurde angelegt, aber die Bestätigungs-E-Mail konnte nicht gesendet werden. Fordere unten einen neuen Link an.</div>
|
||||||
|
|
||||||
<form th:action="@{/register}" method="post">
|
<form th:action="@{/register}" method="post">
|
||||||
<div class="mf-field">
|
<div class="mf-field">
|
||||||
@@ -83,6 +92,14 @@
|
|||||||
<button class="neon-btn" type="submit">Account erstellen</button>
|
<button class="neon-btn" type="submit">Account erstellen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form th:action="@{/verification/resend}" method="post" style="margin-top:1rem">
|
||||||
|
<label class="mf-label" for="resend-email">Bestätigungslink erneut senden</label>
|
||||||
|
<div style="display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.35rem">
|
||||||
|
<input id="resend-email" class="mf-input" type="email" name="email" placeholder="E-Mail-Adresse" required>
|
||||||
|
<button class="mf-btn outline" type="submit">Link senden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p class="mobile-switch">Schon dabei? <a data-goto="login">Zum Login</a></p>
|
<p class="mobile-switch">Schon dabei? <a data-goto="login">Zum Login</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +155,7 @@
|
|||||||
|
|
||||||
// Bei Registrierungs-/Passwortfehler direkt das Signup-Panel öffnen
|
// Bei Registrierungs-/Passwortfehler direkt das Signup-Panel öffnen
|
||||||
var qp = new URLSearchParams(window.location.search);
|
var qp = new URLSearchParams(window.location.search);
|
||||||
if (qp.has('registerError') || qp.has('passwordMismatch')) {
|
if (qp.has('registerError') || qp.has('passwordMismatch') || qp.has('passwordPolicy') || qp.has('emailInvalid') || qp.has('emailTaken') || qp.has('registerMailError')) {
|
||||||
c.classList.add('active');
|
c.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Neues Passwort · Mindflux</title>
|
||||||
|
<link rel="stylesheet" href="/css/theme.css?v=3">
|
||||||
|
<script src="/js/theme.js?v=4"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="mf-theme-float"><button class="mf-theme"></button></div>
|
||||||
|
<main class="mf-container" style="max-width:34rem; padding-top:5rem">
|
||||||
|
<section class="mf-panel">
|
||||||
|
<div class="mf-eyebrow">Account-Zugang</div>
|
||||||
|
<h1>Neues Passwort vergeben</h1>
|
||||||
|
<div th:if="${param.error == 'policy'}" class="mf-alert err">Das Passwort muss mindestens 8 Zeichen lang sein.</div>
|
||||||
|
<div th:if="${param.error == 'mismatch'}" class="mf-alert err">Die Passwörter stimmen nicht überein.</div>
|
||||||
|
<form th:action="@{/reset-password}" method="post">
|
||||||
|
<input type="hidden" name="token" th:value="${token}">
|
||||||
|
<div class="mf-field">
|
||||||
|
<label class="mf-label" for="password">Neues Passwort</label>
|
||||||
|
<input id="password" class="mf-input" type="password" name="password" required minlength="8" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="mf-field">
|
||||||
|
<label class="mf-label" for="confirm-password">Passwort wiederholen</label>
|
||||||
|
<input id="confirm-password" class="mf-input" type="password" name="confirmPassword" required minlength="8" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<button class="mf-btn" type="submit">Passwort speichern</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user