Erweiterungen
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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,9 +19,10 @@ 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() {
|
||||
addClassName("login-view");
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
|
||||
// Find AppUser by email for login
|
||||
AppUser findByEmail(String email);
|
||||
|
||||
AppUser findByPasswordCode(String passwordCode);
|
||||
|
||||
// Custom query methods can be added here if needed
|
||||
// List<AppUser> findByBezeichnung(String bezeichnung);
|
||||
}
|
||||
|
||||
@@ -14,4 +14,6 @@ public interface UserRepository extends MongoRepository<User, String> {
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
void deleteByEmail(String email);
|
||||
|
||||
Optional<User> findByPasswordCode(String passwordCode);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user