SMALL

Spring Security를 처음 접하는 경우 권한 별 접근 제어는 대부분 

WebSecurityConfigurerAdapter를 상속받은 클래스 하위에 configure(HttpSecurity http) 메서드에 설정하게 된다. 

 

public class ServerSecurityConfiguration extends WebSecurityConfigurerAdapter {

	private final LoginService loginService;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
	    SavedRequestAwareAuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
	    successHandler.setTargetUrlParameter("redirectTo");
	    successHandler.setDefaultTargetUrl(this.adminServer.path("/"));

	    http.authorizeRequests(
	        (authorizeRequests) -> authorizeRequests.antMatchers("/assets/**").permitAll() 
	            .antMatchers("/login").permitAll()
	            .antMatchers("/h2-console/**").permitAll()
	            .anyRequest().authenticated() 
	    ).httpBasic(Customizer.withDefaults()) 
	        .csrf((csrf) -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 
	            .ignoringRequestMatchers(
	                new AntPathRequestMatcher("/api/v1/**"),
	                new AntPathRequestMatcher("/h2-console/**")
	                
	            ))
	        .headers().frameOptions().disable().and()
	        .rememberMe((rememberMe) -> rememberMe.key(UUID.randomUUID().toString()).tokenValiditySeconds(1209600));
	}
}

 

 

이 설정 이외에 만약 특정 메서드에서 권한 별 접근 제어를 하고 싶은 경우에는 어떠한 방법이 있는지 알아보도록 하자. 

 

Spring Security에서는 특정 Class, Method에 적용할 수 있는 Annotation을 제공한다.

해당 Annotation을 원하는 위치에 작성하면 해당 클래스 또는 메서드는 권한 체크하는 로직을 타게 된다. 

 

Spring Security에서는 다양한 Annotation을 제공하는데, 오늘은 간단하게 @Secure, @PreAuthorize를 알아보도록 하자.

 

@Secured

@Secured는 특정 권한만 접근이 가능하다는 것을 나타내는 Annotation이다. 

간단하게 특정 기능은 Admin만 이용하게 하고 싶은 경우 위의 Annotation을 붙여주면 된다. 

public class ProjectController {

    private final ProjectService projectService;

    @PostMapping("/api/v1/project")
    @Secured("ROLE_ADMIN")
    public String save(@RequestBody ProjectSaveRequestDto requestDto) throws DuplicateException {

        return projectService.save(requestDto);
    }
}

 

만약에 단순하게 특정 권한을 가진 사람이 아닌 다양한 조건이 들어가야 되는 경우에는 @Secured로 표현하기는 조금 힘들게 된다.

이러한 경우에는 @PreAuthorize, @PostAuthorize를 이용하면 좀 더 복잡한 케이스도 처리할 수 있게 된다.

해당 Annotation에서는 SpringEL을 사용할 수 있어서 다양한 권한에 따른 처리가 가능하다. 

 

Spring Expression Language에서는 다양한 Built-in expression을 제공한다. 

아래의 예시에서는 " isAutheticated()" 를 사용하였다. 

Spring EL에서 제공하는 Expression을 숙지하고 있으면 사용자가 직접 기능을 만들지 않고도 활용할 수 있어 유용하다. 

 

https://docs.spring.io/spring-security/reference/servlet/authorization/expression-based.html#el-common-built-in

 

Expression-Based Access Control :: Spring Security

Any Spring-EL functionality is available within the expression, so you can also access properties on the arguments. For example, if you wanted a particular method to only allow access to a user whose username matched that of the contact, you could write

docs.spring.io

 

@PreAuthorize("isAuthenticated() and (( #user.name == principal.name ) or hasRole('ROLE_ADMIN'))")
@RequestMapping( value = "", method = RequestMethod.PUT)
public ResponseEntity<Project> updateProject( User user ){
    updateProject.update( user );
    return new ResponseEntity<Project>( new Project(), HttpStatus.OK );
}

@PreAuthorize

추가적으로 @PreAuthorize 내에 #user.name를 사용한 부분을 보면

Annotation이 붙은 Method의 Param값을 위와 같이 활용하여 사용할 수 있게 된다. 

 

@PostAuthorize

@PostAuthorize의 경우에는 Method가 실행된 이후의 return값을 활용할 수 있다. 

아래와 같이 returnObject.name과 같이 "returnObject"를 사용하면 된다. 

@PostAuthorize("isAuthenticated() and (( returnObject.name == principal.name ) or hasRole('ROLE_ADMIN'))")
@RequestMapping( value = "/{id}", method = RequestMethod.GET )
public Project getProject( @PathVariable("id") long id ){
    return service.findOne(id);
}

 

Annotation 활성화 설정 추가

추가적으로, 이러한 Annotation을 활성화 시키기 위해서는 설정을 추가해 주어야 한다. 

@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)

@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class ServerSecurityConfiguration extends WebSecurityConfigurerAdapter {

	private final LoginService loginService;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
	    
        //.....
   }
}
LIST
SMALL

@WebMvcTest 어노테이션은 Controller Layer를 테스트할 때 사용된다.

( https://joomn11.tistory.com/86 )

 

해당 어노테이션을 사용하면 Spring MVC 컨트롤러를 이용한 요청과 응답에 필요한 Bean들만 로딩하여 가벼운 테스트 환경을 제공한다. 

 

@WebMvcTest 어노테이션을 사용시 다음 내용만 스캔하도록 제한한다

@Controller,
@ControllerAdvice,
@JsonComponent,
Converter / GenericConverter,
Filter,
WebSecurityConfigurerAdapter,
WebMvcConfigurer,
HandlerMethodArgumentResolver

@Component, @Service or @Repository  Bean들은 스캔에서 제외된다. 

 

Spring Security에 대한 설정은 @WebMvcTest의 스캔 대상에 해당한다. 

하지만, 대부분의 Spring Security에서는 다른 Bean들에 대한 Dependency가 존재하게 된다.

@RequiredArgsConstructor
@EnableWebSecurity 
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService; // Dependency!!

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	
        http.csrf().disable().headers().frameOptions().disable()
                .and().authorizeRequests() 
                .antMatchers("/", "/css/**", "/js/**", "h2-console/**").permitAll()
                .antMatchers("/api/**").hasRole(Role.USER.name()).anyRequest().authenticated() 
                .and().logout().logoutSuccessUrl("/") 
                .and().oauth2Login() 
                .userInfoEndpoint() 
                .userService(customOAuth2UserService); 

    	http.csrf().disable().headers().frameOptions()
    	    	.disable().and().authorizeRequests().anyRequest().permitAll();
    }

}

 

그렇기 때문에  @WebMvcTest를 진행할 경우 아래와 같은 에러를 보게 된다.

Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException:
Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type '${classInfo}' available: expected at least 1 bean which qualifies as autowire candidate.

 

해당 문제를 해결하기 위해서는 Controller Layer에 해당하는 내용에만 Test를 집중할 수 있도록 WebSecurityConfigurerAdapter를 스캔 대상에서 제외하고 @WithMockUser를 통해 Mock 인증 사용자를 생성한다.

 

@WebMvcTest(controllers = HelloController.class, 
        excludeFilters = { //!Added!
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class) })
public class HelloControllerTest {

    @Autowired
    private MockMvc mvc;

    @Test
    @WithMockUser(roles = "USER") //!Added!
    public void hello가_리턴된다() throws Exception {
        String hello = "hello";
        mvc.perform(get("/hello")).andExpect(status().isOk()).andExpect(content().string(hello));
    }

}
LIST
SMALL

Spring의 Controller Layer를 테스트하기 위해서는 

요청을 수행하고 응답을 만들어내는 Servlet API Mock 객체를 사용한다.

 

오늘 소개할 객체는 MockMvc이다.

MockMvc : Main entry point for server-side Spring MVC test support

 

MockMvc를 주입받는 방법에는 크게 두가지 방법이 있다. 

  • @SpringBootTest + @AutoConfigureMockMvc
  • @WebMvcTest 

각각의 방식에 대해서 알아 보도록 하자.

 

@SpringBootTest + @AutoConfigureMockMvc

 

@SpringBootTest는 웹 애플리케이션 컨텍스트와 설정을 모두 불러와 실제 웹 서버에 연결을 시도한다.

만약 Web Layer에 해당하는 부분만 테스트를 진행하고 싶은 경우에는 불필요한 설정도 로드하게 되는 것이다. 

통합 테스트를 할때 사용된다.

 

@SpringBootTest
@AutoConfigureMockMvc
public class SbtControllerTest {
	@Autowired
	private MockMvc mockMvc;
    ....
}

 

@WebMvcTest 

 

해당 설정은 Web Layer에 요청을 처리하기 위해 필요한 Bean들만 로딩한다.

단순히 Spring MVC 컨트롤러를 이용한 요청과 응답을 테스트하고자 한다면 @WebMVCTest를 사용하면 보다 가볍게 테스트할 수 있다.

 

@WebMvcTest
public class WebMvcControllerTest {
	@Autowired
	private MockMvc mockMvc;
    ....
}

 

경량화되어서 주는 장점도 있지만, 그에 상응하는 단점도 존재한다.

보통의 WebApplication은 Controller - Service - Repository Layer로 구성되어서 

Controller와 Service가 Dependency가 존재하는데, @WebMvcTest를 사용하는 경우에는 

Controller에 Service 객체를 주입받을 수 없다.

 

그렇기 때문에 Test를 진행하는 사용자가 Mock 객체를 주입해 주어야 한다. 

 

@WebMvcTest
public class WebMvcControllerTest {
	@Autowired
	private MockMvc mockMvc;
    
	@MockBean //Added !!
	private WebMvcService service;
	...
}

 

Service에 해당하는 객체를 Mock으로 주입 받은 경우 테스트 코드 작성 예시는 아래와 같다.

 

import static org.hamcrest.CoreMatchers.is;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

    @Test
	public void testGet() throws Exception {

		ResponseDto dto = new ResponseDto("id2", "sName2", "sCode2", "this is s2");

		given(service.findById("id2")).willReturn(dto);

		mockMvc.perform(get("/api/{id}", "id2").contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isOk())
				.andExpect(jsonPath("$.name", is("sName2")));

	}

 

Controller의 특정 요청을 했을 때, 호출되는 Service의 메서드의 행동을 미리 정의해 놓는 형태이다. 

다음으로 getList를 테스트하는 경우에 예시 코드를 보자.

 

import static org.hamcrest.CoreMatchers.is;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

    @Test
	public void testGetList() throws Exception {
		List<StageResponseDto> list = Arrays.asList(
				new StageResponseDto("id2", "sName2", "sCode2", "this is s2"));
		
		given(service.findAll()).willReturn(list);
        
		mockMvc.perform(get("/api").contentType(MediaType.APPLICATION_JSON))
					.andDo(print())
					.andExpect(status().isOk())
					.andExpect(jsonPath("$").isArray())
					.andExpect(jsonPath("$", Matchers.hasSize(1)))
					.andExpect(jsonPath("$[0].name", is("sName2")));
	}

 

 

 

 

LIST
SMALL

Interceptor

 

Interceptor는 "가로채다"라는 의미를 가지고 있다.

Spring에서도 해당 의미와 유사하게 

Client에서 Server로 보낸 Request 객체를 Dispatcher Servlet의 Controller에 도달하기 전에 가로채는 역할을 한다.

 

Interceptor를 통해 가로챈 요청에 추가 로직을 넣을 수도 있고, Request 객체를 검증하는 작업도 할 수 있다. 

출처 : http://www.egovframe.org/wiki/doku.php?id=egovframework:rte:ptl:dispatcherservlet

 

Interceptor를 사용했을 때의 장점은 아래와 같다

  • 공통 로직을 사용하는 경우 코드 누락에 대한 위험성 감수
  • 코드 재사용성 증가
    • 메모리 낭비, 서버 부하 감수 

 

어떠한 프로그램이든 공통 로직은 한 곳에서 관리하는 게 가장 좋다.

그런 면에서 Interceptor는 좋은 옵션을 제공한다. 

 

사용 예제

 

@Component
public class MyInterceptor implements HandlerInterceptor {

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		// do what you want .. 
		return true;
	}

	@Override
	public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
		// do what you want .. 
	}

	@Override
	public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
 		   Object handler, Exception ex)
			throws Exception {
		// do what you want .. 
	}

}

 

Interceptor를 구현하기 위해서는 "HandlerInterceptor"를 Implements 받으면 된다.

해당 Interface에는 아래의 3가지 메서드를 제공한다.

  • preHandler(...)
    • Controller가 호출되기 전에 실행된다
    • return 값은 boolean으로 해당 값이 false이면 Controller가 호출되지 않는다
  • postHandler
    • Controller가 호출 된 이후지만, View가 생성되기 전에 실행된다
    • ModelAndView 객체를 인자로 받아 View를 생성하기 전에 해당 객체의 정보를 조작할 수 있다
  • afterCompletion
    • View의 모든 작업이 완료된 후에 호출된다

 

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
	
	private final MyInterceptor interceptor;
    
	@Override
	public void addInterceptors(InterceptorRegistry registry) {

		registry.addInterceptor(interceptor).addPathPatterns("/**");
	}
	
}

 

작성한 Interceptor를 Spring에 등록하기 위해서는 WebMvcConfigurer를 Implements 하여서 코드를 추가해야 한다. 

 

WebMvcConfigurer 해당 Interface에 addInterceptors라는 메서드에서 추가해 주면 된다. 

또한 해당 Interceptor가 어떠한 조건일 때 실행되고, 실행되지 않는 등의 설정을 추가할 수 있다.

 

 

 

 

LIST

+ Recent posts