'Spring Security'에 해당되는 글 2건

  1. 2019.04.29 Spring Security CSRF 프로텍션 4
  2. 2019.03.10 Spring Security 단위테스트 2
Java/Spring2019. 4. 29. 01:18
반응형

Spring Security로 CSRF 프로텍션 적용

최근의 Java기반의 웹 프로젝트는 대부분 Spring(스프링) 프레임워크 기반으로 구현되기 때문에 자연스럽게 Spring Security를 이용하여 보안관련 기능들을 구현하게 됩니다. 그중 CSRF(Cross Site Request Forgery : 사이트 간 요청 위조)는 개발중에는 매우 귀찮은 존재입니다. 웹에서 CSRF 프로텍션은 보안 대책으로 거의 필수적으로 요구되지만 개발 편의성 문제로 개발중에는 전혀 신경쓰지 않거나 비활성화해두는 경우가 많이 있습니다. 결국에는 보안점검을 통해 지적받고 일괄적으로 소스코드를 변경합니다. 하지만 급하게 고친 코드는 항상(?) 문제를 일으킵니다. 또한 운영중에도 잘못된 수정으로 배포후에 예외를 만나는 경우가 많이 있습니다.

CSRF 프로텍션 활성화

먼저 다들 아시는 내용이겠지만 복습차원에서 Spring Security에서 CSRF 기능을 활성화 시키는 방법에 대해서 알아보겠습니다.

dependencies {
    //...
    compile group: 'org.springframework.security', name: 'spring-security-config', version: '5.1.5.RELEASE'
    compile group: 'org.springframework.security', name: 'spring-security-web', version: '5.1.5.RELEASE'
    //...
    //compile('org.springframework.boot:spring-boot-starter-security') - starter를 이용하는 경우
}

위와같이 Spring Security 의존성을 추가하거나 Spring Boot를 사용하는 경우는 Starter를 통해서 의존성을 추가할 수 있습니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...Some Configuration
        http.csrf();
        //...Other Configuration
    }
}

설정방법은 위와 같지만 @EnableWebSecurity에 의해서 Spring Security가 설정되면 기본적으로 CSRF 프로텍션은 활성화되므로 http.csrf(); 이 설정은 사실상 필요없습니다만 설정방식을 명시적으로 보여주는 차원에서 위와같이 기술했습니다.

Spring Security의 CSRF 프로텍션은 Http 세션과 동일한 생명주기(Life Cycle)을 가지는 토큰을 발행한 후 Http 요청(PATCH, POST, PUT, DELETE 메소드인 경우)마다 발행된 토큰이 요청에 포함되어(Http헤더 혹은 파라메터 둘중 하나) 있는지 검사하는 가장 일반적으로 알려진 방식의 구현이 설정되어 있습니다.

Spring Security에서는 토큰의 저장소로 Cookie를 사용하는 방식도 아래의 예와같은 설정을 통해서 지원하고 있습니다. 이 경우에는 Http헤더 혹은 파라메터를 통해 전송된 토큰을 쿠키를 통해서 전송된 토큰과 검증하는 방식으로 동작하게 됩니다. API서버 등과 같이 세션을 사용하지 않는 Stateless한 경우에는 어쩔수 없이 채택하는 방식이긴 합니다만 일반적으로 세션의 속성으로 토큰을 보관하는 방식이 좀더 안전하다고 알려져 있으므로 추천드리지 않습니다. 자세한 내용을 설명하는 것은 이 글의 범위를 벗어나므로 간단하게 설명하면 Cookie를 저장소로 사용할 경우 Cookie에 토큰이 주입되거나 토큰의 유출이 발생할 경우 토큰의 무효화를 할 수 없는 등의 취약점이 있습니다. 더 자세한 내용은 Spring Security의 문서를 참조하시기 바랍니다.

예) http.csrf().csrfTokenRepository(new CookieCsrfTokenRepository());

CSRF 토큰의 구조

public interface CsrfToken extends Serializable {
    String getHeaderName();
    String getParameterName();
    String getToken();
}

위와같이 Http헤더의 헤더명과 파라메터명 그리고 토큰으로 구성되어 있습니다. Http헤더를 사용할 경우는 헤더명을 키로 토큰을 값으로 전송하면 되고 파라메터로 전송할 경우는 파라메터명을 키로 역시 토큰을 값으로 전송하면 됩니다.

CSRF 토큰을 Http요청에 포함시키는 방법

CSRF토큰은 Http 세션과 동일한 생명주기를 가지기 때문에 세션속성으로 저장되게 됩니다. Spring Security의 기본구현은 Http 세션에 org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN 와 같이 다소 긴이름으로 저장되기 때문에 세션을 직접 참조하는 방식을 사용하기가 곤란합니다. 따라서 Spring Security는 해당 토큰을 Http요청마다 '_csrf' 라는 간단한 이름의 Http Request 속성으로 바인딩시켜 사용성을 올려 줍니다.

1. Form의 파라메터

위에서 설명한 바와 같이 Http Request 속성에 바인딩 되어 있기때문에 el표현식을 사용할 수 있는 템플릿 엔진을 사용중이라면 간단하게 Form의 파라메터로 추가할 수 있습니다.

<!-- Some HTML -->
<form action="/some-uri" method="post">
  <input type="text" name="some" value="thing" />
  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
  <input type="submit" />
</form>
<!-- Other HTML-->

위와같이 '_csrf' 라는 Http Request 속성을 통해서 파라메터 명과 토큰을 취득하여 Form의 숨겨진 요소로 추가하면 간단하게 포함 시킬 수 있습니다.

Spring MVC에서 제공하는 <form:form> 태그를 사용하거나 Thymeleaf 2.1이상이면서 @EnableWebSecurity를 사용한 경우에는 CsrfRequestDataValueProcessor가 적용되어 필요한 form에 자동으로 CSRF 토큰이 포함되게 됩니다.

2. Ajax 요청시 헤더

사용자 경험을 향상시키기 위해서 화면이동을 제외한 처리는 대부분 Ajax를 통한 JSON 요청으로 처리하는 경우가 많습니다. 이경우 위와같이 파라메터로 토큰을 전송할 수 없으므로 Http 헤더를 통해서 전송할 수 있습니다.

$.ajaxPrefilter(function (options) {
  var headerName = '${_csrf.headerName}';
  var token = '${_csrf.token}';
  if (options.method === 'POST') {
      options.headers = options.headers || {};
      options.headers[headerName] = token;
  }
});

위의코드는 jQuery를 사용하는 경우 모든 Ajax Post 요청에 대해 Http 헤더에 CSRF 토큰을 설정하는 코드 입니다. 혹시 초반에는 CSRF설정을 무효화 하고 구현하는 경우에는 Ajax요청을 특정 라이브러리로 단일화 하여 구현하기를 추천드립니다. 그럴 경우에는 위처럼 단순한 설정만으로도 모든 요청에 CSRF 토큰을 추가하는 것이 가능하므로 소스코드 수정을 최소화 할 수 있습니다.

그 이외의 설정

비활성화

CSRF 프로텍션을 비활성화 시키고 싶은경우에는(추천하지 않지만) 아래와 같이 disabled()를 호출하면 됩니다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.csrf().disabled();
    //...Other Configuration
}

특정 요청만 예외처리

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.csrf()
        .ignoringAntMatchers()
        .ignoringRequestMatchers();
    //...Other Configuration
}

Ant Matcher 혹은 Request Matcher를 지정하여 특정 요청은 대상에서 제외할 수 있습니다. 예를들어 외부에서 오는 요청이나 콜백의 경우 제외해야할 필요가 있는 경우가 있습니다.

특정 요청은 적용

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.csrf()
        .requireCsrfProtectionMatcher();
    //...Other Configuration
}

그다지 쓸일이 있어보이지는 않습니다만 특정 요청은 적용되어야 할 경우에는 위의 설정을 사용할 수 있습니다. 예를들어 기본적으로 제외되어있는 GET, HEAD, TRACE, OPTIONS 메소드중 일부 요청을 추가할 수 있을 것 같습니다.

예외처리

토큰검증에 실패 했을경우 403 Http 응답코드가 반환됩니다. 이때 이동할 페이지 혹은 예외처리를 위와같은 설정을 통해 할 수 있습니다. 세션이 만료되어 서버에서 토큰정보를 취득할 수 없는 경우에는 MissingCsrfTokenException 예외가 발생하고 전송된 토큰이 다른 경우에는 InvalidCsrfTokenException 예외가 발생하므로 필요에 따라 적절한 예외처리를 기술하면 되겠습니다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    //...Some Configuration
    http.exceptionHandling()
        .accessDeniedPage("/denied.html")
        .accessDeniedHandler(new AccessDeniedHandler() {
            @Override
            public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException, ServletException {
                if (exception instanceof MissingCsrfTokenException) {
                    //Some Exception Handling
                } else if (exception instanceof InvalidCsrfTokenException) {
                    //Some Exception Handling
                }
        });
    //...Other Configuration
}

이상으로 Spring Security를 통해 CSRF 프로텍션을 적용하는 방법에 대해서 알아보았습니다. 위에서 언급한대로 CSRF 프로텍션은 웹 보안에있어 거의 필수로 요구되므로 가급적 개발초기부터 고려해서 빠지지 않게 적용되도록 합시다.

참고자료

2014/07/14 - [Security] - CSRF(Cross Site Request Forgery)

Spring Security 5.0.x - 19. Cross Site Request Forgery

Posted by Reiphiel
Java/Spring2019. 3. 10. 23:26
반응형

 Spring Security(스프링 시큐리티)를 사용하는 경우 단위테스트를 하는 방법을 알아보겠습니다. 

※이글에서 소개하는 내용은 Spring Security 4.1에서 추가된 내용입니다.


 Spring Security는 SecurityContext를 통해서 현재 실행중인 스레드와 연관된 인증정보를 관리합니다. 따라서 테스트 실행시에 SecurityContextHolder를 통해서 인증정보를 설정하면 로그인된 상태등의 인증관련 테스트가 가능합니다.


    //...
    @Before
    public void setUp() {
        SecurityContextHolder.getContext()
            .setAuthentication(new UsernamePasswordAuthenticationToken("username", "password", Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
    }
    //...

※위와 같이 단위테스트 실행전에 인증정보를 설정하면 로그인된 상태로 테스트가 가능합니다. 하지만 위와 같은 코드는 역시 스프링 스럽지 못하다는 왠지 느낌적인 느낌이 듭니다.



 Spring Security 4.1부터 아래와 같은 테스팅 피처가 지원되기 시작했습니다.


  • @WithAnonymousUser - 익명유저의 인증정보를 설정하기 위한 어노테이션
  • @WithUserDetails - UserDetailsService를 통해서 유저정보를 취득하여 설정하기 위한 어노테이션(4.0부터 지원하고 4.1부터 Spring빈의 이름을 지정할 수 있도록 추가됨)
  • @WithMockUser - 별도의 UserDetailsService와 같은 스텁을 제공하지 않아도 간단하게 인증정보를 설정하기 위한 어노테이션(4.0부터 지원되었으나 4.1부터 작동하는것으로 보임)



그럼 각 어노테이션들의 사용법에 대해서 간단하게 알아보도록 하겠습니다.



 첫번째로 @WithAnonymousUser 의 경우 인증되지 않은 상태를 설정하기 위한 어노테이션입니다. 인증하지 않는 경우를 나타내는 어노테이션이 왜 필요할까하지만 위에서 언급한것 처럼 Spring Security는 현재 실행중인 스레드에 연관지어 인증정보를 관리하므로 한번 인증정보를 설정해서 테스트를 진행한 경우에 인증상태를 초기화 시키는데 필요합니다.

    //...
    @Test
    @WithAnonymousUser
    public void some_test() {
        ...
    }
    //...



 두번째로 @WithUserDetails 의 경우는 Spring Security에서 유저정보를 조회하기 위해서 사용하는 UserDetailsService를 이용하여 인증정보를 설정하는 어노테이션입니다. 


    //...
    @Bean
    @Profile("test")
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                return User
                        .withUsername(username)
                        .password("password")
                        .authorities(new SimpleGrantedAuthority("ROLE_USER"))
                        .build();
            }
        };
    }
    //...
 위와같이 테스트용 UserDetailsService빈을 등록하여 사용합니다.
    //...
    @Test
    @WithUserDetails("test_user")
    public void some_test() {
        ...
    }
    //...



 세번째로 @WithMockUser는 단순하게 username과 role정도만 설정하면 인증정보를 설정해주는 어노테이션입니다. 별다른 설정을 추가하지 않고도 사용할 수 있어서 매우 편리할 듯 합니다.


    //...
    @Test
    @WithMockUser(username = "username", roles = "USER")
    public void some_test() {
        ...
    }
    //...


 대부분의 경우는 위의 어노테이션을 이용하면 테스트가 가능할 것으로 생각되지만 인증주체에 관한정보를 UserDetails를 사용하지 않는 경우에는 별도의 커스텀 어노테이션을 만들어서 사용할 수 있습니다.


@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithCustomMockUser {

    String userNo() default "1";

    String userId() default "user";

    String name() default "name";

}
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithCustomMockUser> {
    @Override
    public SecurityContext createSecurityContext(WithCustomMockUser user) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        CustomUser principal = new CustomUser(Long.valueOf(user.memberNo()), user.userId(), user.name());
        Authentication auth = new UsernamePasswordAuthenticationToken(principal, "password", Arrays.asList(new SimpleGrantedAuthority("ROLE_MEMBER")));
        context.setAuthentication(auth);
        return context;
    }
}

 위와같이 커스텀 어노테이션을 만든 후 기본 어노테이션과 동일한 방법으로 사용하면 커스텀 인증정보가 설정된 상태로 테스트를 진행할 수 있습니다.

    //...
    @Test
    @WithCustomMockUser
    public void some_test() {
        ...
    }
    //...


 또한 아래와 같이 어노테이션을 정의하여 반복되는 설정을 간소화 할 수도 있습니다.

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value="admin",roles="ADMIN")
public @interface WithCustomMockAdmin {
}
//...
@Test
@WithCustomMockAdmin
public void some_test() {
    ...
}
//...



 기존에는 로그인 상태를 테스트 할때 장황한 코드가 필요했었는데 Spring Security에서 제공하는 테스팅 피처를 이용하면 간단하게 로그인 상태 별로 테스트가 가능하므로 어플리케이션을 좀더 안전한상태로 개발할 수 있을것 같습니다.




Spring Security 레퍼런스

Posted by Reiphiel