diff --git a/src/main/java/com/study/realworld/global/config/WebSecurityConfig.java b/src/main/java/com/study/realworld/global/config/WebSecurityConfig.java index bf17503d..94bbf3f4 100644 --- a/src/main/java/com/study/realworld/global/config/WebSecurityConfig.java +++ b/src/main/java/com/study/realworld/global/config/WebSecurityConfig.java @@ -2,7 +2,9 @@ import static org.springframework.http.HttpMethod.POST; +import com.study.realworld.security.JwtAuthenticationEntiyPoint; import com.study.realworld.security.JwtAuthenticationTokenFilter; +import com.study.realworld.security.JwtExceptionFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -17,9 +19,15 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; + private final JwtExceptionFilter jwtExceptionFilter; + private final JwtAuthenticationEntiyPoint jwtAuthenticationEntiyPoint; - public WebSecurityConfig(JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter) { + public WebSecurityConfig(JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter, + JwtExceptionFilter jwtExceptionFilter, + JwtAuthenticationEntiyPoint jwtAuthenticationEntiyPoint) { this.jwtAuthenticationTokenFilter = jwtAuthenticationTokenFilter; + this.jwtExceptionFilter = jwtExceptionFilter; + this.jwtAuthenticationEntiyPoint = jwtAuthenticationEntiyPoint; } @Bean @@ -43,6 +51,7 @@ protected void configure(HttpSecurity http) throws Exception { .disable(); http .exceptionHandling() + .authenticationEntryPoint(jwtAuthenticationEntiyPoint) .and() .sessionManagement() @@ -57,8 +66,9 @@ protected void configure(HttpSecurity http) throws Exception { .formLogin() .disable(); http - .addFilterBefore(jwtAuthenticationTokenFilter, - UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthenticationTokenFilter.class) + ; } } diff --git a/src/main/java/com/study/realworld/global/exception/ErrorCode.java b/src/main/java/com/study/realworld/global/exception/ErrorCode.java index de8d6027..ed7c79e9 100644 --- a/src/main/java/com/study/realworld/global/exception/ErrorCode.java +++ b/src/main/java/com/study/realworld/global/exception/ErrorCode.java @@ -4,10 +4,12 @@ public enum ErrorCode { + // user USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "user is not found"), EMAIL_DUPLICATION(HttpStatus.CONFLICT, "Duplicated email exists."), USERNAME_DUPLICATION(HttpStatus.CONFLICT, "Duplicated username exists."), + PASSWORD_DISMATCH(HttpStatus.FORBIDDEN, "password is dismatch."), INVALID_USERNAME_NULL(HttpStatus.BAD_REQUEST, "username must be provided."), @@ -16,7 +18,13 @@ public enum ErrorCode { INVALID_EMAIL_NULL(HttpStatus.BAD_REQUEST, "address must be provided."), INVALID_EMAIL_PATTERN(HttpStatus.BAD_REQUEST, "address must be provided by limited pattern like 'xxx@xxx.xxx'."), INVALID_PASSWORD_NULL(HttpStatus.BAD_REQUEST, "password must be provided."), - INVALID_PASSWORD_LENGTH(HttpStatus.BAD_REQUEST, "password length must be between 6 and 20 characters.") + INVALID_PASSWORD_LENGTH(HttpStatus.BAD_REQUEST, "password length must be between 6 and 20 characters."), + + // authentication + INVALID_EXPIRED_JWT(HttpStatus.BAD_REQUEST, "this jwt has expired."), + INVALID_MALFORMED_JWT(HttpStatus.BAD_REQUEST, "this jwt was malformed."), + INVALID_UNSUPPORTED_JWT(HttpStatus.BAD_REQUEST, "this jwt wat not supported."), + INVALID_ILLEGAL_ARGUMENT_JWT(HttpStatus.BAD_REQUEST, "this jwt was wrong."); ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/study/realworld/global/exception/ErrorResponse.java b/src/main/java/com/study/realworld/global/exception/ErrorResponse.java new file mode 100644 index 00000000..91a4f716 --- /dev/null +++ b/src/main/java/com/study/realworld/global/exception/ErrorResponse.java @@ -0,0 +1,39 @@ +package com.study.realworld.global.exception; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@JsonTypeName("errors") +@JsonTypeInfo(include = JsonTypeInfo.As.WRAPPER_OBJECT, use = JsonTypeInfo.Id.NAME) +public class ErrorResponse { + + @JsonProperty("body") + private List body; + + protected ErrorResponse() { + } + + private ErrorResponse(List body) { + this.body = body; + } + + public static String toJson(RuntimeException e) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(new ErrorResponse(List.of(e.getMessage()))); + } + + public static ResponseEntity of(ErrorCode e) { + return ResponseEntity.status(e.getHttpStatus()).body(new ErrorResponse(List.of(e.getMessage()))); + } + + public static ResponseEntity from(Exception e, HttpStatus httpStatus) { + return ResponseEntity.status(httpStatus).body(new ErrorResponse(List.of(e.getMessage()))); + } + +} diff --git a/src/main/java/com/study/realworld/global/exception/GlobalExceptionHandler.java b/src/main/java/com/study/realworld/global/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..8c71ab3d --- /dev/null +++ b/src/main/java/com/study/realworld/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package com.study.realworld.global.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.METHOD_NOT_ALLOWED; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException excpetion) { + log.debug("Bad request exception occurred : {}", excpetion.getMessage(), excpetion); + return ErrorResponse.from(excpetion, BAD_REQUEST); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotAllowedException(Exception exception) { + return ErrorResponse.from(exception, METHOD_NOT_ALLOWED); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException exception) { + log.warn("Unexpected service exception occurred: {}", exception.getMessage(), exception); + return ErrorResponse.of(exception.getErrorCode()); + } + +// TODO +// @ExceptionHandler(Exception.class) +// public ResponseEntity handleGlobalException(Exception exception) { +// log.error("Unexpected exception occurred: {}", exception.getMessage(), exception); +// return ErrorResponse.from(exception, BAD_REQUEST); +// } + +} diff --git a/src/main/java/com/study/realworld/global/exception/JwtException.java b/src/main/java/com/study/realworld/global/exception/JwtException.java new file mode 100644 index 00000000..ea1fb2a8 --- /dev/null +++ b/src/main/java/com/study/realworld/global/exception/JwtException.java @@ -0,0 +1,30 @@ +package com.study.realworld.global.exception; + +import org.springframework.http.HttpStatus; + +public class JwtException extends RuntimeException { + + private ErrorCode errorCode; + + public JwtException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public HttpStatus getHttpStatus() { + return errorCode.getHttpStatus(); + } + + public int getHttpStatusValue() { + return getHttpStatus().value(); + } + + public String getMessage() { + return errorCode.getMessage(); + } + +} diff --git a/src/main/java/com/study/realworld/security/JjwtService.java b/src/main/java/com/study/realworld/security/JjwtService.java index 92ebcb91..3f63b6af 100644 --- a/src/main/java/com/study/realworld/security/JjwtService.java +++ b/src/main/java/com/study/realworld/security/JjwtService.java @@ -1,5 +1,7 @@ package com.study.realworld.security; +import com.study.realworld.global.exception.ErrorCode; +import com.study.realworld.global.exception.JwtException; import com.study.realworld.user.domain.User; import com.study.realworld.user.domain.UserRepository; import io.jsonwebtoken.Claims; @@ -13,12 +15,16 @@ import java.security.Key; import java.util.Date; import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component public class JjwtService implements JwtService { + private static final Logger log = LoggerFactory.getLogger(JjwtService.class); + private final static String JWT_HEADER_PARAM_TYPE = "typ"; private final Key key; @@ -62,13 +68,13 @@ public Optional getUser(String accessToken) { Long userId = Long.parseLong(claims.getSubject()); return userRepository.findById(userId); } catch (ExpiredJwtException e) { - throw new RuntimeException("만료된 JWT 서명입니다."); + throw new JwtException(ErrorCode.INVALID_EXPIRED_JWT); } catch (SecurityException | MalformedJwtException e) { - throw new MalformedJwtException("잘못된 JWT 서명입니다."); + throw new JwtException(ErrorCode.INVALID_MALFORMED_JWT); } catch (UnsupportedJwtException e) { - throw new UnsupportedJwtException("지원되지 않는 JWT 서명입니다."); + throw new JwtException(ErrorCode.INVALID_UNSUPPORTED_JWT); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("JWT 토큰이 잘못되었습니다."); + throw new JwtException(ErrorCode.INVALID_ILLEGAL_ARGUMENT_JWT); } } diff --git a/src/main/java/com/study/realworld/security/JwtAuthenticationEntiyPoint.java b/src/main/java/com/study/realworld/security/JwtAuthenticationEntiyPoint.java new file mode 100644 index 00000000..51b309bc --- /dev/null +++ b/src/main/java/com/study/realworld/security/JwtAuthenticationEntiyPoint.java @@ -0,0 +1,25 @@ +package com.study.realworld.security; + +import com.study.realworld.global.exception.ErrorResponse; +import java.io.IOException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntiyPoint implements AuthenticationEntryPoint { + + private static final String CONTENT_TYPE = "application/json"; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setContentType(CONTENT_TYPE); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + response.getWriter().write(ErrorResponse.toJson(authException)); + } + +} diff --git a/src/main/java/com/study/realworld/security/JwtExceptionFilter.java b/src/main/java/com/study/realworld/security/JwtExceptionFilter.java new file mode 100644 index 00000000..4a1d72cc --- /dev/null +++ b/src/main/java/com/study/realworld/security/JwtExceptionFilter.java @@ -0,0 +1,43 @@ +package com.study.realworld.security; + +import com.study.realworld.global.exception.ErrorResponse; +import com.study.realworld.global.exception.JwtException; +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(JwtExceptionFilter.class); + + private static final String CONTENT_TYPE = "application/json"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (JwtException exception) { + log.debug("Wrong jwt request exception occurred : {}", exception.getMessage()); + sendErrorMessage(response, exception); + } catch (RuntimeException exception) { + log.error("Unexpected runtime exception occurred: {}", exception.getMessage(), exception); + } + } + + private void sendErrorMessage(HttpServletResponse response, JwtException e) throws IOException { + response.setContentType(CONTENT_TYPE); + response.setStatus(e.getHttpStatusValue()); + + response.getWriter().write(ErrorResponse.toJson(e)); + } + +} diff --git a/src/test/java/com/study/realworld/security/JjwtServiceTest.java b/src/test/java/com/study/realworld/security/JjwtServiceTest.java index 1cf170aa..b6c986de 100644 --- a/src/test/java/com/study/realworld/security/JjwtServiceTest.java +++ b/src/test/java/com/study/realworld/security/JjwtServiceTest.java @@ -5,12 +5,12 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.when; +import com.study.realworld.global.exception.ErrorCode; +import com.study.realworld.global.exception.JwtException; import com.study.realworld.user.domain.User; import com.study.realworld.user.domain.UserRepository; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import java.security.Key; @@ -101,9 +101,9 @@ void getUserByExpiredTokenTest() { .compact(); // when & then - assertThatExceptionOfType(RuntimeException.class) + assertThatExceptionOfType(JwtException.class) .isThrownBy(() -> jwtTokenProvider.getUser(accessToken)) - .withMessageMatching("만료된 JWT 서명입니다."); + .withMessageMatching(ErrorCode.INVALID_EXPIRED_JWT.getMessage()); } @Test @@ -114,9 +114,9 @@ void testValidateTokenByMalFormedToken() { String accessToken = "te.st."; // when & then - assertThatExceptionOfType(MalformedJwtException.class) + assertThatExceptionOfType(JwtException.class) .isThrownBy(() -> jwtTokenProvider.getUser(accessToken)) - .withMessageMatching("잘못된 JWT 서명입니다."); + .withMessageMatching(ErrorCode.INVALID_MALFORMED_JWT.getMessage()); } @Test @@ -130,9 +130,9 @@ void testValidateTokenByExpiredToken() { .compact(); // when & then - assertThatExceptionOfType(RuntimeException.class) + assertThatExceptionOfType(JwtException.class) .isThrownBy(() -> jwtTokenProvider.getUser(accessToken)) - .withMessageMatching("만료된 JWT 서명입니다."); + .withMessageMatching(ErrorCode.INVALID_EXPIRED_JWT.getMessage()); } @Test @@ -147,9 +147,9 @@ void testValidateTokenByUnsupportedToken() { .compact(); // when & then - assertThatExceptionOfType(UnsupportedJwtException.class) + assertThatExceptionOfType(JwtException.class) .isThrownBy(() -> jwtTokenProvider.getUser(accessToken)) - .withMessageMatching("지원되지 않는 JWT 서명입니다."); + .withMessageMatching(ErrorCode.INVALID_UNSUPPORTED_JWT.getMessage()); } @Test @@ -160,9 +160,9 @@ void testValidateTokenByIllegalArgumentToken() { String accessToken = ""; // when & then - assertThatExceptionOfType(IllegalArgumentException.class) + assertThatExceptionOfType(JwtException.class) .isThrownBy(() -> jwtTokenProvider.getUser(accessToken)) - .withMessageMatching("JWT 토큰이 잘못되었습니다."); + .withMessageMatching(ErrorCode.INVALID_ILLEGAL_ARGUMENT_JWT.getMessage()); } } \ No newline at end of file diff --git a/src/test/java/com/study/realworld/user/controller/UserControllerFailTest.java b/src/test/java/com/study/realworld/user/controller/UserControllerFailTest.java new file mode 100644 index 00000000..d5a71923 --- /dev/null +++ b/src/test/java/com/study/realworld/user/controller/UserControllerFailTest.java @@ -0,0 +1,143 @@ +package com.study.realworld.user.controller; + +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.study.realworld.global.exception.BusinessException; +import com.study.realworld.global.exception.ErrorCode; +import com.study.realworld.global.exception.GlobalExceptionHandler; +import com.study.realworld.security.JwtService; +import com.study.realworld.user.domain.Email; +import com.study.realworld.user.domain.Password; +import com.study.realworld.user.domain.User; +import com.study.realworld.user.domain.Username; +import com.study.realworld.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith({MockitoExtension.class, RestDocumentationExtension.class}) +public class UserControllerFailTest { + @Mock + private UserService userService; + + @Mock + private JwtService jwtService; + + @InjectMocks + private UserController userController; + + private MockMvc mockMvc; + + @BeforeEach + void beforeEach(RestDocumentationContextProvider restDocumentationContextProvider) { + SecurityContextHolder.clearContext(); + mockMvc = MockMvcBuilders.standaloneSetup(userController) + .apply(documentationConfiguration(restDocumentationContextProvider)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void joinFailByDuplicatedUsernameTest() throws Exception { + + // setup + User user = User.Builder() + .username(Username.of("username")) + .email(Email.of("test@test.com")) + .password(Password.of("password")) + .build(); + + when(userService.join(any())).thenThrow(new BusinessException(ErrorCode.USERNAME_DUPLICATION)); + + // given + final String URL = "/api/users"; + final String content = "{\"user\":{\"username\":\"" + "username" + + "\",\"email\":\"" + "test@test.com" + + "\",\"password\":\"" + "password" + + "\"}}"; + + // when + ResultActions resultActions = mockMvc.perform(post(URL) + .content(content) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()); + + // then + resultActions + .andExpect(status().is4xxClientError()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + + .andExpect(jsonPath("$.errors.body.[0]", is("Duplicated username exists."))) + ; + } + + @Test + void joinFailByInvalidEmailTest() throws Exception { + + // given + final String URL = "/api/users"; + final String content = "{\"user\":{\"username\":\"" + "username" + + "\",\"email\":\"" + "testtest.com" + + "\",\"password\":\"" + "password" + + "\"}}"; + + // when + ResultActions resultActions = mockMvc.perform(post(URL) + .content(content) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()); + + // then + resultActions + .andExpect(status().is4xxClientError()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + + .andExpect(jsonPath("$.errors.body.[0]", is("address must be provided by limited pattern like 'xxx@xxx.xxx'."))) + ; + } + + @Test + void joinFailByHttpMethodTest() throws Exception { + + // given + final String URL = "/api/users"; + final String content = "{\"user\":{\"username\":\"" + "username" + + "\",\"email\":\"" + "testtest.com" + + "\",\"password\":\"" + "password" + + "\"}}"; + + // when + ResultActions resultActions = mockMvc.perform(put(URL) + .content(content) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()); + + // then + resultActions + .andExpect(status().is4xxClientError()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + + .andExpect(jsonPath("$.errors.body.[0]", is("Request method 'PUT' not supported"))) + ; + } + +}