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 extends GrantedAuthority> 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