diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java index 6a6b415..940d92c 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java @@ -1,6 +1,7 @@ package de.assecutor.votianlt.pages.base.ui.view; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.avatar.AvatarVariant; @@ -19,6 +20,7 @@ import com.vaadin.flow.router.Layout; import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.server.menu.MenuConfiguration; import com.vaadin.flow.server.menu.MenuEntry; +import de.assecutor.votianlt.pages.view.EditProfileView; import de.assecutor.votianlt.security.SecurityService; import static com.vaadin.flow.theme.lumo.LumoUtility.*; @@ -28,18 +30,32 @@ import static com.vaadin.flow.theme.lumo.LumoUtility.*; public final class MainLayout extends AppLayout { private final SecurityService securityService; + private Div headerRef; + private Scroller navRef; + private Component userMenuRef; public MainLayout(SecurityService securityService) { this.securityService = securityService; setPrimarySection(Section.DRAWER); - // Only show navigation drawer if user is logged in - if (securityService.isUserLoggedIn()) { - addToDrawer(createHeader(), new Scroller(createSideNav()), createUserMenu()); - } else { - // Hide drawer completely for anonymous users - setDrawerOpened(false); - } + // Always build the drawer; keep references and toggle visibility on attach and after navigation + headerRef = createHeader(); + navRef = new Scroller(createSideNav()); + userMenuRef = createUserMenu(); + addToDrawer(headerRef, navRef, userMenuRef); + + updateDrawerVisibility(); + + // Re-check on attach (new UI/session) and on every navigation cycle + addAttachListener(e -> updateDrawerVisibility()); + } + + private void updateDrawerVisibility() { + boolean loggedIn = securityService.isUserLoggedIn(); + if (headerRef != null) headerRef.setVisible( loggedIn ); + if (navRef != null) navRef.setVisible( loggedIn ); + if (userMenuRef != null) userMenuRef.setVisible( loggedIn ); + setDrawerOpened(loggedIn); } private Div createHeader() { @@ -123,23 +139,38 @@ public final class MainLayout extends AppLayout { } private Component createUserMenu() { - String currentUser = securityService.getCurrentUsername(); - - var avatar = new Avatar(currentUser); - avatar.addThemeVariants(AvatarVariant.LUMO_XSMALL); - avatar.addClassNames(Margin.Right.SMALL); - avatar.setColorIndex(5); - var userMenu = new MenuBar(); userMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE); userMenu.addClassNames(Margin.MEDIUM); + // Dynamisch aktualisierbare Komponenten + var avatar = new Avatar(); + avatar.addThemeVariants(AvatarVariant.LUMO_XSMALL); + avatar.addClassNames(Margin.Right.SMALL); + avatar.setColorIndex(5); + + var userNameSpan = new Span(); + var userMenuItem = userMenu.addItem(avatar); - userMenuItem.add(currentUser); - userMenuItem.getSubMenu().addItem("Profil anzeigen"); + userMenuItem.add(userNameSpan); + + // Profil anzeigen mit Navigation + userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> + UI.getCurrent().navigate(EditProfileView.class)); userMenuItem.getSubMenu().addItem("Einstellungen"); userMenuItem.getSubMenu().addItem("Abmelden", e -> securityService.logout()); + // Update-Funktion für Benutzername und Avatar + Runnable updateUserInfo = () -> { + String currentUser = securityService.getCurrentUsername(); + avatar.setName(currentUser); + userNameSpan.setText(currentUser); + }; + + // Initial und bei Attach aktualisieren + updateUserInfo.run(); + addAttachListener(e -> updateUserInfo.run()); + return userMenu; } diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java index 74e98e2..a489f5f 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java @@ -117,47 +117,6 @@ public class AddJobService { return jobNumber; } - /** - * Aktualisiert einen bestehenden Job mit neuen Formulardaten - */ - private void updateJobFromForm(Job existingJob, Job formJob) { - existingJob.setCustomerSelection(formJob.getCustomerSelection()); - - // Pickup address - existingJob.setPickupCompany(formJob.getPickupCompany()); - existingJob.setPickupSalutation(formJob.getPickupSalutation()); - existingJob.setPickupFirstName(formJob.getPickupFirstName()); - existingJob.setPickupLastName(formJob.getPickupLastName()); - existingJob.setPickupPhone(formJob.getPickupPhone()); - existingJob.setPickupStreet(formJob.getPickupStreet()); - existingJob.setPickupHouseNumber(formJob.getPickupHouseNumber()); - existingJob.setPickupAddressAddition(formJob.getPickupAddressAddition()); - existingJob.setPickupZip(formJob.getPickupZip()); - existingJob.setPickupCity(formJob.getPickupCity()); - - // Delivery address - existingJob.setDeliveryCompany(formJob.getDeliveryCompany()); - existingJob.setDeliverySalutation(formJob.getDeliverySalutation()); - existingJob.setDeliveryFirstName(formJob.getDeliveryFirstName()); - existingJob.setDeliveryLastName(formJob.getDeliveryLastName()); - existingJob.setDeliveryPhone(formJob.getDeliveryPhone()); - existingJob.setDeliveryStreet(formJob.getDeliveryStreet()); - existingJob.setDeliveryHouseNumber(formJob.getDeliveryHouseNumber()); - existingJob.setDeliveryAddressAddition(formJob.getDeliveryAddressAddition()); - existingJob.setDeliveryZip(formJob.getDeliveryZip()); - existingJob.setDeliveryCity(formJob.getDeliveryCity()); - - // Digital processing - existingJob.setDigitalProcessing(formJob.isDigitalProcessing()); - existingJob.setAppUser(formJob.getAppUser()); - - // Termine, Bemerkung, Aufgaben, Preis (Cargo separat persistiert) - existingJob.setPickupDate(formJob.getPickupDate()); - existingJob.setDeliveryDate(formJob.getDeliveryDate()); - existingJob.setRemark(formJob.getRemark()); - existingJob.setPrice(formJob.getPrice()); - } - /** * Findet den aktuellen Entwurf eines Benutzers */ diff --git a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index 97db01c..8e1d82e 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -5,7 +5,6 @@ import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.formlayout.FormLayout; import com.vaadin.flow.component.html.Div; -import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.IFrame; 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 fc3ad2b..3071754 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/LoginView.java @@ -6,6 +6,8 @@ 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.textfield.TextField; +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.router.AfterNavigationEvent; @@ -15,6 +17,14 @@ 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.security.totp.TwoFactorService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import com.vaadin.flow.server.VaadinSession; @Route("login") @PageTitle("Bei VotianLT anmelden") @@ -22,8 +32,18 @@ import com.vaadin.flow.server.auth.AnonymousAllowed; public class LoginView extends VerticalLayout implements BeforeEnterObserver, AfterNavigationObserver { private final LoginForm loginForm = new LoginForm(); + private final TextField twoFaField = new TextField("2FA Code"); + private final Button verify2faButton = new Button("Code prüfen"); private final Div flashBox = new Div(); + @Autowired + private TwoFactorService twoFactorService; + + @Autowired + private AuthenticationManager authenticationManager; + + private Authentication pendingAuth; + public LoginView() { addClassName("login-view"); setSizeFull(); @@ -31,7 +51,16 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER); setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER); - loginForm.setAction("login"); + // Eigene Authentifizierung: Kein POST auf /login + loginForm.setAction(null); + + twoFaField.setVisible(false); + twoFaField.setMaxLength(6); + twoFaField.setPattern("[0-9]{6}"); + twoFaField.setHelperText("Bitte 6-stelligen Code aus der E-Mail eingeben"); + verify2faButton.setVisible(false); + verify2faButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + verify2faButton.addClickListener(e -> handleVerify2fa()); // Enable built-in forgot password link and navigate to request view loginForm.setForgotPasswordButtonVisible(true); loginForm.addForgotPasswordListener(e -> UI.getCurrent().navigate(ForgotPasswordRequestView.class)); @@ -56,13 +85,59 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af VerticalLayout loginLayout = new VerticalLayout(); loginLayout.setAlignItems(FlexComponent.Alignment.CENTER); - loginLayout.add(flashBox, title, loginForm, registerButton); + loginLayout.add(flashBox, title, loginForm, twoFaField, verify2faButton, registerButton); loginLayout.setMaxWidth("400px"); loginLayout.setPadding(true); add(loginLayout); + // Login-Schritt 1: Benutzername/Passwort + loginForm.addLoginListener(e -> handlePasswordLogin(e.getUsername(), e.getPassword())); } + private void handlePasswordLogin(String username, String password) { + try { + // Prüfe Benutzername/Passwort, aber setze Benutzer noch nicht in den SecurityContext + Authentication auth = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password)); + this.pendingAuth = auth; + twoFaField.setVisible(true); + verify2faButton.setVisible(true); + twoFactorService.initiateTwoFactorFor(username); + Notification.show("2FA-Code per E-Mail gesendet.", 3000, Notification.Position.BOTTOM_CENTER); + } catch (Exception ex) { + loginForm.setError(true); + this.pendingAuth = null; + } + } + + private void handleVerify2fa() { + if (pendingAuth == null) { + Notification.show("Bitte zuerst Benutzername und Passwort eingeben.", 3000, Notification.Position.BOTTOM_CENTER); + return; + } + String username = pendingAuth.getName(); + String code = twoFaField.getValue(); + if (code == null || !code.matches("[0-9]{6}")) { + Notification.show("Ungültiger Code.", 3000, Notification.Position.BOTTOM_CENTER); + return; + } + boolean ok = twoFactorService.verifyTwoFactorCode(username, code); + if (!ok) { + Notification.show("Falscher oder abgelaufener Code.", 3000, Notification.Position.BOTTOM_CENTER); + return; + } + // 2FA korrekt: Benutzer nun anmelden + SecurityContextHolder.getContext().setAuthentication(pendingAuth); + // Persistiere SecurityContext in der HTTP-Session, damit Vaadin/Security ihn in neuen Requests sieht + var vaadinSession = VaadinSession.getCurrent(); + if (vaadinSession != null) { + var wrappedSession = vaadinSession.getSession(); + wrappedSession.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + SecurityContextHolder.getContext()); + } + this.pendingAuth = null; + // Full reload, damit der neue SecurityContext im UI sicher greift + UI.getCurrent().getPage().setLocation("/dashboard"); + } @Override public void beforeEnter(BeforeEnterEvent beforeEnterEvent) { diff --git a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java index 257c322..026160e 100644 --- a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java +++ b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java @@ -5,6 +5,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -74,4 +76,9 @@ public class SecurityConfig extends VaadinWebSecurity { .deleteCookies("JSESSIONID") ); } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } } diff --git a/src/main/java/de/assecutor/votianlt/security/SecurityService.java b/src/main/java/de/assecutor/votianlt/security/SecurityService.java index e80e71c..52d2baf 100644 --- a/src/main/java/de/assecutor/votianlt/security/SecurityService.java +++ b/src/main/java/de/assecutor/votianlt/security/SecurityService.java @@ -1,6 +1,9 @@ package de.assecutor.votianlt.security; import com.vaadin.flow.spring.security.AuthenticationContext; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.bson.types.ObjectId; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; @@ -22,15 +25,41 @@ public class SecurityService { } public boolean isUserLoggedIn() { - return authenticationContext.isAuthenticated(); + if (authenticationContext.isAuthenticated()) return true; + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken); } public String getCurrentUsername() { + // 1) Versuche SecurityContext zuerst (robuster bei Custom Login) + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) { + Object principal = auth.getPrincipal(); + if (principal instanceof CustomUserPrincipal cup) { + de.assecutor.votianlt.model.User u = cup.getUser(); + if (u != null) { + String namePart = (nullToEmpty(u.getFirstname()) + " " + nullToEmpty(u.getName())).trim(); + if (!namePart.isBlank()) return namePart; + if (u.getEmail() != null && !u.getEmail().isBlank()) return u.getEmail(); + } + return cup.getUsername(); + } + if (principal instanceof UserDetails ud) { + return ud.getUsername(); + } + if (principal instanceof String s && !"anonymousUser".equalsIgnoreCase(s)) { + return s; + } + } + + // 2) Fallback: Vaadin AuthenticationContext return getAuthenticatedUser() .map(UserDetails::getUsername) .orElse("Anonymous"); } + private String nullToEmpty(String s) { return s == null ? "" : s; } + /** * Get the complete MongoDB User entity from the session */ diff --git a/src/main/java/de/assecutor/votianlt/security/totp/TwoFactorService.java b/src/main/java/de/assecutor/votianlt/security/totp/TwoFactorService.java new file mode 100644 index 0000000..8782fb1 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/security/totp/TwoFactorService.java @@ -0,0 +1,58 @@ +package de.assecutor.votianlt.security.totp; + +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.repository.UserRepository; +import de.assecutor.votianlt.util.MailUtil; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +public class TwoFactorService { + + private final UserRepository userRepository; + private final MailUtil mailUtil; + private final SecureRandom random = new SecureRandom(); + + public TwoFactorService(UserRepository userRepository, MailUtil mailUtil) { + this.userRepository = userRepository; + this.mailUtil = mailUtil; + } + + public void initiateTwoFactorFor(String email) { + Optional userOpt = userRepository.findByEmail(email); + if (userOpt.isEmpty()) return; + User user = userOpt.get(); + String code = generateSixDigitCode(); + user.setPasswordCode(code); + user.setPasswordTimestamp(LocalDateTime.now()); + userRepository.save(user); + try { + mailUtil.sendMail(email, "Ihr Anmeldecode (2FA)", "Ihr 2FA-Code lautet: " + code + "\nGültig für 10 Minuten."); + } catch (Exception ignored) { } + } + + public boolean verifyTwoFactorCode(String email, String code) { + Optional userOpt = userRepository.findByEmail(email); + if (userOpt.isEmpty()) return false; + User user = userOpt.get(); + if (user.getPasswordCode() == null || !user.getPasswordCode().equals(code)) return false; + if (user.getPasswordTimestamp() == null) return false; + // Gültigkeit 10 Minuten + if (user.getPasswordTimestamp().isBefore(LocalDateTime.now().minusMinutes(10))) return false; + // Code verbrauchen + user.setPasswordCode(null); + user.setPasswordTimestamp(null); + userRepository.save(user); + return true; + } + + private String generateSixDigitCode() { + int n = random.nextInt(1_000_000); // 0..999999 + return String.format("%06d", n); + } +} + +