From bead579d4a9a935e3563ed1ffa39fd7d9d06e460 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 9 Sep 2025 16:22:32 +0200 Subject: [PATCH] Erweiterungen --- .../de/assecutor/votianlt/model/AppUser.java | 7 + .../pages/service/PasswordResetService.java | 150 ++++++++++++++++++ .../pages/view/ForgetPasswordView.java | 93 +++++++++++ .../pages/view/ForgotPasswordRequestView.java | 81 ++++++++++ .../votianlt/pages/view/LoginView.java | 43 ++++- .../repository/AppUserRepository.java | 2 + .../votianlt/repository/UserRepository.java | 2 + .../votianlt/security/SecurityConfig.java | 2 + .../de/assecutor/votianlt/util/MailUtil.java | 1 + src/main/resources/application.properties | 6 +- 10 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/pages/service/PasswordResetService.java create mode 100644 src/main/java/de/assecutor/votianlt/pages/view/ForgetPasswordView.java create mode 100644 src/main/java/de/assecutor/votianlt/pages/view/ForgotPasswordRequestView.java diff --git a/src/main/java/de/assecutor/votianlt/model/AppUser.java b/src/main/java/de/assecutor/votianlt/model/AppUser.java index 47828a3..7217159 100644 --- a/src/main/java/de/assecutor/votianlt/model/AppUser.java +++ b/src/main/java/de/assecutor/votianlt/model/AppUser.java @@ -40,6 +40,13 @@ public class AppUser { @Field("password") private String password; + // Reset-Token und Zeitstempel + @Field("password_code") + private String passwordCode; + + @Field("password_timestamp") + private LocalDateTime passwordTimestamp; + @Field("geraet") private String geraet; diff --git a/src/main/java/de/assecutor/votianlt/pages/service/PasswordResetService.java b/src/main/java/de/assecutor/votianlt/pages/service/PasswordResetService.java new file mode 100644 index 0000000..67a5f76 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/service/PasswordResetService.java @@ -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 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 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); + } +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/ForgetPasswordView.java b/src/main/java/de/assecutor/votianlt/pages/view/ForgetPasswordView.java new file mode 100644 index 0000000..e0d7552 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/ForgetPasswordView.java @@ -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> 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); + } + } +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/ForgotPasswordRequestView.java b/src/main/java/de/assecutor/votianlt/pages/view/ForgotPasswordRequestView.java new file mode 100644 index 0000000..92680de --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/ForgotPasswordRequestView.java @@ -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 ""; + } +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java b/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java index 2d90868..fc3ad2b 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java @@ -3,10 +3,13 @@ package de.assecutor.votianlt.pages.view; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; 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.login.LoginForm; import com.vaadin.flow.component.orderedlayout.FlexComponent; 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.BeforeEnterObserver; import com.vaadin.flow.router.PageTitle; @@ -16,11 +19,12 @@ import com.vaadin.flow.server.auth.AnonymousAllowed; @Route("login") @PageTitle("Bei VotianLT anmelden") @AnonymousAllowed -public class LoginView extends VerticalLayout implements BeforeEnterObserver { +public class LoginView extends VerticalLayout implements BeforeEnterObserver, AfterNavigationObserver { private final LoginForm loginForm = new LoginForm(); + private final Div flashBox = new Div(); - public LoginView() { + public LoginView() { addClassName("login-view"); setSizeFull(); @@ -28,6 +32,9 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver { setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER); 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"); title.getStyle().set("color", "var(--lumo-primary-color)"); @@ -36,15 +43,27 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver { e -> UI.getCurrent().navigate("register")); 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(); loginLayout.setAlignItems(FlexComponent.Alignment.CENTER); - loginLayout.add(title, loginForm, registerButton); + + loginLayout.add(flashBox, title, loginForm, registerButton); loginLayout.setMaxWidth("400px"); loginLayout.setPadding(true); add(loginLayout); } + @Override public void beforeEnter(BeforeEnterEvent beforeEnterEvent) { // Zeige Fehlermeldung bei fehlgeschlagener Anmeldung @@ -55,4 +74,22 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver { 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"); + } + } + } } diff --git a/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java b/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java index 93add48..b884d76 100644 --- a/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java @@ -15,6 +15,8 @@ public interface AppUserRepository extends MongoRepository { // Find AppUser by email for login AppUser findByEmail(String email); + + AppUser findByPasswordCode(String passwordCode); // Custom query methods can be added here if needed // List findByBezeichnung(String bezeichnung); diff --git a/src/main/java/de/assecutor/votianlt/repository/UserRepository.java b/src/main/java/de/assecutor/votianlt/repository/UserRepository.java index bb21ab0..47488e4 100644 --- a/src/main/java/de/assecutor/votianlt/repository/UserRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/UserRepository.java @@ -14,4 +14,6 @@ public interface UserRepository extends MongoRepository { boolean existsByEmail(String email); void deleteByEmail(String email); + + Optional findByPasswordCode(String passwordCode); } diff --git a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java index 2be02dd..257c322 100644 --- a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java +++ b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java @@ -27,6 +27,8 @@ public class SecurityConfig extends VaadinWebSecurity { new AntPathRequestMatcher("/"), new AntPathRequestMatcher("/register"), new AntPathRequestMatcher("/login"), + new AntPathRequestMatcher("/forget-password"), + new AntPathRequestMatcher("/forgot-password-request"), new AntPathRequestMatcher("/images/**"), new AntPathRequestMatcher("/icons/**"), new AntPathRequestMatcher("/favicon.ico"), diff --git a/src/main/java/de/assecutor/votianlt/util/MailUtil.java b/src/main/java/de/assecutor/votianlt/util/MailUtil.java index bf5c2e2..419c629 100644 --- a/src/main/java/de/assecutor/votianlt/util/MailUtil.java +++ b/src/main/java/de/assecutor/votianlt/util/MailUtil.java @@ -32,6 +32,7 @@ public class MailUtil { Properties props = new Properties(); props.put("mail.smtp.auth", "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.port", String.valueOf(port)); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0079313..0ea7e68 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,9 +16,9 @@ spring.jpa.open-in-view=false spring.data.mongodb.uri=mongodb://192.168.180.25:27017/votianlt # Mail Configuration -mail.smtp.username=your-email@gmail.com -mail.smtp.password=your-password -mail.smtp.host=smtp.gmail.com +mail.smtp.username=no-reply@appcreation.de +mail.smtp.password=SV1705CA!noreply +mail.smtp.host=smtp.ionos.de mail.smtp.port=587 # WebSocket and STOMP Configuration