스프링 시큐리티 3.2.0.RC1 하이라이트: CSRF 방어

오래만에 공부좀 해볼까

이 글은 다음 원글을 참고, 번역, 편역, 요약한 글이오니 보다 정확한 정보 습득을 원하시는 분께서는 원문을 참고하시기 바랍니다: http://blog.springsource.org/2013/08/21/spring-security-3-2-0-rc1-highlights-csrf-protection/

스프링 시큐리티가 지난 월요일 3.2.0.RC1으로 버전이 올라갔는데 이번 글에서는 그 기능 중에 CSRF 지원 기능을 살펴보고자 한다. 다음글에서는 이번에 추가한 다양한 시큐리티 헤더를 살펴보겠다.

CSRF 공격

스프링 시큐리티가 CSRF 공격에 대한 방어책을 마련했다는데 CSRF 공격이 뭐고 그걸 어떻게 방어하겠다는걸까? 예제를 사용해 이해해보자.

여러분의 은행 웹 사이트가 현재 로그인한 사용자가 다른 은행으로 돈을 보낼 수 있는 폼을 제공한다고 가정해보자. 예를 들어 그 HTTP 요청은 다음과 같이 생겼다고 하자.

“`

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876

“`

이제 여러분이 은행 웹사이트에 인증을 하고나서 로그아웃하지 않고 다른 나쁜 웹사이트에 접속한다고 가정해보자. 그 나쁜 웹사이트는 다음과 같은 폼을 가진 HTML 페이지를 제공한다.

“`
<form action=”https://bank.example.com/transfer” method=”post”>
<input type=”hidden”
name=”amount”
value=”100.00″/>
<input type=”hidden”
name=”routingNumber”
value=”evilsRoutingNumber”/>
<input type=”hidden”
name=”account”
value=”evilsAccountNumber”/>
<input type=”submit”
value=”Win Money!’/>
</form>

“`

Win Money!를 하고 싶어서 서브밋 버튼을 클릭한 순간, 전혀 의도하지 않았던 100달러를 이상한 사람에게 보내게된다. 나쁜 사이트가 여러분의 쿠키를 보지 않더라도, 여러분 은행과 관련있는 쿠키가 요청을 따라 보내지기 때문에 이런 일이 발생한다.

더 안 좋은건 이 모든 절차를 자바스크립트로 자동화해서 실행할 수 있다는 것이다. 즉, 버튼을 클릭하지 않아도 이런 일이 생길 수 있다는 것이다. 그래서 이걸 어떻게 막았냐고?

SYNCHRONIZER TOKEN 패턴

은행 웹사이트의 HTTP 요청 폼과 나쁜 사이트의 요청 폼이 정확히 일치한다는 것이 문제다. 즉 나쁜 사이트에서 보내는 요청은 막으면서 은행에서 온 요청만 받을 방법은 없다는 뜻이다. CSRF 공격을 방어하려면 나쁜 사이트는 주지 못할 무언가가 요청 안에 들어있다는 것을 확인할 수 있어야 한다.

그런 대안 중 하나로 Synchronizer Token 패턴을 사용하는 방법이 있다. 이 방법은 모든 요청에 세션 쿠키와 더불어 랜덤하게 생성되는 토큰을 HTTP 파라메터로 제공하는 것이다. 요청이 오면, 서버는 반드시 그 토큰에 해당하는 값을 가져와서 요청에 있는 실제 값과 비교한다. 값이 맞지 않으면 그 요청은 실패 처리한다.

상태를 변경하는 HTTP 요청에서만 토큰을 사용하도록하여 이런 기대감을 조금은 완화할 수 있겠다. same origin policy로 인해 나쁜 사이트가 응답을 읽어가진 못할테니 그렇게 해도 비교적 안전하다. (즉, READ는 same origin policy로 막으니까 UPDATE만 sychronized token 패턴으로 막아도 된단 소리) 게다가, 랜덤 토큰을 HTTP GET에 넣는건 토큰이 누출될 가능성도 있다.

예제가 어떻게 바뀌는지 살펴보자. 랜덤하게 생성된 토큰을 _csrf라는 HTTP 파라메터로 넣는다고 가정하자. 가령, 송금 요청은 다음과 같다.

“`

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876&_csrf=<secure-random>

“`

임의의 값을 가진 _csrf 파라메터를 추가한걸 볼 수 있다. 이제 나쁜 사이트는 _csrf 파라메터에 적절한 값을 추측할 수 없으니 서버가 실제 토큰과 기대한 토큰을 비교해보고 송금 요청은 실패하게 된다.

스프링 시큐리티 CSRF 기능 사용하기

그래서 스프링 시큐리티로 웹 사이트를 CSRF 공격으로부터 방어하려면 뭘 해야하냐? 다음 단계를 통해 스프링 시큐리티 CSRF 방어를 사용할 수 있다.

  • 적절한 HTTP 동사 사용하기
  • CSRF 방어 설정하기
  • CSRF 토큰 추가하기

적절한 HTTP 동사 사용하기

CSRF 공격을 방어하는 첫걸음은 웹사이트가 적절한 HTTP 동사를 사용하고 있는지 확인하는 것이다. 구체적으로, 스프링 시큐리티 CSRF 기능을 사용하기 전에, 상태를 변경하는 요청일때 PATCH, POST, PUT, DELETE를 적절히 사용하고 있는지 확인해야한다. 이것은 스프링 시큐리티의 제약사항이 아니라 적절한 CSRF 방어에 기본으로 필요한 것이다.

CSRF 방어 설정하기

다음 단계는 스프링 시큐리티의 CSRF 방어를 애플리케이션에 추가하는 것이다.  XML 설정을 사용하고 있다면, <csrf/> 엘리먼트를 사용하면 된다.

“`

<http …>

<csrf />
</http>

“`

CSRF 방어는 자바 설정에서 기본으로 사용하게된다. 궁금해 하실 분들을 위해 보여주자면, 자바 설정은 다음과 같이 생겼다.

“`

@EnableWebSecurity
@Configuration
public class WebSecurityConfig extends
WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.and()
…;
}
}

“`

CSRF 토큰 추가하기

마지막 단계는 CSRF 토큰을 PATCH, POST, PUT, DELETE 메서드에 추가했는지 확인하는 것이다. _csrf 라는 요청 애트리뷰트를 사용해서 현재 CsrfToken을 가져와 사용할 수 있다. JSP에서 사용하는 예제는 다음과 같다.

“`

<c:url var=”logoutUrl” value=”/logout”/>
<form action=”${logoutUrl}”
method=”post”>
<input type=”submit”
value=”Log out” />
<input type=”hidden”
name=”${_csrf.parameterName}”
value=”${_csrf.token}”/>
</form>

“`

스프링 MVC가 제공하는 <form:form> 태그를 사용하면 CsrfRequestDataValueProcessor를 사용하여 CsrfToken이 자동으로 들어간다.

CSRF 주의할 것

CSRF를 적용할 때 몇가지 주의할 것이 있다.

타임아웃

기대했던 CSRF 토큰이 HttpSession에 저장되기 때문에 HttpSession이 만려되면  AccessDeniedHadnelr가 InvalidCsrfTokenException을 박데된다. 기본 AccessDeniedHandler를 사용하면 브라우저는 HTTP 403을 받게되고 조악한 에러 메시지를 보여줄 것이다.

실제 사용자 경험을 조금 더 완화 시킬 수 있는 간단한 방법으로는 자바스크립트를 사용하여 사용자에게 세션이 만료됐다는 사실을 사용자에게 알려주는 것이다. 사용자는 버튼을 클릭해서 세션을 “계속” 하거나 “갱신” 할 수 있다.

또 다른 대안으로는, 커스텀한 AccessDeniedHandler를 제공하여 InvalidCsrfTokenException을 원하는 방법으로 다루는 것이다. AccessDeniedHandler를 커스터마이징하는 방법은 XML이나 자바 설정으로 제공된 링크를 참고하도록 하자.

CSRF를 쿠키에 넣는건 어때?

다른 도메인이 쿠키를 세팅할 수 있기도 하고, 뭔가 위험해졌을 때 강제적으로 토큰을 제거하지도 못한다는 담점이 있다.

로그인

강제 로그인 요청을 방어하려면 로그인 폼도 CSRF 공겨을 방어해야한다. CsrfToken이  HttpSession에 저장되니까, HttpSession이 그 즉시 생성되야 한다는걸 뜻한다. 이 말은RESTful이나  stateless 아키텍처에는 안좋게 들리겠지만, 현실적으로 실질적인 보안책을 구현하려면 상태를 필요로 한다. 상태 없이는 토큰이 정상인지 확인할 방법이 없다. 현실적으로 말해서, CSRF 토큰은 매우 작은 크기이고 아키텍처에 끼치는 영향은 무시해도 될정도의 수준으로 그쳐야 한다.

로그아웃

CSRF를 사용하면 LogoutFilter가 HTTP POST만 사용하도록 바뀐다. 로그아웃할 때 CSRF 토큰을 사용하고 나쁜 사용자가 다른 사용자를 강제로 로그아웃 시킬 수 없게 한다.

한가지 방법은 로그아웃할 때 폼을 사용하는 것이다. 링크를 사용하고 싶다면 자바스크립트로 POST를 수행하는 링크를 사용하자(히든 폼으로). 자바스크립트를 사용하지 않는 브라우저에서는 선별적으로 POST 요청을 보내는 로그아웃 확인 페이지로 이동시킬 수 있겠다.

HiddenHttpMethodFilter

이 필터가 스프링 시큐리티 필터보다 먼저 등록되어 있어야 한다. In general this is true, but it could have additional implications when protecting against CSRF attacks.

Note that the HiddenHttpMethodFilter only overrides the HTTP method on a POST, so this is actually unlikely to cause any real problems. However, it is still best practice to ensure it is placed before Spring Security’s filters.

나머진 패스.

기본 설정 덮어쓰기

스프링 시큐리티의 목적은 사용자 폼이 노출되는 걸 막는 기본값을 제공하는 것이다. 그렇다고 해서 모두가 그 기본값을 사용하도록 강요하려는 것은 아니다.

가령, 커스텀한 CsrfTokenRepository를 제공하여 CsrfToken이 저장되는 곳을 변경할 수있다.

또한, 커스텀한 RequestMatcher를 설정하여 어떤 요청을 CSRF로 방어할지 결정할 수 있다(예를 들어, 로그아웃은 적용할 필요가 없다던지). 즉, 만약 스프링 시큐리티의 CSRF 방어가 여러분이 원하는 것과 정확히 맞지 않다면 여러분이 그 동작을 커스터마이징 할 수 있단 말이다.

결론

이제 CSRF가 뭔지 이해하고 스프링 시큐리티를 사용해서 애플리케이션을 CSRF 공격으로부터 어떻게 방어하는지 이해했을 것이다.

다음 글에서는 스프링 시큐리티의 헤더 기능을 사용해서 clickjacking 같은 공격으로부터 애플리케이션을 보호하는 방법을 살펴보겠다.

 

흥미진진 하구만~

[스프링 시큐리티&스프링 3.0] 팝업 로그인 처리용 핸들러 만들기

팝업 로그인을 사용할 때 로그인이 성공적이면 팝업이 떠있던 페이지로 가는게 자연스럽다. 그러나.. 스프링 시큐리티에서 사용자가 팝업을 쓸지 뭘 쓸지 어떻게 알겠는가.. 기본 설정을 그대로 사용하면 무조건 루트(/)로 이동한다. 인증이 필요한 페이지를 요청했을 경우에는 로그인이 끝난뒤에 자연스레 요청했던 페이지로 이동해주긴 하지만 팝업은 사용자가 의도적으로 해당 페이지에서 로그인을 눌렀을 경우기 때문에 적절한 처리를 기대할 수 없다.

1. 우선 팝업 로그인 창을 띄우는 컨트롤러를 만들자.

@RequestMapping("/loginpopup")
public void loginpopup(@RequestHeader("Referer") String from, Model model){
    model.addAttribute("_to", from);
}

요기서 재밌는 스프링 @MVC 기능을 쓰고 있는데 바로 @RequestHeader다. 요청을 보내면 헤더 정보에 어디서 온 요청인지 알려주는 Referer 헤더가 들어있다. 팝업 로그인을 요청했던 그 URL이 바로 저기에 담겨오게 되고 나중에 로그인을 처리했을 때 저 URL로 이동하게 해줘야한다.

2. 팝업 로그인 뷰 파일

<input type="hidden" name="_to" value="${_to}" >

이렇게 히든 필드를 추가한다. 이 정보를 로그인 핸들러에서 참조할 수 있게 해주기 위해서..

3. 로그인 Success 핸들러 구현하기

public class PopupLoginSucessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    @Override
    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response) {
        String s = request.getParameter("_to");
        System.out.println(s);
        if(s != null)
            return s;
        else
            return super.determineTargetUrl(request, response);
    }
}

인증이 필요한 요청을 했을 때 로그인 창으로 이동했다가 원래 요청했던 페이지로 이동시켜주는 핸들러가 바로 SavedRequestAwareAuthenticationSuccessHandler다. 따라서 이 기능을 살리면서 내가 원하는 부가 기능을 추가하기 위해 이녀석을 상속했고, 필요한 부분인 타겟 URL 설정 하는 메서드를 재정의했다. 코드 내용은 간단하니까.. 설명 패스..

4. 핸들러 등록

<beans:bean id="popupLoginSucessHandler" class="springsprout.service.security.PopupLoginSucessHandler"/>

<form-login login-page="/login"    authentication-success-handler-ref="popupLoginSucessHandler"/>

이렇게 연결해주면 된다.

끝…

[Spring Security] http 네임스페이스 쓰려면 필터 이름은 항상 고정(?)

http://static.springsource.org/spring-security/site/docs/3.0.x/reference/appendix-namespace.html#nsa-http-attributes

<filter>
<filter-name>securityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
   <filter-mapping>
       <filter-name>securityFilterChain</filter-name>
       <url-pattern>/*</url-pattern>
   </filter-mapping>
    
자 이렇게 필터를 설정하고 
<http>
        <intercept-url pattern=”/base/color/mgt” access=”ROLE_USER” />
        <intercept-url pattern=”/**” access=”IS_AUTHENTICATED_ANONYMOUSLY” />
<form-login login-page=”/login” />
<logout logout-success-url=”/index” />
<remember-me />
</http>
    <beans:bean id=”smdisUserDetailsService” class=”smdis.common.security.SmdisUserDetailsService”/>
<authentication-manager alias=”authenticationManager”>
<authentication-provider user-service-ref=”smdisUserDetailsService”/>
</authentication-manager>
<global-method-security secured-annotations=”enabled”
jsr250-annotations=”enabled” pre-post-annotations=”enabled” />
이렇게 시큐리티 설정을 했다.
잘 돌아갈까?? 안 돌아간다.. 시큐리티 네임스페이스를 사용해서 <http>를 등록하면 FileChainProxy 빈 이름은 항상 springSecurityFilterChain이 된다. 그래서 필터 이름을 springSecurityFilterChain으로 설정해줘야 한다.
뭐.. DelegatingFilterProxy 필터 자체에 targetBeanName 속성을 사용해서 연결할 빈 이름을 설정할 수도 있지만 기본적으로 이 이름은 필터 이름을 따르게 된다. 필터 이름을 바꾸고 targetBeanName을 또 설정해 주느니 그냥 필터 이름을 springSecurityFileterChain으로 하는게 좋겠다.

<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
    <filter-mapping>
        <filter-name>springSecurityFilterChain</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

[Spring Security] sec:authentication

참조: http://static.springsource.org/spring-security/site/docs/3.0.x/reference/taglibs.html

스터디의 각 모임에는 댓글을 달 수 있습니다. 삭제 기능이 필요해져서 댓글 삭제 기능을 만들었는데 이것을 일반적인 User-Role 기반 Voter를 사용해서 권한 처리하는것이 그렇게 단순하지는 않습니다. 
관리자 권한이 있는 사람만 삭제할 수 있게 할까요? 아님 회원 권한을 가진 사람은 누구나 삭제하게 할까요? 비즈니스 룰이야 정하기 나름이지만 그렇게 무식하게 정하고 싶지는 않았습니다. 작성자만 자신이 작성한 댓글을 삭제하는 것이 가장 정당해 보입니다.
먼저 문제가 되는 부분은 뷰. 삭제 버튼을 감춰야 합니다.
<sec:authentication property=”principal.username” var=”currentUserName”/>
<c:if test=”${currentUserName == comment.writer.email}”>
//감출 버튼
</c:if>
이렇게 authenrication 태그를 사용해서 principal 객체와 그 속성값에 접근할 수 있습니다. 현재 로그인 되어있는 사용자의 username을 가져와서 현재 댓글 작성자의 email과 비교합니다. 봄싹에서는 email을 아이디로 사용하고 있기 때문에 이렇게 비교합니다. 그 둘이 일치하는 경우에만 버튼을 보여줍니다. 
참 쉽죠;
하지만… 버튼을 감춘다고 다가 아니죠. URL 보내면 보내집니다. 웹 보안으로는 위에서 언급했듯이 힘듭니다. 이럴 때는 서비스 쪽에서 객체 정보와 pricipal 정보를 기반으로 권한 처리를 해주면 되겠습니다. 그건 다음에.

[스프링 시큐리티 3.0] @PreAuthorize

    <global-method-security secured-annotations=”enabled”
        jsr250-annotations=”enabled” pre-post-annotations=”enabled” />

시큐리티 설정 파일에 위와 같이 설정하면 @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter를 사용할 수 있습니다.

이들 애노테이션에서는 스프링 EL을 사용해서 현재 사용자 정보에 접근하거나, (pre 인 경우)메서드의 인자값 또는 (post 인 경우)메서드의 반환값의 정보에 접근할 수 있습니다.

    @PreAuthorize(“(#study.manager.email == principal.Username) or hasRole(‘ROLE_ADMIN’)”)
    public void updateStudy(Study study) {
        repository.update(study);
    }

위 예제는 다음 주에 오픈 할 봄싹 프로젝트에서 사용하고 있는 코드입니다. Study를 수정하려는 사람이 관리자이거나, 스터디를 만든 사람인지 확인한 뒤에 메서드를 실행합니다. 만약 해당 EL이 false로 판단되면 Access Dinied 에러를 던져줍니다.

애노테이션을 메서드에만 붙이지 않고 클래스에도 붙여서 클래스에 정의한 모든 메서드에 적용할 수도 있습니다. 이런식으로요.

@Service
@Transactional
@PreAuthorize(“hasRole(‘ROLE_USER’)”)
public class StudyService {

}