diff --git a/src/main/java/org/runimo/runimo/auth/exceptions/UnauthorizedAccessException.java b/src/main/java/org/runimo/runimo/auth/exceptions/UnauthorizedAccessException.java new file mode 100644 index 00000000..f2038369 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/exceptions/UnauthorizedAccessException.java @@ -0,0 +1,15 @@ +package org.runimo.runimo.auth.exceptions; + +import org.runimo.runimo.auth.filters.UserErrorCode; +import org.runimo.runimo.exceptions.BusinessException; + +public class UnauthorizedAccessException extends BusinessException { + + public UnauthorizedAccessException(String message) { + super(UserErrorCode.INSUFFICIENT_PERMISSIONS, message); + } + + public UnauthorizedAccessException(Throwable cause) { + super(UserErrorCode.INSUFFICIENT_PERMISSIONS, cause.getMessage()); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java index c8aef7a8..d5a99813 100644 --- a/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java +++ b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java @@ -15,6 +15,7 @@ import org.runimo.runimo.auth.jwt.JwtResolver; import org.runimo.runimo.auth.jwt.UserDetail; import org.runimo.runimo.common.response.ErrorResponse; +import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -39,6 +40,14 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws IOException { try { + + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && + !(auth instanceof AnonymousAuthenticationToken)) { + filterChain.doFilter(request, response); + return; + } + if (!hasValidAuthorizationHeader(request)) { setErrorResponse(UserErrorCode.JWT_NOT_FOUND, response); return; diff --git a/src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java b/src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java index e7f2e039..19637d34 100644 --- a/src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java +++ b/src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java @@ -16,6 +16,9 @@ public enum UserErrorCode implements CustomResponseCode { JWT_BROKEN("UEH4014", HttpStatus.UNAUTHORIZED, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"), REFRESH_FAILED("UEH4015", HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다.", "리프레시 토큰이 만료되었습니다."), + // 403 + INSUFFICIENT_PERMISSIONS("UEH4031", HttpStatus.FORBIDDEN, "권한이 부족합니다.", "권한이 부족합니다."), + // 404 USER_NOT_FOUND("UEH4041", HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"), ; diff --git a/src/main/java/org/runimo/runimo/config/SecurityConfig.java b/src/main/java/org/runimo/runimo/config/SecurityConfig.java index fc85d17b..0b9ac3ea 100644 --- a/src/main/java/org/runimo/runimo/config/SecurityConfig.java +++ b/src/main/java/org/runimo/runimo/config/SecurityConfig.java @@ -1,13 +1,22 @@ package org.runimo.runimo.config; +import static org.runimo.runimo.config.SecurityConstants.ADMIN_ENDPOINT_PATTERN; +import static org.runimo.runimo.config.SecurityConstants.ADMIN_ROLE; +import static org.runimo.runimo.config.SecurityConstants.COMMON_PUBLIC_ENDPOINTS; +import static org.runimo.runimo.config.SecurityConstants.DEV_PUBLIC_ENDPOINTS; +import static org.runimo.runimo.config.SecurityConstants.USER_ENDPOINT_PATTERN; +import static org.runimo.runimo.config.SecurityConstants.USER_ROLE; + import lombok.RequiredArgsConstructor; import org.runimo.runimo.auth.filters.JwtAuthenticationFilter; +import org.runimo.runimo.exceptions.CustomAccessDeniedHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -18,48 +27,52 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAccessDeniedHandler customAccessDeniedHandler; @Bean @Profile({"prod", "test"}) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/v1/auth/**").permitAll() - .requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN") - .requestMatchers("/checker/**").permitAll() - .requestMatchers("/actuator/**").permitAll() - .requestMatchers(("/error")).permitAll() - .anyRequest().authenticated() - ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - - return http.build(); + return buildSecurityFilterChain(http) + .authorizeHttpRequests(this::configureProdAuthorization) + .build(); } @Bean @Profile("dev") public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception { - http + return buildSecurityFilterChain(http) + .authorizeHttpRequests(this::configureDevAuthorization) + .build(); + } + + private HttpSecurity buildSecurityFilterChain(HttpSecurity http) throws Exception { + return http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/v1/auth/**").permitAll() - .requestMatchers("/actuator/prometheus").permitAll() - .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**") - .permitAll() - .requestMatchers(("/error")).permitAll() - .requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN") - .requestMatchers("/checker/**").permitAll() - .anyRequest().authenticated() + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDeniedHandler) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + } + + private void configureProdAuthorization( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorize) { + authorize + .requestMatchers(COMMON_PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(ADMIN_ENDPOINT_PATTERN).hasRole(ADMIN_ROLE) + .requestMatchers(USER_ENDPOINT_PATTERN).hasAnyRole(USER_ROLE, ADMIN_ROLE) + .anyRequest().authenticated(); + } - return http.build(); + private void configureDevAuthorization( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorize) { + authorize + .requestMatchers(COMMON_PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(DEV_PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(ADMIN_ENDPOINT_PATTERN).hasRole(ADMIN_ROLE) + .requestMatchers(USER_ENDPOINT_PATTERN).hasAnyRole(USER_ROLE, ADMIN_ROLE) + .anyRequest().authenticated(); } } diff --git a/src/main/java/org/runimo/runimo/config/SecurityConstants.java b/src/main/java/org/runimo/runimo/config/SecurityConstants.java new file mode 100644 index 00000000..96501742 --- /dev/null +++ b/src/main/java/org/runimo/runimo/config/SecurityConstants.java @@ -0,0 +1,29 @@ +package org.runimo.runimo.config; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class SecurityConstants { + + + static final String[] COMMON_PUBLIC_ENDPOINTS = { + "/api/v1/auth/**", + "/checker/**", + "/actuator/**", + "/error" + }; + + static final String[] DEV_PUBLIC_ENDPOINTS = { + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**" + }; + + static final String ADMIN_ENDPOINT_PATTERN = "/api/v1/admin/**"; + + static final String USER_ENDPOINT_PATTERN = "/api/v1/users/**"; + + static final String USER_ROLE = "USER"; + static final String ADMIN_ROLE = "ADMIN"; +} diff --git a/src/main/java/org/runimo/runimo/exceptions/CustomAccessDeniedHandler.java b/src/main/java/org/runimo/runimo/exceptions/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..82dfc205 --- /dev/null +++ b/src/main/java/org/runimo/runimo/exceptions/CustomAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package org.runimo.runimo.exceptions; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.runimo.runimo.auth.filters.UserErrorCode; +import org.runimo.runimo.common.response.ErrorResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + + log.warn("[ERROR]접근 거부됨 - URI: {}, 사용자: {}", request.getRequestURI(), + SecurityContextHolder.getContext().getAuthentication() != null + ? SecurityContextHolder.getContext().getAuthentication().getName() : "Unknown"); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = ErrorResponse.of(UserErrorCode.INSUFFICIENT_PERMISSIONS); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java index 7c249f4a..b3942040 100644 --- a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java @@ -4,7 +4,7 @@ import java.util.NoSuchElementException; import lombok.extern.slf4j.Slf4j; import org.runimo.runimo.auth.exceptions.SignUpException; -import org.runimo.runimo.auth.exceptions.UnRegisteredUserException; +import org.runimo.runimo.auth.exceptions.UnauthorizedAccessException; import org.runimo.runimo.auth.exceptions.UserJwtException; import org.runimo.runimo.common.response.ErrorResponse; import org.runimo.runimo.external.ExternalServiceException; @@ -19,6 +19,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @RestControllerAdvice @@ -27,6 +28,22 @@ public class GlobalExceptionHandler { private static final String ERROR_LOG_HEADER = "ERROR: "; + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException( + NoResourceFoundException e) { + log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.of("요청한 리소스를 찾을 수 없습니다.", e.getMessage())); + } + + @ExceptionHandler(UnauthorizedAccessException.class) + public ResponseEntity handleUnauthorizedAccessException( + UnauthorizedAccessException e) { + log.debug("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ErrorResponse.of(e.getErrorCode())); + } + @ExceptionHandler(RunimoException.class) public ResponseEntity handleRunimoException(RunimoException e) { log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); diff --git a/src/test/java/org/runimo/runimo/security/SecurityConfigDevTest.java b/src/test/java/org/runimo/runimo/security/SecurityConfigDevTest.java new file mode 100644 index 00000000..a490bbbf --- /dev/null +++ b/src/test/java/org/runimo/runimo/security/SecurityConfigDevTest.java @@ -0,0 +1,41 @@ +package org.runimo.runimo.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("dev") +class SecurityConfigDevTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("개발 환경에서 Swagger 접근 가능") + void swaggerAccessibleInDev() throws Exception { + mockMvc.perform(get("/swagger-ui.html")) + .andExpect(status().isFound()); // 302 redirect to swagger-ui/index.html + + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("개발 환경에서도 기본 보안 규칙 적용") + void basicSecurityRulesStillApply() throws Exception { + mockMvc.perform(get("/api/v1/users/me")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/org/runimo/runimo/security/SecurityConfigTest.java b/src/test/java/org/runimo/runimo/security/SecurityConfigTest.java new file mode 100644 index 00000000..cc7931ec --- /dev/null +++ b/src/test/java/org/runimo/runimo/security/SecurityConfigTest.java @@ -0,0 +1,96 @@ +package org.runimo.runimo.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import 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.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SecurityConfigTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("공개 엔드포인트는 인증 없이 접근 가능") + void publicEndpointsAccessibleWithoutAuth() throws Exception { + mockMvc.perform(get("/checker/check")) + .andExpect(status().isNotFound()); + + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("사용자 엔드포인트는 인증 없이 접근 시 401") + void userEndpointsRequireAuth() throws Exception { + mockMvc.perform(get("/api/v1/users/me")) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentType("application/json;charset=UTF-8")); + } + + @Test + @DisplayName("관리자 엔드포인트는 인증 없이 접근 시 401") + void adminEndpointsRequireAuth() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentType("application/json;charset=UTF-8")); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("USER 역할로 사용자 엔드포인트 접근 가능") + void userCanAccessUserEndpoints() throws Exception { + mockMvc.perform(get("/api/v1/users/me")) + .andExpect(status().isNotFound()); // 404는 정상 (실제 컨트롤러 메서드 없어서) + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("USER 역할로 관리자 엔드포인트 접근 시 403") + void userCannotAccessAdminEndpoints() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isForbidden()) + .andExpect(content().contentType("application/json;charset=UTF-8")); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("ADMIN 역할로 모든 엔드포인트 접근 가능") + void adminCanAccessAllEndpoints() throws Exception { + mockMvc.perform(get("/api/v1/users/me")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("인증되지 않은 요청의 에러 응답 형태 확인") + void unauthorizedResponseFormat() throws Exception { + mockMvc.perform(get("/api/v1/users/me")) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentType("application/json;charset=UTF-8")) + .andExpect(jsonPath("$.code").exists()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("권한 부족 시 에러 응답 형태 확인") + void forbiddenResponseFormat() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isForbidden()) + .andExpect(content().contentType("application/json;charset=UTF-8")) + .andExpect(jsonPath("$.code").exists()) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/src/test/java/org/runimo/runimo/security/SecurityFilterIntegrationTest.java b/src/test/java/org/runimo/runimo/security/SecurityFilterIntegrationTest.java new file mode 100644 index 00000000..5be57e83 --- /dev/null +++ b/src/test/java/org/runimo/runimo/security/SecurityFilterIntegrationTest.java @@ -0,0 +1,68 @@ +package org.runimo.runimo.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SecurityFilterIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("Authorization 헤더 없이 요청 시 JWT_NOT_FOUND 에러") + void requestWithoutAuthHeader() throws Exception { + mockMvc.perform(get("/api/v1/users/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UEH4012")); + } + + @Test + @DisplayName("잘못된 Bearer 토큰 형식으로 요청 시 에러") + void requestWithInvalidBearerFormat() throws Exception { + mockMvc.perform(get("/api/v1/users/me") + .header(HttpHeaders.AUTHORIZATION, "InvalidFormat token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UEH4012")); + } + + @Test + @DisplayName("Bearer 없는 토큰으로 요청 시 에러") + void requestWithoutBearerPrefix() throws Exception { + mockMvc.perform(get("/api/v1/users/me") + .header(HttpHeaders.AUTHORIZATION, "some-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UEH4012")); + } + + @Test + @DisplayName("손상된 JWT 토큰으로 요청 시 JWT_BROKEN 에러") + void requestWithBrokenJwt() throws Exception { + mockMvc.perform(get("/api/v1/users/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer invalid.jwt.token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UEH4014")); + } + + @Test + @DisplayName("White list 엔드포인트는 JWT 필터 통과") + void whiteListEndpointsSkipJwtFilter() throws Exception { + mockMvc.perform(get("/api/v1/auth/login")) + .andExpect(status().isNotFound()); // JWT 에러가 아닌 404 + + mockMvc.perform(get("/checker/health")) + .andExpect(status().isNotFound()); // JWT 에러가 아닌 404 + } +}