스프링 시큐리티(Spring Security) 다중 필터 체인(multiple filter chain) 구성 방법

5 분 소요


RECOMMEND POSTS BEFORE THIS

1. Problem

일반 사용자들을 위한 애플리케이션 프로젝트에 관리자를 위한 애플리케이션이 추가되었다. 해당 프로젝트는 스프링 시큐리티(spring security)를 사용하고 있었는데, 하나의 프로젝트에서 두 개의 도메인을 다루기 시작하면서 리소스(resource)에 대한 두 애플리케이션의 인가(authorization) 정책이 서로 다른 것이 문제가 됐다.

  • 관리자 애플리케이션 인가 정책
    • 모든 리소스가 관리자 권한 필요
  • 사용자 애플리케이션 인가 정책
    • 대부분의 리소스 허용
    • 일부 개인 정보 리소스 인증(authenticated) 필요
    • 특정 리소스 생성 및 수정은 관리자 권한 필요

2. Solve the problem

SecurityFilterChain 빈(bean)을 여러 개 만들면 하나의 애플리케이션에 여러 개 시큐리티 필터 체인들을 정의할 수 있다. 대신 각 시큐리티 필터 체인마다 자신이 책임을 갖는 API 경로를 지정해야 한다. 아래 그림은 두 개의 시큐리티 필터 체인을 구성하고 각 필터 체인마다 자신이 담당하는 요청 경로를 지정해 준 모습이다.

  1. 서버가 클라이언트로부터 /admin/api/articles 요청을 전달받는다.
  2. 서블릿 필터 체인에 위치한 FilterChainProxy 인스턴스는 요청 경로를 바탕으로 적절한 시큐리티 필터 체인을 찾는다.
    • 각 서블릿 필터 체인은 자신이 담당하는 경로 정보를 RequestMatcher 인스턴스 형태로 가지고 있다.
  3. 적합한 시큐리티 필터 체인을 통해 인증, 인가 처리를 수행한다.

2.1. API Design

기존 API 경로를 변경하는 작업이 필요했다. 한 프로젝트에 도메인이 늘어났기 때문에 API 경로를 명확히 구분 짓기 위해 기존 애플리케이션에서 사용했던 경로인 /api/** 앞에 도메인 이름을 추가한다. 두 도메인은 서로 다른 로그인 방식을 제공하기 때문에 로그인 경로도 추가적으로 구분한다.

  • 관리자 애플리케이션 리소스 API 경로
    • /admin/api/**
    • /admin/login
  • 일반 사용자 애플리케이션 리소스 API 경로
    • /app/api/**
    • /app/login

2.2. SecurityConfig Class

코드를 일부 각색한 설정(configuration) 클래스이다.

  • adminSecurityFilterChain 메서드
    • 담당 리소스 경로는 /admin/api/**, /admin/login 이다.
    • 모든 리소스 경로는 ADMIN 권한을 가진 사용자만 접근할 수 있다.
    • 폼(form) 로그인 인증 방식을 사용한다.
      • 로그인 성공 시 리다이렉트 URL은 /admin/home 이다.
      • 로그인 실패 시 리다이렉트 URL은 /admin/login?error 이다.
  • appSecurityFilterChain 메서드
    • 담당 경로는 /app/api/**, /app/login 이다.
    • 다음과 같은 리소스 인가 규칙을 가진다.
      • /app/api/private-articles 경로는 인증된 사용자만 접근할 수 있다.
      • 이 외 경로는 모두 허용이다.
    • 폼(form) 로그인 인증 방식을 사용한다.
      • 로그인 성공 시 리다이렉트 URL은 /app/home 이다.
      • 로그인 실패 시 리다이렉트 URL은 /app/login?error 이다.
package action.in.blog.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private static final String[] adminSecurityMatcher = new String[]{
            "/admin/api/**",
            "/admin/login"
    };

    private static final String[] appSecurityMatcher = new String[]{
            "/app/api/**",
            "/app/login"
    };

    @Bean
    public SecurityFilterChain adminSecurityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.securityMatcher(adminSecurityMatcher);
        httpSecurity.authorizeHttpRequests(
                registry -> registry.anyRequest().hasRole("ADMIN")
        );
        httpSecurity.formLogin(
                configurer -> configurer
                        .loginProcessingUrl("/admin/login")
                        .defaultSuccessUrl("/admin/home")
                        .failureUrl("/admin/login?error")
        );
        httpSecurity.csrf(
                AbstractHttpConfigurer::disable
        );
        return httpSecurity.build();
    }

    @Bean
    public SecurityFilterChain appSecurityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.securityMatcher(appSecurityMatcher);
        httpSecurity.authorizeHttpRequests(
                registry -> registry
                        .requestMatchers(HttpMethod.GET, "/app/api/private-articles").authenticated()
                        .anyRequest().permitAll()
        );
        httpSecurity.formLogin(
                configurer -> configurer
                        .loginProcessingUrl("/app/login")
                        .defaultSuccessUrl("/app/home")
                        .failureUrl("/app/login?error")
        );
        httpSecurity.csrf(
                AbstractHttpConfigurer::disable
        );
        return httpSecurity.build();
    }
}

3. Test

테스트 코드를 통해 설정 클래스에서 정의한 두 개의 시큐리티 필터 체인이 정상적으로 동작하는지 확인한다. 로그인 테스트를 위해 InMemoryUserDetailsManager 객체를 사용해 두 명의 임시 사용자를 준비한다.

package action.in.blog;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class MockUsers {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public UserDetailsService inMemoryUserDetailsManager() {
        var inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(
                User.withDefaultPasswordEncoder()
                        .username("junhyunny")
                        .password("123")
                        .roles("ADMIN")
                        .build()
        );
        inMemoryUserDetailsManager.createUser(
                User.withDefaultPasswordEncoder()
                        .username("jua")
                        .password("123")
                        .roles("USER")
                        .build()
        );
        return inMemoryUserDetailsManager;
    }
}

3.1. Admin Resource

관리자 애플리케이션의 컨트롤러 클래스를 먼저 살펴본다. AdminController 클래스는 다음과 같이 구성된다.

package action.in.blog.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/admin/api")
public class AdminController {

    @GetMapping("/articles")
    public String articles() {
        return "admin articles";
    }
}

AdminController 클래스를 테스트하기 위한 AdminControllerTest 클래스를 살펴보자. 테스트 대상 컨트롤러를 스코핑하고 설정한 시큐리티 필터 체인이 동작하도록 @WebMvcTest 애너테이션을 통해 통합 테스트(integration test)를 수행한다.

다음과 같은 추가 의존성이 필요하다.

  • 설정한 시큐리티 필터 체인이 적용될 수 있도록 SecurityConfig 클래스를 추가(import)한다.
  • 로그인 테스트를 위해 MockUsers 클래스를 추가한다.

각 테스트 별로 다음과 같은 내용을 검증한다.

  • login 메서드
    • InMemoryUserDetailsManager 객체에 추가된 사용자로 로그인을 수행한다.
    • 관리자 시큐리티 필터 체인에 정의한 로그인 성공 URL로 리다이렉트 되는지 확인한다.
  • wrongCredential_login_redirectFailure 메서드
    • 잘못된 사용자 자격 증명을 사용해 로그인을 수행한다.
    • 관리자 시큐리티 필터 체인에 정의한 로그인 실패 URL로 리다이렉트 되는지 확인한다.
  • articles 메서드
    • 관리자 애플리케이션 리소스에 접근한다.
    • 관리자 권한을 가진 사용자로 접근하는 경우 정상적으로 응답을 받는지 확인한다.
  • withoutAuthentication_articles_redirectToLogin 메서드
    • 관리자 애플리케이션 리소스에 접근한다.
    • 사용자 인증 없이 접근하는 경우 로그인 페이지로 리다이렉트 되는지 확인한다.
  • withoutAuthorization_articles_statusForbidden 메서드
    • 관리자 애플리케이션 리소스에 접근한다.
    • 인증은 되었지만, 관리자 권한이 아닌 사용자가 접근하는 경우 forbidden(403) 응답을 받는지 확인한다.
package action.in.blog.controller;

import action.in.blog.MockUsers;
import action.in.blog.config.SecurityConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Import({SecurityConfig.class, MockUsers.class})
@WebMvcTest(controllers = {AdminController.class})
class AdminControllerTest {

    @Autowired
    MockMvc sut;

    UserDetails sampleAdmin() {
        return User.withDefaultPasswordEncoder()
                .username("junhyunny")
                .password("123")
                .roles("ADMIN")
                .build();
    }

    UserDetails sampleUser() {
        return User.withDefaultPasswordEncoder()
                .username("jua")
                .password("123")
                .roles("USER")
                .build();
    }

    @Test
    void login() throws Exception {

        sut.perform(
                        post("/admin/login")
                                .param("username", "junhyunny")
                                .param("password", "123")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/admin/home"))
        ;
    }

    @Test
    void wrongCredential_login_redirectFailure() throws Exception {

        sut.perform(
                        post("/admin/login")
                                .param("username", "junhyunny")
                                .param("password", "12345")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/admin/login?error"))
        ;
    }

    @Test
    void articles() throws Exception {

        sut.perform(
                        get("/admin/api/articles")
                                .with(user(sampleAdmin()))
                )
                .andExpect(content().string("admin articles"))
        ;
    }

    @Test
    void withoutAuthentication_articles_redirectToLogin() throws Exception {

        sut.perform(
                        get("/admin/api/articles")
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("http://localhost/login"))
        ;
    }

    @Test
    void withoutAuthorization_articles_statusForbidden() throws Exception {

        sut.perform(
                        get("/admin/api/articles")
                                .with(user(sampleUser()))
                )
                .andExpect(status().isForbidden())
        ;
    }
}

3.2. User Resource

다음으로 사용자 애플리케이션의 컨트롤러 클래스를 살펴본다. UserController 클래스는 다음과 같이 구현했다.

package action.in.blog.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/app/api")
public class UserController {

    @GetMapping("/articles")
    public String articles() {
        return "user articles";
    }

    @GetMapping("/private-articles")
    public String privateArticles() {
        return "user's private articles";
    }

}

UserController 클래스의 테스트 환경을 구성하는 방법도 위와 동일하다. 각 테스트 별로 다음과 같은 내용을 검증한다.

  • login 메서드
    • InMemoryUserDetailsManager 객체에 추가된 사용자로 로그인을 수행한다.
    • 앱 시큐리티 필터 체인에 정의한 로그인 성공 URL로 리다이렉트 되는지 확인한다.
  • wrongCredential_login_redirectFailure 메서드
    • 잘못된 사용자 자격 증명을 사용해 로그인을 수행한다.
    • 앱 시큐리티 필터 체인에 정의한 로그인 실패 URL로 리다이렉트 되는지 확인한다.
  • articles 메서드
    • 애플리케이션 리소스에 접근한다.
    • 인증하지 않은 사용자로 접근하는 경우 정상적으로 응답을 받는지 확인한다.
  • privateArticles 메서드
    • 애플리케이션 리소스에 접근한다.
    • 인증된 사용자인 경우 정상적으로 응답을 받는지 확인한다.
  • withoutAuthentication_privateArticles_redirectToLogin 메서드
    • 애플리케이션 리소스에 접근한다.
    • 사용자 인증 없이 접근하는 경우 로그인 페이지로 리다이렉트 되는지 확인한다.
package action.in.blog.controller;

import action.in.blog.MockUsers;
import action.in.blog.config.SecurityConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Import({SecurityConfig.class, MockUsers.class})
@WebMvcTest(controllers = {UserController.class})
class UserControllerTest {

    @Autowired
    MockMvc sut;

    UserDetails sampleUser() {
        return User.withDefaultPasswordEncoder()
                .username("jua")
                .password("123")
                .build();
    }

    @Test
    void login() throws Exception {

        sut.perform(
                        post("/app/login")
                                .param("username", "jua")
                                .param("password", "123")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/app/home"))
        ;
    }

    @Test
    void wrongCredential_login_redirectFailure() throws Exception {

        sut.perform(
                        post("/app/login")
                                .param("username", "jua")
                                .param("password", "12345")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/app/login?error"))
        ;
    }

    @Test
    void articles() throws Exception {

        sut.perform(
                        get("/app/api/articles")
                )
                .andExpect(content().string("user articles"))
        ;
    }

    @Test
    void privateArticles() throws Exception {

        sut.perform(
                        get("/app/api/private-articles")
                                .with(user(sampleUser()))
                )
                .andExpect(content().string("user's private articles"))
        ;
    }

    @Test
    void withoutAuthentication_privateArticles_redirectToLogin() throws Exception {

        sut.perform(
                        get("/app/api/private-articles")
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("http://localhost/login"))
        ;
    }
}

TEST CODE REPOSITORY

댓글남기기