From e953ad6d536b0251e3f68c456ee1302e7dc9c37b Mon Sep 17 00:00:00 2001 From: Michel ten Voorde Date: Mon, 27 Oct 2025 14:17:17 +0100 Subject: [PATCH] Use cookies --- pom.xml | 14 ++- .../swiss/auth/AuthController.java | 88 +++++++++++++++++-- .../config/CookieJwtAuthenticationFilter.java | 58 ++++++++++++ .../swiss/config/SecurityConfig.java | 26 +++--- src/main/resources/application-local.yaml | 9 ++ src/main/resources/application.yaml | 3 + 6 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 src/main/java/nl/connectedit/swiss/config/CookieJwtAuthenticationFilter.java diff --git a/pom.xml b/pom.xml index d693ad2..ef7e1a0 100755 --- a/pom.xml +++ b/pom.xml @@ -14,9 +14,9 @@ swiss-backend Swiss Backend - 25 - 25 - 25 + 21 + 21 + 21 @@ -50,10 +50,6 @@ h2 runtime - - org.springframework.boot - spring-boot-starter-security - org.springframework.boot spring-boot-starter-web @@ -155,8 +151,8 @@ maven-compiler-plugin 3.14.1 - 25 - 25 + 21 + 21 org.projectlombok diff --git a/src/main/java/nl/connectedit/swiss/auth/AuthController.java b/src/main/java/nl/connectedit/swiss/auth/AuthController.java index 8e21159..3b2b88c 100644 --- a/src/main/java/nl/connectedit/swiss/auth/AuthController.java +++ b/src/main/java/nl/connectedit/swiss/auth/AuthController.java @@ -1,36 +1,48 @@ package nl.connectedit.swiss.auth; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.web.bind.annotation.CrossOrigin; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.Instant; import java.util.stream.Collectors; @RestController -@CrossOrigin @RequestMapping("/api/auth") public class AuthController { private final JwtEncoder encoder; + @Value("${jwt.cookie.secure:true}") + private boolean secureCookie; + + @Value("${jwt.cookie.samesite:false") + private String sameSite; + + @Value("${jwt.cookie.domain:}") + private String cookieDomain; + public AuthController(JwtEncoder encoder) { this.encoder = encoder; } @PostMapping - public String auth(Authentication authentication) { + public void auth(Authentication authentication, HttpServletResponse response) { Instant now = Instant.now(); long expiry = 36000L; + String scope = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(",")); + JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("self") .issuedAt(now) @@ -38,7 +50,67 @@ public class AuthController { .subject(authentication.getName()) .claim("scope", scope) .build(); - return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); + + String token = this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); + + // Create HttpOnly cookie + Cookie cookie = new Cookie("AUTH-TOKEN", token); + cookie.setHttpOnly(true); + cookie.setSecure(secureCookie); // true for production (HTTPS), false for local dev + cookie.setPath("/"); + cookie.setMaxAge((int) expiry); // 10 hours in seconds + +// String sameSite = "None"; // required for cross-origin + boolean secure = secureCookie; // true in production, false for local dev + + response.setHeader("Set-Cookie", String.format( + "%s=%s; Path=%s; Max-Age=%d; HttpOnly; %s SameSite=%s%s", + cookie.getName(), + cookie.getValue(), + cookie.getPath(), + cookie.getMaxAge(), + secure ? "Secure;" : "", + sameSite, + cookieDomain.isEmpty() ? "" : "; Domain=" + cookieDomain + )); + + response.setStatus(HttpServletResponse.SC_OK); } -} + @PostMapping("/logout") + public void logout(HttpServletResponse response) { + // Clear the cookie by setting Max-Age to 0 + Cookie cookie = new Cookie("AUTH-TOKEN", ""); + cookie.setHttpOnly(true); + cookie.setSecure(secureCookie); + cookie.setPath("/"); + cookie.setMaxAge(0); + + response.setHeader("Set-Cookie", String.format( + "%s=%s; Path=%s; Max-Age=%d; HttpOnly; %s SameSite=Lax", + cookie.getName(), + cookie.getValue(), + cookie.getPath(), + cookie.getMaxAge(), + secureCookie ? "Secure;" : "" + )); + + response.setStatus(HttpServletResponse.SC_OK); + } + + @GetMapping("/me") + public ResponseEntity me(Authentication authentication) { + if (authentication == null) { + return ResponseEntity.status(HttpServletResponse.SC_UNAUTHORIZED).build(); + } + + return ResponseEntity.ok( + Map.of( + "username", authentication.getName(), + "roles", authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList() + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/nl/connectedit/swiss/config/CookieJwtAuthenticationFilter.java b/src/main/java/nl/connectedit/swiss/config/CookieJwtAuthenticationFilter.java new file mode 100644 index 0000000..50a205e --- /dev/null +++ b/src/main/java/nl/connectedit/swiss/config/CookieJwtAuthenticationFilter.java @@ -0,0 +1,58 @@ +package nl.connectedit.swiss.config; + +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.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class CookieJwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtDecoder jwtDecoder; + private final JwtAuthenticationConverter jwtAuthenticationConverter; + + public CookieJwtAuthenticationFilter(JwtDecoder jwtDecoder, + JwtAuthenticationConverter jwtAuthenticationConverter) { + this.jwtDecoder = jwtDecoder; + this.jwtAuthenticationConverter = jwtAuthenticationConverter; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // Extract token from cookies only + String token = null; + Cookie[] cookies = request.getCookies(); + + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("AUTH-TOKEN".equals(cookie.getName())) { + token = cookie.getValue(); + break; + } + } + } + + if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { + try { + Jwt jwt = jwtDecoder.decode(token); + var authentication = jwtAuthenticationConverter.convert(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + // Invalid token, continue without authentication + logger.debug("JWT validation failed: " + e.getMessage()); + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java b/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java index 1ebb3c7..eada35f 100644 --- a/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java +++ b/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java @@ -6,8 +6,6 @@ import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; -import jakarta.servlet.DispatcherType; -import nl.connectedit.swiss.auth.Roles; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,8 +13,6 @@ import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; 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.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; @@ -30,6 +26,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; @@ -40,6 +37,8 @@ import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.util.Arrays; +import nl.connectedit.swiss.auth.Roles; + @Configuration @EnableWebSecurity public class SecurityConfig { @@ -57,16 +56,20 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(csrf -> csrf.ignoringRequestMatchers("/api/auth")) + .csrf(csrf -> csrf.ignoringRequestMatchers("/api/auth", "/api/auth/logout", "/api/auth/me")) .authorizeHttpRequests(authorize -> authorize - // Allow OPTIONS requests for CORS preflight .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - // Allow POST to /api/auth without JWT (Basic Auth will still be required) .requestMatchers(HttpMethod.POST, "/api/auth").permitAll() - // All other requests require authentication + .requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll() + .requestMatchers(HttpMethod.GET, "/api/auth/me").authenticated() .anyRequest().authenticated() ) .httpBasic(Customizer.withDefaults()) + // Add cookie-based JWT filter before the Bearer token filter + .addFilterBefore( + new CookieJwtAuthenticationFilter(jwtDecoder(), jwtAuthenticationConverter()), + BearerTokenAuthenticationFilter.class + ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(exceptions -> exceptions @@ -80,11 +83,9 @@ public class SecurityConfig { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - // Allow your Angular app's origin(s) configuration.setAllowedOrigins(Arrays.asList( "http://localhost:4200", "http://localhost:8080", - // Add your production URL here when deploying "https://badminton-toernooi.nl", "https://test.badminton-toernooi.nl" )); @@ -99,6 +100,7 @@ public class SecurityConfig { "Accept" )); + // CRITICAL: Must be true for cookies to work cross-origin configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); @@ -137,12 +139,10 @@ public class SecurityConfig { @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); - // Remove the SCOPE_ prefix grantedAuthoritiesConverter.setAuthorityPrefix(""); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return jwtAuthenticationConverter; } - -} +} \ No newline at end of file diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 147d732..dc79da1 100755 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,6 +1,8 @@ + spring: application: name: swiss + # autoconfigure: # exclude: # - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration @@ -37,3 +39,10 @@ security: true # descriptor: # sql: # BasicBinder: TRACE +server: + port: 8080 + +jwt: + cookie: + secure: false + samesite: Lax \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 83d09de..f5b8940 100755 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,3 +37,6 @@ jwt: key: classpath:certs/private.pem public: key: classpath:certs/public.pem + cookie: + secure: true + samesite: None \ No newline at end of file