diff --git a/RestroHub-FrontEnd/src/pages/public/Login.jsx b/RestroHub-FrontEnd/src/pages/public/Login.jsx index edf9e2ec..ebefdb08 100644 --- a/RestroHub-FrontEnd/src/pages/public/Login.jsx +++ b/RestroHub-FrontEnd/src/pages/public/Login.jsx @@ -170,18 +170,29 @@ const Login = () => { const result = res.data; if (result.success) { - const { accessToken, refreshToken, roles } = result.data; + const { + accessToken, + refreshToken, + roles, + isResetRequire + } = result.data; localStorage.setItem("accessToken", accessToken); localStorage.setItem("refreshToken", refreshToken); localStorage.setItem("roles", JSON.stringify(roles)); + if (isResetRequire) { + toast.error("Password expired. Please reset your password."); + navigate("/forgot-password"); + return; + } + axios.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`; toast.success("Login successful!"); navigate("/admin/dashboard"); - } else { + }else { toast.error(result.message || "Login failed"); } } catch (err) { diff --git a/RestroHub/.gitignore b/RestroHub/.gitignore new file mode 100644 index 00000000..d9ec3be2 --- /dev/null +++ b/RestroHub/.gitignore @@ -0,0 +1 @@ +logs/ diff --git a/RestroHub/build.gradle b/RestroHub/build.gradle index 8af798a7..6d2aa188 100644 --- a/RestroHub/build.gradle +++ b/RestroHub/build.gradle @@ -84,6 +84,8 @@ dependencies { // Logging (JSON format for production) implementation 'ch.qos.logback.contrib:logback-json-classic:0.1.5' implementation 'ch.qos.logback.contrib:logback-jackson:0.1.5' + + implementation 'org.springframework.boot:spring-boot-starter-mail' } // tasks.named('test') { diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java index 2000c370..e45f1bc4 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/controller/AuthController.java @@ -228,7 +228,39 @@ public ResponseEntity> refreshToken( return ResponseEntity.ok(ApiResponse.success(authResponse, "Token refreshed successfully")); } + @PostMapping("/forgot-password") +public ResponseEntity forgotPassword(@RequestParam String email) { + if(email == null || email.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Email cannot be empty"); +} +if(!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + return ResponseEntity.badRequest().body("Invalid email format"); +} + authService.forgotPassword(email); + + return ResponseEntity.ok("Reset token generated successfully"); +} + +@PostMapping("/reset-password") +public ResponseEntity resetPassword( + @RequestParam String email, + @RequestParam(required = false) String token, + @RequestParam String newPassword) { + if(newPassword == null || newPassword.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Password cannot be empty"); +} + +if(newPassword.length() < 6) { + return ResponseEntity.badRequest().body("Password must be at least 6 characters"); +} +if(token != null && token.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Invalid token"); +} + authService.resetPassword(email, token, newPassword); + return ResponseEntity.ok("Password reset successful"); +} + @PostMapping("/logout") @Operation( summary = "Logout user", diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/AuthResponse.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/AuthResponse.java index cc13d913..e79ffc6c 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/AuthResponse.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/dto/AuthResponse.java @@ -33,6 +33,9 @@ public class AuthResponse { @Schema(description = "User roles", example = "[\"ROLE_ADMIN\"]") private List roles; + + @Schema(description = "Indicates whether password reset is required") + private Boolean isResetRequire; @Schema(description = "Token issue timestamp") @Builder.Default diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthService.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthService.java index 97eaae65..381d6286 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthService.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthService.java @@ -13,4 +13,8 @@ public interface AuthService { AuthResponse refreshToken(RefreshTokenRequest refreshTokenRequest); void logout(String token); + + void forgotPassword(String email); + + void resetPassword(String email, String token, String newPassword); } \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthServiceImpl.java index b84736aa..e354ed8e 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthServiceImpl.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/AuthServiceImpl.java @@ -30,17 +30,52 @@ import java.util.Collections; import java.util.stream.Collectors; +import com.restroly.qrmenu.user.entity.User; +import com.restroly.qrmenu.user.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; + @Service @RequiredArgsConstructor @Slf4j public class AuthServiceImpl implements AuthService { + private static class TokenData { + private final String token; + private final LocalDateTime expiryTime; private final UserRepository userRepository; private final RoleRepository roleRepository; private final PasswordEncoder passwordEncoder; + public TokenData(String token, LocalDateTime expiryTime) { + this.token = token; + this.expiryTime = expiryTime; + } + + public String getToken() { + return token; + } + + public LocalDateTime getExpiryTime() { + return expiryTime; + } +} + private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final EmailService emailService; + + private final Map resetTokenCache = new ConcurrentHashMap<>(); + + @Value("${reset.expiry.threshold.days:90}") + private int resetExpiryThresholdDays; @Override public AuthResponse login(LoginRequest loginRequest) { @@ -57,6 +92,16 @@ public AuthResponse login(LoginRequest loginRequest) { SecurityContextHolder.getContext().setAuthentication(authentication); UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + User user = userRepository.findByEmail(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + + boolean isResetRequired = false; + + if (user.getResetPassExpiryDate() != null && + LocalDateTime.now().isAfter(user.getResetPassExpiryDate())) { + + isResetRequired = true; + } String accessToken = jwtTokenProvider.generateAccessToken(userDetails); String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails); @@ -73,6 +118,7 @@ public AuthResponse login(LoginRequest loginRequest) { .expiresIn(jwtTokenProvider.getExpirationInSeconds()) .username(userDetails.getUsername()) .roles(roles) + .isResetRequire(isResetRequired) .build(); } catch (BadCredentialsException ex) { @@ -134,28 +180,65 @@ public void logout(String token) { @Override public AuthResponse register(RegisterRequest registerRequest) { - if (userRepository.findByEmail(registerRequest.getEmail()).isPresent()) { - throw new DuplicateResourceException("User already exists with this email"); - } + User user = userRepository.findByEmail(email) + .orElse(null); + if (user == null) { + return; + } - User user = User.builder() - .name(registerRequest.getFirstName() + " " + registerRequest.getLastName()) - .email(registerRequest.getEmail()) - .password(passwordEncoder.encode(registerRequest.getPassword())) - .isActive(true) - .isLocked(false) - .authProvider("LOCAL") - .build(); + String token = String.format("%06d", new Random().nextInt(999999)); - Role customerRole = roleRepository.findByName("CUSTOMER") - .orElseThrow(() -> new RuntimeException("Default CUSTOMER role not found")); + LocalDateTime expiryTime = LocalDateTime.now().plusMinutes(10); + + resetTokenCache.put( + email, + new TokenData(token, expiryTime) + ); - user.setRoles(new ArrayList<>(Collections.singletonList(customerRole))); - userRepository.save(user); return AuthResponse.builder() .username(user.getEmail()) .build(); } + @Override + public void resetPassword(String email, String token, String newPassword) { + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + if (passwordEncoder.matches(newPassword, user.getPassword())) { + throw new RuntimeException("New password cannot be same as current password"); + } + + if (token != null && !token.trim().isEmpty()) { + + TokenData tokenData = resetTokenCache.get(email); + + if (tokenData == null) { + throw new RuntimeException("Invalid or expired token"); + } + + if (LocalDateTime.now().isAfter(tokenData.getExpiryTime())) { + resetTokenCache.remove(email); + throw new RuntimeException("Token expired, please regenerate token"); + } + + if (!tokenData.getToken().equals(token)) { + throw new RuntimeException("Invalid token"); + } + + resetTokenCache.remove(email); + } + + user.setPassword(passwordEncoder.encode(newPassword)); + + user.setResetPassExpiryDate( + LocalDateTime.now().plusDays(resetExpiryThresholdDays) + ); + + userRepository.save(user); + + System.out.println("Password reset successful"); +} } \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/EmailService.java b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/EmailService.java new file mode 100644 index 00000000..62382ea6 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/auth/service/EmailService.java @@ -0,0 +1,35 @@ +package com.restroly.qrmenu.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String mailUsername; + + public void sendResetEmail(String toEmail, String token) { + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(mailUsername); + message.setTo(toEmail); + message.setSubject("Password Reset Request"); + + message.setText( + "Click the link below to reset your password:\n\n" + + "https://example.com/reset-password?token=" + + token + ); + + mailSender.send(message); + + System.out.println("Reset email sent successfully"); + } +} \ No newline at end of file diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java b/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java index 1db1c176..9a1ea3e4 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/user/entity/User.java @@ -29,6 +29,9 @@ public class User { @Column(name = "user_password", nullable = false) private String password; + @Column(name = "reset_pass_expiry_date") + private LocalDateTime resetPassExpiryDate; + @Column(name = "phone_number") private String phoneNumber; diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/user/repository/UserRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/user/repository/UserRepository.java index 76fd6f48..823bf8a7 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/user/repository/UserRepository.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/user/repository/UserRepository.java @@ -15,7 +15,7 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); - + boolean existsByEmail(String email); boolean existsByEmailAndUserIdNot(String email, Long userId); diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/user/service/UserServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/user/service/UserServiceImpl.java index 787ef418..dc001a26 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/user/service/UserServiceImpl.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/user/service/UserServiceImpl.java @@ -24,6 +24,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import org.springframework.beans.factory.annotation.Value; + import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -42,6 +45,9 @@ public class UserServiceImpl implements UserService { private final RestaurantRepository restaurantRepository; private final PasswordEncoder passwordEncoder; + @Value("${reset.expiry.threshold.days:90}") + private int resetExpiryThresholdDays; + // ============================= // REGISTER USER // ============================= @@ -66,6 +72,9 @@ public UserResponse registerUser(UserRequest request) { .phoneNumber(request.getPhone()) .isActive(request.getIsActive() != null ? request.getIsActive() : true) .isLocked(false) + .resetPassExpiryDate( + LocalDateTime.now().plusDays(resetExpiryThresholdDays) + ) .build(); if (request.getRoleIds() != null && !request.getRoleIds().isEmpty()) { diff --git a/RestroHub/src/main/resources/application-dev.properties b/RestroHub/src/main/resources/application-dev.properties index 9332792b..a9d7922e 100644 --- a/RestroHub/src/main/resources/application-dev.properties +++ b/RestroHub/src/main/resources/application-dev.properties @@ -3,7 +3,7 @@ # =============================== spring.datasource.url=jdbc:postgresql://127.0.0.1:5432/RestroHub_DB spring.datasource.username=${DB_USERNAME:postgres} -spring.datasource.password=${DB_PASSWORD:postgres} +spring.datasource.password=${DB_PASSWORD:${DB_PASSWORD}} spring.datasource.driver-class-name=org.postgresql.Driver # =============================== # HikariCP diff --git a/RestroHub/src/main/resources/application.properties b/RestroHub/src/main/resources/application.properties index e812b174..5f0e00fd 100644 --- a/RestroHub/src/main/resources/application.properties +++ b/RestroHub/src/main/resources/application.properties @@ -147,3 +147,4 @@ spring.servlet.multipart.max-request-size=10MB # Service Requests Configuration # =============================== service.request.types=CALL_WAITER,REQUEST_BILL +reset.expiry.threshold.days=90