diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java index 19e7dcd0..3b5fac2a 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java @@ -1,11 +1,14 @@ package com.sportsmatch.auth; import com.sportsmatch.repositories.TokenRepository; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Arrays; +import java.util.List; import lombok.NonNull; import lombok.RequiredArgsConstructor; @@ -14,9 +17,12 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import static com.sportsmatch.auth.SecurityConfig.API_WHITE_LIST_URL; + @Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { @@ -25,6 +31,9 @@ public class JwtAuthFilter extends OncePerRequestFilter { private final UserDetailsService userDetailsService; private final TokenRepository tokenRepository; + private final List protectedPaths = + List.of(new AntPathRequestMatcher("/api/v1/**")); + @Override protected void doFilterInternal( @NonNull HttpServletRequest request, @@ -33,30 +42,51 @@ protected void doFilterInternal( throws ServletException, IOException { final String authHeader = request.getHeader("Authorization"); + boolean requiresAuthentication = protectedPaths.stream().anyMatch(m -> m.matches(request)); + boolean isWhitelisted = + Arrays.stream(API_WHITE_LIST_URL) + .anyMatch(path -> new AntPathRequestMatcher(path).matches(request)); - if (isBearerTokenNotPresent(authHeader)) { + // Allow unsecured requests to pass through + if (!requiresAuthentication || isWhitelisted) { filterChain.doFilter(request, response); return; } - final String jwt; - final String userEmail; + if (isBearerTokenNotPresent(authHeader)) { + sendUnauthorizedError(response, "Token not present"); + return; + } try { - jwt = authHeader.substring(7); - userEmail = jwtService.extractUserName(jwt); - } catch (Exception e) { - filterChain.doFilter(request, response); + handleJwtAuthentication(authHeader, request, response); + } catch (JwtException e) { + sendUnauthorizedError(response, "Invalid token"); return; } - if (isUserAuthenticated(userEmail)) { - UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); - if (jwtService.isTokenValid(jwt, userDetails) && !tokenRepository.existsByToken(jwt)) { + filterChain.doFilter(request, response); + } + + private void handleJwtAuthentication( + String authHeader, HttpServletRequest request, HttpServletResponse response) + throws IOException { + final String jwt = authHeader.substring(7); + final String userEmail = jwtService.extractUserName(jwt); + UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); + + if (!isAuthenticationNeeded(userEmail)) { + // No authentication needed for this request + return; + } + + if (jwtService.isTokenValid(jwt, userDetails)) { + if (!tokenRepository.existsByToken(jwt)) { updateSecurityContext(request, userDetails); } + } else { + sendUnauthorizedError(response, "Invalid or expired token"); } - filterChain.doFilter(request, response); } public boolean isBearerTokenNotPresent(String authHeader) { @@ -67,14 +97,19 @@ public boolean isBearerTokenNotPresent(String authHeader) { return !authHeader.startsWith("Bearer ") || tokenParts.length != 2; } - public boolean isUserAuthenticated(String userEmail) { + private boolean isAuthenticationNeeded(String userEmail) { return userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null; } - public void updateSecurityContext(HttpServletRequest request, UserDetails userDetails) { + private void updateSecurityContext(HttpServletRequest request, UserDetails userDetails) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } + + private void sendUnauthorizedError(HttpServletResponse response, String errorMessage) + throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage); + } } diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java index 83b0b2fe..aca25ff9 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java @@ -4,7 +4,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -12,7 +11,6 @@ import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.web.cors.CorsConfiguration; @@ -29,29 +27,24 @@ public class SecurityConfig { @Value("${app.sportsmingle.frontend.url}") private String frontendUrl; - private static final String[] WHITE_LIST_URL = { - "/api/v1/auth/**", - "/h2-console/**", - "/v3/api-docs", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/api/v1/places/search", - "/api/v1/event/nearby", - "/api/v1/sports/all" + static final String[] API_WHITE_LIST_URL = { + "/api/v1/auth/**", "/api/v1/places/search", "/api/v1/event/nearby", "/api/v1/sports/all" }; + private final JwtAuthFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; private final LogoutHandler logoutHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .exceptionHandling((exception) -> exception.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) - .csrf(AbstractHttpConfigurer::disable) + http.csrf(AbstractHttpConfigurer::disable) .headers(h -> h.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) .authorizeHttpRequests( - r -> r.requestMatchers(WHITE_LIST_URL).permitAll().anyRequest().authenticated()) + r -> { + r.requestMatchers(API_WHITE_LIST_URL).permitAll(); + r.requestMatchers("/api/v1/**").authenticated(); + r.anyRequest().permitAll(); + }) .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) .authenticationProvider(authenticationProvider) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/configs/GlobalExceptionHandler.java b/backend/sportsmatch/src/main/java/com/sportsmatch/configs/GlobalExceptionHandler.java new file mode 100644 index 00000000..e08e8da1 --- /dev/null +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/configs/GlobalExceptionHandler.java @@ -0,0 +1,33 @@ +package com.sportsmatch.configs; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import javax.security.sasl.AuthenticationException; + +@ControllerAdvice +public class GlobalExceptionHandler { + + // handle request body + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + + // handle parameter with @Valid + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); + } +} diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java b/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java index 7716c85b..714711d0 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java @@ -14,6 +14,7 @@ public class AuthRequestDTO { @NotNull(message = "Email address is required.") @Email(message = "Please provide a valid email address") + @NotBlank(message = "email cannot be blank") private String email; @NotBlank(message = "Password cannot be blank") diff --git a/backend/sportsmatch/src/main/resources/application.properties b/backend/sportsmatch/src/main/resources/application.properties index 96924486..79aaaadd 100644 --- a/backend/sportsmatch/src/main/resources/application.properties +++ b/backend/sportsmatch/src/main/resources/application.properties @@ -20,3 +20,5 @@ app.sportsmingle.frontend.url=http://localhost:5173 app.sportsmingle.num-game-threshold=5,15,25 app.sportsmingle.k-factors=25.0,15.0,10.0 app.sportsmingle.k-factor-default=5.0 + +logging.level.org.springframework.security=DEBUG \ No newline at end of file diff --git a/backend/sportsmatch/src/test/java/com/sportsmatch/controllers/PlaceControllerTest.java b/backend/sportsmatch/src/test/java/com/sportsmatch/controllers/PlaceControllerTest.java index 71de20d2..1e3dc94a 100644 --- a/backend/sportsmatch/src/test/java/com/sportsmatch/controllers/PlaceControllerTest.java +++ b/backend/sportsmatch/src/test/java/com/sportsmatch/controllers/PlaceControllerTest.java @@ -2,7 +2,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sportsmatch.BaseTest; +import com.sportsmatch.auth.JwtService; import com.sportsmatch.dtos.PlaceDTO; +import com.sportsmatch.models.Gender; +import com.sportsmatch.models.Role; +import com.sportsmatch.models.User; +import com.sportsmatch.repositories.UserRepository; import com.sportsmatch.services.PlaceService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,20 +29,20 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; - @ExtendWith(SpringExtension.class) @AutoConfigureMockMvc @SpringBootTest class PlaceControllerTest extends BaseTest { - @Autowired - private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @Autowired private JwtService jwtService; - @Autowired - private ObjectMapper objectMapper; + @Autowired private UserRepository userRepository; - @MockBean - private PlaceService placeService; + @MockBean private PlaceService placeService; PlaceDTO createPlaceDTO1() { return PlaceDTO.builder() @@ -67,9 +72,11 @@ void addNewPlaceShouldReturn403NotAuthenticatedUser() throws Exception { .thenReturn(ResponseEntity.ok("Place added successfully")); // Perform a POST request to add a new place without authentication - mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/places/add") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(placeDTO))) + mockMvc + .perform( + MockMvcRequestBuilders.post("/api/v1/places/add") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(placeDTO))) // Verify that the response status is 401 Forbidden .andExpect(MockMvcResultMatchers.status().isUnauthorized()); } @@ -84,10 +91,25 @@ void addNewPlaceShouldReturn200AndSuccessfulMessageAuthenticatedUser() throws Ex when(placeService.addNewPlace(any(PlaceDTO.class))) .thenReturn(ResponseEntity.ok("Place successfully added")); + // Create User + User user = + User.builder() + .email("testuser@mail.com") + .password("password") + .name("testuser") + .gender(Gender.MALE) + .role(Role.USER) + .build(); + userRepository.save(user); + String token = jwtService.generateToken(user); + // Perform a POST request to add a new place with authentication - mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/places/add") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(placeDTO))) + mockMvc + .perform( + MockMvcRequestBuilders.post("/api/v1/places/add") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(placeDTO))) // Verify that the response status is 200 OK .andExpect(MockMvcResultMatchers.status().isOk()) // Verify that the response content contains the expected success message @@ -105,16 +127,19 @@ void searchPlaces() throws Exception { when(placeService.searchPlaces(any(String.class))).thenReturn(expectedPlaces); // Perform a GET request to search for places with a name parameter "test" - mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/places/search") - .param("name", "test") - .contentType(MediaType.APPLICATION_JSON)) + mockMvc + .perform( + MockMvcRequestBuilders.get("/api/v1/places/search") + .param("name", "test") + .contentType(MediaType.APPLICATION_JSON)) // Verify the response is an array .andExpect(MockMvcResultMatchers.jsonPath("$").isArray()) - // Verify the name of the first place in the response matches the name of the first expected place + // Verify the name of the first place in the response matches the name of the first expected + // place .andExpect(MockMvcResultMatchers.jsonPath("$[0].name").value("Test Place Name1")) - // Verify the name of the second place in the response matches the name of the second expected place + // Verify the name of the second place in the response matches the name of the second + // expected place .andExpect(MockMvcResultMatchers.jsonPath("$[1].name").value("Test Place Name2")); - } -} \ No newline at end of file +}