From a3ac8671b44cf9838a7e4a68ecc2da6fb4730021 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:07:24 +0000 Subject: [PATCH 1/2] feat: Add JWT cookie-based authentication - Add jjwt-api, jjwt-impl, jjwt-jackson dependencies (0.12.5) - Add JWT secret (via env var) and expiration config in application.properties - Create JwtUtil for token generation, extraction, and validation - Create JwtAuthenticationFilter to authenticate requests via JWT cookie - Create AuthController handling login/logout with JWT cookies - Update SecurityConfig to stateless sessions with JWT filter - Remove /login mapping from BankController (moved to AuthController) Co-Authored-By: Ivan Sobina --- pom.xml | 17 +++++ .../config/JwtAuthenticationFilter.java | 55 ++++++++++++++++ .../com/example/bankapp/config/JwtUtil.java | 59 ++++++++++++++++++ .../bankapp/config/SecurityConfig.java | 35 ++++++----- .../bankapp/controller/AuthController.java | 62 +++++++++++++++++++ .../bankapp/controller/BankController.java | 5 -- src/main/resources/application.properties | 4 ++ 7 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/example/bankapp/config/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/example/bankapp/config/JwtUtil.java create mode 100644 src/main/java/com/example/bankapp/controller/AuthController.java diff --git a/pom.xml b/pom.xml index fc5bfeac..8112404d 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,23 @@ spring-security-test test + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + runtime + diff --git a/src/main/java/com/example/bankapp/config/JwtAuthenticationFilter.java b/src/main/java/com/example/bankapp/config/JwtAuthenticationFilter.java new file mode 100644 index 00000000..12ed3515 --- /dev/null +++ b/src/main/java/com/example/bankapp/config/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.example.bankapp.config; + +import com.example.bankapp.service.AccountService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final AccountService accountService; + + public JwtAuthenticationFilter(JwtUtil jwtUtil, AccountService accountService) { + this.jwtUtil = jwtUtil; + this.accountService = accountService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = null; + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("jwt".equals(cookie.getName())) { + token = cookie.getValue(); + break; + } + } + } + + if (token != null && jwtUtil.validateToken(token)) { + String username = jwtUtil.extractUsername(token); + UserDetails userDetails = accountService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/example/bankapp/config/JwtUtil.java b/src/main/java/com/example/bankapp/config/JwtUtil.java new file mode 100644 index 00000000..3a3c4668 --- /dev/null +++ b/src/main/java/com/example/bankapp/config/JwtUtil.java @@ -0,0 +1,59 @@ +package com.example.bankapp.config; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtUtil { + + private final SecretKey key; + private final long expirationMs; + + public JwtUtil(@Value("${jwt.secret}") String secret, + @Value("${jwt.expiration-ms}") long expirationMs) { + this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret)); + this.expirationMs = expirationMs; + } + + public String generateToken(String username) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationMs); + return Jwts.builder() + .subject(username) + .issuedAt(now) + .expiration(expiry) + .signWith(key) + .compact(); + } + + public String extractUsername(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + public long getExpirationMs() { + return expirationMs; + } +} diff --git a/src/main/java/com/example/bankapp/config/SecurityConfig.java b/src/main/java/com/example/bankapp/config/SecurityConfig.java index 4dbd1572..960e5207 100644 --- a/src/main/java/com/example/bankapp/config/SecurityConfig.java +++ b/src/main/java/com/example/bankapp/config/SecurityConfig.java @@ -4,13 +4,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @@ -19,31 +22,32 @@ public class SecurityConfig { @Autowired AccountService accountService; + @Autowired + JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public static PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authz -> authz - .requestMatchers("/register").permitAll() + .requestMatchers("/login", "/register", "/css/**", "/js/**", "/images/**").permitAll() .anyRequest().authenticated() ) - .formLogin(form -> form - .loginPage("/login") - .loginProcessingUrl("/login") - .defaultSuccessUrl("/dashboard", true) - .permitAll() - ) - .logout(logout -> logout - .invalidateHttpSession(true) - .clearAuthentication(true) - .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) - .logoutSuccessUrl("/login?logout") - .permitAll() + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((request, response, authException) -> + response.sendRedirect("/login")) ) .headers(headers -> headers .frameOptions(frameOptions -> frameOptions.sameOrigin()) @@ -55,6 +59,5 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(accountService).passwordEncoder(passwordEncoder()); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/bankapp/controller/AuthController.java b/src/main/java/com/example/bankapp/controller/AuthController.java new file mode 100644 index 00000000..cb3c3753 --- /dev/null +++ b/src/main/java/com/example/bankapp/controller/AuthController.java @@ -0,0 +1,62 @@ +package com.example.bankapp.controller; + +import com.example.bankapp.config.JwtUtil; +import com.example.bankapp.service.AccountService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + + public AuthController(AuthenticationManager authenticationManager, + JwtUtil jwtUtil) { + this.authenticationManager = authenticationManager; + this.jwtUtil = jwtUtil; + } + + @GetMapping("/login") + public String loginPage() { + return "login"; + } + + @PostMapping("/login") + public String login(@RequestParam String username, + @RequestParam String password, + HttpServletResponse response) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, password)); + String token = jwtUtil.generateToken(username); + Cookie cookie = new Cookie("jwt", token); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge((int) (jwtUtil.getExpirationMs() / 1000)); + response.addCookie(cookie); + return "redirect:/dashboard"; + } catch (AuthenticationException e) { + return "redirect:/login?error"; + } + } + + @GetMapping("/logout") + public String logout(HttpServletResponse response) { + Cookie cookie = new Cookie("jwt", ""); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + SecurityContextHolder.clearContext(); + return "redirect:/login?logout"; + } +} diff --git a/src/main/java/com/example/bankapp/controller/BankController.java b/src/main/java/com/example/bankapp/controller/BankController.java index 19fcded7..3ea671e3 100644 --- a/src/main/java/com/example/bankapp/controller/BankController.java +++ b/src/main/java/com/example/bankapp/controller/BankController.java @@ -42,11 +42,6 @@ public String registerAccount(@RequestParam String username, @RequestParam Strin } } - @GetMapping("/login") - public String login() { - return "login"; - } - @PostMapping("/deposit") public String deposit(@RequestParam BigDecimal amount) { String username = SecurityContextHolder.getContext().getAuthentication().getName(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 08663a63..75663d66 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,7 @@ spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect spring.jpa.show-sql=true + +# JWT configuration +jwt.secret=${JWT_SECRET} +jwt.expiration-ms=3600000 From fa2592f9b3ca4e3d6395e3f34ac04117978f3c31 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:34:33 +0000 Subject: [PATCH 2/2] fix: Disable Spring Security LogoutFilter to fix JWT cookie clearing on logout Spring Security's built-in LogoutFilter was intercepting /logout requests before the AuthController, preventing the JWT cookie from being cleared. Fix disables the default LogoutFilter and uses ResponseEntity for explicit control over the Set-Cookie header in the logout response. Co-Authored-By: Ivan Sobina --- .../bankapp/config/SecurityConfig.java | 3 ++- .../bankapp/controller/AuthController.java | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/bankapp/config/SecurityConfig.java b/src/main/java/com/example/bankapp/config/SecurityConfig.java index 960e5207..1f93cac7 100644 --- a/src/main/java/com/example/bankapp/config/SecurityConfig.java +++ b/src/main/java/com/example/bankapp/config/SecurityConfig.java @@ -41,10 +41,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authz -> authz - .requestMatchers("/login", "/register", "/css/**", "/js/**", "/images/**").permitAll() + .requestMatchers("/login", "/register", "/logout", "/css/**", "/js/**", "/images/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .logout(logout -> logout.disable()) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> response.sendRedirect("/login")) diff --git a/src/main/java/com/example/bankapp/controller/AuthController.java b/src/main/java/com/example/bankapp/controller/AuthController.java index cb3c3753..bd1cbc10 100644 --- a/src/main/java/com/example/bankapp/controller/AuthController.java +++ b/src/main/java/com/example/bankapp/controller/AuthController.java @@ -4,6 +4,10 @@ import com.example.bankapp.service.AccountService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.AuthenticationException; @@ -50,13 +54,16 @@ public String login(@RequestParam String username, } @GetMapping("/logout") - public String logout(HttpServletResponse response) { - Cookie cookie = new Cookie("jwt", ""); - cookie.setHttpOnly(true); - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); + public ResponseEntity logout() { SecurityContextHolder.clearContext(); - return "redirect:/login?logout"; + ResponseCookie cookie = ResponseCookie.from("jwt", "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build(); + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, "/login?logout") + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .build(); } }