Erweiterungen

This commit is contained in:
2025-09-09 16:22:32 +02:00
parent 3062061813
commit bead579d4a
10 changed files with 381 additions and 6 deletions

View File

@@ -40,6 +40,13 @@ public class AppUser {
@Field("password") @Field("password")
private String password; private String password;
// Reset-Token und Zeitstempel
@Field("password_code")
private String passwordCode;
@Field("password_timestamp")
private LocalDateTime passwordTimestamp;
@Field("geraet") @Field("geraet")
private String geraet; private String geraet;

View File

@@ -0,0 +1,150 @@
package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.util.MailUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Optional;
@Service
public class PasswordResetService {
public enum UserType { USERS, APP_USER }
private final UserRepository userRepository;
private final AppUserRepository appUserRepository;
private final PasswordEncoder passwordEncoder;
private final MailUtil mailUtil;
private static final Duration TOKEN_VALIDITY = Duration.ofMinutes(15);
public PasswordResetService(UserRepository userRepository,
AppUserRepository appUserRepository,
PasswordEncoder passwordEncoder,
MailUtil mailUtil) {
this.userRepository = userRepository;
this.appUserRepository = appUserRepository;
this.passwordEncoder = passwordEncoder;
this.mailUtil = mailUtil;
}
public void initiateReset(String email, UserType userType, String baseUrl) {
// existing typed initiation
initiateResetInternal(email, userType, baseUrl);
}
/**
* Initiate reset without asking for user type. Looks up the email in both collections
* and only proceeds if it exists in exactly one of them. Otherwise, it silently returns
* to avoid leaking account existence.
*/
public void initiateResetAuto(String email, String baseUrl) {
if (email == null) return;
String normalized = email.trim();
if (normalized.isEmpty()) return;
var userOpt = userRepository.findByEmail(normalized);
var appUser = appUserRepository.findByEmail(normalized);
boolean inUsers = userOpt.isPresent();
boolean inAppUsers = appUser != null;
if (inUsers == inAppUsers) {
// Either in both or in none: do nothing to keep behavior indistinguishable
return;
}
UserType type = inUsers ? UserType.USERS : UserType.APP_USER;
initiateResetInternal(normalized, type, baseUrl);
}
private void initiateResetInternal(String email, UserType userType, String baseUrl) {
String token = generateToken64();
LocalDateTime now = LocalDateTime.now();
String typeParam = userType == UserType.USERS ? "users" : "app_user";
String link = baseUrl + "/forget-password?token=" + token + "&type=" + typeParam;
switch (userType) {
case USERS -> {
Optional<User> optional = userRepository.findByEmail(email);
if (optional.isEmpty()) {
// Do not leak existence; simply return
return;
}
User user = optional.get();
user.setPasswordCode(token);
user.setPasswordTimestamp(now);
userRepository.save(user);
sendMail(email, link);
}
case APP_USER -> {
AppUser appUser = appUserRepository.findByEmail(email);
if (appUser == null) {
return;
}
appUser.setPasswordCode(token);
appUser.setPasswordTimestamp(now);
appUserRepository.save(appUser);
sendMail(email, link);
}
}
}
private void sendMail(String to, String link) {
String subject = "Passwort zurücksetzen";
String body = "Hallo,\n\n" +
"Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. " +
"Dieser Link ist 15 Minuten gültig:\n" + link + "\n\n" +
"Wenn Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.";
try {
mailUtil.sendMail(to, subject, body);
} catch (Exception e) {
// In this minimal implementation we swallow to avoid leaking details to attackers
}
}
public boolean isTokenValid(String token, UserType userType) {
LocalDateTime ts = switch (userType) {
case USERS -> userRepository.findByPasswordCode(token).map(User::getPasswordTimestamp).orElse(null);
case APP_USER -> Optional.ofNullable(appUserRepository.findByPasswordCode(token)).map(AppUser::getPasswordTimestamp).orElse(null);
};
if (ts == null) return false;
return Duration.between(ts, LocalDateTime.now()).compareTo(TOKEN_VALIDITY) <= 0;
}
public boolean resetPassword(String token, UserType userType, String newPassword) {
if (!isTokenValid(token, userType)) return false;
switch (userType) {
case USERS -> {
Optional<User> optional = userRepository.findByPasswordCode(token);
if (optional.isEmpty()) return false;
User user = optional.get();
user.setPassword(passwordEncoder.encode(newPassword));
user.setPasswordCode(null);
user.setPasswordTimestamp(null);
userRepository.save(user);
return true;
}
case APP_USER -> {
AppUser appUser = appUserRepository.findByPasswordCode(token);
if (appUser == null) return false;
appUser.setPassword(passwordEncoder.encode(newPassword));
appUser.setPasswordCode(null);
appUser.setPasswordTimestamp(null);
appUserRepository.save(appUser);
return true;
}
}
return false;
}
private String generateToken64() {
// 32 bytes -> 64 hex characters
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
}

View File

@@ -0,0 +1,93 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.votianlt.pages.service.PasswordResetService;
import java.util.Map;
@Route("forget-password")
@PageTitle("Passwort zurücksetzen")
@AnonymousAllowed
public class ForgetPasswordView extends VerticalLayout implements BeforeEnterObserver {
private final PasswordResetService passwordResetService;
private String token;
private PasswordResetService.UserType userType;
private PasswordField newPassword;
private PasswordField confirmPassword;
private Button submit;
public ForgetPasswordView(PasswordResetService passwordResetService) {
this.passwordResetService = passwordResetService;
setSizeFull();
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
VerticalLayout container = new VerticalLayout();
container.setWidth("400px");
container.setPadding(true);
container.setSpacing(true);
container.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
H1 title = new H1("Neues Passwort festlegen");
title.getStyle().set("text-align", "center");
newPassword = new PasswordField("Neues Passwort");
confirmPassword = new PasswordField("Passwort bestätigen");
submit = new Button("Passwort speichern", e -> onSubmit());
submit.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
container.add(title, newPassword, confirmPassword, submit);
add(container);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
Map<String, java.util.List<String>> params = event.getLocation().getQueryParameters().getParameters();
String tokenParam = params.getOrDefault("token", java.util.List.of("")) .get(0);
String typeParam = params.getOrDefault("type", java.util.List.of("")) .get(0);
this.token = tokenParam != null ? tokenParam.trim() : "";
this.userType = "app_user".equalsIgnoreCase(typeParam) ? PasswordResetService.UserType.APP_USER : PasswordResetService.UserType.USERS;
if (this.token.isEmpty() || !passwordResetService.isTokenValid(this.token, this.userType)) {
// Store a flash message in the VaadinSession so it persists through reroute
com.vaadin.flow.server.VaadinSession.getCurrent().setAttribute("flashMessage", "Ungültiger oder abgelaufener Token.");
event.rerouteTo(LoginView.class);
}
}
private void onSubmit() {
String p1 = newPassword.getValue();
String p2 = confirmPassword.getValue();
if (p1 == null || p1.isBlank()) {
Notification.show("Bitte geben Sie ein neues Passwort ein.", 3000, Notification.Position.MIDDLE);
return;
}
if (!p1.equals(p2)) {
Notification.show("Die Passwörter stimmen nicht überein.", 3000, Notification.Position.MIDDLE);
return;
}
boolean ok = passwordResetService.resetPassword(token, userType, p1);
if (ok) {
Notification.show("Passwort wurde erfolgreich geändert.", 3000, Notification.Position.MIDDLE);
getUI().ifPresent(ui -> ui.navigate("login"));
} else {
Notification.show("Token ungültig oder abgelaufen.", 3000, Notification.Position.MIDDLE);
}
}
}

View File

@@ -0,0 +1,81 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.votianlt.pages.service.PasswordResetService;
@Route("forgot-password-request")
@PageTitle("Passwort zurücksetzen E-Mail angeben")
@AnonymousAllowed
public class ForgotPasswordRequestView extends VerticalLayout {
private final PasswordResetService passwordResetService;
public ForgotPasswordRequestView(PasswordResetService passwordResetService) {
this.passwordResetService = passwordResetService;
setSizeFull();
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
setPadding(true);
VerticalLayout container = new VerticalLayout();
container.setWidth("400px");
container.setPadding(true);
container.setSpacing(true);
container.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
H1 title = new H1("Passwort zurücksetzen");
title.getStyle().set("text-align", "center");
EmailField emailField = new EmailField("E-Mail-Adresse");
emailField.setWidthFull();
emailField.setRequired(true);
emailField.setAutofocus(true);
Button cancel = new Button("Abbrechen", e -> getUI().ifPresent(ui -> ui.navigate("login")));
Button submit = new Button("E-Mail senden", e -> {
String email = emailField.getValue();
if (email == null || email.isBlank()) {
Notification.show("Bitte E-Mail eingeben.", 3000, Notification.Position.MIDDLE);
return;
}
String baseUrl = getBaseUrl();
passwordResetService.initiateResetAuto(email.trim(), baseUrl);
Notification.show("Falls die E-Mail existiert, wurde ein Link versendet.", 4000, Notification.Position.MIDDLE);
getUI().ifPresent(ui -> ui.navigate("login"));
});
submit.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout actions = new HorizontalLayout(cancel, submit);
actions.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
container.add(title, emailField, actions);
add(container);
}
private String getBaseUrl() {
var vReq = VaadinService.getCurrentRequest();
if (vReq instanceof com.vaadin.flow.server.VaadinServletRequest servletRequest) {
var req = servletRequest.getHttpServletRequest();
String scheme = req.getScheme();
String serverName = req.getServerName();
int serverPort = req.getServerPort();
String contextPath = req.getContextPath();
String portPart = ("http".equals(scheme) && serverPort == 80) || ("https".equals(scheme) && serverPort == 443)
? "" : ":" + serverPort;
return scheme + "://" + serverName + portPart + contextPath;
}
return "";
}
}

View File

@@ -3,10 +3,13 @@ package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H1; import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.login.LoginForm; import com.vaadin.flow.component.login.LoginForm;
import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.AfterNavigationEvent;
import com.vaadin.flow.router.AfterNavigationObserver;
import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
@@ -16,9 +19,10 @@ import com.vaadin.flow.server.auth.AnonymousAllowed;
@Route("login") @Route("login")
@PageTitle("Bei VotianLT anmelden") @PageTitle("Bei VotianLT anmelden")
@AnonymousAllowed @AnonymousAllowed
public class LoginView extends VerticalLayout implements BeforeEnterObserver { public class LoginView extends VerticalLayout implements BeforeEnterObserver, AfterNavigationObserver {
private final LoginForm loginForm = new LoginForm(); private final LoginForm loginForm = new LoginForm();
private final Div flashBox = new Div();
public LoginView() { public LoginView() {
addClassName("login-view"); addClassName("login-view");
@@ -28,6 +32,9 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver {
setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER); setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
loginForm.setAction("login"); loginForm.setAction("login");
// Enable built-in forgot password link and navigate to request view
loginForm.setForgotPasswordButtonVisible(true);
loginForm.addForgotPasswordListener(e -> UI.getCurrent().navigate(ForgotPasswordRequestView.class));
H1 title = new H1("VotianLT"); H1 title = new H1("VotianLT");
title.getStyle().set("color", "var(--lumo-primary-color)"); title.getStyle().set("color", "var(--lumo-primary-color)");
@@ -36,15 +43,27 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver {
e -> UI.getCurrent().navigate("register")); e -> UI.getCurrent().navigate("register"));
registerButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); registerButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
// Inline flash message box (hidden by default)
flashBox.getStyle()
.set("background", "var(--lumo-error-color-10pct)")
.set("color", "var(--lumo-error-text-color)")
.set("border", "1px solid var(--lumo-error-color)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("padding", "var(--lumo-space-m)")
.set("width", "100%")
.set("display", "none");
VerticalLayout loginLayout = new VerticalLayout(); VerticalLayout loginLayout = new VerticalLayout();
loginLayout.setAlignItems(FlexComponent.Alignment.CENTER); loginLayout.setAlignItems(FlexComponent.Alignment.CENTER);
loginLayout.add(title, loginForm, registerButton);
loginLayout.add(flashBox, title, loginForm, registerButton);
loginLayout.setMaxWidth("400px"); loginLayout.setMaxWidth("400px");
loginLayout.setPadding(true); loginLayout.setPadding(true);
add(loginLayout); add(loginLayout);
} }
@Override @Override
public void beforeEnter(BeforeEnterEvent beforeEnterEvent) { public void beforeEnter(BeforeEnterEvent beforeEnterEvent) {
// Zeige Fehlermeldung bei fehlgeschlagener Anmeldung // Zeige Fehlermeldung bei fehlgeschlagener Anmeldung
@@ -55,4 +74,22 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver {
loginForm.setError(true); loginForm.setError(true);
} }
} }
@Override
public void afterNavigation(AfterNavigationEvent event) {
// Show flash message after the view is attached and rendered
var session = com.vaadin.flow.server.VaadinSession.getCurrent();
if (session != null) {
Object msg = session.getAttribute("flashMessage");
if (msg instanceof String && !((String) msg).isBlank()) {
session.setAttribute("flashMessage", null); // clear
// Inline message for guaranteed visibility
flashBox.setText((String) msg);
flashBox.getStyle().set("display", "block");
} else {
flashBox.setText("");
flashBox.getStyle().set("display", "none");
}
}
}
} }

View File

@@ -16,6 +16,8 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
// Find AppUser by email for login // Find AppUser by email for login
AppUser findByEmail(String email); AppUser findByEmail(String email);
AppUser findByPasswordCode(String passwordCode);
// Custom query methods can be added here if needed // Custom query methods can be added here if needed
// List<AppUser> findByBezeichnung(String bezeichnung); // List<AppUser> findByBezeichnung(String bezeichnung);
} }

View File

@@ -14,4 +14,6 @@ public interface UserRepository extends MongoRepository<User, String> {
boolean existsByEmail(String email); boolean existsByEmail(String email);
void deleteByEmail(String email); void deleteByEmail(String email);
Optional<User> findByPasswordCode(String passwordCode);
} }

View File

@@ -27,6 +27,8 @@ public class SecurityConfig extends VaadinWebSecurity {
new AntPathRequestMatcher("/"), new AntPathRequestMatcher("/"),
new AntPathRequestMatcher("/register"), new AntPathRequestMatcher("/register"),
new AntPathRequestMatcher("/login"), new AntPathRequestMatcher("/login"),
new AntPathRequestMatcher("/forget-password"),
new AntPathRequestMatcher("/forgot-password-request"),
new AntPathRequestMatcher("/images/**"), new AntPathRequestMatcher("/images/**"),
new AntPathRequestMatcher("/icons/**"), new AntPathRequestMatcher("/icons/**"),
new AntPathRequestMatcher("/favicon.ico"), new AntPathRequestMatcher("/favicon.ico"),

View File

@@ -32,6 +32,7 @@ public class MailUtil {
Properties props = new Properties(); Properties props = new Properties();
props.put("mail.smtp.auth", "true"); props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", "true"); props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
props.put("mail.smtp.host", host); props.put("mail.smtp.host", host);
props.put("mail.smtp.port", String.valueOf(port)); props.put("mail.smtp.port", String.valueOf(port));

View File

@@ -16,9 +16,9 @@ spring.jpa.open-in-view=false
spring.data.mongodb.uri=mongodb://192.168.180.25:27017/votianlt spring.data.mongodb.uri=mongodb://192.168.180.25:27017/votianlt
# Mail Configuration # Mail Configuration
mail.smtp.username=your-email@gmail.com mail.smtp.username=no-reply@appcreation.de
mail.smtp.password=your-password mail.smtp.password=SV1705CA!noreply
mail.smtp.host=smtp.gmail.com mail.smtp.host=smtp.ionos.de
mail.smtp.port=587 mail.smtp.port=587
# WebSocket and STOMP Configuration # WebSocket and STOMP Configuration