Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SMA-91 rest api should return 400 when data is invalid #81

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -25,6 +31,9 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final TokenRepository tokenRepository;

private final List<AntPathRequestMatcher> protectedPaths =
List.of(new AntPathRequestMatcher("/api/v1/**"));

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
Expand All @@ -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) {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@
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;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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;
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions backend/sportsmatch/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand Down Expand Up @@ -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());
}
Expand All @@ -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("[email protected]")
.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
Expand All @@ -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"));

}
}
}
Loading