본문 바로가기
관리자

Programming-[Backend]/Spring Security

[스프링 시큐리티] 17. Ajax 인증 구현 - DSL, CSRF

728x90
반응형

1. DSL

DSL(Domain-specific language, 도메인 특화 언어)는 특정화된 도메인을 적용하는데 특화된 언어이다. HTML과 같이 웹 전체에서 널리 쓰이는 언어의 개념이 아니라 특정한 도메인에서만 쓰이는 언어라고 보면 된다.

 

지금 학습하고 있는 맥락에서 이해하자면, filter나 handler 등의 http. 환경설정 메서드의 set를 만든다고 생각하면 될 것 같다. spring security를 전반적으로 다 이해하고 환경설정 set를 만들어서 활용할 정도가 되어야 사용할 것 같다. 그래서 이 부분은 가볍게 이해하고 넘어가면 될 것 같다.

 

 

AbstractHttpConfigurer

초기화를 위한 설정 클래스이다. 이 클래스를 이용해서 스프링 시큐리티의 환경설정 set를 만든다. configs 패키지에 AjaxLoginConfigurer 클래스를 추가한다.

강의에서 나온 코드는 아래와 같다.

 

public final class AjaxLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
        AbstractAuthenticationFilterConfigurer<H, AjaxLoginConfigurer<H>, AjaxLoginProcessingFilter> {

    private AuthenticationSuccessHandler successHandler;
    private AuthenticationFailureHandler failureHandler;
    private AuthenticationManager authenticationManager;

    public AjaxLoginConfigurer() {
        super(new AjaxLoginProcessingFilter(), null);
    }

    @Override
    public void init(H http) throws Exception {
        super.init(http);
    }

    @Override
    public void configure(H http) {

        if(authenticationManager == null){
            authenticationManager = http.getSharedObject(AuthenticationManager.class);
        }
        getAuthenticationFilter().setAuthenticationManager(authenticationManager);
        getAuthenticationFilter().setAuthenticationSuccessHandler(successHandler);
        getAuthenticationFilter().setAuthenticationFailureHandler(failureHandler);

        SessionAuthenticationStrategy sessionAuthenticationStrategy = http
                .getSharedObject(SessionAuthenticationStrategy.class);
        if (sessionAuthenticationStrategy != null) {
            getAuthenticationFilter().setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
        }
        RememberMeServices rememberMeServices = http
                .getSharedObject(RememberMeServices.class);
        if (rememberMeServices != null) {
            getAuthenticationFilter().setRememberMeServices(rememberMeServices);
        }
        http.setSharedObject(AjaxLoginProcessingFilter.class,getAuthenticationFilter());
        http.addFilterBefore(getAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    public AjaxLoginConfigurer<H> successHandlerAjax(AuthenticationSuccessHandler successHandler) {
        this.successHandler = successHandler;
        return this;
    }

    public AjaxLoginConfigurer<H> failureHandlerAjax(AuthenticationFailureHandler authenticationFailureHandler) {
        this.failureHandler = authenticationFailureHandler;
        return this;
    }

    public AjaxLoginConfigurer<H> setAuthenticationManager(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        return this;
    }

    @Override
    protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
        return new AntPathRequestMatcher(loginProcessingUrl, "POST");
    }

}

 

 

HttpSecurityBuilder, AbstractAuthenticationFilter

우선 이 두 클래스를 상속받으면서, 각종 알 수 없는 Generic 타입을 이용한다. 지금 수준에서 이해하긴 어려울 것 같다.

 

configure 메서드에서 getAuthenticationFilter 메서드를 통해서 인증 성공, 실패 시 handler를 지정해주었다. 그리고 getSharedObject 메서드를 통해서 AuthenticationManager, Session, RememberMe 객체를 넣어주는 것을 알 수 있다. 제네릭으로 되어있어서 아직은 무슨 내용인지 알기가 어려운데, 아마 메인이 되는 SecurityConfig 파일에 전달할 객체를 지정하는 것 같다.

 

 

SecurityConfig 파일 수정

 

이제 작성한 Configurer 객체를 SecurityConfig에 넣어주면 된다.

 

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .antMatcher("/api/**")
                .authorizeRequests()
                .antMatchers("/api/messages").hasAuthority("MANAGER")
                .anyRequest().authenticated()
//                .and()
//                .addFilterBefore(ajaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) //기존 필터 앞에 위치할때 사용하는 method
        ;

        http
                .exceptionHandling()
                .authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint())
                .accessDeniedHandler(new AjaxAccessDeniedHandler())
                ;

        http.csrf().disable(); //기본적으로 csrf 토큰값을 들고 요청을 해야되는데, 임시적으로 off 한다.

        customConfigurerAjax(http);
    }

    private void customConfigurerAjax(HttpSecurity http) throws Exception {
        http
                .apply(new AjaxLoginConfigurer<>())
                .successHandlerAjax(ajaxAuthenticationSuccessHandler())
                .failureHandlerAjax(ajaxAuthenticationFailureHandler())
                .setAuthenticationManager(authenticationManagerBean())
                .loginProcessingUrl("/api/login")
        ;
    }

 

customConfigurerAjax 메서드를 만들고, http .apply 메서드를 통해 Configurer를 연결시켜 주었다. 그리고 여기서 success, failure, AuthenticationManager 및 로그인 url 설정을 통해서 기존 ajaxFilter가 하던 기능을 위임시켜주었다. 실제 원래 사용하던 ajaxLoginProcessingFilter Bean은 주석처리 해줘도 정상작동한다.

 

 


 

2. 로그인 Ajax 구현 & CSRF

 

이 글 시리즈의 8편 CSRF에서 CSRF는 클라이언트가 서버에 요청을 보낼 때마다 발급되고, 클라이언트도 이 값을 hidden 태그 형태로 갖고 있다가 요청에 실어서 보낸다고 배웠었다.

 

이번 장에서는 클라이언트 페이지에 이 csrf 값을 담고 서버로 보내주는 형태를 직접 만들어본다. 이전 글에서 언급한대로 csrf Filter는 disable 처리를 하지 않는 한 자동으로 작동된다. 따라서 클라이언트측에서 csrfHeader에 csrfToken을 넣어주어야 한다. login.html, home.html 파일을 아래와 같이 수정한다.

 

login.html 파일

 

Body 부분

 

form 태그 내부의 맨 아래 button 태그가 onclick 이벤트가 달려서 formLogin() 메서드를 호출하는 것을 볼 수 있다. 여기서 csrf 관련 처리를 하게 된다. 이 부분은 아래 header 및 script 태그 부분에 기술한다.

<body>
<div th:replace="layout/top::header"></div>
<div class="container text-center">
    <div class="login-form d-flex justify-content-center">
        <div class="col-sm-5" style="margin-top: 30px;">
            <div class="panel">
                <p>아이디와 비밀번호를 입력해주세요</p>
            </div>
            <div th:if="${param.error}" class="form-group">
                <span th:text="${exception}" class="alert alert-danger">잘못된 아이디나 암호입니다</span>
            </div>
            <form th:action="@{/login_proc}" class="form-signin" method="post">
                <input type="hidden" th:value="secret" name="secret_key" />
                <div class="form-group">
                    <input type="text" class="form-control" name="username" placeholder="아이디" required="required" autofocus="autofocus">
                </div>
                <div class="form-group">
                    <input type="password" class="form-control" name="password" placeholder="비밀번호" required="required">
                </div>
                <button type="submit" onclick="formLogin()" id="formbtn" class="btn btn-lg btn-primary btn-block">로그인</button>
                <!--<button type="submit" class="btn btn-lg btn-primary btn-block">로그인</button>-->
            </form>
        </div>
    </div>
</div>
</body>
</html>

 

header 및 script 태그

 

meta 태그를 이용해서 csrf 정보를 기본 정보로 담도록 처리한다. 서버에서 지정하는 csrf의 headerName의 기본 값(8. 위조 방지 글에서 배움) 으로 "_csrf"의 token과 headerName을 가져와서 th:content로 매핑해주었다.

 

그리고 formLogin 메서드에서는 csrfHeader, csrfToken 변수를 meta 태그에 입력된 content 값을 받아오도록 하였고, $.ajax 문법을 통해 ajax 요청으로 서버에 이 csrf값을 담아서 보내도록 설정해준다. setRequestHeader로 헤더값에 csrf 값을 넣고, 요청 성공과 실패 시 각각 success, error 에서 처리하도록 지정하였다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<meta id="_csrf" name="_csrf" th:content="${_csrf.token}"/>
<meta id="_csrf_header" name="_csrf_header" th:content="${_csrf.headerName}"/>

<head th:replace="layout/header::userHead"></head>
<script>
    function formLogin(e) {

        var username = $("input[name='username']").val().trim();
        var password = $("input[name='password']").val().trim();
        var data = {"username" : username, "password" : password};

        var csrfHeader = $('meta[name="_csrf_header"]').attr('content')
        var csrfToken = $('meta[name="_csrf"]').attr('content')

        $.ajax({
            type: "post",
            url: "/api/login",
            data: JSON.stringify(data),
            dataType: "json",
            beforeSend : function(xhr){
                xhr.setRequestHeader(csrfHeader, csrfToken);
                xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
                xhr.setRequestHeader("Content-type","application/json");
            },
            success: function (data) {
                console.log(data);
                window.location = '/';

            },
            error : function(xhr, status, error) {
                console.log(error);
                window.location = '/login?error=true&exception=' + xhr.responseText;
            }
        });
    }
</script>

 

home.html 파일도 login.html 파일과 거의 유사하게 작성되었다. 따로 볼 필요는 없을 것 같다.

 

Elements 일부

home.html 파일을 예로 브라우저의 Elements 부분을 보면, meta 태그와 script 내용이 잘 들어가 있는 것을 확인할 수 있다.

 

 

CSRF Filter 동작

 

이제 CsrfFilter 파일의 doFilterInternal 부분에 break point를 걸고 요청을 보내면 요청 시마다 debugging에 걸리는 것을 확인할 수 있다.

 

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    request.setAttribute(HttpServletResponse.class.getName(), response);
    CsrfToken csrfToken = this.tokenRepository.loadToken(request);
    boolean missingToken = csrfToken == null;
    if (missingToken) {
        csrfToken = this.tokenRepository.generateToken(request);
        this.tokenRepository.saveToken(csrfToken, request, response);
    }

    request.setAttribute(CsrfToken.class.getName(), csrfToken);
    request.setAttribute(csrfToken.getParameterName(), csrfToken);
    if (!this.requireCsrfProtectionMatcher.matches(request)) {
        filterChain.doFilter(request, response);
    } else {
        String actualToken = request.getHeader(csrfToken.getHeaderName());
        if (actualToken == null) {
            actualToken = request.getParameter(csrfToken.getParameterName());
        }

        if (!csrfToken.getToken().equals(actualToken)) {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));
            }

            if (missingToken) {
                this.accessDeniedHandler.handle(request, response, new MissingCsrfTokenException(actualToken));
            } else {
                this.accessDeniedHandler.handle(request, response, new InvalidCsrfTokenException(csrfToken, actualToken));
            }

        } else {
            filterChain.doFilter(request, response);
        }
    }
}

 

메서드 내 3번째 줄에서 tokenRepository.loadToken 메서드를 통해 token 정보를 불러온다.

 

 

그리고 request.setAttibute 부분에서 request 내부(여러번 들어가야함)의 attributes에 "_csrf" 속성으로 token 값을 set 해준다.

 

 

 

requireCsrfProtectionMatcher

 

csrfFilter의 doInternalFilter 메서드의 아랫 부분만 다시 살펴보자. 여기서 requireCsrfProtectionMatcher 이 요청의 url 정보와 기존 url 정보를 비교한다. 이 부분은 클라이언트에서 csrf Header 값을 실어서 보내는 경우 success시와 fail시에 이동하는 url과 최초 요청의 url을 비교한다.

 

예를 들어 위에 작성한 login.html의 경우 로그인 버튼을 눌러 submit 요청 시 "/api/login"으로 요청이 가면서 csrf 필터를 거쳐서 csrfToken을 발급받고, 브라우저에서 token값을 들고 있는 상황이 된다. 그런데 formLogin 메서드에서 success로 정의한 부분은 첫 번째 요청 성공 시, 다시 windows.location("/")을 통해 다시 홈 주소로 이동하게 하였으므로 한 번 더 csrfFilter를 호출하게 된다. 이때는 처음 요청의 "/api/login" 부분과 "/" 부분의 URL이 다르므로 requireCsrfProtectionMatcher가 url이 다른 것을 인지하고 과연 브라우저가 보낸 csrfToken이 기존과 일치하는지 검사하는 것이다.

 

 if (!this.requireCsrfProtectionMatcher.matches(request)) {
        filterChain.doFilter(request, response);
    } else {
        String actualToken = request.getHeader(csrfToken.getHeaderName());
        if (actualToken == null) {
            actualToken = request.getParameter(csrfToken.getParameterName());
        }

        if (!csrfToken.getToken().equals(actualToken)) {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));
            }

            if (missingToken) {
                this.accessDeniedHandler.handle(request, response, new MissingCsrfTokenException(actualToken));
            } else {
                this.accessDeniedHandler.handle(request, response, new InvalidCsrfTokenException(csrfToken, actualToken));
            }

        } else {
            filterChain.doFilter(request, response);
        }
    }

 

 

 

CSRFToken의 Session 저장

 

csrfToken 정보도 session에 저장되는 것 같다. 강의에서 나오지 않은 내용이라 정확하진 않지만, CsrfFilter 부분에 break point를 걸고 loadToken 메서드를 통해 생성되는 token 값을 보면 로그인을 했든 안했든 어떤 자원에 접근 시 똑같은 Token 값이 나오는 것을 확인할 수 있다.

 

 

이것은 최초로 도메인에 접근 시 발급되고, 그 이후로는 아래처럼 JSESSIONID에 저장되는 것 같다고 추정된다. 왜냐하면 브라우저에서 JSESSIONID 값을 삭제하고 다시 어떤 자원에 접근하면 위 doInternalFilter의 상단 missingToken 부분에 걸려서 서버에서 다시 토큰을 생성하고 브라우저로 전달하는 과정이 진행되기 때문이다. 불필요한 토큰 생성을 방지하기 위해서 최적화 작업을 해놨다고 일단 이해하면 좋을 것 같다.

 

 


 

 

참조

 

1) 인프런 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 정수원님 강의

https://www.inflearn.com/course/%EC%BD%94%EC%96%B4-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%E

728x90
반응형