diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 8dc16b5..208884d 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -10,6 +10,7 @@ @@ -28,6 +29,7 @@ @@ -62,6 +64,7 @@ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..90125ec --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/auth/build.gradle b/auth/build.gradle index 81a7670..d9c6110 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -24,8 +24,6 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -36,7 +34,6 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/auth/src/main/java/com/sangyunpark/auth/application/AuthService.java b/auth/src/main/java/com/sangyunpark/auth/application/AuthService.java index d799d4b..c6f50be 100644 --- a/auth/src/main/java/com/sangyunpark/auth/application/AuthService.java +++ b/auth/src/main/java/com/sangyunpark/auth/application/AuthService.java @@ -9,7 +9,6 @@ import com.sangyunpark.auth.exception.BusinessException; import com.sangyunpark.auth.infrastructure.repository.RedisTokenRepository; import com.sangyunpark.auth.jwt.TokenProvider; -import com.sangyunpark.auth.jwt.UserPrincipal; import com.sangyunpark.auth.presentation.dto.request.LoginRequestDto; import com.sangyunpark.auth.presentation.dto.response.TokenResponseDto; import lombok.RequiredArgsConstructor; @@ -33,11 +32,10 @@ public TokenResponseDto login(final LoginRequestDto loginRequestDto) { return new TokenResponseDto(token); } - public TokenResponseDto reissue(final String refreshToken, final UserPrincipal userPrincipal) { - final String email = userPrincipal.getEmail(); + public TokenResponseDto reissue(final String refreshToken, final String email, final String userType, final String userStatus) { tokenProvider.validateToken(refreshToken); validateStoredRefreshToken(email, refreshToken); - Token newToken = generateAndStoreToken(email, userPrincipal.getUserType(), userPrincipal.getUserStatus()); + Token newToken = generateAndStoreToken(email, UserType.valueOf(userType), UserStatus.valueOf(userStatus)); return new TokenResponseDto(newToken); } diff --git a/auth/src/main/java/com/sangyunpark/auth/config/SecurityConfig.java b/auth/src/main/java/com/sangyunpark/auth/config/SecurityConfig.java index 0b1aeef..efe4dd8 100644 --- a/auth/src/main/java/com/sangyunpark/auth/config/SecurityConfig.java +++ b/auth/src/main/java/com/sangyunpark/auth/config/SecurityConfig.java @@ -1,46 +1,14 @@ package com.sangyunpark.auth.config; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sangyunpark.auth.global.filter.JwtAuthenticationFilter; -import com.sangyunpark.auth.global.security.JwtAccessDeniedHandler; -import com.sangyunpark.auth.jwt.TokenProvider; -import com.sangyunpark.auth.jwt.TokenValidator; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @RequiredArgsConstructor public class SecurityConfig { - - private final TokenProvider tokenProvider; - private final TokenValidator tokenValidator; - private final ObjectMapper objectMapper; - private final JwtAccessDeniedHandler accessDeniedHandler; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/v1/auth/login", "/api/v1/auth/reissue").permitAll() - .requestMatchers("/api/v1/admin/**").hasAuthority("ADMIN") - .anyRequest().authenticated() - ) - .exceptionHandling(handler -> - handler.accessDeniedHandler(accessDeniedHandler)) - .addFilterBefore(new JwtAuthenticationFilter(tokenProvider, tokenValidator, objectMapper), UsernamePasswordAuthenticationFilter.class) - .build(); - } - @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/auth/src/main/java/com/sangyunpark/auth/global/filter/JwtAuthenticationFilter.java b/auth/src/main/java/com/sangyunpark/auth/global/filter/JwtAuthenticationFilter.java deleted file mode 100644 index a2993f2..0000000 --- a/auth/src/main/java/com/sangyunpark/auth/global/filter/JwtAuthenticationFilter.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.sangyunpark.auth.global.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sangyunpark.auth.constants.code.ErrorCode; -import com.sangyunpark.auth.jwt.TokenProvider; -import com.sangyunpark.auth.jwt.TokenValidator; -import com.sangyunpark.auth.jwt.UserPrincipal; -import com.sangyunpark.auth.presentation.dto.response.ErrorResponse; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.List; - -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private static final String LOGIN_URL = "/api/v1/auth/login"; - private static final String REISSUE_URL = "/api/v1/auth/issue"; - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; - - private final TokenProvider tokenProvider; - private final TokenValidator tokenValidator; - private final ObjectMapper objectMapper; - - @Override - protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { - - final String url = request.getRequestURI(); - if(isLoginOrReissueRequest(url)) { - filterChain.doFilter(request, response); - return; - } - - final String token = resolveToken(request); - - if(!isValidToken(token)) { - handleInvalidTokenResponse(response); - return; - } - - setAuthenticationContext(token); - filterChain.doFilter(request, response); - } - - private boolean isLoginOrReissueRequest(final String url) { - return url.startsWith(LOGIN_URL) || url.startsWith(REISSUE_URL); - } - - private boolean isValidToken(final String token) { - return tokenValidator.validateAccessToken(token); - } - - private void setAuthenticationContext(final String token) { - final String email = tokenProvider.getEmail(token); - final String userType = tokenProvider.getUserType(token); - final String userStatus = tokenProvider.getUserStatus(token); - - UserPrincipal userPrincipal = new UserPrincipal(email, userType, userStatus); - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userPrincipal, null, List.of(new SimpleGrantedAuthority(userType))); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - private String resolveToken(final HttpServletRequest request) { - final String header = request.getHeader(AUTHORIZATION_HEADER); - return header != null ? header.substring(BEARER_PREFIX.length()) : null; - } - - private void handleInvalidTokenResponse(final HttpServletResponse response) throws IOException { - response.setStatus(ErrorCode.INVALID_TOKEN.getStatus().value()); - response.setContentType("application/json"); - response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(ErrorCode.INVALID_TOKEN.getCode()))); - } -} diff --git a/auth/src/main/java/com/sangyunpark/auth/global/handler/GlobalExceptionHandler.java b/auth/src/main/java/com/sangyunpark/auth/global/handler/GlobalExceptionHandler.java index 6ab5bcf..313891c 100644 --- a/auth/src/main/java/com/sangyunpark/auth/global/handler/GlobalExceptionHandler.java +++ b/auth/src/main/java/com/sangyunpark/auth/global/handler/GlobalExceptionHandler.java @@ -4,7 +4,6 @@ import com.sangyunpark.auth.exception.BusinessException; import com.sangyunpark.auth.presentation.dto.response.ErrorResponse; import feign.FeignException; -import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; diff --git a/auth/src/main/java/com/sangyunpark/auth/global/security/JwtAccessDeniedHandler.java b/auth/src/main/java/com/sangyunpark/auth/global/security/JwtAccessDeniedHandler.java deleted file mode 100644 index d003578..0000000 --- a/auth/src/main/java/com/sangyunpark/auth/global/security/JwtAccessDeniedHandler.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.sangyunpark.auth.global.security; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -public class JwtAccessDeniedHandler implements AccessDeniedHandler { - - private static final String RESPONSE_MESSAGE = "{\"code\": \"ACCESS_DENIED\"}"; - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { - response.setContentType("application/json"); - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.getWriter().write(RESPONSE_MESSAGE); - } -} diff --git a/auth/src/main/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepository.java b/auth/src/main/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepository.java index 98988f6..f2d0a79 100644 --- a/auth/src/main/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepository.java +++ b/auth/src/main/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepository.java @@ -10,6 +10,9 @@ @Repository public class RedisTokenRepository { + private final String BLACK_LIST_KEY = "black_list:"; + private final String REFRESH_TOKEN_KEY = "refresh_token:"; + private final StringRedisTemplate redisTemplate; private final long refreshTokenExpireTime; @@ -19,11 +22,11 @@ public RedisTokenRepository(final StringRedisTemplate redisTemplate, @Value("${j } public void save(final String email, final String refreshToken) { - redisTemplate.opsForValue().set(email, refreshToken, refreshTokenExpireTime, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue().set(REFRESH_TOKEN_KEY + email, refreshToken, refreshTokenExpireTime, TimeUnit.MILLISECONDS); } public Optional findByEmail(final String email) { - return Optional.ofNullable(redisTemplate.opsForValue().get(email)); + return Optional.ofNullable(redisTemplate.opsForValue().get(REFRESH_TOKEN_KEY + email)); } public void delete(final String email) { @@ -35,10 +38,10 @@ public boolean exists(final String email) { } public void saveLogOutToken(final String accessToken, final long remainingTime) { - redisTemplate.opsForValue().set(accessToken, "logout", remainingTime, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue().set(BLACK_LIST_KEY + accessToken,"", remainingTime, TimeUnit.MILLISECONDS); } public boolean isLogOutToken(final String accessToken) { - return Boolean.TRUE.equals(redisTemplate.hasKey(accessToken)); + return Boolean.TRUE.equals(redisTemplate.hasKey(BLACK_LIST_KEY + accessToken)); } -} +} \ No newline at end of file diff --git a/auth/src/main/java/com/sangyunpark/auth/jwt/TokenProvider.java b/auth/src/main/java/com/sangyunpark/auth/jwt/TokenProvider.java index c0cde50..f9a0755 100644 --- a/auth/src/main/java/com/sangyunpark/auth/jwt/TokenProvider.java +++ b/auth/src/main/java/com/sangyunpark/auth/jwt/TokenProvider.java @@ -77,10 +77,6 @@ public String getUserType(final String token) { return parseClaims(token).get(USER_TYPE, String.class); } - public String getUserStatus(final String token) { - return parseClaims(token).get(USER_STATUS, String.class); - } - public long getRemainingExpiration(final String accessToken) { Date expiration = parseClaims(accessToken).getExpiration(); return expiration.getTime() - System.currentTimeMillis(); diff --git a/auth/src/main/java/com/sangyunpark/auth/jwt/TokenValidator.java b/auth/src/main/java/com/sangyunpark/auth/jwt/TokenValidator.java deleted file mode 100644 index e2edc82..0000000 --- a/auth/src/main/java/com/sangyunpark/auth/jwt/TokenValidator.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sangyunpark.auth.jwt; - -import com.sangyunpark.auth.infrastructure.repository.RedisTokenRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class TokenValidator { - - private final TokenProvider tokenProvider; - private final RedisTokenRepository redisTokenRepository; - - public boolean validateAccessToken(final String accessToken) { - return accessToken != null && !accessToken.isBlank() && tokenProvider.validateToken(accessToken) && !redisTokenRepository.isLogOutToken(accessToken); - } -} diff --git a/auth/src/main/java/com/sangyunpark/auth/jwt/UserPrincipal.java b/auth/src/main/java/com/sangyunpark/auth/jwt/UserPrincipal.java deleted file mode 100644 index bb2b1fd..0000000 --- a/auth/src/main/java/com/sangyunpark/auth/jwt/UserPrincipal.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.sangyunpark.auth.jwt; - -import com.sangyunpark.auth.constants.enums.UserStatus; -import com.sangyunpark.auth.constants.enums.UserType; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.Collection; -import java.util.List; - -@Getter -@AllArgsConstructor -public class UserPrincipal implements UserDetails { - - private final String email; - private final String userType; - private final String userStatus; - - @Override - public Collection getAuthorities() { - return List.of(() -> userType); - } - - @Override - public String getPassword() { - return null; - } - - @Override - public String getUsername() { - return email; - } - - @Override - public boolean isEnabled() { - return UserStatus.valueOf(userStatus) == UserStatus.ACTIVE; - } - - public UserType getUserType() { - return UserType.valueOf(userType); - } - - public UserStatus getUserStatus() { - return UserStatus.valueOf(userStatus); - } -} diff --git a/auth/src/main/java/com/sangyunpark/auth/presentation/AuthController.java b/auth/src/main/java/com/sangyunpark/auth/presentation/AuthController.java index c48cf57..c09b2c3 100644 --- a/auth/src/main/java/com/sangyunpark/auth/presentation/AuthController.java +++ b/auth/src/main/java/com/sangyunpark/auth/presentation/AuthController.java @@ -1,12 +1,10 @@ package com.sangyunpark.auth.presentation; import com.sangyunpark.auth.application.AuthService; -import com.sangyunpark.auth.jwt.UserPrincipal; import com.sangyunpark.auth.presentation.dto.request.LoginRequestDto; import com.sangyunpark.auth.presentation.dto.response.TokenResponseDto; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -15,6 +13,9 @@ public class AuthController { private static final String HEADER_REFRESH_TOKEN = "X-Refresh-Token"; + private static final String HEADER_USER_EMAIL = "X-User-Email"; + private static final String HEADER_USER_TYPE = "X-User-Type"; + private static final String HEADER_USER_STATUS = "X-User-Status"; private static final String HEADER_AUTHORIZATION = "Authorization"; private final AuthService authService; @@ -25,8 +26,13 @@ public TokenResponseDto login(@Valid @RequestBody LoginRequestDto loginRequestDt } @PostMapping("/reissue") - public TokenResponseDto reissue(@RequestHeader(HEADER_REFRESH_TOKEN) final String refreshToken, @AuthenticationPrincipal UserPrincipal userPrincipal) { - return authService.reissue(refreshToken, userPrincipal); + public TokenResponseDto reissue( + @RequestHeader(HEADER_REFRESH_TOKEN) final String refreshToken, + @RequestHeader(HEADER_USER_EMAIL) final String email, + @RequestHeader(HEADER_USER_TYPE) final String userType, + @RequestHeader(HEADER_USER_STATUS) final String userStatus + ) { + return authService.reissue(refreshToken, email, userType, userStatus); } @PostMapping("/logout") diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index 6b9cd5c..18b7af9 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -7,6 +7,11 @@ spring: username: root password: + data: + redis: + host: localhost + port: 6379 + jpa: hibernate: ddl-auto: update diff --git a/auth/src/test/java/com/sangyunpark/auth/application/AuthServiceTest.java b/auth/src/test/java/com/sangyunpark/auth/application/AuthServiceTest.java index 6121497..0c44c2c 100644 --- a/auth/src/test/java/com/sangyunpark/auth/application/AuthServiceTest.java +++ b/auth/src/test/java/com/sangyunpark/auth/application/AuthServiceTest.java @@ -9,7 +9,6 @@ import com.sangyunpark.auth.exception.BusinessException; import com.sangyunpark.auth.infrastructure.repository.RedisTokenRepository; import com.sangyunpark.auth.jwt.TokenProvider; -import com.sangyunpark.auth.jwt.UserPrincipal; import com.sangyunpark.auth.presentation.dto.request.LoginRequestDto; import com.sangyunpark.auth.presentation.dto.response.TokenResponseDto; import org.junit.jupiter.api.BeforeEach; @@ -103,10 +102,12 @@ void setUp() { 1L // refresh = 1ms ); - String expiredRefreshToken = shortLivedProvider.createRefreshToken("test@example.com", UserType.NORMAL, UserStatus.ACTIVE); + final String email = "test@example.com"; + + String expiredRefreshToken = shortLivedProvider.createRefreshToken(email, UserType.NORMAL, UserStatus.ACTIVE); // when & then - assertThatThrownBy(() -> authService.reissue(expiredRefreshToken, new UserPrincipal("test@example.com", UserType.NORMAL.name(), UserStatus.ACTIVE.name()))) + assertThatThrownBy(() -> authService.reissue(expiredRefreshToken, email, UserType.NORMAL.name(), UserStatus.ACTIVE.name())) .isInstanceOf(BusinessException.class) .extracting("errorCode") .isEqualTo(ErrorCode.INVALID_TOKEN); @@ -117,12 +118,10 @@ void setUp() { void 저장되지_않은_refreshToken_실패() { // given String email = "abc@example.com"; - UserPrincipal principal = new UserPrincipal(email, "NORMAL", "ACTIVE"); - String refreshToken = tokenProvider.createRefreshToken(email, UserType.NORMAL, UserStatus.ACTIVE); // when & then - assertThatThrownBy(() -> authService.reissue(refreshToken, principal)) + assertThatThrownBy(() -> authService.reissue(refreshToken, email, UserType.NORMAL.name(), UserStatus.ACTIVE.name())) .isInstanceOf(BusinessException.class) .extracting("errorCode") .isEqualTo(ErrorCode.INVALID_TOKEN); diff --git a/auth/src/test/java/com/sangyunpark/auth/global/filter/JwtAuthenticationFilterTest.java b/auth/src/test/java/com/sangyunpark/auth/global/filter/JwtAuthenticationFilterTest.java deleted file mode 100644 index 03cc7e0..0000000 --- a/auth/src/test/java/com/sangyunpark/auth/global/filter/JwtAuthenticationFilterTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.sangyunpark.auth.global.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sangyunpark.auth.jwt.TokenProvider; -import com.sangyunpark.auth.jwt.TokenValidator; -import com.sangyunpark.auth.jwt.UserPrincipal; -import jakarta.servlet.FilterChain; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.core.context.SecurityContextHolder; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@SuppressWarnings("NonAsciiCharacters") -class JwtAuthenticationFilterTest { - - private TokenProvider tokenProvider; - private JwtAuthenticationFilter filter; - private MockHttpServletRequest request; - private MockHttpServletResponse response; - private FilterChain filterChain; - private TokenValidator tokenValidator; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - tokenProvider = mock(TokenProvider.class); - tokenValidator = mock(TokenValidator.class); - ObjectMapper objectMapper = mock(ObjectMapper.class); - filter = new JwtAuthenticationFilter(tokenProvider, tokenValidator, objectMapper); - request = new MockHttpServletRequest(); - response = new MockHttpServletResponse(); - filterChain = mock(FilterChain.class); - SecurityContextHolder.clearContext(); - } - - @Test - @DisplayName("유효한 토큰이면 SecurityContext에 인증 정보가 저장된다.") - void 유효한_토큰_인증_정보_저장_성공() throws Exception { - // given - String email = "test@example.com"; - String userType = "NORMAL"; - String token = "valid-token"; - String status = "ACTIVE"; - - request = new MockHttpServletRequest(); - request.addHeader("Authorization", "Bearer " + token); - response = new MockHttpServletResponse(); - filterChain = mock(FilterChain.class); - - when(tokenValidator.validateAccessToken(anyString())).thenReturn(true); - when(tokenProvider.getEmail(token)).thenReturn(email); - when(tokenProvider.getUserType(token)).thenReturn(userType); - when(tokenProvider.getUserStatus(token)).thenReturn(status); - - // when - filter.doFilterInternal(request, response, filterChain); - - UserPrincipal principal = (UserPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - - // then - assertThat(principal).isNotNull(); - assertThat(principal).isInstanceOf(UserPrincipal.class); - assertThat(principal.getEmail()).isEqualTo(email); - assertThat(principal.getUserType().name()).isEqualTo(userType); - assertThat(principal.getUserStatus().name()).isEqualTo(status); - - verify(filterChain).doFilter(request, response); - } -} \ No newline at end of file diff --git a/auth/src/test/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepositoryTest.java b/auth/src/test/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepositoryTest.java index 8054522..5b72d79 100644 --- a/auth/src/test/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepositoryTest.java +++ b/auth/src/test/java/com/sangyunpark/auth/infrastructure/repository/RedisTokenRepositoryTest.java @@ -14,10 +14,11 @@ @SpringBootTest class RedisTokenRepositoryTest { - private static final String EMAIL = "test@example.com"; - private static final String REFRESH_TOKEN = "refreshToken123"; - private static final String TEST_ACCESS_TOKEN = "test-access-token"; - private static final long REMAINING_TIME = 500L; + private final String EMAIL = "test@example.com"; + private final String REFRESH_TOKEN = "refreshToken123"; + private final String TEST_ACCESS_TOKEN = "test-access-token"; + private final String BLACKLIST_KEY = "black_list:"; + private final long REMAINING_TIME = 500L; @Autowired private RedisTokenRepository redisTokenRepository; @@ -48,7 +49,7 @@ class RedisTokenRepositoryTest { redisTokenRepository.saveLogOutToken(TEST_ACCESS_TOKEN, REMAINING_TIME); // then - assertThat(redisTokenRepository.exists(TEST_ACCESS_TOKEN)).isTrue(); + assertThat(redisTokenRepository.exists(BLACKLIST_KEY + TEST_ACCESS_TOKEN)).isTrue(); } @Test diff --git a/auth/src/test/java/com/sangyunpark/auth/integration/AuthIntegrationTest.java b/auth/src/test/java/com/sangyunpark/auth/integration/AuthIntegrationTest.java deleted file mode 100644 index c04a29c..0000000 --- a/auth/src/test/java/com/sangyunpark/auth/integration/AuthIntegrationTest.java +++ /dev/null @@ -1,243 +0,0 @@ -package com.sangyunpark.auth.integration; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sangyunpark.auth.client.UserClient; -import com.sangyunpark.auth.client.dto.response.FeignUserResponseDto; -import com.sangyunpark.auth.constants.code.ErrorCode; -import com.sangyunpark.auth.constants.enums.RegisterType; -import com.sangyunpark.auth.constants.enums.UserStatus; -import com.sangyunpark.auth.constants.enums.UserType; -import com.sangyunpark.auth.infrastructure.repository.RedisTokenRepository; -import com.sangyunpark.auth.jwt.TokenProvider; -import com.sangyunpark.auth.jwt.TokenValidator; -import com.sangyunpark.auth.presentation.dto.request.LoginRequestDto; -import com.sangyunpark.auth.presentation.dto.response.TokenResponseDto; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.http.MediaType; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@SuppressWarnings("NonAsciiCharacters") -public class AuthIntegrationTest { - - private static final String EMAIL = "test@example.com"; - private static final String RAW_PASSWORD = "password123"; - private static final String LOGIN_URL = "/api/v1/auth/login"; - private static final String LOGOUT_URL = "/api/v1/auth/logout"; - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - TokenProvider tokenProvider; - - @Autowired - TokenValidator tokenValidator; - - @MockitoBean - private UserClient userClient; - - @Autowired - private RedisTokenRepository redisTokenRepository; - - @Autowired - private StringRedisTemplate stringRedisTemplate; - - - @Test - @DisplayName("정상적인 로그인 요청이 오면 토큰 정보를 반환하고, 리프레시 토큰이 Redis에 저장된다.") - void 로그인_성공_및_리프레시_토큰_저장_검증() throws Exception { - // given - String email = EMAIL; - String rawPassword = RAW_PASSWORD; - - LoginRequestDto request = new LoginRequestDto(email, rawPassword); - - FeignUserResponseDto userResponse = FeignUserResponseDto.builder() - .id(1L) - .email(email) - .password(new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder().encode(rawPassword)) - .username("상윤") - .userType(UserType.NORMAL) - .userStatus(UserStatus.ACTIVE) - .registerType(RegisterType.EMAIL) - .phoneNumber("010-1234-5678") - .build(); - - when(userClient.findUserByEmail(email)).thenReturn(userResponse); - - // when - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.token.accessToken").isNotEmpty()) - .andExpect(jsonPath("$.token.refreshToken").isNotEmpty()); - - // then - assertThat(redisTokenRepository.exists(email)).isTrue(); - assertThat(redisTokenRepository.findByEmail(email)).isPresent(); - } - - @Test - @DisplayName("accessToken을 입력하지 않는 경우 refreshToken 재발급 시 오류 발생") - void 리프레시_토큰_재발급_오류() throws Exception { - String email = "test@example.com"; - LoginRequestDto request = new LoginRequestDto(email, "password123"); - - String newEncodedPassword = new BCryptPasswordEncoder().encode("password123"); - - FeignUserResponseDto userResponse = FeignUserResponseDto.builder() - .email(email) - .password(newEncodedPassword) - .userType(UserType.NORMAL) - .userStatus(UserStatus.ACTIVE) - .registerType(RegisterType.EMAIL) - .username("상윤") - .phoneNumber("010-1234-5678") - .build(); - - when(userClient.findUserByEmail(email)).thenReturn(userResponse); - - mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - mockMvc.perform(post("/api/v1/auth/reissue") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().is(ErrorCode.INVALID_TOKEN.getStatus().value())); - } - - @Test - @DisplayName("유효하지 않은 accessToken을 입력하는 경우 refreshToken 재발급 시 오류 발생") - void 유효하지_않은_accessToken_토큰_재발급_오류() throws Exception { - String email = "test@example.com"; - LoginRequestDto request = new LoginRequestDto(email, "password123"); - - String newEncodedPassword = new BCryptPasswordEncoder().encode("password123"); - - FeignUserResponseDto userResponse = FeignUserResponseDto.builder() - .email(email) - .password(newEncodedPassword) - .userType(UserType.NORMAL) - .userStatus(UserStatus.ACTIVE) - .registerType(RegisterType.EMAIL) - .username("상윤") - .phoneNumber("010-1234-5678") - .build(); - - when(userClient.findUserByEmail(anyString())).thenReturn(userResponse); - - mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - mockMvc.perform(post("/api/v1/auth/reissue") - .header("Authorization", "Bearer asdfsfs") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().is(ErrorCode.INVALID_TOKEN.getStatus().value())); - } - - @Test - @DisplayName("동일 유저가 다시 로그인하면 refreshToken이 갱신된다.") - void 로그인_재시도_refreshToken_갱신() throws Exception { - String email = "test@example.com"; - LoginRequestDto request = new LoginRequestDto(email, "password123"); - - String newEncodedPassword = new BCryptPasswordEncoder().encode("password123"); - - FeignUserResponseDto userResponse = FeignUserResponseDto.builder() - .email(email) - .password(newEncodedPassword) - .userType(UserType.NORMAL) - .userStatus(UserStatus.ACTIVE) - .registerType(RegisterType.EMAIL) - .username("상윤") - .phoneNumber("010-1234-5678") - .build(); - - when(userClient.findUserByEmail(email)).thenReturn(userResponse); - - mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - final String firstRefreshToken = redisTokenRepository.findByEmail(email).orElseThrow(); - - Thread.sleep(1000); - - mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - final String secondRefreshToken = redisTokenRepository.findByEmail(email).orElseThrow(); - - assertThat(firstRefreshToken).isNotEqualTo(secondRefreshToken); - } - - @Test - @DisplayName("로그아웃 요청 시, 해당 accessToken이 블랙리스트에 추가된다.") - void 로그아웃_요청() throws Exception { - // given - String email = "test@example.com"; - LoginRequestDto request = new LoginRequestDto(email, "password123"); - - String newEncodedPassword = new BCryptPasswordEncoder().encode("password123"); - - FeignUserResponseDto userResponse = FeignUserResponseDto.builder() - .email(email) - .password(newEncodedPassword) - .userType(UserType.NORMAL) - .userStatus(UserStatus.ACTIVE) - .registerType(RegisterType.EMAIL) - .username("상윤") - .phoneNumber("010-1234-5678") - .build(); - - when(userClient.findUserByEmail(email)).thenReturn(userResponse); - - TokenResponseDto response = objectMapper.readValue( - mockMvc.perform(post(LOGIN_URL) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.token.accessToken").exists()) // 응답에 accessToken이 포함되었는지 확인 - .andReturn().getResponse().getContentAsString(), TokenResponseDto.class - ); - - final String accessToken = response.token().accessToken(); - - mockMvc.perform(post(LOGOUT_URL) - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()); - - assertThat(redisTokenRepository.isLogOutToken(accessToken)).isTrue(); - } - - @AfterEach - void tearDown() { - Set keys = stringRedisTemplate.keys("*"); - stringRedisTemplate.delete(keys); - } -} diff --git a/auth/src/test/java/com/sangyunpark/auth/integration/AuthorizationIntegrationTest.java b/auth/src/test/java/com/sangyunpark/auth/integration/AuthorizationIntegrationTest.java deleted file mode 100644 index 9c9ff30..0000000 --- a/auth/src/test/java/com/sangyunpark/auth/integration/AuthorizationIntegrationTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.sangyunpark.auth.integration; - -import com.sangyunpark.auth.constants.code.ErrorCode; -import com.sangyunpark.auth.constants.enums.UserStatus; -import com.sangyunpark.auth.constants.enums.UserType; -import com.sangyunpark.auth.jwt.TokenProvider; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SuppressWarnings("NonAsciiCharacters") -@SpringBootTest -@AutoConfigureMockMvc -public class AuthorizationIntegrationTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private TokenProvider tokenProvider; - - private String createAccessToken(String email, String userType) { - return tokenProvider.createAccessToken(email, UserType.valueOf(userType), UserStatus.ACTIVE); - } - - @Test - @DisplayName("ADMIN 권한이 있는 경우 관리자 API 접근 가능") - void admin_권한_접근_허용() throws Exception { - String token = createAccessToken("admin@example.com", "ADMIN"); - - mockMvc.perform(get("/api/v1/admin/test") - .header("Authorization", "Bearer " + token)) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("ADMIN 권한이 없는 경우 관리자 API 접근 거부") - void admin_권한_없는_경우_접근_거부() throws Exception { - String token = createAccessToken("user@example.com", "NORMAL"); - - mockMvc.perform(get("/api/v1/admin/test") - .header("Authorization", "Bearer " + token)) - .andExpect(status().isForbidden()); - } - - @Test - @DisplayName("토큰 없이 요청하면 401 Unauthorized") - void 토큰_없는_경우_401() throws Exception { - mockMvc.perform(get("/api/v1/admin/test")) - .andExpect(status().is(ErrorCode.INVALID_TOKEN.getStatus().value())); - } -} diff --git a/auth/src/test/java/com/sangyunpark/auth/jwt/TokenValidatorTest.java b/auth/src/test/java/com/sangyunpark/auth/jwt/TokenValidatorTest.java deleted file mode 100644 index 1ddfb10..0000000 --- a/auth/src/test/java/com/sangyunpark/auth/jwt/TokenValidatorTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.sangyunpark.auth.jwt; - -import com.sangyunpark.auth.infrastructure.repository.RedisTokenRepository; -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 static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class TokenValidatorTest { - - @Mock - private TokenProvider tokenProvider; - - @Mock - private RedisTokenRepository redisTokenRepository; - - @InjectMocks - private TokenValidator tokenValidator; - - private static final String TOKEN = "token"; - - @Test - @DisplayName("토큰이 null 이거나 빈 문자열일 경우 false 반환") - void validateToken_whenTokenIsNullOrBlank_returnsFalse() { - // when & then - assertFalse(tokenValidator.validateAccessToken(null)); - assertFalse(tokenValidator.validateAccessToken("")); - } - - @Test - @DisplayName("유효하지 않은 토큰일 경우 false 반환") - void validateToken_whenTokenIsInvalid_returnsFalse() { - // given - when(tokenProvider.validateToken(TOKEN)).thenReturn(false); - - // when & then - assertFalse(tokenValidator.validateAccessToken(TOKEN)); - } - - @Test - @DisplayName("블랙리스트에 있는 토큰일 경우 false 반환") - void validateToken_whenTokenIsInBlackList_returnsFalse() { - // given - when(tokenProvider.validateToken(TOKEN)).thenReturn(true); - when(redisTokenRepository.isLogOutToken(TOKEN)).thenReturn(true); - - // when & then - assertFalse(tokenValidator.validateAccessToken(TOKEN)); - } - - @Test - @DisplayName("정상적인 토큰일 경우 true 반환") - void validateToken_whenTokenIsValid_returnsTrue() { - // given - when(tokenProvider.validateToken(TOKEN)).thenReturn(true); - when(redisTokenRepository.isLogOutToken(TOKEN)).thenReturn(false); - - // when & then - assertTrue(tokenValidator.validateAccessToken(TOKEN)); - } -} \ No newline at end of file diff --git a/gateway/.gitattributes b/gateway/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/gateway/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/gateway/.gitignore b/gateway/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/gateway/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/gateway/build.gradle b/gateway/build.gradle new file mode 100644 index 0000000..d8346ca --- /dev/null +++ b/gateway/build.gradle @@ -0,0 +1,54 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.4' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'org.sangyunpark' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', "2024.0.1") +} + +dependencies { + implementation 'org.springframework.cloud:spring-cloud-starter-gateway' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' + implementation 'org.apache.commons:commons-lang3:3.12.0' + + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + runtimeOnly "io.netty:netty-resolver-dns-native-macos:4.1.108.Final:osx-aarch_64" + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.mockito:mockito-core' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.register("prepareKotlinBuildScriptModel"){} diff --git a/gateway/gradle/wrapper/gradle-wrapper.jar b/gateway/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gateway/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gateway/gradle/wrapper/gradle-wrapper.properties b/gateway/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gateway/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gateway/gradlew b/gateway/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gateway/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gateway/gradlew.bat b/gateway/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gateway/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gateway/src/main/java/org/sangyunpark/gateway/GatewayApplication.java b/gateway/src/main/java/org/sangyunpark/gateway/GatewayApplication.java new file mode 100644 index 0000000..9cd0a3b --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/GatewayApplication.java @@ -0,0 +1,13 @@ +package org.sangyunpark.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } + +} diff --git a/gateway/src/main/java/org/sangyunpark/gateway/application/AuthenticationService.java b/gateway/src/main/java/org/sangyunpark/gateway/application/AuthenticationService.java new file mode 100644 index 0000000..81d62d4 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/application/AuthenticationService.java @@ -0,0 +1,82 @@ +package org.sangyunpark.gateway.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.sangyunpark.gateway.filter.dto.CachedUser; +import org.sangyunpark.gateway.infrastructure.redis.RedisTokenRepository; +import org.sangyunpark.gateway.jwt.TokenProvider; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final String AUTH_TOKEN_KEY = "auth_token:"; + private final String USER_TYPE = "userType"; + private final String USER_STATUS = "userStatus"; + + private final TokenProvider tokenProvider; + private final RedisTokenRepository redisTokenRepository; + private final ObjectMapper objectMapper; + + public Mono logout(final String token) { + final String email = tokenProvider.parseClaims(token).getSubject(); + return checkBlackList(token, redisTokenRepository.removeAuthToken(token,email).then()); + } + + public Mono getAuthenticatedUser(final String token) { + return checkBlackList(token, Mono.defer(() -> getCachedUser(token).switchIfEmpty(parseAndCacheUser(token)))); + } + + private Mono checkBlackList(String token, Mono nextAction) { + return redisTokenRepository.isBlackList(token) + .flatMap(isBlacklisted -> { + if (isBlacklisted) { + return Mono.error(new RuntimeException()); + } + return nextAction; + }); + } + + public Mono getCachedUser(final String token) { + return redisTokenRepository.getCachedUser(token) + .flatMap(json -> { + try { + CachedUser cachedUser = objectMapper.readValue(json, CachedUser.class); + return Mono.just(cachedUser); + }catch (Exception e) { + return Mono.error(new RuntimeException()); + } + }); + } + + public Mono parseAndCacheUser(final String token) { + return parseClaims(token) + .flatMap(cachedUser -> cacheUser(token, cachedUser).thenReturn(cachedUser)); + } + + public Mono parseClaims(final String token) { + return Mono.fromCallable(() -> { + Claims claims = tokenProvider.parseClaims(token); + return new CachedUser( + claims.getSubject(), + claims.get(USER_TYPE, String.class), + claims.get(USER_STATUS, String.class) + ); + }); + } + + public Mono cacheUser(final String token, final CachedUser cachedUser) { + try { + final String key = AUTH_TOKEN_KEY + token; + final String value = objectMapper.writeValueAsString(cachedUser); + return redisTokenRepository.saveAuthToken(key, value, Duration.ofMinutes(10)); + }catch (Exception e) { + return Mono.error(new RuntimeException()); + } + } +} \ No newline at end of file diff --git a/gateway/src/main/java/org/sangyunpark/gateway/constant/code/ErrorCode.java b/gateway/src/main/java/org/sangyunpark/gateway/constant/code/ErrorCode.java new file mode 100644 index 0000000..9bae594 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/constant/code/ErrorCode.java @@ -0,0 +1,18 @@ +package org.sangyunpark.gateway.constant.code; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + + INVALID_TOKEN("INVALID_TOKEN", HttpStatus.UNAUTHORIZED); + + private final String code; + private final HttpStatus status; + + ErrorCode(String errorCode, HttpStatus httpStatus) { + this.code = errorCode; + this.status = httpStatus; + } +} diff --git a/gateway/src/main/java/org/sangyunpark/gateway/constant/code/UserType.java b/gateway/src/main/java/org/sangyunpark/gateway/constant/code/UserType.java new file mode 100644 index 0000000..3068f41 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/constant/code/UserType.java @@ -0,0 +1,5 @@ +package org.sangyunpark.gateway.constant.code; + +public enum UserType { + NORMAL, ADMIN +} diff --git a/gateway/src/main/java/org/sangyunpark/gateway/filter/AuthenticationFilter.java b/gateway/src/main/java/org/sangyunpark/gateway/filter/AuthenticationFilter.java new file mode 100644 index 0000000..cefbd8a --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/filter/AuthenticationFilter.java @@ -0,0 +1,75 @@ +package org.sangyunpark.gateway.filter; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sangyunpark.gateway.application.AuthenticationService; +import org.sangyunpark.gateway.constant.code.ErrorCode; +import org.sangyunpark.gateway.constant.code.UserType; +import org.sangyunpark.gateway.filter.dto.CachedUser; +import org.sangyunpark.gateway.filter.matcher.UrlMatcher; +import org.sangyunpark.gateway.filter.matcher.WhitelistMatcher; +import org.sangyunpark.gateway.filter.utils.HttpResponseUtils; +import org.sangyunpark.gateway.filter.utils.RequestMutationUtils; +import org.sangyunpark.gateway.jwt.TokenProvider; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthenticationFilter implements GlobalFilter { + + private final AuthenticationService authenticationService; + private final TokenProvider tokenProvider; + private final WhitelistMatcher whitelistMatcher; + private final HttpResponseUtils httpResponseUtils; + private final UrlMatcher urlMatcher; + private final RequestMutationUtils requestMutationUtils; + + @Override + public Mono filter(final ServerWebExchange exchange, final GatewayFilterChain chain) { + final ServerHttpRequest request = exchange.getRequest(); + + if (whitelistMatcher.isWhitelisted(request)) { + return chain.filter(exchange); + } + + final String token = tokenProvider.resolveToken(request); + if (token.isBlank()) { + return httpResponseUtils.unauthorized(exchange, ErrorCode.INVALID_TOKEN); + } + + if (urlMatcher.isLogOutUrl(request)) { + return handleLogoutRequest(token, chain, exchange); + } + + return handleAuthorizedRequest(token, chain, exchange); + } + + private Mono handleLogoutRequest(final String token, final GatewayFilterChain chain, final ServerWebExchange exchange) { + return authenticationService.logout(token) + .then(chain.filter(exchange)) + .onErrorResume(e -> httpResponseUtils.unauthorized(exchange, ErrorCode.INVALID_TOKEN)); + } + + private Mono handleAuthorizedRequest(final String token, final GatewayFilterChain chain, final ServerWebExchange exchange) { + return authenticationService.getAuthenticatedUser(token) + .flatMap(cachedUser -> authorizeAndMutateRequest(exchange, chain, cachedUser)) + .onErrorResume(e -> httpResponseUtils.unauthorized(exchange, ErrorCode.INVALID_TOKEN)); + } + + private Mono authorizeAndMutateRequest(final ServerWebExchange exchange, final GatewayFilterChain chain, final CachedUser user) { + final ServerHttpRequest request = exchange.getRequest(); + final String role = user.userType(); + + if (urlMatcher.isAdminUrl(request) && !UserType.ADMIN.name().equals(role)) { + return httpResponseUtils.unauthorized(exchange, ErrorCode.INVALID_TOKEN); + } + + return chain.filter(requestMutationUtils.mutateRequestWithClaims(exchange, user)); + } +} \ No newline at end of file diff --git a/gateway/src/main/java/org/sangyunpark/gateway/filter/dto/CachedUser.java b/gateway/src/main/java/org/sangyunpark/gateway/filter/dto/CachedUser.java new file mode 100644 index 0000000..5045357 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/filter/dto/CachedUser.java @@ -0,0 +1,8 @@ +package org.sangyunpark.gateway.filter.dto; + +public record CachedUser( + String email, + String userType, + String userStatus +) { +} diff --git a/gateway/src/main/java/org/sangyunpark/gateway/filter/matcher/UrlMatcher.java b/gateway/src/main/java/org/sangyunpark/gateway/filter/matcher/UrlMatcher.java new file mode 100644 index 0000000..4b060a5 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/filter/matcher/UrlMatcher.java @@ -0,0 +1,18 @@ +package org.sangyunpark.gateway.filter.matcher; + +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; + +@Component +public class UrlMatcher { + private final String ADMIN = "admin"; + private final String LOGOUT_URL = "/api/v1/auth/logout"; + + public boolean isAdminUrl(ServerHttpRequest request) { + return request.getURI().getPath().contains(ADMIN); + } + + public boolean isLogOutUrl(ServerHttpRequest request) { + return request.getURI().getPath().contains(LOGOUT_URL); + } +} \ No newline at end of file diff --git a/gateway/src/main/java/org/sangyunpark/gateway/filter/matcher/WhitelistMatcher.java b/gateway/src/main/java/org/sangyunpark/gateway/filter/matcher/WhitelistMatcher.java new file mode 100644 index 0000000..3814e5a --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/filter/matcher/WhitelistMatcher.java @@ -0,0 +1,25 @@ +package org.sangyunpark.gateway.filter.matcher; + +import org.sangyunpark.gateway.filter.vo.WhiteListVo; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class WhitelistMatcher { + + private final List WHITELIST = List.of( + new WhiteListVo(HttpMethod.POST, "/api/v1/users"), + new WhiteListVo(HttpMethod.POST, "/api/v1/auth/login") + ); + + public boolean isWhitelisted(ServerHttpRequest request) { + final HttpMethod method = request.getMethod(); + final String path = request.getURI().getPath(); + + return WHITELIST.stream() + .anyMatch(entry -> entry.method().equals(method) && entry.path().equals(path)); + } +} diff --git a/gateway/src/main/java/org/sangyunpark/gateway/filter/utils/HttpResponseUtils.java b/gateway/src/main/java/org/sangyunpark/gateway/filter/utils/HttpResponseUtils.java new file mode 100644 index 0000000..7590034 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/filter/utils/HttpResponseUtils.java @@ -0,0 +1,33 @@ +package org.sangyunpark.gateway.filter.utils; + +import org.sangyunpark.gateway.constant.code.ErrorCode; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class HttpResponseUtils { + + private final String JSON_CONTENT_TYPE = "application/json"; + private final String ERROR_RESPONSE_FORMAT = "{\"code\":\"%s\"}"; + + public Mono unauthorized(final ServerWebExchange exchange, final ErrorCode errorCode) { + final ServerHttpResponse response = exchange.getResponse(); + setJsonHeaders(response, errorCode); + final byte[] bytes = createErrorBody(errorCode).getBytes(); + final DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Mono.just(buffer)); + } + + public void setJsonHeaders(ServerHttpResponse response, ErrorCode errorCode) { + response.setStatusCode(errorCode.getStatus()); + response.getHeaders().add(HttpHeaders.CONTENT_TYPE, JSON_CONTENT_TYPE); + } + + private String createErrorBody(ErrorCode errorCode) { + return ERROR_RESPONSE_FORMAT.formatted(errorCode.getCode()); + } +} \ No newline at end of file diff --git a/gateway/src/main/java/org/sangyunpark/gateway/filter/utils/RequestMutationUtils.java b/gateway/src/main/java/org/sangyunpark/gateway/filter/utils/RequestMutationUtils.java new file mode 100644 index 0000000..adb5fff --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/filter/utils/RequestMutationUtils.java @@ -0,0 +1,30 @@ +package org.sangyunpark.gateway.filter.utils; + +import org.sangyunpark.gateway.filter.dto.CachedUser; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; + +@Component +public class RequestMutationUtils { + + public final String X_USER_EMAIL = "X-User-Email"; + public final String X_USER_TYPE = "X-User-Type"; + public final String X_USER_STATUS = "X-User-Status"; + + public ServerWebExchange mutateRequestWithClaims(final ServerWebExchange exchange, final CachedUser user) { + final ServerHttpRequest mutatedRequest = exchange.getRequest().mutate() + .header(X_USER_EMAIL, user.email()) + .header(X_USER_TYPE, user.userType()) + .header(X_USER_STATUS, user.userStatus()) + .build(); + + final ServerWebExchange mutatedExchange = exchange.mutate() + .request(mutatedRequest) + .build(); + + return mutatedExchange.mutate() + .request(mutatedRequest) + .build(); + } +} \ No newline at end of file diff --git a/gateway/src/main/java/org/sangyunpark/gateway/filter/vo/WhiteListVo.java b/gateway/src/main/java/org/sangyunpark/gateway/filter/vo/WhiteListVo.java new file mode 100644 index 0000000..13449ad --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/filter/vo/WhiteListVo.java @@ -0,0 +1,6 @@ +package org.sangyunpark.gateway.filter.vo; + +import org.springframework.http.HttpMethod; + +public record WhiteListVo(HttpMethod method, String path) { +} diff --git a/gateway/src/main/java/org/sangyunpark/gateway/infrastructure/redis/RedisTokenRepository.java b/gateway/src/main/java/org/sangyunpark/gateway/infrastructure/redis/RedisTokenRepository.java new file mode 100644 index 0000000..7f8b579 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/infrastructure/redis/RedisTokenRepository.java @@ -0,0 +1,39 @@ +package org.sangyunpark.gateway.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Repository +@RequiredArgsConstructor +public class RedisTokenRepository { + + private final String BLACKLIST_KEY = "black_list:"; + private final String AUTH_TOKEN_PREFIX = "auth_token:"; + private final String REFRESH_TOKEN_KEY = "refresh_token:"; + + private final ReactiveStringRedisTemplate redisTemplate; + + public Mono isBlackList(final String token) { + return redisTemplate.hasKey(BLACKLIST_KEY + token); + } + + public Mono getCachedUser(final String token) { + return redisTemplate.opsForValue().get(AUTH_TOKEN_PREFIX+token); + } + + public Mono saveAuthToken(final String key, final String value, final Duration duration) { + return redisTemplate.opsForValue().set(key, value, duration); + } + + public Mono removeAuthToken(final String token, final String email) { + Mono deleteRefresh = redisTemplate.opsForValue().delete(REFRESH_TOKEN_KEY + email); + Mono deleteAccess = redisTemplate.opsForValue().delete(AUTH_TOKEN_PREFIX + token); + + return Mono.zip(deleteRefresh, deleteAccess) + .map(tuple -> tuple.getT1() && tuple.getT2()); // 둘 다 삭제되어야 true + } +} diff --git a/gateway/src/main/java/org/sangyunpark/gateway/jwt/TokenProvider.java b/gateway/src/main/java/org/sangyunpark/gateway/jwt/TokenProvider.java new file mode 100644 index 0000000..7cd2260 --- /dev/null +++ b/gateway/src/main/java/org/sangyunpark/gateway/jwt/TokenProvider.java @@ -0,0 +1,43 @@ +package org.sangyunpark.gateway.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; + +@Component +public class TokenProvider { + + private final String BEARER_PREFIX = "Bearer"; + + private final SecretKey secretKey; + + public TokenProvider(@Value("${jwt.secret-key}") final String secretKey) { + this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + public String resolveToken(final ServerHttpRequest request) { + final String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + + if(!StringUtils.startsWith(authHeader, BEARER_PREFIX)) { + return ""; + } + + return authHeader.substring(BEARER_PREFIX.length()).trim(); + } + + + public Claims parseClaims(final String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml new file mode 100644 index 0000000..c2aba21 --- /dev/null +++ b/gateway/src/main/resources/application.yml @@ -0,0 +1,22 @@ +server: + port: 8000 + +spring: + cloud: + gateway: + routes: + - id: user-service + uri: http://localhost:8080 + predicates: + - Path=/api/v1/users/** + - id: auth-service + uri: http://localhost:8081 + predicates: + - Path=/api/v1/auth/** + data: + redis: + host: localhost + port: 6379 + +jwt: + secret-key: sdjlfkjdslkfjsdlkfjsdlfksdjflkdsjfldskfjdslkfjsdlkfjdslkfjkldsljfldksjfdslfksjl \ No newline at end of file diff --git a/gateway/src/test/java/org/sangyunpark/gateway/GatewayApplicationTests.java b/gateway/src/test/java/org/sangyunpark/gateway/GatewayApplicationTests.java new file mode 100644 index 0000000..5da81a7 --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/GatewayApplicationTests.java @@ -0,0 +1,13 @@ +package org.sangyunpark.gateway; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class GatewayApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/gateway/src/test/java/org/sangyunpark/gateway/application/AuthenticationServiceTest.java b/gateway/src/test/java/org/sangyunpark/gateway/application/AuthenticationServiceTest.java new file mode 100644 index 0000000..df4cbab --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/application/AuthenticationServiceTest.java @@ -0,0 +1,138 @@ +package org.sangyunpark.gateway.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +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.sangyunpark.gateway.filter.dto.CachedUser; +import org.sangyunpark.gateway.infrastructure.redis.RedisTokenRepository; +import org.sangyunpark.gateway.jwt.TokenProvider; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; + +import static org.mockito.Mockito.*; + +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceTest { + + @Mock + private RedisTokenRepository redisTokenRepository; + + @Mock + private TokenProvider tokenProvider; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private AuthenticationService authenticationService; + + @Test + @DisplayName("캐시에 사용자 정보가 있는 경우 바로 캐싱된 값을 리턴합니다.") + void 캐시에_사용자_정보가_있으면_바로_리턴() throws Exception { + CachedUser cachedUser = new CachedUser("user@example.com", "NORMAL", "ACTIVE"); + String json = "{\"email\":\"user@example.com\",\"userType\":\"NORMAL\",\"userStatus\":\"ACTIVE\"}"; + + when(redisTokenRepository.isBlackList("token")).thenReturn(Mono.just(false)); + when(redisTokenRepository.getCachedUser("token")).thenReturn(Mono.just(json)); + when(objectMapper.readValue(json, CachedUser.class)).thenReturn(cachedUser); + + StepVerifier.create(authenticationService.getAuthenticatedUser("token")) + .expectNextMatches(user -> user.email().equals("user@example.com")) + .verifyComplete(); + + verify(redisTokenRepository).isBlackList("token"); + } + + @Test + @DisplayName("캐시에 사용자 정보가 없으면 JWT 파싱 후 캐싱하고 리턴") + void 캐시_미스_시_파싱하고_캐싱() throws Exception { + // given + Claims claims = Jwts.claims(); + claims.setSubject("user@example.com"); + claims.put("userType", "NORMAL"); + claims.put("userStatus", "ACTIVE"); + + CachedUser expectedUser = new CachedUser("user@example.com", "NORMAL", "ACTIVE"); + String json = objectMapper.writeValueAsString(expectedUser); + + when(redisTokenRepository.getCachedUser("token")).thenReturn(Mono.empty()); + when(redisTokenRepository.isBlackList("token")).thenReturn(Mono.just(false)); + when(tokenProvider.parseClaims("token")).thenReturn(claims); + when(objectMapper.writeValueAsString(expectedUser)).thenReturn(json); + when(redisTokenRepository.saveAuthToken("auth_token:token", json, Duration.ofMinutes(10))).thenReturn(Mono.just(true)); + + // when + then + StepVerifier.create(authenticationService.getAuthenticatedUser("token")) + .expectNext(expectedUser) + .verifyComplete(); + } + + @Test + @DisplayName("블랙리스트 토큰이면 AuthenticatedUser에서 오류 발생") + void 블랙리스트_토큰_예외() { + // given + when(redisTokenRepository.isBlackList("token")).thenReturn(Mono.just(true)); + + // when + then + StepVerifier.create(authenticationService.getAuthenticatedUser("token")) + .expectError(RuntimeException.class) + .verify(); + + // ✅ 캐시 조회는 시도하지 않았는지 검증 + verify(redisTokenRepository, never()).getCachedUser(any()); + } + + @Test + @DisplayName("JWT에서 Claims 파싱 후 CachedUser 객체 생성") + void parseClaims_정상작동() { + Claims claims = Jwts.claims(); + claims.setSubject("user@example.com"); + claims.put("userType", "NORMAL"); + claims.put("userStatus", "ACTIVE"); + + when(tokenProvider.parseClaims("token")).thenReturn(claims); + + StepVerifier.create(authenticationService.parseClaims("token")) + .expectNextMatches(user -> + user.email().equals("user@example.com") && + user.userType().equals("NORMAL") && + user.userStatus().equals("ACTIVE")) + .verifyComplete(); + } + + @Test + @DisplayName("캐시 저장이 성공하면 true 반환") + void cacheUser_성공() throws Exception { + CachedUser user = new CachedUser("user@example.com", "NORMAL", "ACTIVE"); + String json = objectMapper.writeValueAsString(user); + + when(objectMapper.writeValueAsString(user)).thenReturn(json); + when(redisTokenRepository.saveAuthToken("auth_token:token", json, Duration.ofMinutes(10))).thenReturn(Mono.just(true)); + + StepVerifier.create(authenticationService.cacheUser("token", user)) + .expectNext(true) + .verifyComplete(); + } + + @Test + @DisplayName("Redis에서 잘못된 JSON 반환 시 예외 발생") + void getCachedUser_JSON파싱실패() throws Exception { + String invalidJson = "{invalid_json}"; + + when(redisTokenRepository.getCachedUser("token")).thenReturn(Mono.just(invalidJson)); + when(objectMapper.readValue(invalidJson, CachedUser.class)).thenThrow(new RuntimeException("파싱 오류")); + + StepVerifier.create(authenticationService.getCachedUser("token")) + .expectError(RuntimeException.class) + .verify(); + } +} \ No newline at end of file diff --git a/gateway/src/test/java/org/sangyunpark/gateway/filter/AuthenticationFilterTest.java b/gateway/src/test/java/org/sangyunpark/gateway/filter/AuthenticationFilterTest.java new file mode 100644 index 0000000..e0afaa1 --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/filter/AuthenticationFilterTest.java @@ -0,0 +1,188 @@ +package org.sangyunpark.gateway.filter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sangyunpark.gateway.application.AuthenticationService; +import org.sangyunpark.gateway.constant.code.ErrorCode; +import org.sangyunpark.gateway.filter.dto.CachedUser; +import org.sangyunpark.gateway.filter.matcher.UrlMatcher; +import org.sangyunpark.gateway.filter.matcher.WhitelistMatcher; +import org.sangyunpark.gateway.filter.utils.HttpResponseUtils; +import org.sangyunpark.gateway.filter.utils.RequestMutationUtils; +import org.sangyunpark.gateway.infrastructure.redis.RedisTokenRepository; +import org.sangyunpark.gateway.jwt.TokenProvider; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@SuppressWarnings("NonAsciiCharacters") +class AuthenticationFilterTest { + + private final String BEARER = "Bearer "; + private final String VALID_TOKEN = "valid-token"; + private final String INVALID_TOKEN = "invalid-token"; + private final String EMAIL = "user@example.com"; + private final String USER_TYPE = "userType"; + private final String USER_STATUS = "userStatus"; + + private GatewayFilterChain filterChain; + private AuthenticationFilter filter; + private RedisTokenRepository redisTokenRepository; + private TokenProvider tokenProvider; + private AuthenticationService authenticationService; + private WhitelistMatcher whitelistMatcher; + private HttpResponseUtils httpResponseUtils; + private UrlMatcher urlMatcher; + private RequestMutationUtils requestMutationUtils; + + @BeforeEach + void setUp() { + redisTokenRepository = mock(RedisTokenRepository.class); + tokenProvider = mock(TokenProvider.class); + authenticationService = mock(AuthenticationService.class); + filterChain = mock(GatewayFilterChain.class); + whitelistMatcher = mock(WhitelistMatcher.class); + httpResponseUtils = new HttpResponseUtils(); + urlMatcher = new UrlMatcher(); + requestMutationUtils = new RequestMutationUtils(); + filter = new AuthenticationFilter(authenticationService, tokenProvider, whitelistMatcher,httpResponseUtils, urlMatcher, requestMutationUtils); + } + + @Test + @DisplayName("화이트리스트 경로는 토큰 없이 통과") + void 화이트리스트_경로는_토큰_없이_통과() { + ServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/api/v1/users").build() + ); + + when(filterChain.filter(exchange)).thenReturn(Mono.empty()); + when(whitelistMatcher.isWhitelisted(any(ServerHttpRequest.class))).thenReturn(true); + + StepVerifier.create(filter.filter(exchange, filterChain)) + .verifyComplete(); + + verify(filterChain, times(1)).filter(exchange); + } + + @Test + @DisplayName("Authorization 헤더가 없으면 401 반환") + void 헤더_없으면_401() { + ServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/secure").build() + ); + + when(tokenProvider.resolveToken(any(ServerHttpRequest.class))).thenReturn(" "); + + StepVerifier.create(filter.filter(exchange, filterChain)) + .then(() -> { + HttpStatus status = (HttpStatus) exchange.getResponse().getStatusCode(); + assert status == ErrorCode.INVALID_TOKEN.getStatus(); + }) + .verifyComplete(); + } + + @Test + @DisplayName("토큰이 유효하지 않으면 401 반환") + void 유효하지_않은_토큰은_401() { + ServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/secure") + .header(HttpHeaders.AUTHORIZATION, BEARER + INVALID_TOKEN) + .build() + ); + + when(redisTokenRepository.isBlackList(anyString())).thenReturn(Mono.just(false)); + when(tokenProvider.resolveToken(any(ServerHttpRequest.class))).thenReturn(" "); + + StepVerifier.create(filter.filter(exchange, filterChain)) + .then(() -> { + HttpStatus status = (HttpStatus) exchange.getResponse().getStatusCode(); + assert status == ErrorCode.INVALID_TOKEN.getStatus(); + }) + .verifyComplete(); + } + + @Test + @DisplayName("유효한 토큰이면 필터 체인 통과") + void 유효한_토큰은_통과() { + // given + CachedUser user = new CachedUser(EMAIL, USER_TYPE, USER_STATUS); + + ServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/products") // 화이트리스트가 아닌 URL + .header(HttpHeaders.AUTHORIZATION, BEARER + VALID_TOKEN) + .build() + ); + + when(tokenProvider.resolveToken(any())).thenReturn(VALID_TOKEN); + when(authenticationService.getAuthenticatedUser(VALID_TOKEN)).thenReturn(Mono.just(user)); + when(filterChain.filter(any())).thenReturn(Mono.empty()); + + // when, then + StepVerifier.create(filter.filter(exchange, filterChain)) + .verifyComplete(); + + verify(filterChain, times(1)).filter(any()); + } + + @Test + @DisplayName("일반 유저가 정상 API 접근 허용") + void 일반_유저가_정상_API_접근_허용() { + // given + CachedUser user = new CachedUser(EMAIL, USER_TYPE, USER_STATUS); + + when(tokenProvider.resolveToken(any())).thenReturn(VALID_TOKEN); + when(authenticationService.getAuthenticatedUser(VALID_TOKEN)).thenReturn(Mono.just(user)); + when(filterChain.filter(any())).thenReturn(Mono.empty()); + + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/users") // 관리자 전용 X + .header(HttpHeaders.AUTHORIZATION, BEARER + VALID_TOKEN) + .build() + ); + + // when + Mono result = filter.filter(exchange, filterChain); + + // then + StepVerifier.create(result).verifyComplete(); + verify(filterChain, times(1)).filter(any(ServerWebExchange.class)); + } + + + @Test + @DisplayName("관리자 권한 없이 관리자 API에 접근하면 401 Unauthorized를 반환한다") + void 관리자_권한_없으면_인가_실패() { + // given + CachedUser user = new CachedUser(EMAIL, USER_TYPE, USER_STATUS); + + when(tokenProvider.resolveToken(any())).thenReturn(VALID_TOKEN); + when(authenticationService.getAuthenticatedUser(VALID_TOKEN)).thenReturn(Mono.just(user)); + + ServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/admin/test") // 관리자 전용 URL + .header(HttpHeaders.AUTHORIZATION, BEARER + VALID_TOKEN) + .build() + ); + + // when + Mono result = filter.filter(exchange, filterChain); + + // then + StepVerifier.create(result) + .then(() -> assertThat(exchange.getResponse().getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED)) + .verifyComplete(); + + verify(filterChain, never()).filter(any()); + } +} \ No newline at end of file diff --git a/gateway/src/test/java/org/sangyunpark/gateway/filter/matcher/UrlMatcherTest.java b/gateway/src/test/java/org/sangyunpark/gateway/filter/matcher/UrlMatcherTest.java new file mode 100644 index 0000000..960151e --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/filter/matcher/UrlMatcherTest.java @@ -0,0 +1,42 @@ +package org.sangyunpark.gateway.filter.matcher; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("NonAsciiCharacters") +class UrlMatcherTest { + + private final UrlMatcher urlMatcher = new UrlMatcher(); + + @Test + @DisplayName("URL 경로에 admin이 포함되면 true 반환") + void isAdminUrl_성공() { + ServerHttpRequest request = MockServerHttpRequest.get("/api/v1/admin/dashboard").build(); + assertThat(urlMatcher.isAdminUrl(request)).isTrue(); + } + + @Test + @DisplayName("URL 경로에 admin이 포함되지 않으면 false 반환") + void isAdminUrl_실패() { + ServerHttpRequest request = MockServerHttpRequest.get("/api/v1/users").build(); + assertThat(urlMatcher.isAdminUrl(request)).isFalse(); + } + + @Test + @DisplayName("정확한 로그아웃 경로일 경우 true 반환") + void isLogoutUrl_성공() { + ServerHttpRequest request = MockServerHttpRequest.get("/api/v1/auth/logout").build(); + assertThat(urlMatcher.isLogOutUrl(request)).isTrue(); + } + + @Test + @DisplayName("로그아웃 경로가 아니라면 false 반환") + void isLogoutUrl_실패() { + ServerHttpRequest request = MockServerHttpRequest.get("/api/v1/auth/login").build(); + assertThat(urlMatcher.isLogOutUrl(request)).isFalse(); + } +} \ No newline at end of file diff --git a/gateway/src/test/java/org/sangyunpark/gateway/filter/matcher/WhitelistMatcherTest.java b/gateway/src/test/java/org/sangyunpark/gateway/filter/matcher/WhitelistMatcherTest.java new file mode 100644 index 0000000..17c0f01 --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/filter/matcher/WhitelistMatcherTest.java @@ -0,0 +1,58 @@ +package org.sangyunpark.gateway.filter.matcher; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("NonAsciiCharacters") +class WhitelistMatcherTest { + + private final WhitelistMatcher whitelistMatcher; + + WhitelistMatcherTest() { + whitelistMatcher = new WhitelistMatcher(); + } + + @Test + @DisplayName("POST /api/v1/users 는 화이트리스트에 포함됨") + void 화이트리스트_포함_유저생성() { + ServerHttpRequest request = MockServerHttpRequest + .post("/api/v1/users") + .build(); + + assertThat(whitelistMatcher.isWhitelisted(request)).isTrue(); + } + + @Test + @DisplayName("POST /api/v1/auth/login 은 화이트리스트에 포함됨") + void 화이트리스트_포함_로그인() { + ServerHttpRequest request = MockServerHttpRequest + .post("/api/v1/auth/login") + .build(); + + assertThat(whitelistMatcher.isWhitelisted(request)).isTrue(); + } + + @Test + @DisplayName("GET 요청은 화이트리스트에 포함되지 않음") + void 화이트리스트_미포함_메서드불일치() { + ServerHttpRequest request = MockServerHttpRequest + .get("/api/v1/users") + .build(); + + assertThat(whitelistMatcher.isWhitelisted(request)).isFalse(); + } + + @Test + @DisplayName("정의되지 않은 경로는 화이트리스트에 포함되지 않음") + void 화이트리스트_미포함_경로불일치() { + ServerHttpRequest request = MockServerHttpRequest + .post("/api/v1/products") + .build(); + + assertThat(whitelistMatcher.isWhitelisted(request)).isFalse(); + } +} \ No newline at end of file diff --git a/gateway/src/test/java/org/sangyunpark/gateway/filter/utils/HttpResponseUtilsTest.java b/gateway/src/test/java/org/sangyunpark/gateway/filter/utils/HttpResponseUtilsTest.java new file mode 100644 index 0000000..e591d6e --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/filter/utils/HttpResponseUtilsTest.java @@ -0,0 +1,49 @@ +package org.sangyunpark.gateway.filter.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sangyunpark.gateway.constant.code.ErrorCode; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import reactor.test.StepVerifier; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpResponseUtilsTest { + + private final HttpResponseUtils httpResponseUtils = new HttpResponseUtils(); + + @Test + @DisplayName("401 Unauthorized 응답이 생성된다") + void unauthorizedResponseTest() { + // given + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/protected-resource") + ); + + // when + StepVerifier.create(httpResponseUtils.unauthorized(exchange, ErrorCode.INVALID_TOKEN)) + .verifyComplete(); + + // then + var response = exchange.getResponse(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); + + // Body 내용 검증 + StepVerifier.create(response.getBody() + .map(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + return new String(bytes, StandardCharsets.UTF_8); + })) + .expectNext("{\"code\":\"INVALID_TOKEN\"}") // ErrorCode.INVALID_TOKEN의 code가 "A001"인 경우 + .verifyComplete(); + } +} \ No newline at end of file diff --git a/gateway/src/test/java/org/sangyunpark/gateway/filter/utils/RequestMutationUtilsTest.java b/gateway/src/test/java/org/sangyunpark/gateway/filter/utils/RequestMutationUtilsTest.java new file mode 100644 index 0000000..5e07210 --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/filter/utils/RequestMutationUtilsTest.java @@ -0,0 +1,49 @@ +package org.sangyunpark.gateway.filter.utils; + +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sangyunpark.gateway.filter.dto.CachedUser; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RequestMutationUtilsTest { + + private final RequestMutationUtils requestMutationUtils = new RequestMutationUtils(); + private final String EMAIL = "user@example.com"; + private final String USER_TYPE = "userType"; + private final String USER_STATUS = "userStatus"; + private final String NORMAL = "NORMAL"; + private final String ACTIVE = "ACTIVE"; + + private final String URL = "/api/v1/resource"; + + @Test + @DisplayName("Claims 정보를 헤더에 추가하여 요청을 변경한다") + void mutateRequestWithClaims_addsHeaders() { + // given + Claims claims = mock(Claims.class); + when(claims.getSubject()).thenReturn(EMAIL); + when(claims.get(USER_TYPE, String.class)).thenReturn(NORMAL); + when(claims.get(USER_STATUS, String.class)).thenReturn(ACTIVE); + + MockServerHttpRequest request = MockServerHttpRequest.get(URL).build(); + ServerWebExchange exchange = MockServerWebExchange.from(request); + + // when + ServerWebExchange mutatedExchange = requestMutationUtils.mutateRequestWithClaims(exchange, new CachedUser(EMAIL, NORMAL, ACTIVE)); + + ServerHttpRequest mutatedRequest = mutatedExchange.getRequest(); + + // then + assertThat(mutatedRequest.getHeaders().getFirst(requestMutationUtils.X_USER_EMAIL)).isEqualTo(EMAIL); + assertThat(mutatedRequest.getHeaders().getFirst(requestMutationUtils.X_USER_TYPE)).isEqualTo(NORMAL); + assertThat(mutatedRequest.getHeaders().getFirst(requestMutationUtils.X_USER_STATUS)).isEqualTo(ACTIVE); + } +} \ No newline at end of file diff --git a/gateway/src/test/java/org/sangyunpark/gateway/infrastructure/redis/RedisTokenRepositoryTest.java b/gateway/src/test/java/org/sangyunpark/gateway/infrastructure/redis/RedisTokenRepositoryTest.java new file mode 100644 index 0000000..035f913 --- /dev/null +++ b/gateway/src/test/java/org/sangyunpark/gateway/infrastructure/redis/RedisTokenRepositoryTest.java @@ -0,0 +1,79 @@ +package org.sangyunpark.gateway.infrastructure.redis; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import reactor.test.StepVerifier; + +import java.time.Duration; + +@SuppressWarnings("NonAsciiCharacters") +@Import(RedisTokenRepository.class) +@DataRedisTest +class RedisTokenRepositoryTest { + + @Autowired + RedisTokenRepository redisTokenRepository; + + @Autowired + ReactiveStringRedisTemplate redisTemplate; + + private static final String TOKEN = "sample-token"; + private static final String BLACKLIST_KEY = "black_list:" + TOKEN; + private static final String AUTH_TOKEN_KEY = "auth_token:" + TOKEN; + private static final String VALUE = "{\"email\":\"test@example.com\",\"userType\":\"NORMAL\",\"userStatus\":\"ACTIVE\"}"; + + @Test + @DisplayName("블랙리스트 키 저장 후 존재 여부 확인") + void 블랙리스트_저장_및_존재_확인() { + redisTokenRepository.saveAuthToken(BLACKLIST_KEY, "logout", Duration.ofMinutes(5)).block(); + + StepVerifier.create(redisTokenRepository.isBlackList(TOKEN)) + .expectNext(true) + .verifyComplete(); + } + + @Test + @DisplayName("캐시된 사용자 정보 저장 후 조회") + void 캐시_저장_후_조회() { + redisTokenRepository.saveAuthToken(AUTH_TOKEN_KEY, VALUE, Duration.ofMinutes(10)).block(); + + StepVerifier.create(redisTokenRepository.getCachedUser(TOKEN)) + .expectNext(VALUE) + .verifyComplete(); + } + @Test + @DisplayName("인증 및 리프레시 토큰 삭제 성공") + void 인증_및_리프레시_토큰_삭제_성공() { + String email = "test@example.com"; + String refreshTokenKey = "refresh_token:" + email; + + // given + redisTokenRepository.saveAuthToken(AUTH_TOKEN_KEY, VALUE, Duration.ofMinutes(10)).block(); + redisTokenRepository.saveAuthToken(refreshTokenKey, "refresh-token", Duration.ofMinutes(10)).block(); + + // when & then + StepVerifier.create(redisTokenRepository.removeAuthToken(TOKEN, email)) + .expectNext(true) + .verifyComplete(); + + StepVerifier.create(redisTemplate.opsForValue().get(AUTH_TOKEN_KEY)) + .expectNextCount(0) + .verifyComplete(); + + StepVerifier.create(redisTemplate.opsForValue().get(refreshTokenKey)) + .expectNextCount(0) + .verifyComplete(); + } + + + @AfterEach + void tearDown() { + redisTemplate.delete(BLACKLIST_KEY).block(); + redisTemplate.delete(AUTH_TOKEN_KEY).block(); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 31430e8..2870958 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,5 @@ rootProject.name = 's-market' + include 'user' -include 'auth' \ No newline at end of file +include 'auth' +include 'gateway' \ No newline at end of file diff --git a/user/.gitignore b/user/.gitignore index ee37c22..c2065bc 100644 --- a/user/.gitignore +++ b/user/.gitignore @@ -35,6 +35,3 @@ out/ ### VS Code ### .vscode/ - -### applicaiton.yml ### -/src/main/resources/application.yml diff --git a/user/src/main/resources/application.yml b/user/src/main/resources/application.yml new file mode 100644 index 0000000..e52a077 --- /dev/null +++ b/user/src/main/resources/application.yml @@ -0,0 +1,18 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:mysql://localhost:3306/smarket + username: root + password: + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + database-platform: org.hibernate.dialect.MySQL8Dialect \ No newline at end of file