Skip to content

Commit

Permalink
SMA-91 rest api should return 400 when data is invalid (#81)
Browse files Browse the repository at this point in the history
  • Loading branch information
markpernia authored Apr 23, 2024
1 parent 4a1f9d1 commit 73ad94f
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 49 deletions.
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"));

}
}
}

0 comments on commit 73ad94f

Please sign in to comment.