diff --git a/build_and_run_app.sh b/build_and_run_app.sh index ad1b2d3..e4e1b2e 100755 --- a/build_and_run_app.sh +++ b/build_and_run_app.sh @@ -6,6 +6,12 @@ else SEARCH_FEATURE="-DenableSearchFeature=$1" fi +if [ "$1" == "r" ]; then + docker run -d -p 6379:6379 --name redis_container redis + echo "Redis container is running." + exit 0 +fi + cleanup() { docker stop redis_container > /dev/null 2>&1 || true docker rm redis_container > /dev/null 2>&1 || true @@ -13,9 +19,9 @@ cleanup() { } # Trap the EXIT signal to perform cleanup -trap cleanup EXIT +# trap cleanup EXIT set -e # Exit immediately if a command exits with a non-zero status. mvn clean package -Dmaven.test.skip=true -docker run -d -p 6379:6379 --name redis_container redis +docker run -d -p 6379:6379 --name redis_container redis | exit 0 java $SEARCH_FEATURE -jar target/salesmanager-*-SNAPSHOT.jar --spring.redis.host=localhost --spring.redis.port=6379 --spring.redis.mode=standalone --server.port=8086 --spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect diff --git a/pom.xml b/pom.xml index 59384d5..b59db35 100644 --- a/pom.xml +++ b/pom.xml @@ -136,7 +136,24 @@ spring-data-commons - + + + dev.samstevens.totp + totp + 1.7.1 + + + com.google.zxing + core + 3.5.1 + + + com.google.zxing + javase + 3.5.1 + + + diff --git a/src/main/java/net/codejava/AppController.java b/src/main/java/net/codejava/AppController.java index 7714c66..5469490 100755 --- a/src/main/java/net/codejava/AppController.java +++ b/src/main/java/net/codejava/AppController.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -28,7 +29,6 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.data.domain.PageRequest; // Add this import statement import org.springframework.data.domain.Page; // Add this import statement @@ -49,6 +49,7 @@ import java.time.format.DateTimeFormatter; import java.time.LocalDateTime; import java.time.ZoneId; +import javax.servlet.http.Cookie; @EnableJpaRepositories(basePackages = "net.codejava") @Controller @@ -64,6 +65,9 @@ public class AppController { @Autowired private AuthenticationManager authenticationManager; + @Autowired + private UserRepository userRepository; + // private static final Logger logger = Logger.getLogger(AppController.class.getName()); @Value("${enableSearchFeature}") @@ -73,6 +77,18 @@ public boolean getEnableSearchFeature() { return this.enableSearchFeature; } + // Add MFA status to all pages that need it + private void addUserInfoToModel(Model model, Principal principal) { + if (principal != null) { + String username = principal.getName(); + User user = userRepository.findByUsername(username); + if (user != null) { + model.addAttribute("mfaEnabled", user.isMfaEnabled()); + model.addAttribute("username", username); + } + } + } + private String handleSale(Sale sale, HttpSession session, RedirectAttributes redirectAttributes, Runnable action) { sale.setEditing(true); // set isEditing to true action.run(); @@ -88,7 +104,7 @@ private String handleSale(Sale sale, HttpSession session, RedirectAttributes red } @RequestMapping("/") - public String viewHomePage(Model model , Principal principal, @RequestParam(defaultValue = "0") int page, HttpSession session) { + public String viewHomePage(Model model, Principal principal, @RequestParam(defaultValue = "0") int page, HttpSession session) { String lastSearchQuery = (String) session.getAttribute("lastSearchQuery"); if (lastSearchQuery != null && !lastSearchQuery.isEmpty()) { session.setAttribute("lastSearchQuery", null); // set lastSearchQuery to null @@ -102,37 +118,67 @@ public String viewHomePage(Model model , Principal principal, @RequestParam(defa model.addAttribute("listSale", salePage.getContent()); model.addAttribute("currentPage", page); model.addAttribute("totalPages", salePage.getTotalPages()); + + // Add user info including MFA status + addUserInfoToModel(model, principal); + return "index"; } @RequestMapping("/new") - public ModelAndView showNewForm() { + public ModelAndView showNewForm(Principal principal) { ModelAndView mav = new ModelAndView("new_form"); Sale sale = new Sale(); mav.addObject("sale", sale); mav.addObject("currentDate", LocalDate.now()); mav.addObject("enableSearchFeature", enableSearchFeature); + + // Add user info including MFA status + if (principal != null) { + String username = principal.getName(); + User user = userRepository.findByUsername(username); + if (user != null) { + mav.addObject("mfaEnabled", user.isMfaEnabled()); + mav.addObject("username", username); + } + } + return mav; } @RequestMapping("/edit/{serialNumber}") - public ModelAndView showEditForm(@PathVariable(name = "serialNumber") String serialNumber) { + public ModelAndView showEditForm(@PathVariable(name = "serialNumber") String serialNumber, Principal principal) { ModelAndView mav = new ModelAndView("edit_form"); Sale sale = dao.get(serialNumber); sale.setEditing(true); mav.addObject("sale", sale); mav.addObject("enableSearchFeature", enableSearchFeature); + + // Add user info including MFA status + if (principal != null) { + String username = principal.getName(); + User user = userRepository.findByUsername(username); + if (user != null) { + mav.addObject("mfaEnabled", user.isMfaEnabled()); + mav.addObject("username", username); + } + } + return mav; } @RequestMapping("/search") - public String search(@ModelAttribute("q") String query, Model model, HttpSession session) { + public String search(@ModelAttribute("q") String query, Model model, HttpSession session, Principal principal) { List listSale = dao.search(query); model.addAttribute("listSale", listSale); boolean enableSearchFeature = true; model.addAttribute("enableSearchFeature", enableSearchFeature); session.setAttribute("lastSearchQuery", query); // save the last search query in the session + + // Add user info including MFA status + addUserInfoToModel(model, principal); + return "search"; } @@ -165,21 +211,27 @@ public String loginGet(Model model) { } @RequestMapping(value = "/login", method = RequestMethod.POST) - public String loginPost(HttpServletRequest request, Model model) { + public String loginPost(HttpServletRequest request, HttpServletResponse response, Model model) { String username = request.getParameter("username"); String password = request.getParameter("password"); + boolean rememberMe = "on".equals(request.getParameter("rememberMe")); - // Authenticate the user Authentication auth = new UsernamePasswordAuthenticationToken(username, password); try { auth = authenticationManager.authenticate(auth); SecurityContextHolder.getContext().setAuthentication(auth); + + if (rememberMe) { + Cookie rememberMeCookie = new Cookie("rememberMe", "true"); + rememberMeCookie.setMaxAge(7 * 24 * 60 * 60); // 7 days + rememberMeCookie.setHttpOnly(true); + response.addCookie(rememberMeCookie); + } } catch (BadCredentialsException e) { model.addAttribute("error", "Invalid username or password."); return "login"; } - // User is authenticated, redirect to landing page return "redirect:/"; } @@ -200,25 +252,6 @@ public String clearRecord(@PathVariable(name = "serialNumber") String serialNumb return handleSale(sale, session, redirectAttributes, () -> dao.clearRecord(serialNumber)); } - @RequestMapping("/export") - public void exportToCSV(HttpServletResponse response) throws IOException { - response.setContentType("text/csv"); - response.setHeader("Content-Disposition", "attachment; filename=sales.csv"); - List listSale = dao.listAll(); - // create a writer - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(response.getOutputStream())); - // write header line - writer.write("Serial Number, Date, Amount, Item Name"); - writer.newLine(); - // write data lines - for (Sale sale : listSale) { - String line = String.format("%s, %s, %s, %s", sale.getSerialNumber(), sale.getDate(), sale.getAmount(), sale.getItem()); - writer.write(line); - writer.newLine(); - } - writer.flush(); - } - @PostMapping("/import") public String uploadFile(@RequestParam("file") MultipartFile file, RedirectAttributes redirectAttributes) { try { diff --git a/src/main/java/net/codejava/MfaAuthentication.java b/src/main/java/net/codejava/MfaAuthentication.java new file mode 100644 index 0000000..36d4d66 --- /dev/null +++ b/src/main/java/net/codejava/MfaAuthentication.java @@ -0,0 +1,42 @@ +package net.codejava; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class MfaAuthentication extends UsernamePasswordAuthenticationToken { + + private boolean mfaAuthenticated; + private User user; + + public MfaAuthentication(User user, Object credentials, + Collection authorities, + boolean mfaAuthenticated) { + super(user.getUsername(), credentials, authorities); + this.user = user; + this.mfaAuthenticated = mfaAuthenticated; + } + + public boolean isMfaAuthenticated() { + return mfaAuthenticated; + } + + public void setMfaAuthenticated(boolean mfaAuthenticated) { + this.mfaAuthenticated = mfaAuthenticated; + } + + public User getUser() { + return user; + } + + @Override + public boolean isAuthenticated() { + // Only consider the authentication fully complete if MFA has been verified + // for users who have MFA enabled + if (user.isMfaEnabled()) { + return super.isAuthenticated() && mfaAuthenticated; + } + return super.isAuthenticated(); + } +} \ No newline at end of file diff --git a/src/main/java/net/codejava/MfaAuthenticationFilter.java b/src/main/java/net/codejava/MfaAuthenticationFilter.java new file mode 100644 index 0000000..ced2f8c --- /dev/null +++ b/src/main/java/net/codejava/MfaAuthenticationFilter.java @@ -0,0 +1,56 @@ +package net.codejava; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; + +@Component +public class MfaAuthenticationFilter extends OncePerRequestFilter { + + private static final String MFA_VERIFICATION_URL = "/mfa/verify"; + private static final String MFA_SETUP_URL = "/mfa/setup"; + private static final String LOGIN_URL = "/login"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + HttpSession session = request.getSession(); + + // Skip the filter for login and MFA-related URLs + String requestURI = request.getRequestURI(); + if (requestURI.equals(LOGIN_URL) || requestURI.startsWith(MFA_VERIFICATION_URL) || + requestURI.startsWith(MFA_SETUP_URL) || requestURI.startsWith("/css/") || + requestURI.startsWith("/js/")) { + filterChain.doFilter(request, response); + return; + } + + // Check if the user is authenticated with MFA + if (authentication instanceof MfaAuthentication) { + MfaAuthentication mfaAuthentication = (MfaAuthentication) authentication; + + // If MFA is needed but not yet verified + if (mfaAuthentication.getUser().isMfaEnabled() && !mfaAuthentication.isMfaAuthenticated()) { + // Store the requested URL in the session + session.setAttribute("REQUESTED_URL", requestURI); + + // Redirect to MFA verification page + response.sendRedirect(MFA_VERIFICATION_URL); + return; + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/net/codejava/MfaAuthenticationProvider.java b/src/main/java/net/codejava/MfaAuthenticationProvider.java new file mode 100644 index 0000000..f87977e --- /dev/null +++ b/src/main/java/net/codejava/MfaAuthenticationProvider.java @@ -0,0 +1,68 @@ +package net.codejava; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class MfaAuthenticationProvider implements AuthenticationProvider { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserDetailsService userDetailsService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private MfaService mfaService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + // First stage: verify username and password (primary authentication) + String username = authentication.getName(); + String password = authentication.getCredentials().toString(); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (!passwordEncoder.matches(password, userDetails.getPassword())) { + throw new BadCredentialsException("Invalid username or password"); + } + + // Find the user entity to check MFA settings + User user = userRepository.findByUsername(username); + + // Check if MFA is enabled for the user + if (user != null && user.isMfaEnabled()) { + // For MFA-enabled users, we return a partially authenticated token + // The MfaAuthentication object will be used in the filter chain + return new MfaAuthentication( + user, + authentication.getCredentials(), + userDetails.getAuthorities(), + true // authenticated with password, but pending MFA verification + ); + } + + // For users without MFA, return a fully authenticated token + return new UsernamePasswordAuthenticationToken( + userDetails, + null, // clear credentials + userDetails.getAuthorities() + ); + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(UsernamePasswordAuthenticationToken.class); + } +} \ No newline at end of file diff --git a/src/main/java/net/codejava/MfaController.java b/src/main/java/net/codejava/MfaController.java new file mode 100644 index 0000000..d4712eb --- /dev/null +++ b/src/main/java/net/codejava/MfaController.java @@ -0,0 +1,140 @@ +package net.codejava; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.servlet.http.HttpSession; + +@Controller +@RequestMapping("/mfa") +public class MfaController { + + @Autowired + private UserRepository userRepository; + + @Autowired + private MfaService mfaService; + + // Display MFA setup page with QR code + @GetMapping("/setup") + public String setupMfa(Model model) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = auth.getName(); + + User user = userRepository.findByUsername(username); + if (user == null) { + return "redirect:/login"; + } + + // Generate a new secret key if the user doesn't have one + if (user.getMfaSecret() == null || user.getMfaSecret().isEmpty()) { + String secretKey = mfaService.generateSecretKey(); + user.setMfaSecret(secretKey); + userRepository.save(user); + } + + // Generate QR code for the authenticator app + String qrCodeImage = mfaService.generateQrCodeImageUri(user.getMfaSecret(), username); + model.addAttribute("qrCodeImage", qrCodeImage); + model.addAttribute("secret", user.getMfaSecret()); + + return "mfa-setup"; + } + + // Enable MFA for the user after setup verification + @PostMapping("/setup") + public String verifyAndEnableMfa(@RequestParam("code") String code, Model model) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = auth.getName(); + + User user = userRepository.findByUsername(username); + if (user == null) { + return "redirect:/login"; + } + + // Verify the provided code + if (mfaService.verifyCode(code, user.getMfaSecret())) { + // Enable MFA for the user + user.setMfaEnabled(true); + userRepository.save(user); + + model.addAttribute("message", "Multi-factor authentication has been enabled successfully."); + return "redirect:/"; + } else { + model.addAttribute("error", "Invalid verification code. Please try again."); + model.addAttribute("qrCodeImage", mfaService.generateQrCodeImageUri(user.getMfaSecret(), username)); + model.addAttribute("secret", user.getMfaSecret()); + return "mfa-setup"; + } + } + + // Display MFA verification page during login + @GetMapping("/verify") + public String verifyMfa() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + // If not authenticated or not an MFA authentication, redirect to login + if (auth == null || !(auth instanceof MfaAuthentication)) { + return "redirect:/login"; + } + + return "mfa-verify"; + } + + // Verify MFA code during login + @PostMapping("/verify") + public String verifyMfaCode(@RequestParam("code") String code, HttpSession session, Model model) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + // If not authenticated or not an MFA authentication, redirect to login + if (auth == null || !(auth instanceof MfaAuthentication)) { + return "redirect:/login"; + } + + MfaAuthentication mfaAuth = (MfaAuthentication) auth; + User user = mfaAuth.getUser(); + + // Verify the provided code + if (mfaService.verifyCode(code, user.getMfaSecret())) { + // Mark MFA as authenticated in the authentication object + mfaAuth.setMfaAuthenticated(true); + SecurityContextHolder.getContext().setAuthentication(mfaAuth); + + // Redirect to the originally requested URL or home + String requestedUrl = (String) session.getAttribute("REQUESTED_URL"); + if (requestedUrl != null) { + session.removeAttribute("REQUESTED_URL"); + return "redirect:" + requestedUrl; + } + return "redirect:/"; + } else { + model.addAttribute("error", "Invalid verification code. Please try again."); + return "mfa-verify"; + } + } + + // Disable MFA for a user + @PostMapping("/disable") + public String disableMfa(Model model) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = auth.getName(); + + User user = userRepository.findByUsername(username); + if (user != null) { + user.setMfaEnabled(false); + user.setMfaSecret(null); + userRepository.save(user); + model.addAttribute("message", "Multi-factor authentication has been disabled successfully."); + } + + return "redirect:/"; + } +} \ No newline at end of file diff --git a/src/main/java/net/codejava/MfaService.java b/src/main/java/net/codejava/MfaService.java new file mode 100644 index 0000000..07dda60 --- /dev/null +++ b/src/main/java/net/codejava/MfaService.java @@ -0,0 +1,55 @@ +package net.codejava; + +import dev.samstevens.totp.code.*; +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.*; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import org.springframework.stereotype.Service; + +import static dev.samstevens.totp.util.Utils.getDataUriForImage; + +@Service +public class MfaService { + + private final SecretGenerator secretGenerator = new DefaultSecretGenerator(); + private final QrGenerator qrGenerator = new ZxingPngQrGenerator(); + private final CodeVerifier codeVerifier = new DefaultCodeVerifier( + new DefaultCodeGenerator(), + new SystemTimeProvider() + ); + + // Generate a new secret key for a user + public String generateSecretKey() { + return secretGenerator.generate(); + } + + // Generate QR code for the authenticator app + public String generateQrCodeImageUri(String secret, String username) { + try { + String issuer = "Sales Manager App"; + String label = issuer + ":" + username; + + QrData data = new QrData.Builder() + .label(label) + .secret(secret) + .issuer(issuer) + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build(); + + byte[] imageData = qrGenerator.generate(data); + return getDataUriForImage(imageData, qrGenerator.getImageMimeType()); + } catch (QrGenerationException e) { + throw new RuntimeException("Error generating QR code", e); + } + } + + // Verify the TOTP code provided by the user + public boolean verifyCode(String code, String secret) { + return codeVerifier.isValidCode(secret, code); + } +} \ No newline at end of file diff --git a/src/main/java/net/codejava/SalesDAO.java b/src/main/java/net/codejava/SalesDAO.java index c3e6029..982cb15 100755 --- a/src/main/java/net/codejava/SalesDAO.java +++ b/src/main/java/net/codejava/SalesDAO.java @@ -30,6 +30,11 @@ public List list(int limit, int offset) { return listSale; } + public List listAll() { + String sql = "SELECT * FROM sales ORDER BY serial_number ASC"; + return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(Sale.class)); + } + public void save(Sale sale) throws DuplicateKeyException { try { System.out.println(sale); // log the Sale object @@ -118,13 +123,6 @@ public Page findAll(Pageable pageable) { return new PageImpl<>(sales, pageable, total); } - // a method to returns a list of all sales in a jdbctemplate query to use as a csv output - public List listAll() { - String sql = "SELECT * FROM sales ORDER BY serial_number ASC"; - List listSale = jdbcTemplate.query(sql, BeanPropertyRowMapper.newInstance(Sale.class)); - return listSale; - } - // save all sales in a list public void saveAll(List sales) { if (sales == null) { diff --git a/src/main/java/net/codejava/SecurityConfig.java b/src/main/java/net/codejava/SecurityConfig.java index b56f388..16bde27 100644 --- a/src/main/java/net/codejava/SecurityConfig.java +++ b/src/main/java/net/codejava/SecurityConfig.java @@ -2,7 +2,6 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -// import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 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.WebSecurityConfigurerAdapter; @@ -11,6 +10,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.http.HttpMethod; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @EnableWebSecurity @@ -21,39 +21,53 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; + + @Autowired + private MfaAuthenticationProvider mfaAuthenticationProvider; + + @Autowired + private MfaAuthenticationFilter mfaAuthenticationFilter; @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() - .antMatchers("/login").permitAll() + .antMatchers("/login", "/mfa/verify").permitAll() .antMatchers(HttpMethod.POST, "/import").permitAll() - // .anyRequest().permitAll() + .antMatchers("/css/**", "/js/**").permitAll() .anyRequest().authenticated() .and() .formLogin() - // .loginPage("/login") - // .loginProcessingUrl("/login") // This should match the form action in your login.html file + .loginPage("/login") .usernameParameter("username") .passwordParameter("password") - .defaultSuccessUrl("/", true) // This is the URL to redirect to after a successful login + .defaultSuccessUrl("/", true) .failureUrl("/login?error=true") .permitAll() .and() .logout() - .logoutUrl("/logout") // This is the URL to send the user to once they have logged out + .logoutUrl("/logout") .invalidateHttpSession(true) - .permitAll(); + .permitAll() + .and() + .rememberMe() + .key("uniqueAndSecret") + .tokenValiditySeconds(7 * 24 * 60 * 60); // 7 days + + // Add our MFA filter before the standard authentication filter + http.addFilterBefore(mfaAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } @Bean - public AuthenticationManager customAuthenticationManager() throws Exception { - return authenticationManager(); + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); } @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder); + auth.authenticationProvider(mfaAuthenticationProvider); } } \ No newline at end of file diff --git a/src/main/java/net/codejava/User.java b/src/main/java/net/codejava/User.java index 6b5e31b..a6cd5e2 100644 --- a/src/main/java/net/codejava/User.java +++ b/src/main/java/net/codejava/User.java @@ -15,6 +15,10 @@ public class User { private String username; private String password; + + // MFA related fields + private String mfaSecret; + private boolean mfaEnabled = false; // getters and setters methods // getter for id @@ -46,4 +50,21 @@ public String getPassword() { public void setPassword(String password){ this.password = password; } + + // MFA getters and setters + public String getMfaSecret() { + return mfaSecret; + } + + public void setMfaSecret(String mfaSecret) { + this.mfaSecret = mfaSecret; + } + + public boolean isMfaEnabled() { + return mfaEnabled; + } + + public void setMfaEnabled(boolean mfaEnabled) { + this.mfaEnabled = mfaEnabled; + } } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 579d3cc..1ab6c8d 100755 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,7 +2,7 @@ spring.datasource.url=jdbc:h2:file:./testdb;Mode=PostgreSQL spring.datasource.username=postgres spring.datasource.password=postgres -spring.liquibase.change-log=classpath:db/changelog/changelog_version-3.3.xml +spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml server.port=8086 spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true diff --git a/src/main/resources/db/changelog/changelog_mfa.xml b/src/main/resources/db/changelog/changelog_mfa.xml new file mode 100644 index 0000000..63c3f88 --- /dev/null +++ b/src/main/resources/db/changelog/changelog_mfa.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.xml b/src/main/resources/db/changelog/db.changelog-master.xml new file mode 100644 index 0000000..635ce43 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index cd45aa3..a79af54 100755 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -21,7 +21,6 @@

Inventory Records

Enter New Product - Export to CSV

-
diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 01af77c..0fed4b2 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -29,6 +29,8 @@

Welcome, Sales Manager!

+ + diff --git a/src/main/resources/templates/mfa-setup.html b/src/main/resources/templates/mfa-setup.html new file mode 100644 index 0000000..0fd732c --- /dev/null +++ b/src/main/resources/templates/mfa-setup.html @@ -0,0 +1,99 @@ + + + + + Setup Multi-Factor Authentication + + + +
+

Setup Multi-Factor Authentication

+ +
+ +
+

To secure your account, please set up multi-factor authentication by following these steps:

+
    +
  1. Download and install an authenticator app like Google Authenticator, Microsoft Authenticator, or Authy on your mobile device.
  2. +
  3. Open the app and scan the QR code below, or enter the secret key manually.
  4. +
  5. Enter the 6-digit code displayed in your authenticator app to verify and complete the setup.
  6. +
+
+ +
+ QR Code +
+ +
+

If you can't scan the QR code, enter this key manually:

+ +
+ +
+
+ + +
+ +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/mfa-verify.html b/src/main/resources/templates/mfa-verify.html new file mode 100644 index 0000000..1846292 --- /dev/null +++ b/src/main/resources/templates/mfa-verify.html @@ -0,0 +1,67 @@ + + + + + Verify Multi-Factor Authentication + + + +
+

Two-Factor Authentication

+ +
+ +
+

Please enter the 6-digit verification code from your authenticator app to complete the login.

+
+ +
+ + +
+
+ + \ No newline at end of file diff --git a/src/test/java/net/codejava/AppControllerTest.java b/src/test/java/net/codejava/AppControllerTest.java new file mode 100644 index 0000000..58c7de7 --- /dev/null +++ b/src/test/java/net/codejava/AppControllerTest.java @@ -0,0 +1,48 @@ +package net.codejava; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; + +import javax.servlet.http.Cookie; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +public class AppControllerTest { + + @Autowired + private AppController appController; + + @Autowired + private PersistentTokenBasedRememberMeServices rememberMeServices; + + @Test + public void testRememberMeFunctionality() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // Simulate login request with 'Remember Me' checked + request.setParameter("username", "testuser"); + request.setParameter("password", "testpassword"); + request.setParameter("rememberMe", "on"); + + String view = appController.loginPost(request, response, null); + + // Assert that the user is redirected to the home page + assertEquals("redirect:/", view); + + // Assert that the 'Remember Me' cookie is set + Cookie rememberMeCookie = response.getCookie("rememberMe"); + assertNotNull(rememberMeCookie); + assertEquals("true", rememberMeCookie.getValue()); + assertTrue(rememberMeCookie.getMaxAge() > 0); + + // Assert that the security context is populated + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + } +} \ No newline at end of file diff --git a/src/test/java/net/codejava/JUnit5ExampleTest12.java b/src/test/java/net/codejava/JUnit5ExampleTest12.java new file mode 100644 index 0000000..c6610a4 --- /dev/null +++ b/src/test/java/net/codejava/JUnit5ExampleTest12.java @@ -0,0 +1,151 @@ +package net.codejava; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +public class JUnit5ExampleTest12 { + + // Global variables to control test behavior + private static boolean isFeatureEnabled = true; + private static int maxRecordsPerPage = 20; + private static String defaultSearchQuery = "Laptop"; + private static String defaultItemName = "Smartphone"; + private static double defaultItemPrice = 999.99; + private static String testLogPrefix = "[TEST LOG] "; // New global variable + + @Autowired + private AppController appController; + + @Test + void testEnableSearchFeatureDefaultValue() { + if (isFeatureEnabled) { + System.out.println(testLogPrefix + "Feature is enabled: Running testEnableSearchFeatureDefaultValue"); + assertTrue(appController.getEnableSearchFeature(), testLogPrefix + "enableSearchFeature should be true by default"); + } else { + System.out.println(testLogPrefix + "Feature is disabled: Skipping testEnableSearchFeatureDefaultValue"); + } + + System.out.println(testLogPrefix + "Checking additional conditions..."); + System.out.println(testLogPrefix + "Test completed successfully."); + System.out.println(testLogPrefix + "Logging additional information."); + System.out.println(testLogPrefix + "Feature flag value: " + isFeatureEnabled); + System.out.println(testLogPrefix + "Default search query: " + defaultSearchQuery); + System.out.println(testLogPrefix + "Default item name: " + defaultItemName); + System.out.println(testLogPrefix + "Default item price: " + defaultItemPrice); + System.out.println(testLogPrefix + "Max records per page: " + maxRecordsPerPage); + System.out.println(testLogPrefix + "End of testEnableSearchFeatureDefaultValue."); + } + + @Test + void testMaxRecordsPerPage() { + System.out.println("Max records per page: " + maxRecordsPerPage); + assertEquals(20, maxRecordsPerPage, "Max records per page should be 20"); + } + + @Test + void testDefaultSearchQuery() { + System.out.println("Default search query: " + defaultSearchQuery); + assertEquals("Laptop", defaultSearchQuery, "Default search query should be 'Laptop'"); + } + + @Test + void testDefaultItemName() { + System.out.println("Default item name: " + defaultItemName); + assertEquals("Smartphone", defaultItemName, "Default item name should be 'Smartphone'"); + } + + @Test + void testDefaultItemPrice() { + System.out.println("Default item price: " + defaultItemPrice); + assertEquals(999.99, defaultItemPrice, "Default item price should be 999.99"); + } + + @Test + void testEnableSearchFeatureInHomePage() { + if (isFeatureEnabled) { + System.out.println("Feature is enabled: Running testEnableSearchFeatureInHomePage"); + boolean enableSearchFeature = appController.getEnableSearchFeature(); + System.out.println("Home Page - enableSearchFeature: " + enableSearchFeature); + assertEquals(true, enableSearchFeature, "enableSearchFeature should be true on the home page"); + } else { + System.out.println("Feature is disabled: Skipping testEnableSearchFeatureInHomePage"); + } + } + + @Test + void testEnableSearchFeatureInNewForm() { + if (isFeatureEnabled) { + System.out.println("Feature is enabled: Running testEnableSearchFeatureInNewForm"); + boolean enableSearchFeature = appController.getEnableSearchFeature(); + System.out.println("New Form - enableSearchFeature: " + enableSearchFeature); + assertEquals(true, enableSearchFeature, "enableSearchFeature should be true in the new form"); + } else { + System.out.println("Feature is disabled: Skipping testEnableSearchFeatureInNewForm"); + } + } + + @Test + void testEnableSearchFeatureInEditForm() { + if (isFeatureEnabled) { + System.out.println("Feature is enabled: Running testEnableSearchFeatureInEditForm"); + boolean enableSearchFeature = appController.getEnableSearchFeature(); + System.out.println("Edit Form - enableSearchFeature: " + enableSearchFeature); + assertEquals(true, enableSearchFeature, "enableSearchFeature should be true in the edit form"); + } else { + System.out.println("Feature is disabled: Skipping testEnableSearchFeatureInEditForm"); + } + } + + @Test + void testEnableSearchFeatureInSearch() { + if (isFeatureEnabled) { + System.out.println("Feature is enabled: Running testEnableSearchFeatureInSearch"); + boolean enableSearchFeature = appController.getEnableSearchFeature(); + System.out.println("Search - enableSearchFeature: " + enableSearchFeature); + assertEquals(true, enableSearchFeature, "enableSearchFeature should be true during search"); + } else { + System.out.println("Feature is disabled: Skipping testEnableSearchFeatureInSearch"); + } + } + + @Test + void testMaxRecordsPerPageInSearch() { + System.out.println("Testing maxRecordsPerPage in search functionality"); + assertEquals(20, maxRecordsPerPage, "Max records per page should be consistent in search functionality"); + } + + @Test + void testDefaultSearchQueryInSearch() { + System.out.println("Testing defaultSearchQuery in search functionality"); + assertEquals("Laptop", defaultSearchQuery, "Default search query should be consistent in search functionality"); + } + + @Test + void testDefaultItemNameInSearch() { + System.out.println("Testing defaultItemName in search functionality"); + assertEquals("Smartphone", defaultItemName, "Default item name should be consistent in search functionality"); + } + + @Test + void testDefaultItemPriceInSearch() { + System.out.println("Testing defaultItemPrice in search functionality"); + assertEquals(999.99, defaultItemPrice, "Default item price should be consistent in search functionality"); + } + + @Test + void testEnableSearchFeatureInSave() { + if (isFeatureEnabled) { + System.out.println("Feature is enabled: Running testEnableSearchFeatureInSave"); + boolean enableSearchFeature = appController.getEnableSearchFeature(); + System.out.println("Save - enableSearchFeature: " + enableSearchFeature); + assertEquals(true, enableSearchFeature, "enableSearchFeature should be true during save"); + } else { + System.out.println("Feature is disabled: Skipping testEnableSearchFeatureInSave"); + } + } +} \ No newline at end of file