Erweiterungen

This commit is contained in:
2025-09-10 11:10:42 +02:00
parent 20a7afc82c
commit 4e2fdb1e26
7 changed files with 219 additions and 61 deletions

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.pages.base.ui.view; package de.assecutor.votianlt.pages.base.ui.view;
import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.avatar.Avatar;
import com.vaadin.flow.component.avatar.AvatarVariant; 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.auth.AnonymousAllowed;
import com.vaadin.flow.server.menu.MenuConfiguration; import com.vaadin.flow.server.menu.MenuConfiguration;
import com.vaadin.flow.server.menu.MenuEntry; import com.vaadin.flow.server.menu.MenuEntry;
import de.assecutor.votianlt.pages.view.EditProfileView;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import static com.vaadin.flow.theme.lumo.LumoUtility.*; 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 { public final class MainLayout extends AppLayout {
private final SecurityService securityService; private final SecurityService securityService;
private Div headerRef;
private Scroller navRef;
private Component userMenuRef;
public MainLayout(SecurityService securityService) { public MainLayout(SecurityService securityService) {
this.securityService = securityService; this.securityService = securityService;
setPrimarySection(Section.DRAWER); setPrimarySection(Section.DRAWER);
// Only show navigation drawer if user is logged in // Always build the drawer; keep references and toggle visibility on attach and after navigation
if (securityService.isUserLoggedIn()) { headerRef = createHeader();
addToDrawer(createHeader(), new Scroller(createSideNav()), createUserMenu()); navRef = new Scroller(createSideNav());
} else { userMenuRef = createUserMenu();
// Hide drawer completely for anonymous users addToDrawer(headerRef, navRef, userMenuRef);
setDrawerOpened(false);
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() { private Div createHeader() {
@@ -123,23 +139,38 @@ public final class MainLayout extends AppLayout {
} }
private Component createUserMenu() { 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(); var userMenu = new MenuBar();
userMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE); userMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE);
userMenu.addClassNames(Margin.MEDIUM); 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); var userMenuItem = userMenu.addItem(avatar);
userMenuItem.add(currentUser); userMenuItem.add(userNameSpan);
userMenuItem.getSubMenu().addItem("Profil anzeigen");
// Profil anzeigen mit Navigation
userMenuItem.getSubMenu().addItem("Profil anzeigen", e ->
UI.getCurrent().navigate(EditProfileView.class));
userMenuItem.getSubMenu().addItem("Einstellungen"); userMenuItem.getSubMenu().addItem("Einstellungen");
userMenuItem.getSubMenu().addItem("Abmelden", e -> securityService.logout()); 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; return userMenu;
} }

View File

@@ -117,47 +117,6 @@ public class AddJobService {
return jobNumber; 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 * Findet den aktuellen Entwurf eines Benutzers
*/ */

View File

@@ -5,7 +5,6 @@ import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.formlayout.FormLayout; import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.Div; 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.Span;
import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.IFrame; import com.vaadin.flow.component.html.IFrame;

View File

@@ -6,6 +6,8 @@ import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Div; 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.textfield.TextField;
import com.vaadin.flow.component.notification.Notification;
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.AfterNavigationEvent;
@@ -15,6 +17,14 @@ import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed; 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") @Route("login")
@PageTitle("Bei VotianLT anmelden") @PageTitle("Bei VotianLT anmelden")
@@ -22,8 +32,18 @@ import com.vaadin.flow.server.auth.AnonymousAllowed;
public class LoginView extends VerticalLayout implements BeforeEnterObserver, AfterNavigationObserver { public class LoginView extends VerticalLayout implements BeforeEnterObserver, AfterNavigationObserver {
private final LoginForm loginForm = new LoginForm(); 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(); private final Div flashBox = new Div();
@Autowired
private TwoFactorService twoFactorService;
@Autowired
private AuthenticationManager authenticationManager;
private Authentication pendingAuth;
public LoginView() { public LoginView() {
addClassName("login-view"); addClassName("login-view");
setSizeFull(); setSizeFull();
@@ -31,7 +51,16 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER); setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.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 // Enable built-in forgot password link and navigate to request view
loginForm.setForgotPasswordButtonVisible(true); loginForm.setForgotPasswordButtonVisible(true);
loginForm.addForgotPasswordListener(e -> UI.getCurrent().navigate(ForgotPasswordRequestView.class)); loginForm.addForgotPasswordListener(e -> UI.getCurrent().navigate(ForgotPasswordRequestView.class));
@@ -56,13 +85,59 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
VerticalLayout loginLayout = new VerticalLayout(); VerticalLayout loginLayout = new VerticalLayout();
loginLayout.setAlignItems(FlexComponent.Alignment.CENTER); loginLayout.setAlignItems(FlexComponent.Alignment.CENTER);
loginLayout.add(flashBox, title, loginForm, registerButton); loginLayout.add(flashBox, title, loginForm, twoFaField, verify2faButton, registerButton);
loginLayout.setMaxWidth("400px"); loginLayout.setMaxWidth("400px");
loginLayout.setPadding(true); loginLayout.setPadding(true);
add(loginLayout); 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 @Override
public void beforeEnter(BeforeEnterEvent beforeEnterEvent) { public void beforeEnter(BeforeEnterEvent beforeEnterEvent) {

View File

@@ -5,6 +5,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -74,4 +76,9 @@ public class SecurityConfig extends VaadinWebSecurity {
.deleteCookies("JSESSIONID") .deleteCookies("JSESSIONID")
); );
} }
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
} }

View File

@@ -1,6 +1,9 @@
package de.assecutor.votianlt.security; package de.assecutor.votianlt.security;
import com.vaadin.flow.spring.security.AuthenticationContext; 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.bson.types.ObjectId;
import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
@@ -22,15 +25,41 @@ public class SecurityService {
} }
public boolean isUserLoggedIn() { 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() { 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() return getAuthenticatedUser()
.map(UserDetails::getUsername) .map(UserDetails::getUsername)
.orElse("Anonymous"); .orElse("Anonymous");
} }
private String nullToEmpty(String s) { return s == null ? "" : s; }
/** /**
* Get the complete MongoDB User entity from the session * Get the complete MongoDB User entity from the session
*/ */

View File

@@ -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<User> 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<User> 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);
}
}