SMALL

Spring Security에서 Annotation 형태로 Class, Method 레벨 단위로 권한별 접근제어 기능을 제공한다.

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

 

그중에서 @PreAuthorize, @PostAuthorize Annotation의 경우에는 SpringEL에서 제공하는 Expression 이외에도 사용자가 정의한 Custom Expression(Method)를 호출할 수 있는 기능을 제공한다.

해당 기능에 대해서 알아보도록 하자.

 

@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 );
}

우선 위의 예제와 같이 isAuthenticated, hasRole과 같은 Spring EL에서 기본제공하는 기능을 활용할 수도 있다. 

 

다만, 사용자가 원하는 기능이 기본으로 제공하는 기능에 존재하지 않는 경우에는 문제가 될 수 있다.

이러한 경우를 커버하기 위해서 Spring Security에서는 Custom Expression을 사용할 수 있는 기능을 열어 두었다.

 

 

Component 생성

먼저, Custom Expression(Method)를 정의할 Component를 생성한다. 

 

// @Service
@Component("loginService")
public class LoginService {
	public boolean pageReadRoleCheck() {
		return true;
	}
}

 

@PreAuthorize, @PostAuthorize Annotation에서 사용

@GetMapping("/api/v1/projects")
@PreAuthorize("@loginService.pageReadRoleCheck()")
public List<ProjectResponseDto> getProjectList() {
	return projectService.findAll();
}

 

위와 같이 간단하게 Custom Expression을 Spring Security Annotation에서 사용이 가능하다.

 

Param 정보 전달

추가적으로, Custom Expression에 필요한 정보를 Param으로 넘길 수도 있다.

#pj와 같이 사용하여 Annotation이 붙은 Method의 Param 정보도 전달 할 수 있다.

// @Service
@Component("loginService")
public class LoginService {
	public boolean pageReadRoleCheck(String id) {
		return true;
	}
}
/////
@GetMapping("/api/v1/projects")
@PreAuthorize("@loginService.pageReadRoleCheck(#pj.id)")
public List<ProjectResponseDto> getProjectList(Project pj) {
	return projectService.findAll();
}

 

returnObject

@PostAuthorize의 경우에는 해당 Method의 수행 결과 값도 전달 할 수 있다. 

returnObject라는 예약어를 사용하면 된다.

@PostAuthorize("@loginService.pageReadRoleCheck(returnObject.name)")
@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 {
	@Override
	protected void configure(HttpSecurity http) throws Exception {
        //.....
   }
}

 

LIST
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

+ Recent posts