diff --git a/api/src/main/java/dev/hooon/auth/AuthApiController.java b/api/src/main/java/dev/hooon/auth/AuthApiController.java new file mode 100644 index 00000000..d212028d --- /dev/null +++ b/api/src/main/java/dev/hooon/auth/AuthApiController.java @@ -0,0 +1,42 @@ +package dev.hooon.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import dev.hooon.auth.annotation.NoAuth; +import dev.hooon.auth.application.AuthService; +import dev.hooon.auth.dto.TokenReIssueRequest; +import dev.hooon.auth.dto.request.AuthRequest; + +import dev.hooon.auth.dto.response.AuthResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RequestMapping("/api/auth") +@RestController +@RequiredArgsConstructor +public class AuthApiController { + + private final AuthService authService; + + @NoAuth + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody AuthRequest authRequest + ) { + AuthResponse authResponse = authService.login(authRequest); + return ResponseEntity.ok(authResponse); + } + + @NoAuth + @PostMapping("/token") + public ResponseEntity reIssueAccessToken( + @RequestBody TokenReIssueRequest tokenReIssueRequest + ) { + String accessToken = authService.createAccessTokenByRefreshToken(tokenReIssueRequest.refreshToken()); + return ResponseEntity.ok(accessToken); + } +} diff --git a/api/src/main/java/dev/hooon/auth/annotation/NoAuth.java b/api/src/main/java/dev/hooon/auth/annotation/NoAuth.java new file mode 100644 index 00000000..fface886 --- /dev/null +++ b/api/src/main/java/dev/hooon/auth/annotation/NoAuth.java @@ -0,0 +1,11 @@ +package dev.hooon.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface NoAuth { +} diff --git a/api/src/main/java/dev/hooon/auth/dto/TokenReIssueRequest.java b/api/src/main/java/dev/hooon/auth/dto/TokenReIssueRequest.java new file mode 100644 index 00000000..0ff8c8d7 --- /dev/null +++ b/api/src/main/java/dev/hooon/auth/dto/TokenReIssueRequest.java @@ -0,0 +1,7 @@ +package dev.hooon.auth.dto; + + +public record TokenReIssueRequest( + String refreshToken +) { +} diff --git a/api/src/main/java/dev/hooon/auth/jwt/JwtAuthorization.java b/api/src/main/java/dev/hooon/auth/jwt/JwtAuthorization.java new file mode 100644 index 00000000..aa09443b --- /dev/null +++ b/api/src/main/java/dev/hooon/auth/jwt/JwtAuthorization.java @@ -0,0 +1,13 @@ +package dev.hooon.auth.jwt; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface JwtAuthorization { +} diff --git a/api/src/main/java/dev/hooon/auth/jwt/JwtAuthorizationArgumentResolver.java b/api/src/main/java/dev/hooon/auth/jwt/JwtAuthorizationArgumentResolver.java new file mode 100644 index 00000000..e5393c02 --- /dev/null +++ b/api/src/main/java/dev/hooon/auth/jwt/JwtAuthorizationArgumentResolver.java @@ -0,0 +1,47 @@ +package dev.hooon.auth.jwt; + +import static dev.hooon.auth.exception.AuthErrorCode.*; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import dev.hooon.auth.application.JwtProvider; +import dev.hooon.common.exception.NotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtAuthorizationArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtProvider jwtProvider; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(JwtAuthorization.class); + } + + @Override + public Object resolveArgument( + @NonNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest httpServletRequest = webRequest.getNativeRequest(HttpServletRequest.class); + + if (httpServletRequest != null) { + String accessToken = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION); + return jwtProvider.getClaim(accessToken); + } + + throw new NotFoundException(NOT_FOUND_REQUEST); + } + +} diff --git a/api/src/main/java/dev/hooon/auth/jwt/JwtInterceptor.java b/api/src/main/java/dev/hooon/auth/jwt/JwtInterceptor.java new file mode 100644 index 00000000..1597e361 --- /dev/null +++ b/api/src/main/java/dev/hooon/auth/jwt/JwtInterceptor.java @@ -0,0 +1,47 @@ +package dev.hooon.auth.jwt; + +import static dev.hooon.auth.exception.AuthErrorCode.*; + +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import dev.hooon.auth.annotation.NoAuth; +import dev.hooon.auth.application.JwtProvider; +import dev.hooon.auth.exception.AuthException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtInterceptor implements HandlerInterceptor { + + private final JwtProvider jwtProvider; + + private boolean isAnnotationPresent(Object handler) { + HandlerMethod handlerMethod = (HandlerMethod)handler; + return handlerMethod.getMethodAnnotation(NoAuth.class) != null; + } + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (isAnnotationPresent(handler)) { + return true; + } + + String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION); + if (accessToken == null) { + throw new AuthException(NOT_INCLUDE_ACCESS_TOKEN); + } + + jwtProvider.validateToken(accessToken); + return true; + } +} diff --git a/api/src/main/java/dev/hooon/bookingcancel/BookingCancelApiController.java b/api/src/main/java/dev/hooon/bookingcancel/BookingCancelApiController.java index d85a608b..58d874dd 100644 --- a/api/src/main/java/dev/hooon/bookingcancel/BookingCancelApiController.java +++ b/api/src/main/java/dev/hooon/bookingcancel/BookingCancelApiController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.booking.application.BookingService; import dev.hooon.booking.dto.response.BookingCancelResponse; import lombok.RequiredArgsConstructor; @@ -16,6 +17,7 @@ public class BookingCancelApiController { private final BookingService bookingService; + @NoAuth @PostMapping("/api/bookings/cancel/{booking_id}") public ResponseEntity cancelBooking( @RequestParam(name = "userId") Long userId, // TODO diff --git a/api/src/main/java/dev/hooon/common/exception/config/WebConfig.java b/api/src/main/java/dev/hooon/common/exception/config/WebConfig.java new file mode 100644 index 00000000..2e073ea7 --- /dev/null +++ b/api/src/main/java/dev/hooon/common/exception/config/WebConfig.java @@ -0,0 +1,31 @@ +package dev.hooon.common.exception.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import dev.hooon.auth.jwt.JwtAuthorizationArgumentResolver; +import dev.hooon.auth.jwt.JwtInterceptor; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final JwtAuthorizationArgumentResolver jwtAuthorizationArgumentResolver; + private final JwtInterceptor jwtInterceptor; + + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(jwtAuthorizationArgumentResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(jwtInterceptor) + .addPathPatterns("/api/**"); + } +} diff --git a/api/src/main/java/dev/hooon/show/RankingApiController.java b/api/src/main/java/dev/hooon/show/RankingApiController.java index 2e37b79c..0f898260 100644 --- a/api/src/main/java/dev/hooon/show/RankingApiController.java +++ b/api/src/main/java/dev/hooon/show/RankingApiController.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.show.application.facade.RankingCacheFacade; import dev.hooon.show.dto.request.RankingRequest; import dev.hooon.show.dto.response.RankingResponse; @@ -17,6 +18,7 @@ public class RankingApiController { private final RankingCacheFacade rankingCacheFacade; + @NoAuth @GetMapping("/api/shows/ranking") public ResponseEntity getShowRanking(@Valid @ModelAttribute RankingRequest request) { RankingResponse rankingResponse = rankingCacheFacade.getShowRankingWithCache(request); diff --git a/api/src/main/java/dev/hooon/show/ShowApiController.java b/api/src/main/java/dev/hooon/show/ShowApiController.java index d3eea594..87050dc7 100644 --- a/api/src/main/java/dev/hooon/show/ShowApiController.java +++ b/api/src/main/java/dev/hooon/show/ShowApiController.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.show.application.ShowService; import dev.hooon.show.dto.response.AbleBookingDateRoundResponse; import lombok.RequiredArgsConstructor; @@ -15,6 +16,7 @@ public class ShowApiController { private final ShowService showService; + @NoAuth @GetMapping("/api/shows/{show_id}/available") public ResponseEntity getAbleBookingDateRoundInfo( @PathVariable("show_id") Long showId diff --git a/api/src/main/java/dev/hooon/show/ShowDetailApiController.java b/api/src/main/java/dev/hooon/show/ShowDetailApiController.java index 0d5a85c0..9733cae4 100644 --- a/api/src/main/java/dev/hooon/show/ShowDetailApiController.java +++ b/api/src/main/java/dev/hooon/show/ShowDetailApiController.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.show.application.ShowService; import dev.hooon.show.dto.response.ShowDetailsInfoResponse; import lombok.RequiredArgsConstructor; @@ -15,6 +16,7 @@ public class ShowDetailApiController { private final ShowService showService; + @NoAuth @GetMapping("/api/shows/{show_id}") public ResponseEntity getShowDetailInfo( @PathVariable("show_id") Long showId diff --git a/api/src/main/java/dev/hooon/show/ShowSeatsApiController.java b/api/src/main/java/dev/hooon/show/ShowSeatsApiController.java index a186c339..3b8cbcd9 100644 --- a/api/src/main/java/dev/hooon/show/ShowSeatsApiController.java +++ b/api/src/main/java/dev/hooon/show/ShowSeatsApiController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.show.application.SeatService; import dev.hooon.show.application.ShowSeatsService; import dev.hooon.show.dto.request.BookedSeatQueryRequest; @@ -25,6 +26,7 @@ public class ShowSeatsApiController { private final ShowSeatsService showSeatsService; private final SeatService seatService; + @NoAuth @GetMapping("/api/shows/{showId}/seats") public ResponseEntity getShowSeatsInfo( @PathVariable("showId") Long showId, @@ -37,6 +39,7 @@ public ResponseEntity getShowSeatsInfo( return ResponseEntity.ok(showSeatsResponse); } + @NoAuth @GetMapping("/api/shows/seats/booked") public ResponseEntity getBookedSeatInfo( @Valid @ModelAttribute BookedSeatQueryRequest request diff --git a/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java b/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java index 2037f8dc..4794a449 100644 --- a/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java +++ b/api/src/main/java/dev/hooon/ticketbooking/TicketBookingApiController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.booking.application.fascade.TicketBookingFacade; import dev.hooon.booking.dto.request.TicketBookingRequest; import dev.hooon.booking.dto.response.TicketBookingResponse; @@ -18,6 +19,7 @@ public class TicketBookingApiController { private final TicketBookingFacade ticketBookingFacade; + @NoAuth @PostMapping("/api/bookings") public ResponseEntity bookingTicket( @RequestParam(name = "userId") Long userId, // TODO diff --git a/api/src/main/java/dev/hooon/user/UserApiController.java b/api/src/main/java/dev/hooon/user/UserApiController.java index 1e536da3..12a1d7bd 100644 --- a/api/src/main/java/dev/hooon/user/UserApiController.java +++ b/api/src/main/java/dev/hooon/user/UserApiController.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.user.application.UserService; import dev.hooon.user.dto.request.UserJoinRequest; import dev.hooon.user.dto.response.UserJoinResponse; @@ -17,6 +18,7 @@ public class UserApiController { private final UserService userService; + @NoAuth @PostMapping("/api/users") public ResponseEntity join( final @Valid @RequestBody UserJoinRequest userJoinRequest diff --git a/api/src/main/java/dev/hooon/waitingbooking/WaitingBookingApiController.java b/api/src/main/java/dev/hooon/waitingbooking/WaitingBookingApiController.java index ad28186e..e81c55ec 100644 --- a/api/src/main/java/dev/hooon/waitingbooking/WaitingBookingApiController.java +++ b/api/src/main/java/dev/hooon/waitingbooking/WaitingBookingApiController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import dev.hooon.auth.annotation.NoAuth; import dev.hooon.waitingbooking.application.facade.WaitingBookingFacade; import dev.hooon.waitingbooking.dto.request.WaitingRegisterRequest; import dev.hooon.waitingbooking.dto.response.WaitingRegisterResponse; @@ -18,6 +19,7 @@ public class WaitingBookingApiController { private final WaitingBookingFacade waitingBookingFacade; + @NoAuth @PostMapping("/api/waiting_bookings") public ResponseEntity registerWaitingBooking( @RequestParam(name = "userId") Long userId, // TODO 추후에 인증정보 ArgumentResolver 가 구현되면 수정 예정 diff --git a/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java b/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java new file mode 100644 index 00000000..97aaaceb --- /dev/null +++ b/api/src/test/java/dev/hooon/auth/AuthApiControllerTest.java @@ -0,0 +1,78 @@ +package dev.hooon.auth; + +import static org.hamcrest.Matchers.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import dev.hooon.auth.application.AuthService; +import dev.hooon.auth.dto.TokenReIssueRequest; +import dev.hooon.auth.dto.request.AuthRequest; +import dev.hooon.auth.dto.response.AuthResponse; +import dev.hooon.common.support.ApiTestSupport; +import dev.hooon.user.application.UserService; +import dev.hooon.user.dto.request.UserJoinRequest; + +@DisplayName("[AuthApiController API 테스트]") +class AuthApiControllerTest extends ApiTestSupport { + + @Autowired + private MockMvc mockMvc; + @Autowired + private UserService userService; + @Autowired + private AuthService authService; + + @BeforeEach + void setUp() { + UserJoinRequest userJoinRequest = new UserJoinRequest("user@example.com", "password123", "name123"); + userService.join(userJoinRequest); + } + + @Test + @DisplayName("[로그인 API를 호출하면 토큰이 응답된다]") + void loginTest() throws Exception { + // given + AuthRequest authRequest = new AuthRequest("user@example.com", "password123"); + + // when + ResultActions actions = mockMvc.perform( + post("/api/auth/login") + .contentType(APPLICATION_JSON) + .content(toJson(authRequest)) + ); + + // then + actions.andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").exists()) + .andExpect(jsonPath("$.refreshToken").exists()); + } + + @Test + @DisplayName("[토큰 재발급 API를 호출하면 새로운 엑세스 토큰이 응답된다]") + void reIssueAccessTokenTest() throws Exception { + // given + AuthRequest authRequest = new AuthRequest("user@example.com", "password123"); + AuthResponse authResponse = authService.login(authRequest); + String refreshToken = authResponse.refreshToken(); + TokenReIssueRequest tokenReIssueRequest = new TokenReIssueRequest(refreshToken); + + // when + ResultActions actions = mockMvc.perform( + post("/api/auth/token") + .contentType(APPLICATION_JSON) + .content(toJson(tokenReIssueRequest)) + ); + + // then + actions.andExpect(status().isOk()) + .andExpect(content().string(not(emptyOrNullString()))); + } +} diff --git a/api/src/test/java/dev/hooon/auth/JwtAuthorizationArgumentResolverTest.java b/api/src/test/java/dev/hooon/auth/JwtAuthorizationArgumentResolverTest.java new file mode 100644 index 00000000..0a1d8426 --- /dev/null +++ b/api/src/test/java/dev/hooon/auth/JwtAuthorizationArgumentResolverTest.java @@ -0,0 +1,71 @@ +package dev.hooon.auth; + +import static dev.hooon.auth.exception.AuthErrorCode.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +import dev.hooon.auth.application.JwtProvider; +import dev.hooon.auth.jwt.JwtAuthorizationArgumentResolver; +import dev.hooon.common.exception.NotFoundException; +import jakarta.servlet.http.HttpServletRequest; + +@DisplayName("[JwtAuthorizationArgumentResolver 테스트]") +@ExtendWith(MockitoExtension.class) +class JwtAuthorizationArgumentResolverTest { + + @InjectMocks + private JwtAuthorizationArgumentResolver resolver; + @Mock + private JwtProvider jwtProvider; + @Mock + private MethodParameter parameter; + @Mock + private ModelAndViewContainer mavContainer; + private final MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + @Mock + private NativeWebRequest webRequest; + @Mock + private WebDataBinderFactory binderFactory; + + @Test + @DisplayName("[토큰이 유효하면 claim 의 userId를 반환한다]") + void shouldResolveArgumentWithValidToken() { + // given + Long userId = 123L; + mockHttpServletRequest.addHeader(HttpHeaders.AUTHORIZATION, "validToken"); + when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(mockHttpServletRequest); + when(jwtProvider.getClaim("validToken")).thenReturn(userId); + + // when + Object o = resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + + // then + assertThat(o).isEqualTo(userId); + } + + @Test + @DisplayName("[요청이 없으면 예외를 던진다]") + void shouldThrowNotFoundExceptionWhenRequestMissing() { + // given + when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(null); + + // when + then + assertThatThrownBy(() -> resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining(NOT_FOUND_REQUEST.getMessage()); + } + +} diff --git a/api/src/test/java/dev/hooon/auth/JwtInterceptorTest.java b/api/src/test/java/dev/hooon/auth/JwtInterceptorTest.java new file mode 100644 index 00000000..254de9d2 --- /dev/null +++ b/api/src/test/java/dev/hooon/auth/JwtInterceptorTest.java @@ -0,0 +1,82 @@ +package dev.hooon.auth; + +import static dev.hooon.auth.exception.AuthErrorCode.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.method.HandlerMethod; + +import dev.hooon.auth.annotation.NoAuth; +import dev.hooon.auth.application.JwtProvider; +import dev.hooon.auth.exception.AuthException; +import dev.hooon.auth.jwt.JwtInterceptor; +import jakarta.servlet.http.HttpServletResponse; + +@DisplayName("[JwtInterceptor 테스트]") +@ExtendWith(MockitoExtension.class) +class JwtInterceptorTest { + + @InjectMocks + private JwtInterceptor jwtInterceptor; + @Mock + private JwtProvider jwtProvider; + private final MockHttpServletRequest request = new MockHttpServletRequest(); + @Mock + private HttpServletResponse response; + @Mock + private HandlerMethod handlerMethod; + + @Test + @DisplayName("[토큰이 없고 @NoAuth 있으면 -> 통과]") + void shouldPassWithoutTokenWhenNoAuthAnnotationIsPresent() { + // given, when + when(handlerMethod.getMethodAnnotation(NoAuth.class)) + .thenReturn(mock(NoAuth.class)); + // then + assertThat(jwtInterceptor.preHandle(request, response, handlerMethod)) + .isTrue(); + } + + @Test + @DisplayName("[토큰이 유효하다면 -> 통과]") + void shouldPassWithValidToken() { + // given + request.addHeader(HttpHeaders.AUTHORIZATION, "validToken"); + // when + doNothing().when(jwtProvider).validateToken("validToken"); + // then + assertThat(jwtInterceptor.preHandle(request, response, handlerMethod)) + .isTrue(); + } + + @Test + @DisplayName("[토큰이 없고 @NoAuth 없으면 -> 실패]") + void shouldThrowExceptionWhenTokenIsMissing() { + assertThatThrownBy(() -> jwtInterceptor.preHandle(request, response, handlerMethod)) + .isInstanceOf(AuthException.class) + .hasMessageContaining(NOT_INCLUDE_ACCESS_TOKEN.getMessage()); + } + + @Test + @DisplayName("[토큰이 유효하지 않고 @NoAuth 없으면 -> 실패]") + void shouldThrowExceptionWithInvalidToken() { + // given + request.addHeader(HttpHeaders.AUTHORIZATION, "invalidToken"); + doThrow(new AuthException(TOKEN_EXPIRED)) + .when(jwtProvider).validateToken("invalidToken"); + + // when, then + assertThatThrownBy(() -> jwtInterceptor.preHandle(request, response, handlerMethod)) + .isInstanceOf(AuthException.class) + .hasMessageContaining(TOKEN_EXPIRED.getMessage()); + } + +} diff --git a/api/src/test/java/dev/hooon/waitingbooking/WaitingBookingApiControllerTest.java b/api/src/test/java/dev/hooon/waitingbooking/WaitingBookingApiControllerTest.java index d7b81cba..8248db10 100644 --- a/api/src/test/java/dev/hooon/waitingbooking/WaitingBookingApiControllerTest.java +++ b/api/src/test/java/dev/hooon/waitingbooking/WaitingBookingApiControllerTest.java @@ -63,4 +63,4 @@ void registerWaitingBookingTest() throws Exception { jsonPath("$.waitingBookingId").value(allWaitingBookings.get(0).getId()) ); } -} \ No newline at end of file +} diff --git a/api/src/test/resources/application.yml b/api/src/test/resources/application.yml index 990772f0..e7a88f8a 100644 --- a/api/src/test/resources/application.yml +++ b/api/src/test/resources/application.yml @@ -10,4 +10,8 @@ spring: logging: level: - org.hibernate.sql: info \ No newline at end of file + org.hibernate.sql: info + +jwt: + secret: fdflsjhflkwejfblkjhvuixochvuhsofiuesafbidsfab223411 + token-validity-in-seconds: 2400 diff --git a/core/build.gradle b/core/build.gradle index 6f9533e5..7048d6e5 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -11,6 +11,10 @@ dependencies { testFixturesImplementation 'org.springframework:spring-tx:6.1.1' // 패스워드 암호화 implementation group: 'org.mindrot', name: 'jbcrypt', version: '0.3m' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // 레디스 LocalDateTime 역직렬화 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'com.fasterxml.jackson.core:jackson-databind' diff --git a/core/src/main/java/dev/hooon/auth/application/AuthService.java b/core/src/main/java/dev/hooon/auth/application/AuthService.java new file mode 100644 index 00000000..bfa1fb4b --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/application/AuthService.java @@ -0,0 +1,69 @@ + +package dev.hooon.auth.application; + +import static dev.hooon.auth.exception.AuthErrorCode.*; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import dev.hooon.auth.domain.entity.Auth; +import dev.hooon.auth.domain.repository.AuthRepository; +import dev.hooon.auth.dto.request.AuthRequest; +import dev.hooon.auth.dto.response.AuthResponse; +import dev.hooon.auth.entity.EncryptHelper; +import dev.hooon.common.exception.NotFoundException; +import dev.hooon.user.application.UserService; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserService userService; + private final JwtProvider jwtProvider; + private final AuthRepository authRepository; + private final EncryptHelper encryptHelper; + + private Auth getAuthByRefreshToken(String refreshToken) { + return authRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_REFRESH_TOKEN)); + } + + private AuthResponse saveAuth(Long userId) { + String refreshToken = jwtProvider.createRefreshToken(userId); + String accessToken = jwtProvider.createAccessToken(userId); + Optional auth = authRepository.findByUserId(userId); + + auth.ifPresentOrElse( + (none) -> authRepository.updateRefreshToken(auth.get().getId(), refreshToken), + () -> { + Auth newAuth = Auth.of(userId, refreshToken); + authRepository.save(newAuth); + } + ); + + return AuthResponse.of(refreshToken, accessToken); + } + + @Transactional + public AuthResponse login(AuthRequest authRequest) { + Long userId = userService.getUserByEmail(authRequest.email()).getId(); + AuthResponse authResponse = saveAuth(userId); + String plainPassword = authRequest.password(); + String hashedPassword = userService.getUserById(userId).getPassword(); + + if (encryptHelper.isMatch(plainPassword, hashedPassword)) { + return authResponse; + } + throw new NotFoundException(FAILED_LOGIN_BY_ANYTHING); + } + + public String createAccessTokenByRefreshToken(String refreshToken) { + Auth auth = getAuthByRefreshToken(refreshToken); + Long userId = userService.getUserById(auth.getUserId()).getId(); + return jwtProvider.createAccessToken(userId); + } + +} diff --git a/core/src/main/java/dev/hooon/auth/application/JwtProvider.java b/core/src/main/java/dev/hooon/auth/application/JwtProvider.java new file mode 100644 index 00000000..43a3b73a --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/application/JwtProvider.java @@ -0,0 +1,87 @@ +package dev.hooon.auth.application; + +import static dev.hooon.auth.exception.AuthErrorCode.*; + +import java.security.Key; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import dev.hooon.auth.exception.AuthException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +@Component +public class JwtProvider { + + private final int tokenValidSeconds; + private final Key key; + private static final String USER_ID = "userId"; + + public JwtProvider( + @Value("${jwt.secret}") String secretKey, + @Value("${jwt.token-validity-in-seconds}") int tokenValidSeconds + ) { + this.tokenValidSeconds = tokenValidSeconds; + + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public Long getClaim(String token) { + Claims claimsBody = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + return Long.valueOf((Integer)claimsBody.get(USER_ID)); + } + + public String createAccessToken(Long userId) { + Date now = new Date(); + + return Jwts.builder() + .setHeaderParam("type", "jwt") + .claim(USER_ID, userId) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + tokenValidSeconds)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(Long userId) { + Date now = new Date(); + + return Jwts.builder() + .setHeaderParam("type", "jwt") + .claim(USER_ID, userId) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + tokenValidSeconds * 30L)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public void validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (io.jsonwebtoken.ExpiredJwtException e) { + throw new AuthException(TOKEN_EXPIRED); + } catch (io.jsonwebtoken.UnsupportedJwtException e) { + throw new AuthException(UNSUPPORTED_TOKEN); + } catch (io.jsonwebtoken.MalformedJwtException e) { + throw new AuthException(MALFORMED_TOKEN); + } catch (Exception e) { + throw new AuthException(INVALID_TOKEN_ETC); + } + } + +} diff --git a/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java b/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java new file mode 100644 index 00000000..92fe7afb --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/domain/entity/Auth.java @@ -0,0 +1,42 @@ +package dev.hooon.auth.domain.entity; + +import static dev.hooon.common.exception.CommonValidationError.*; +import static jakarta.persistence.GenerationType.*; + +import org.springframework.util.Assert; + +import dev.hooon.user.domain.entity.UserRole; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "auth_table") +public class Auth { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "auth_id") + private Long id; + + @Column(name = "auth_user_id", nullable = false, unique = true) + private Long userId; + + @Column(name = "auth_refresh_token", nullable = false, unique = true) + private String refreshToken; + + private Auth(Long userId, String refreshToken) { + this.userId = userId; + this.refreshToken = refreshToken; + } + + public static Auth of(Long userId, String refreshToken) { + return new Auth(userId, refreshToken); + } +} diff --git a/core/src/main/java/dev/hooon/auth/domain/repository/AuthRepository.java b/core/src/main/java/dev/hooon/auth/domain/repository/AuthRepository.java new file mode 100644 index 00000000..c25cae08 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/domain/repository/AuthRepository.java @@ -0,0 +1,17 @@ +package dev.hooon.auth.domain.repository; + +import java.util.Optional; + +import dev.hooon.auth.domain.entity.Auth; + +public interface AuthRepository { + Optional findById(Long id); + + Optional findByUserId(Long userId); + + Optional findByRefreshToken(String refreshToken); + + Auth save(Auth auth); + + void updateRefreshToken(Long id, String refreshToken); +} diff --git a/core/src/main/java/dev/hooon/auth/dto/request/AuthRequest.java b/core/src/main/java/dev/hooon/auth/dto/request/AuthRequest.java new file mode 100644 index 00000000..e4a75bd0 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/dto/request/AuthRequest.java @@ -0,0 +1,15 @@ +package dev.hooon.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record AuthRequest( + + @Email + @NotBlank + String email, + + @NotBlank + String password +) { +} diff --git a/core/src/main/java/dev/hooon/auth/dto/response/AuthResponse.java b/core/src/main/java/dev/hooon/auth/dto/response/AuthResponse.java new file mode 100644 index 00000000..aebe8407 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/dto/response/AuthResponse.java @@ -0,0 +1,13 @@ +package dev.hooon.auth.dto.response; + +public record AuthResponse( + String refreshToken, + String accessToken +) { + public static AuthResponse of( + String refreshToken, + String accessToken + ) { + return new AuthResponse(refreshToken, accessToken); + } +} diff --git a/core/src/main/java/dev/hooon/auth/entity/BcryptImpl.java b/core/src/main/java/dev/hooon/auth/entity/BcryptImpl.java index 1f92b267..b842558b 100644 --- a/core/src/main/java/dev/hooon/auth/entity/BcryptImpl.java +++ b/core/src/main/java/dev/hooon/auth/entity/BcryptImpl.java @@ -7,12 +7,12 @@ public class BcryptImpl implements EncryptHelper { @Override - public String encrypt(String password) { - return BCrypt.hashpw(password, BCrypt.gensalt()); + public String encrypt(String plainPassword) { + return BCrypt.hashpw(plainPassword, BCrypt.gensalt()); } @Override - public boolean isMatch(String password, String hashed) { - return BCrypt.checkpw(password, hashed); + public boolean isMatch(String plainPassword, String hashedPassword) { + return BCrypt.checkpw(plainPassword, hashedPassword); } } diff --git a/core/src/main/java/dev/hooon/auth/entity/EncryptHelper.java b/core/src/main/java/dev/hooon/auth/entity/EncryptHelper.java index 9103f96b..fcdab1ac 100644 --- a/core/src/main/java/dev/hooon/auth/entity/EncryptHelper.java +++ b/core/src/main/java/dev/hooon/auth/entity/EncryptHelper.java @@ -1,6 +1,6 @@ package dev.hooon.auth.entity; public interface EncryptHelper { - String encrypt(String password); - boolean isMatch(String password, String hashed); + String encrypt(String plainPassword); + boolean isMatch(String plainPassword, String hashedPassword); } diff --git a/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java b/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java new file mode 100644 index 00000000..687ee99f --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/exception/AuthErrorCode.java @@ -0,0 +1,24 @@ +package dev.hooon.auth.exception; + +import dev.hooon.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + FAILED_LOGIN_BY_ANYTHING("등록되지 않은 아이디이거나 아이디 또는 비밀번호를 잘못 입력했습니다.", "A_001"), + NOT_PERMITTED_USER("해당 요청에 대한 권한이 없습니다.", "A_002"), + INVALID_TOKEN_ETC("기타 보안 문제로 토큰이 유효하지 못합니다.", "A_003"), + NOT_FOUND_REFRESH_TOKEN("해당 리프레쉬 토큰의 인증 데이터가 존재하지 않습니다.", "A_004"), + NOT_INCLUDE_ACCESS_TOKEN("요청에 액세스 토큰이 존재하지 않습니다.", "A_005"), + NOT_FOUND_REQUEST("해당 요청이 존재하지 않습니다.", "A_006"), + NOT_FOUND_USER_ID("해당 유저의 인증 데이터가 존재하지 않습니다.", "A_007"), + TOKEN_EXPIRED("토큰이 만료 시간을 초과했습니다.", "A_008"), + UNSUPPORTED_TOKEN("토큰 유형이 지원되지 않습니다.", "A_010"), + MALFORMED_TOKEN("토큰의 구조가 올바르지 않습니다.", "A_011"); + + private final String message; + private final String code; +} diff --git a/core/src/main/java/dev/hooon/auth/exception/AuthException.java b/core/src/main/java/dev/hooon/auth/exception/AuthException.java new file mode 100644 index 00000000..64ccc596 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/exception/AuthException.java @@ -0,0 +1,15 @@ +package dev.hooon.auth.exception; + +import dev.hooon.common.exception.ErrorCode; +import lombok.Getter; + +@Getter +public class AuthException extends RuntimeException { + + private final String code; + + public AuthException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } +} diff --git a/core/src/main/java/dev/hooon/auth/infrastructure/AuthJpaRepository.java b/core/src/main/java/dev/hooon/auth/infrastructure/AuthJpaRepository.java new file mode 100644 index 00000000..365e2f8c --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/infrastructure/AuthJpaRepository.java @@ -0,0 +1,20 @@ +package dev.hooon.auth.infrastructure; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import dev.hooon.auth.domain.entity.Auth; + +public interface AuthJpaRepository extends JpaRepository { + Optional findByRefreshToken(String refreshToken); + + Optional findByUserId(Long userId); + + @Modifying + @Query("update Auth a SET a.refreshToken = :refreshToken where a.id = :id") + void updateRefreshToken(@Param("id") Long id, @Param("refreshToken") String refreshToken); +} diff --git a/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/AuthRepositoryAdaptor.java b/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/AuthRepositoryAdaptor.java new file mode 100644 index 00000000..0ae0f1a0 --- /dev/null +++ b/core/src/main/java/dev/hooon/auth/infrastructure/adaptor/AuthRepositoryAdaptor.java @@ -0,0 +1,45 @@ +package dev.hooon.auth.infrastructure.adaptor; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import dev.hooon.auth.domain.entity.Auth; +import dev.hooon.auth.domain.repository.AuthRepository; +import dev.hooon.auth.infrastructure.AuthJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class AuthRepositoryAdaptor implements AuthRepository { + + private final AuthJpaRepository authJpaRepository; + + @Override + public Optional findById(Long id) { + return authJpaRepository.findById(id); + } + + @Override + public Optional findByUserId(Long userId) { + return authJpaRepository.findByUserId(userId); + } + + @Override + public Auth save(Auth auth) { + return authJpaRepository.save(auth); + } + + @Override + public void updateRefreshToken(Long id, String refreshToken) { + authJpaRepository.updateRefreshToken(id, refreshToken); + } + + @Override + public Optional findByRefreshToken(String refreshToken) { + return authJpaRepository.findByRefreshToken(refreshToken); + } + +} diff --git a/core/src/main/java/dev/hooon/user/application/UserService.java b/core/src/main/java/dev/hooon/user/application/UserService.java index d0b6744c..d84c40a1 100644 --- a/core/src/main/java/dev/hooon/user/application/UserService.java +++ b/core/src/main/java/dev/hooon/user/application/UserService.java @@ -31,6 +31,11 @@ public User getUserById(Long userId) { .orElseThrow(() -> new NotFoundException(NOT_FOUND_BY_ID)); } + public User getUserByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_BY_EMAIL)); + } + @Transactional public Long join(UserJoinRequest userJoinRequest) { validateDuplicateEmail(userJoinRequest.email()); @@ -44,5 +49,4 @@ public Long join(UserJoinRequest userJoinRequest) { ); return userRepository.save(user); } - } diff --git a/core/src/main/java/dev/hooon/user/domain/repository/UserRepository.java b/core/src/main/java/dev/hooon/user/domain/repository/UserRepository.java index f2b3fb3b..29241002 100644 --- a/core/src/main/java/dev/hooon/user/domain/repository/UserRepository.java +++ b/core/src/main/java/dev/hooon/user/domain/repository/UserRepository.java @@ -8,8 +8,9 @@ public interface UserRepository { Optional findById(Long id); + Optional findByEmail(String email); + Optional findByName(String name); - Long save(User user); - Optional findByEmail(String email); + Long save(User user); } diff --git a/core/src/main/java/dev/hooon/user/dto/request/UserJoinRequest.java b/core/src/main/java/dev/hooon/user/dto/request/UserJoinRequest.java index 92245279..041a5f01 100644 --- a/core/src/main/java/dev/hooon/user/dto/request/UserJoinRequest.java +++ b/core/src/main/java/dev/hooon/user/dto/request/UserJoinRequest.java @@ -6,9 +6,11 @@ public record UserJoinRequest( @Email + @NotBlank String email, @NotBlank String password, @NotBlank String name -) {} +) { +} diff --git a/core/src/main/java/dev/hooon/user/exception/UserErrorCode.java b/core/src/main/java/dev/hooon/user/exception/UserErrorCode.java index 0ee1ed70..202cddb3 100644 --- a/core/src/main/java/dev/hooon/user/exception/UserErrorCode.java +++ b/core/src/main/java/dev/hooon/user/exception/UserErrorCode.java @@ -9,7 +9,8 @@ public enum UserErrorCode implements ErrorCode { NOT_FOUND_BY_ID("ID 에 해당하는 사용자가 존재하지 않습니다", "U_001"), - DUPLICATED_EMAIL("이미 존재하는 이메일입니다. 회원가입을 다시 진행해주세요.", "U_002"); + NOT_FOUND_BY_EMAIL("EMAIL 에 해당하는 사용자가 존재하지 않습니다", "U_002"), + DUPLICATED_EMAIL("이미 존재하는 이메일입니다. 회원가입을 다시 진행해주세요.", "U_003"); private final String message; private final String code; diff --git a/core/src/main/java/dev/hooon/user/infrastructure/adaptor/UserRepositoryAdaptor.java b/core/src/main/java/dev/hooon/user/infrastructure/adaptor/UserRepositoryAdaptor.java index 4f4f3003..e90f1e5e 100644 --- a/core/src/main/java/dev/hooon/user/infrastructure/adaptor/UserRepositoryAdaptor.java +++ b/core/src/main/java/dev/hooon/user/infrastructure/adaptor/UserRepositoryAdaptor.java @@ -1,6 +1,5 @@ package dev.hooon.user.infrastructure.adaptor; -import java.util.List; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -21,6 +20,12 @@ public Optional findById(Long id) { return userJpaRepository.findById(id); } + @Override + public Optional findByEmail(String email) { + return userJpaRepository.findByEmail(email); + } + + @Override public Optional findByName(String name) { return userJpaRepository.findByName(name); @@ -30,9 +35,4 @@ public Optional findByName(String name) { public Long save(User user) { return userJpaRepository.save(user).getId(); } - - @Override - public Optional findByEmail(String email) { - return userJpaRepository.findByEmail(email); - } } diff --git a/core/src/main/java/dev/hooon/user/infrastructure/repository/UserJpaRepository.java b/core/src/main/java/dev/hooon/user/infrastructure/repository/UserJpaRepository.java index a1589bf2..f34d14d0 100644 --- a/core/src/main/java/dev/hooon/user/infrastructure/repository/UserJpaRepository.java +++ b/core/src/main/java/dev/hooon/user/infrastructure/repository/UserJpaRepository.java @@ -7,6 +7,7 @@ import dev.hooon.user.domain.entity.User; public interface UserJpaRepository extends JpaRepository { + Optional findByName(String name); Optional findByEmail(String email); diff --git a/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java b/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java new file mode 100644 index 00000000..ea2f9a02 --- /dev/null +++ b/core/src/test/java/dev/hooon/auth/application/AuthServiceTest.java @@ -0,0 +1,77 @@ +package dev.hooon.auth.application; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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 dev.hooon.auth.domain.entity.Auth; +import dev.hooon.auth.domain.repository.AuthRepository; +import dev.hooon.auth.dto.request.AuthRequest; +import dev.hooon.auth.dto.response.AuthResponse; +import dev.hooon.auth.entity.EncryptHelper; +import dev.hooon.common.exception.NotFoundException; +import dev.hooon.user.application.UserService; +import dev.hooon.user.domain.entity.User; +import dev.hooon.user.domain.entity.UserRole; + +@DisplayName("[AuthService 테스트]") +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @InjectMocks + private AuthService authService; + @Mock + private AuthRepository authRepository; + @Mock + private UserService userService; + @Mock + private JwtProvider jwtProvider; + @Mock + private EncryptHelper encryptHelper; + + @Test + @DisplayName("[로그인 성공 시 토큰을 발급한다]") + void loginSuccessTest() { + // given + AuthRequest authRequest = new AuthRequest("user@example.com", "password"); + User user = User.testUser(1L, "user@example.com", "name", "password", UserRole.BUYER); + + when(userService.getUserByEmail(authRequest.email())).thenReturn(user); + when(userService.getUserById(1L)).thenReturn(user); + when(encryptHelper.isMatch(anyString(), anyString())).thenReturn(true); + Auth anyAuth = new Auth(); + when(authRepository.findByUserId(user.getId())).thenReturn(Optional.of(anyAuth)); + when(jwtProvider.createAccessToken(anyLong())).thenReturn("access-token"); + when(jwtProvider.createRefreshToken(anyLong())).thenReturn("refresh-token"); + + // when + AuthResponse authResponse = authService.login(authRequest); + + // then + assertEquals("access-token", authResponse.accessToken()); + assertEquals("refresh-token", authResponse.refreshToken()); + } + + @Test + @DisplayName("로그인 실패 시 예외를 던진다") + void loginFailTest() { + // given + AuthRequest authRequest = new AuthRequest("user@example.com", "wrong-password"); + User user = User.testUser(1L, "user@example.com", "name", "password", UserRole.BUYER); + + when(userService.getUserByEmail(authRequest.email())).thenReturn(user); + when(userService.getUserById(1L)).thenReturn(user); + when(encryptHelper.isMatch(anyString(), anyString())).thenReturn(false); + + // when & then + assertThrows(NotFoundException.class, () -> authService.login(authRequest)); + } +} diff --git a/core/src/test/java/dev/hooon/auth/application/JwtProviderTest.java b/core/src/test/java/dev/hooon/auth/application/JwtProviderTest.java new file mode 100644 index 00000000..77dab601 --- /dev/null +++ b/core/src/test/java/dev/hooon/auth/application/JwtProviderTest.java @@ -0,0 +1,159 @@ +package dev.hooon.auth.application; + +import static dev.hooon.auth.exception.AuthErrorCode.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.security.Key; +import java.util.Date; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import dev.hooon.auth.exception.AuthException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +@DisplayName("[JwtProvider 테스트]") +@ExtendWith(MockitoExtension.class) +class JwtProviderTest { + + private JwtProvider jwtProvider; + private Key key; + private int tokenValidSeconds; + private String secretKey; + + @BeforeEach + void setUp() { + secretKey = "fdflsjhflkwejfblkjhvuixochvuhsofiuesafbidsfab223411"; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + tokenValidSeconds = 3600; + jwtProvider = new JwtProvider(secretKey, tokenValidSeconds); + } + + @Test + @DisplayName("Access Token을 성공적으로 만든다") + void createAccessTokenTest() { + // given + Long userId = 123L; + + // when + String token = jwtProvider.createAccessToken(userId); + + Jws claimsJws = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + Claims claims = claimsJws.getBody(); + + // then + assertThat(token).isNotNull(); + assertThat(userId).isEqualTo(claims.get("userId", Long.class)); + assertThat(claims.getIssuedAt()).isNotNull(); + assertThat(claims.getExpiration()).isNotNull(); + assertThat(claims.getExpiration().getTime() - claims.getIssuedAt().getTime()) + .isCloseTo(tokenValidSeconds, within(1000L)); // 토큰 생성 자체에 드는 시간 고려 + } + + @Test + @DisplayName("Refresh Token을 성공적으로 만든다") + void createRefreshTokenTest() { + // given + Long userId = 123L; + + // when + String token = jwtProvider.createRefreshToken(userId); + + Jws claimsJws = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + Claims claims = claimsJws.getBody(); + + // then + assertThat(token).isNotNull(); + assertThat(userId).isEqualTo(claims.get("userId", Long.class)); + assertThat(claims.getIssuedAt()).isNotNull(); + assertThat(claims.getExpiration()).isNotNull(); + assertThat(claims.getExpiration().getTime() - claims.getIssuedAt().getTime()) + .isCloseTo(tokenValidSeconds * 30L, within(1000L)); // 토큰 생성 자체에 드는 시간 고려 + } + + @Test + @DisplayName("Claim 에서 UserId를 뽑아온다") + void getClaimTest() { + // given + Long userId = 123L; + String token = jwtProvider.createAccessToken(userId); + + // when + Long extractedUserId = jwtProvider.getClaim(token); + + // then + assertThat(extractedUserId).isEqualTo(userId); + } + + @Test + @DisplayName("유효성 검사에서 정상적인 토큰은 성공한다") + void validateValidTokenTest() { + // given + Long userId = 123L; + String validToken = jwtProvider.createAccessToken(userId); + + // when, then + assertDoesNotThrow(() -> jwtProvider.validateToken(validToken)); + } + + @Test + @DisplayName("유효성 검사에서 구조가 안 맞는 토큰은 실패한다") + void validateInValidTokenTest() { + // given + String invalidToken = "invalidToken"; + + // when, then + assertThatThrownBy( + () -> jwtProvider.validateToken(invalidToken) + ) + .isInstanceOf(AuthException.class) + .hasMessageContaining(MALFORMED_TOKEN.getMessage()); + } + + @Test + @DisplayName("유효성 검사에서 유효 기간이 지난 토큰은 실패한다") + void validateExpiredTokenTest() { + // given + Long userId = 123L; + jwtProvider = new JwtProvider(secretKey, -1000000); + String expiredToken = jwtProvider.createAccessToken(userId); + + // when, then + assertThatThrownBy( + () -> jwtProvider.validateToken(expiredToken) + ) + .isInstanceOf(AuthException.class) + .hasMessageContaining(TOKEN_EXPIRED.getMessage()); + } + + @Test + @DisplayName("유효성 검사에서 잘못된 서명의 토큰은 실패한다") + void validateTokenWithAlteredSignatureTest() { + // given + Long userId = 123L; + String validToken = jwtProvider.createAccessToken(userId); + String alteredToken = validToken.substring(0, validToken.length() - 4) + "abcd"; + + // when, then + assertThatThrownBy( + () -> jwtProvider.validateToken(alteredToken) + ) + .isInstanceOf(AuthException.class) + .hasMessageContaining(INVALID_TOKEN_ETC.getMessage()); + } +} diff --git a/core/src/test/resources/application.yml b/core/src/test/resources/application.yml index 990772f0..e7a88f8a 100644 --- a/core/src/test/resources/application.yml +++ b/core/src/test/resources/application.yml @@ -10,4 +10,8 @@ spring: logging: level: - org.hibernate.sql: info \ No newline at end of file + org.hibernate.sql: info + +jwt: + secret: fdflsjhflkwejfblkjhvuixochvuhsofiuesafbidsfab223411 + token-validity-in-seconds: 2400 diff --git a/scheduler/src/test/resources/application.yml b/scheduler/src/test/resources/application.yml index 5cf3cbd3..48f1bf95 100644 --- a/scheduler/src/test/resources/application.yml +++ b/scheduler/src/test/resources/application.yml @@ -23,4 +23,8 @@ spring: logging: level: - org.hibernate.sql: info \ No newline at end of file + org.hibernate.sql: info + +jwt: + secret: fdflsjhflkwejfblkjhvuixochvuhsofiuesafbidsfab223411 + token-validity-in-seconds: 2400