ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [스프링 인 액션] Chapter 4 - 스프링 시큐리티 :: 웹 요청 보안 처리하기
    개발서적읽기/Spring in Action 제 5판 2020. 7. 30. 13:40



    타코를 디자인하거나 주문하기 전에 사용자를 인증해야 한다는 것이 


    타코 클라우드 애플리케이션의 보안 요구사항이다.


    그러나 홈페이지, 로그인 페이지, 등록 페이지는 인증되지 않은 모든


    사용자가 사용할 수 있어야 한다.


    그러기 위해선 SecurityConfig에서 configure(HttpSecurity)을 사용해야 한다.


    HttpSecurity를 사용해서 구성할 수 있는 것은 다음과 같다.


    - HTTP 요청 처리를 허용하기 전에 충족되어야 할 특정 보안 조건을 구성한다.


    - 커스텀 로그인 페이지를 구성한다.


    - 사용자가 애플리케이션의 로그아웃을 할 수 있도록 한다.


    - CSRF 공격으로부터 보호한다.





    ■웹 요청 보안 처리하기


    /design과 /orders의 요청은 인증된 사용자에게만 허용되어야 한다.


    그리고 이외의 모든 다른 요청은 모든 사용자에게 허용되어야 한다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/design", "/orders")
    .hasRole("ROLE_USER")
    .antMatchers("/", "/**")
    .permitAll();
    }

    위 규칙을 지정할 때는 순서가 중요하다.


    antMatchers()에서 지정된 경로의 패턴 일치를 검사하므로 


    먼저 지정된 보안 규칙이 우선적으로 처리된다.




    hasRole()과 permitAll()은 요청 경로의 보안 요구를 선언하는 메서드다.


    아래는 사용 가능한 모든 메서드 리스트이다.




    위 표에 있는 대부분의 메서드는 요청 처리의 기본적인 보안 규칙을 제공한다.


    그러나 각 메서드에 정의된 보안 규칙만 사용된다는 제약이 있다.


    이의 대안으로 access()와 SpEl을 사용하면 더 풍부한 보안 규칙을 선언할 수 있다.



    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/design", "/orders")
    .access("hasRole('ROLE_USER')")
    .antMatchers("/", "/**")
    .access("permitAll")
    .and()
    .httpBasic();
    }

    이렇게도 작성할 수 있는 것이다.


    더 복잡하게 사용할 수도 있다.


    예를 들어, 화요일의 타코 생성은 ROLE_USER 권한을 갖는 사용자에게만 허용하고 싶다면?

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/design", "/orders")
    .access("hasRole('ROLE_USER') && " +
    "T(java.util.Calendar).getInstance().get(" +
    "T(java.util.Calendar).DAY_OF_WEEK) == " +
    "T(java.util.Calendar).TUESDAY")
    .antMatchers("/","/**").access("permitAll");
    }

    위 처럼 요구사항을 구현할 수 있다.




    ■커스텀 로그인 페이지 생성하기


    현재의 HTTP 기본 로그인 대화상자는 너무 평범하다.


    기본 로그인 페이지를 교체하려면 우선 우리의 커스텀 로그인 페이지가 있는 경로를


    스프링 시큐리티에 알려줘야 한다. 


    configure(HttpSecurity) 의 인자로 전달되는 


    HttpSecurity의 formLogin()을 호출하면 된다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/design", "/orders")
    .hasRole("ROLE_USER")
    .antMatchers("/", "/**")
    .permitAll()
    .and()
    .formLogin()
    .loginPage("/login");
    }

    formLogin()은 커스텀 로그인 폼을 구성하기 위해 먼저 호출되는 메소드다.


    이제 해당 경로의 요청을 처리하는 Controller를 만들어야 한다.


    WebConfig에 뷰 컨트롤러로 선언하자.

    package tacos;
    /*
    * @USER JungHyun
    * @DATE 2020-07-29
    * @DESCRIPTION
    */

    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
    registry.addViewController("/").setViewName("home");
    registry.addViewController("/login");
    }
    }

    그리고 templates 밑에 login.html을 생성하자.

    <!DOCTYPE html>
    <html xmlns="http://www.w3.org/1999/xhtml"
    xmlns:th="http://www.thymeleaf.org">
    <head>
    <title>Taco Cloud</title>
    </head>

    <body>
    <h1>Login</h1>
    <img th:src="@{/images/TacoCloud.png}"/>

    <div th:if="${error}">
    Unable to login. Check your username and password.
    </div>

    <p>New here? Click
    <a th:href="@{/register}">here</a> to register.</p>

    <!-- tag::thAction[] -->
    <form method="POST" th:action="@{/login}" id="loginForm">
    <!-- end::thAction[] -->
    <label for="username">Username: </label>
    <input type="text" name="username" id="username" /><br/>

    <label for="password">Password: </label>
    <input type="password" name="password" id="password" /><br/>

    <input type="submit" value="Login"/>
    </form>
    </body>
    </html>

    이 페이지에서 주목할 부분은 POST 요청 경로 및 사용자 이름과 비밀번호 필드다.


    기본적으로 스프링 시큐리티는 /login 경로로 로그인 요청을 처리한다.


    그리고 사용자 이름과 비밀번호 필드의 이름은 username과 password로 간주한다.


    (configure(HttpSecurity) 에서 커스터마이징할 수도 있다)




    로그인하면 해당 사용자의 로그인이 필요하다고 스프링 시큐리티가 판단했을 당시에 


    사용자가 머물던 페이지로 바로 이동한다. 그러나 사용자가 직접 로그인 페이지로


    이동했을 경우는 로그인한 후 루트 경로로 이동한다. 


    로그인한 후 이동할 페이지를 다음과 같이 설정할 수도 있다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/design", "/orders")
    .hasRole("ROLE_USER")
    .antMatchers("/", "/**")
    .permitAll()
    .and()
    .formLogin()
    .loginPage("/login")
    .defaultSuccessUrl("/design", true);
    }

    맨 마지막에 true를 설정했는데, 사용자가 로그인 전에 어떤 페이지에 있었는지와 무관하게


    로그인 후에는 무조건 /design 페이지로 이동한다는 의미이다.


    이제 로그아웃을 설정해보자.




    ■로그아웃하기


    HttpSecuriy를 수정하자.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/design", "/orders")
    .hasRole("ROLE_USER")
    .antMatchers("/", "/**")
    .permitAll()
    .and()
    .formLogin()
    .loginPage("/login")
    .defaultSuccessUrl("/design", true)
    .and()
    .logout()
    .logoutSuccessUrl("/");
    }

    이 코드는 /logout의 POST 요청을 가로채는 보안 필터를 설정한다.


    따라서 로그아웃 기능을 제공하기 위해 애플리케이션의 해당 뷰에 


    로그아웃 폼과 버튼을 추가해야한다. 이는 나중에 구현하자.


    그리고 사용자가 로그아웃 버튼을 클릭하면 세션이 종료되고


    애플리케이션에서 로그아웃된다. 이때 사용자는 기본적으로 로그인 페이지로 


    다시 이동된다. 그러나 다른 페이지로 이동시키고 싶다면


    로그아웃 이후에 이동할 페이지를 지정하여 logoutSuccessUrl()을 호출하면 된다.




    ■CSRF 공격 방어하기


    CSRF(Cross-Site Request Forgery, 크로스 사이트 요청 위조)는 많이 알려진 보안 공격이다.


    즉, 사용자가 웹사이트에 로그인한 상태에서 악의적인 코드가 삽입된 페이지를 열면


    공격 대상이 되는 웹사이트에 자동으로 폼이 제출되고 이 사이트는 위조된 공격 명령이


    믿을 수 있는 사용자로부터 제출된 것으로 판단하게 되어 공격에 노출된다.


    예를 들어, 자동으로 해당 사용자의 거래 은행 웹사이트 URL로 다른 폼을 제출하는


    공격자 웹사이트의 폼을 사용자가 볼 수 있다.


    이 경우 사용자는 자신의 계좌에서 돈이 인출되었는지 실제 확인하지 않는 한


    공격이 이루어졌다는 것을 모를 수 있다.




    CSRF 공격을 막기 위해 애플리케이션에서는 폼의 숨김 필드에 넣을 


    CSRF 토큰을 생성한다.


    그리고 해당 필드에 토큰을 넣은 후 나주엥 서버에서 사용한다.


    이후에 해당 폼이 제출될 때는 폼의 다른 데이터와 함께 토큰도 서버로 전송된다.


    그리고 서버에서는 이 토큰을 원래 생성해놨던 토큰과 비교하며, 토큰이 일치하면


    해당 요청의 처리가 허용된다. 그렇지 않으면 해당 폼은 토큰이 있다는 사실을


    모르는 악의적인 웹사이트에서 제출된 것이다.




    다행스럽게도 스프링 시큐리티에는 내장된 CSRF 방어 기능이 있다.


    또한, 이 기능이 기본으로 활성화되어 있어서 우리가 별도로 구성할 필요가 없다.


    단지 CSRF 토큰을 넣을 _csrf라는 이름의 필드를 


    애플리케이션이 제출하는 폼에 넣으면 된다.


    게다가 스프링 시큐리티에서는 CSRF 토큰을 넣는 것조차도 쉽게 해준다.


    _csrf라는 이름의 요청 속서엥 넣으면 되기 때문이다.


    이 경우 Thymeleaf 템플릿에서는 다음과 같이 숨김 필드에 


    CSRF 토큰을 나타낼 수 있다.

    <input type="hidden" name="_csrf" th:value="${_csrf.token}"/>

    만일 스프링 MVC의 JSP 태그 라이브러리 또는 Thymeleaf를 스프링 시큐리티 dialect와


    함께 사용 중이라면 숨김 필드조차도 자동으로 생성되므로 우리가 지정할 필요 없다.




    Thymeleaf에서는 <form> 요소의 속성 중 하나가 Thymeleaf 속성임을 나타내는 


    접두사를 갖도록 하면 된다. 


    이것은 Thymeleaf가 컨텍스트의 상대 경로를 나타내기 위해 흔히 하는 것이므로


    문제가 되지 않는다.


    예를 들어 Thymeleaf가 숨김 필드를 포함하도록 하기 위해 다음과 같이


    th:action 속성만 지정하면 된다.

    <form method="POST" th:action="@{/login}" id="loginForm">



    CSRF 지원을 비활성화시킬 수도 있다.


    그러나 절대로 그렇게 하지 말자.


    CSRF 방어는 중요하고 폼에서 쉽게 처리되므로 굳이 비활성화할 이유가 없기 때문이다.


    그러나 진정으로 그렇게 하고 싶다면 다음과 같이 disable()을 호출하면 된다.

    .and()
    .csrf()
    .disable();

    아래는 SecurityConfig의 최종 코드이다.

    package tacos.security;
    /*
    * @USER JungHyun
    * @DATE 2020-07-30
    * @DESCRIPTION
    */

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Bean;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;

    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userRepositoryUserDetailsService;

    @Bean
    public PasswordEncoder encoder() {
    return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/design", "/orders")
    .access("hasRole('ROLE_USER')")
    .antMatchers("/", "/**")
    .access("permitAll")

    .and()
    .formLogin()
    .loginPage("/login")

    .and()
    .logout()
    .logoutSuccessUrl("/")

    .and()
    .csrf();
    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userRepositoryUserDetailsService)
    .passwordEncoder(encoder());
    }
    }

    이제는 타코 클라우드 애플리케이션의 웹 계층 보안에 관한 모든 것이 구성되었다.


    무엇보다도 이제는 커스텀 로그인 페이지를 갖게 되었으며, JPA 기반의 사용자 repository에


    대한 사용자 인증을 할 수 있게 되었다.


    다음 포스팅에서는 로그인한 사용자에 관한 정보를 얻을 수 있는 방법을 알아보자.

    댓글

Designed by Tistory.