From c193e24f86c1c8039af90bcb1da55d920abd28f6 Mon Sep 17 00:00:00 2001 From: kwonhee1 Date: Thu, 30 Oct 2025 13:17:33 +0900 Subject: [PATCH 1/2] save' --- .../NextLevel/demo/config/SecurityConfig.java | 65 +++++++++++++++++++ .../demo/config/SecurityRequestMatcher.txt | 44 +++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/main/java/NextLevel/demo/config/SecurityRequestMatcher.txt diff --git a/src/main/java/NextLevel/demo/config/SecurityConfig.java b/src/main/java/NextLevel/demo/config/SecurityConfig.java index 945f879..1eaed61 100644 --- a/src/main/java/NextLevel/demo/config/SecurityConfig.java +++ b/src/main/java/NextLevel/demo/config/SecurityConfig.java @@ -14,20 +14,32 @@ import NextLevel.demo.user.service.LoginService; import NextLevel.demo.util.jwt.JWTUtil; import jakarta.persistence.EntityManager; +import jakarta.servlet.http.HttpServletRequest; +import java.util.function.Supplier; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorityAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.config.Customizer; 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.AuthorizeHttpRequestsConfigurer; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.servlet.HandlerExceptionResolver; @Configuration @@ -81,8 +93,61 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api1/**").hasRole("USER") .requestMatchers("/social/**").hasRole("SOCIAL") .requestMatchers("/admin/**").hasRole("ADMIN") + .requestMatchers("/admin/**").access(new AuthorizationManager() { + @Override + public AuthorizationDecision check( + Supplier authentication, + RequestAuthorizationContext object + ) { + return null; + } + }) .anyRequest().denyAll() // 그 외 요청은 모두 거절 ) + .authorizeHttpRequests( + new Customizer.AuthorizationManagerRequestMatcherRegistry>() { + @Override + public void customize( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorizationManagerRequestMatcherRegistry) { + authorizationManagerRequestMatcherRegistry.requestMatchers() + } + }) + .authorizeHttpRequests(auth-> + auth.requestMatchers("/api1/**").access(new AuthorizationManager() { + @Override + public void verify(Supplier authentication, + RequestAuthorizationContext object) { + AuthorizationManager.super.verify(authentication, object); + } + @Override + public AuthorizationDecision check(Supplier authentication, + RequestAuthorizationContext object) { + withRoleHierarchy(AuthorityAuthorizationManager + .hasAnyRole(AuthorizeHttpRequestsConfigurer.this.rolePrefix, new String[] { role })) + } + }) + ) + .authorizeHttpRequests( + new Customizer.AuthorizationManagerRequestMatcherRegistry>() { + @Override + public void customize( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorizationManagerRequestMatcherRegistry) { + authorizationManagerRequestMatcherRegistry.requestMatchers(new RequestMatcher() { + @Override + public boolean matches(HttpServletRequest request) { + return false; + } + }).access(new AuthorizationManager() { + + @Override + public AuthorizationDecision check(Supplier authentication, + RequestAuthorizationContext object) { + return null; + } + }); + } + } + ) .oauth2Login(oauth2 -> oauth2 .authorizedClientRepository(new NullAuthorizedClientRepository()) diff --git a/src/main/java/NextLevel/demo/config/SecurityRequestMatcher.txt b/src/main/java/NextLevel/demo/config/SecurityRequestMatcher.txt new file mode 100644 index 0000000..68a20e6 --- /dev/null +++ b/src/main/java/NextLevel/demo/config/SecurityRequestMatcher.txt @@ -0,0 +1,44 @@ +HttpSecurity에 context를 주면서 context에 List로 uri별 권한 설정을 쌓게 됨 + context : AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry + uri별 권한 설정 : RequestMatcherEntry {RequestMatcher matcher, AuthorizationManager manager} + +RequestMatcher : url 기반의 정보 +AuthorizationManger : authentication을 가지고 AuthorizationDecision을 반환함 + +HttpSecurity.build() 실행시 + AuthorizeHttpRequestsConfigurer.configure() 실행 + 저장된 모든 uri별 권한 설정을 AuthorizationFilter 필터 한개로 변환하고 security filter chain에 등록함 + AuthorizationFilter를 만들 때 AuthorizationManager를 생성자로 넘겨줌 + List>>를 AuthorizationManager 으로 변환함 (by RequestMatcherDelegatingAuthorizationManager.class 생성자) (filter에서는 HttpServletRequest만 사용하기 때문 by gpt) + AuthorizationManager.authorize 함수를 톧해 권한을 설정함 + +AuthorizationFilter 에서는 AuthorizationManager를 가지고 모든 요청을 url과 권한을 가지고 판단함 + AuthorizationManager.authorize 함수를 실행시킴 + +1. HttpSecurity에서 authorizeHttpRequests() 함수 실행 + AuthorizeHttpRequestsConfigure.class 반환 + AuthorizeHttpRequestsConfigure내부 class AuthorizationManagerRequestMatcherRegistry, AuthorizedUrl를 반복하며url 입력, manager 입력을 받는다 + 입력 받은 RequestMatcher와 AuthorizationManager를 RequestMatherEntry로 두고 RequestMatcherDelegatingAuthorizationManager.Builder에 쌓는다 (builder에서는 List>>으로 저장함) +2. HttpSecurity.build() 함수 실행시 + AuthorizeHttpRequestConfigure.configure()함수 실행됨 + RequestMatcherDelegatingAuthorizationManager.Builder.build()를 통해 AuthorizationManager 생성 + (형변환은 하지 않음 RequestMatcherDelegatingAuthorizationManager의 내부 변수 mappings가 List>> 형태임 + AuthorizationManager authorizationManager 가지는 Authorization 생성 / filter chain 등록 +3. 매 요청에 AuthorizationFilter 작동 + 매 요청 마다 List>>를 순회함 + 입력 받은 RequestMatherEntry.RequestMatcher를 통해 url 검증 + RequestMatherEntry.AuthorizationManager를 통해 authentic 검증 (AuthorizationManager.authorize() 함수 호출) +4. 매 요청 마다 발생하는 uri별 Exception을 다르게 처리하기 위해 authorize함수를 override하여 throw CustomException을 처리 예정 + 문제 발생 check함수에서 throw를 맘대로 던져도 되는가? + 다행히 AuthorizationFilter는 boolean값인 AuthorizationResult을 반환하는 check함수에서 Exception을 반환하는 verify로 변환을 준비중이다 + 아직 변환되지는 않았지만 문제되 점은 크게 많지 않아 보임 + 1. this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, result); 문장 실행 안됨 + AuthenticFilter내부 변수 AuthenticationEventPublisher eventPublisher에 저장중이다 + publisher는 filter를 생성하는 AuthorizeHttpRequestsConfigure에서 부터 내려왔으며 ApplicationContext에 저장된 객체이다 + 실패 횟수, 로그 등에 사용 되는 event임 (건너 뛰는 것은 좋지 않지만 다른 방법이 없으면 무시하겠음) + 해결 방법 탐색 + 1. event 무시하고 그냥 throw 던지기 + 2. 깔끔하게 AuthorizationFilter를 직접 구현하여 실패시도 알맞은 publishAuthorizationEvent를 발행하게 한다 + AuthorizationFilter를 생성하는 AuthorizeHttpRequestsConfigure을 상속해야 하는데 하필 AuthorizeHttpRequestsConfigure은 final class이다 (불가능) + 3. 매우 더럽게 HttpSecurity부터 override하고, AuthorizeHttpRequestsConfigure의 모든 interface를 구현한다 (사실 복 붙이라 직접 구현은 아니겠지만) (싫음) + 깔금하게 log event따위 무시하고 throw 던지기 (언젠가 security의 버전이 올라가며 AuthorizationManager.check가 완전히 사라진다면 무시된 event에 대한 코드도 수정이 되어있을 것으로 예상) From 9fdce62ddfd818501d4f445743b7e5e7388e98eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B6=8C=ED=9D=AC?= Date: Sun, 20 Sep 2026 15:07:54 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat=20(Security=20Config):=20uri=20?= =?UTF-8?q?=EB=B3=84=20=EB=8B=A4=EB=A5=B8=20error=20code=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat deletaging authorization manager : 자식 manager를 authorize()로 호출하는 7.0 방식 manager 구현 - fix spring security : AuthorizationFilter 리플렉션으로 deletagingManager 설정 - fix spring security : fix authenticationEntryPoint, accessDeniedHandler - test security config : uri, role 별 인가 결과 및 error code 검증 --- .../DelegatingAuthorizationManager.java | 43 ++++ .../config/ErrorCodeAuthorizationResult.java | 23 +++ .../NextLevel/demo/config/SecurityConfig.java | 168 +++++++++------- .../demo/config/SecurityConfigTest.java | 185 ++++++++++++++++++ 4 files changed, 350 insertions(+), 69 deletions(-) create mode 100644 src/main/java/NextLevel/demo/config/DelegatingAuthorizationManager.java create mode 100644 src/main/java/NextLevel/demo/config/ErrorCodeAuthorizationResult.java create mode 100644 src/test/java/NextLevel/demo/config/SecurityConfigTest.java diff --git a/src/main/java/NextLevel/demo/config/DelegatingAuthorizationManager.java b/src/main/java/NextLevel/demo/config/DelegatingAuthorizationManager.java new file mode 100644 index 0000000..a96310c --- /dev/null +++ b/src/main/java/NextLevel/demo/config/DelegatingAuthorizationManager.java @@ -0,0 +1,43 @@ +package NextLevel.demo.config; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.function.Supplier; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.security.web.util.matcher.RequestMatcher.MatchResult; +import org.springframework.security.web.util.matcher.RequestMatcherEntry; + +public class DelegatingAuthorizationManager implements AuthorizationManager { + + private final List>> mappings; + + public DelegatingAuthorizationManager( + List>> mappings + ) { + this.mappings = mappings; + } + + @Override + public AuthorizationResult authorize(Supplier authentication, HttpServletRequest request) { + for (RequestMatcherEntry> mapping : this.mappings) { + MatchResult matchResult = mapping.getRequestMatcher().matcher(request); + + if (matchResult.isMatch()) { + return mapping.getEntry().authorize( + authentication, + new RequestAuthorizationContext(request, matchResult.getVariables()) + ); + } + } + return new AuthorizationDecision(false); + } + + @Override + public AuthorizationDecision check(Supplier authentication, HttpServletRequest request) { + throw new UnsupportedOperationException("use authorize()"); + } +} diff --git a/src/main/java/NextLevel/demo/config/ErrorCodeAuthorizationResult.java b/src/main/java/NextLevel/demo/config/ErrorCodeAuthorizationResult.java new file mode 100644 index 0000000..323124b --- /dev/null +++ b/src/main/java/NextLevel/demo/config/ErrorCodeAuthorizationResult.java @@ -0,0 +1,23 @@ +package NextLevel.demo.config; + +import NextLevel.demo.exception.ErrorCode; +import lombok.Getter; +import org.springframework.security.authorization.AuthorizationResult; + +public class ErrorCodeAuthorizationResult implements AuthorizationResult { + + private final ErrorCode errorCode; + + public ErrorCodeAuthorizationResult(ErrorCode errorCode) { + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + @Override + public boolean isGranted() { + return false; + } +} diff --git a/src/main/java/NextLevel/demo/config/SecurityConfig.java b/src/main/java/NextLevel/demo/config/SecurityConfig.java index 1eaed61..2d5c53e 100644 --- a/src/main/java/NextLevel/demo/config/SecurityConfig.java +++ b/src/main/java/NextLevel/demo/config/SecurityConfig.java @@ -9,37 +9,39 @@ import NextLevel.demo.oauth.OAuthFailureHandler; import NextLevel.demo.oauth.OAuthSuccessHandler; import NextLevel.demo.oauth.SocialLoginService; +import NextLevel.demo.role.UserRole; import NextLevel.demo.user.repository.UserHistoryRepository; import NextLevel.demo.user.repository.UserRepository; import NextLevel.demo.user.service.LoginService; import NextLevel.demo.util.jwt.JWTUtil; import jakarta.persistence.EntityManager; +import jakarta.servlet.Filter; import jakarta.servlet.http.HttpServletRequest; +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; import java.util.function.Supplier; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.authorization.AuthorityAuthorizationManager; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.config.Customizer; 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.AuthorizeHttpRequestsConfigurer; -import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.Authentication; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager; +import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.authentication.logout.LogoutFilter; -import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcherEntry; import org.springframework.web.servlet.HandlerExceptionResolver; @Configuration @@ -90,64 +92,61 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/login/**").permitAll() .requestMatchers("/public/**").permitAll() .requestMatchers("/payment/**").permitAll() - .requestMatchers("/api1/**").hasRole("USER") .requestMatchers("/social/**").hasRole("SOCIAL") - .requestMatchers("/admin/**").hasRole("ADMIN") - .requestMatchers("/admin/**").access(new AuthorizationManager() { - @Override - public AuthorizationDecision check( - Supplier authentication, - RequestAuthorizationContext object - ) { - return null; - } - }) + .requestMatchers("/api1/**").access(new AuthorizationManager() { + @Override + public AuthorizationResult authorize( + Supplier authentication, + RequestAuthorizationContext object + ) { + Authentication auth = authentication.get(); + if (auth == null || auth instanceof AnonymousAuthenticationToken) + return new ErrorCodeAuthorizationResult(ErrorCode.NO_AUTHENTICATED); + + Collection authorities = auth.getAuthorities(); + + if (authorities.containsAll(UserRole.USER.getAuthorities())) + return new AuthorizationDecision(true); + + if (authorities.containsAll(UserRole.SOCIAL.getAuthorities())) + return new ErrorCodeAuthorizationResult(ErrorCode.NEED_ADDITIONAL_DATA); + + throw new CustomException(ErrorCode.SIBAL_WHAT_IS_IT, "not social, admin, user, anonymous"); + } + @Override + public AuthorizationDecision check( + Supplier authentication, + RequestAuthorizationContext object + ) { + throw new UnsupportedOperationException("use authorize()"); + } + }) + .requestMatchers("/admin/**").access(new AuthorizationManager() { + @Override + public AuthorizationResult authorize( + Supplier authentication, + RequestAuthorizationContext object + ) { + Authentication auth = authentication.get(); + if (auth == null || auth instanceof AnonymousAuthenticationToken) + return new ErrorCodeAuthorizationResult(ErrorCode.NO_AUTHENTICATED); + + if (auth.getAuthorities().containsAll(UserRole.ADMIN.getAuthorities())) + return new AuthorizationDecision(true); + + return new ErrorCodeAuthorizationResult(ErrorCode.NOT_ADMIN); + } + + @Override + public AuthorizationDecision check( + Supplier authentication, + RequestAuthorizationContext object + ) { + throw new UnsupportedOperationException("use authorize()"); + } + }) .anyRequest().denyAll() // 그 외 요청은 모두 거절 ) - .authorizeHttpRequests( - new Customizer.AuthorizationManagerRequestMatcherRegistry>() { - @Override - public void customize( - AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorizationManagerRequestMatcherRegistry) { - authorizationManagerRequestMatcherRegistry.requestMatchers() - } - }) - .authorizeHttpRequests(auth-> - auth.requestMatchers("/api1/**").access(new AuthorizationManager() { - @Override - public void verify(Supplier authentication, - RequestAuthorizationContext object) { - AuthorizationManager.super.verify(authentication, object); - } - @Override - public AuthorizationDecision check(Supplier authentication, - RequestAuthorizationContext object) { - withRoleHierarchy(AuthorityAuthorizationManager - .hasAnyRole(AuthorizeHttpRequestsConfigurer.this.rolePrefix, new String[] { role })) - } - }) - ) - .authorizeHttpRequests( - new Customizer.AuthorizationManagerRequestMatcherRegistry>() { - @Override - public void customize( - AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorizationManagerRequestMatcherRegistry) { - authorizationManagerRequestMatcherRegistry.requestMatchers(new RequestMatcher() { - @Override - public boolean matches(HttpServletRequest request) { - return false; - } - }).access(new AuthorizationManager() { - - @Override - public AuthorizationDecision check(Supplier authentication, - RequestAuthorizationContext object) { - return null; - } - }); - } - } - ) .oauth2Login(oauth2 -> oauth2 .authorizedClientRepository(new NullAuthorizedClientRepository()) @@ -164,18 +163,49 @@ public AuthorizationDecision check(Supplier authentication, .exceptionHandling((exceptions) -> exceptions .authenticationEntryPoint((request, response, authenticationException)-> { authenticationException.printStackTrace(); - handlerExceptionResolver.resolveException(request, response, null, - new CustomException(ErrorCode.NO_AUTHENTICATED)); + if(authenticationException instanceof InsufficientAuthenticationException) + // denyAll() + handlerExceptionResolver.resolveException(request, response, null, new CustomException(ErrorCode.NO_AUTHENTICATED)); + else if(authenticationException instanceof CustomException) + handlerExceptionResolver.resolveException(request, response, null, (CustomException)authenticationException); + else + handlerExceptionResolver.resolveException(request, response, null, new CustomException(ErrorCode.SIBAL_WHAT_IS_IT, authenticationException.getMessage())); }) .accessDeniedHandler((request, response, accessDeniedException)-> { - accessDeniedException.printStackTrace(); - handlerExceptionResolver.resolveException(request, response, null, new CustomException(ErrorCode.NEED_ADDITIONAL_DATA)); + if( + accessDeniedException instanceof AuthorizationDeniedException + && ((AuthorizationDeniedException)accessDeniedException).getAuthorizationResult() instanceof ErrorCodeAuthorizationResult + ) { + ErrorCode errorCode = ((ErrorCodeAuthorizationResult) ((AuthorizationDeniedException)accessDeniedException).getAuthorizationResult()).getErrorCode(); + handlerExceptionResolver.resolveException(request, response, null, new CustomException(errorCode)); + } else + handlerExceptionResolver.resolveException(request, response, null, new CustomException(ErrorCode.SIBAL_WHAT_IS_IT, accessDeniedException.getMessage())); }) ) ; - return http.build(); + SecurityFilterChain filterChain = http.build(); + replaceAuthorizationManager(filterChain); + + return filterChain; + } + + private void replaceAuthorizationManager(SecurityFilterChain chain) throws Exception { + for (Filter filter : chain.getFilters()) { + if (!(filter instanceof AuthorizationFilter authorizationFilter)) + continue; + + AuthorizationManager origin = authorizationFilter.getAuthorizationManager(); + Field mappingsField = origin.getClass().getDeclaredField("mappings"); + mappingsField.setAccessible(true); + List>> mappings = + (List>>) mappingsField.get(origin); + + Field managerField = AuthorizationFilter.class.getDeclaredField("authorizationManager"); + managerField.setAccessible(true); + managerField.set(authorizationFilter, new DelegatingAuthorizationManager(mappings)); + } } @Bean diff --git a/src/test/java/NextLevel/demo/config/SecurityConfigTest.java b/src/test/java/NextLevel/demo/config/SecurityConfigTest.java new file mode 100644 index 0000000..761f18d --- /dev/null +++ b/src/test/java/NextLevel/demo/config/SecurityConfigTest.java @@ -0,0 +1,185 @@ +package NextLevel.demo.config; + +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 NextLevel.demo.exception.ErrorCode; +import NextLevel.demo.role.UserRole; +import NextLevel.demo.user.entity.UserEntity; +import NextLevel.demo.user.repository.UserRepository; +import NextLevel.demo.util.jwt.JWTUtil; +import jakarta.servlet.http.Cookie; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +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.boot.test.context.TestConfiguration; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * SecurityConfig uri 별 인가 결과 / ErrorCode 응답 검증 + * - 비로그인 거부 → entryPoint → NO_AUTHENTICATED + * - 로그인 거부 → accessDeniedHandler → ErrorCodeAuthorizationResult의 ErrorCode + */ +@SpringBootTest +@AutoConfigureMockMvc +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SecurityConfigTest { + + // TestController에 /admin/test가 없어서 테스트 전용으로 추가 (중첩 @RestController는 @TestConfiguration이 자동 등록) + @TestConfiguration + static class AdminTestControllerConfig { + @RestController + static class AdminTestController { + @GetMapping("/admin/test") + public ResponseEntity adminTest() { + return ResponseEntity.ok("adminTest"); + } + } + } + + // MockMvc 기본 remoteAddr — AccessTokenFilter의 ip 검증과 일치해야 함 + private static final String IP = "127.0.0.1"; + + @Autowired private MockMvc mockMvc; + @Autowired private JWTUtil jwtUtil; + @Autowired private UserRepository userRepository; + + private final List users = new ArrayList<>(); + + private Cookie userToken; + private Cookie adminToken; + private Cookie socialToken; + + @BeforeAll + void setUp() { + userToken = accessToken(saveUser(UserRole.USER)); + adminToken = accessToken(saveUser(UserRole.ADMIN)); + socialToken = accessToken(saveUser(UserRole.SOCIAL)); + } + + @AfterAll + void tearDown() { + userRepository.deleteAll(users); + } + + private UserEntity saveUser(UserRole role) { + UserEntity user = UserEntity.builder().name("security-test-" + role.name()).build(); + user.setRole(role.name()); + user = userRepository.save(user); + users.add(user); + return user; + } + + private Cookie accessToken(UserEntity user) { + String token = jwtUtil.makeToken( + user.getId().toString(), + Map.of("ip", IP, "role", user.getRole()), + jwtUtil.ACCESS_TOKEN_TIME + ); + return new Cookie(JWTUtil.ACCESS_TOKEN, token); + } + + private static void expectError(ResultActions result, ErrorCode errorCode) throws Exception { + result + .andExpect(status().is(errorCode.statusCode.value())) + .andExpect(jsonPath("$.code").value(errorCode.CustomErrorCode)); + } + + // ---- 비로그인 ---- + + @Test + void 비로그인_public_허용() throws Exception { + mockMvc.perform(get("/public/test")) + .andExpect(status().isOk()); + } + + @Test + void 비로그인_api1_NO_AUTHENTICATED() throws Exception { + expectError(mockMvc.perform(get("/api1/test")), ErrorCode.NO_AUTHENTICATED); + } + + @Test + void 비로그인_admin_NO_AUTHENTICATED() throws Exception { + expectError(mockMvc.perform(get("/admin/test")), ErrorCode.NO_AUTHENTICATED); + } + + @Test + void 비로그인_social_NO_AUTHENTICATED() throws Exception { + expectError(mockMvc.perform(get("/social/test")), ErrorCode.NO_AUTHENTICATED); + } + + @Test + void 비로그인_미등록uri_NO_AUTHENTICATED() throws Exception { + expectError(mockMvc.perform(get("/etc/test")), ErrorCode.NO_AUTHENTICATED); + } + + // ---- /api1 ---- + + @Test + void USER_api1_허용() throws Exception { + mockMvc.perform(get("/api1/test").cookie(userToken)) + .andExpect(status().isOk()); + } + + @Test + void ADMIN_api1_허용() throws Exception { + mockMvc.perform(get("/api1/test").cookie(adminToken)) + .andExpect(status().isOk()); + } + + @Test + void SOCIAL_api1_NEED_ADDITIONAL_DATA() throws Exception { + expectError(mockMvc.perform(get("/api1/test").cookie(socialToken)), ErrorCode.NEED_ADDITIONAL_DATA); + } + + // ---- /admin ---- + + @Test + void ADMIN_admin_허용() throws Exception { + mockMvc.perform(get("/admin/test").cookie(adminToken)) + .andExpect(status().isOk()); + } + + @Test + void USER_admin_NOT_ADMIN() throws Exception { + expectError(mockMvc.perform(get("/admin/test").cookie(userToken)), ErrorCode.NOT_ADMIN); + } + + @Test + void SOCIAL_admin_NOT_ADMIN() throws Exception { + expectError(mockMvc.perform(get("/admin/test").cookie(socialToken)), ErrorCode.NOT_ADMIN); + } + + // ---- /social ---- + + @Test + void SOCIAL_social_허용() throws Exception { + mockMvc.perform(get("/social/test").cookie(socialToken)) + .andExpect(status().isOk()); + } + + @Test + void USER_social_허용() throws Exception { + mockMvc.perform(get("/social/test").cookie(userToken)) + .andExpect(status().isOk()); + } + + // ---- 미등록 uri ---- + + @Test + void USER_미등록uri_SIBAL_WHAT_IS_IT() throws Exception { + expectError(mockMvc.perform(get("/etc/test").cookie(userToken)), ErrorCode.SIBAL_WHAT_IS_IT); + } +}