Spring Batch를 공부하면서, 이에 못지않게 중요하고 Web Application의 강력한 프레임워크인 Spring Security에 대해 공부한 내용도 같이 기록하고자 한다.
Spring Security는 Batch 처럼 본질, 접근방향, 개념보다는 프레임워크에 대한 내용, 특히 Spring Framework의 생명주기에 강제적인 보안흐름(FilterChain)을 주입하여 자동적으로 보안 환경에 대한 설정정보를 시스템에 연동하는 방법, 즉 구현 로직에 대해 좀 더 집중하는 것이 맞다는 판단으로 이 기조를 유지하며 기록하고자 한다.
Spring Security의 핵심은 강제적 보안흐름을 주입하고, 이에 따라 Spring Framework는 자동적으로 보안 환경설정이 구축되어 개발자 입장에서는 이를 사용하기만 하면 된다는 점이다.
언제나 그랬듯 내부 작동원리와 구조를 세부적으로 살펴보면서 Spring Security에 대해서도 한번 정복해보고자 한다.
Spring Security를 사용하는 가장 강력하고도 명확한 사용 이유 중 하나로, Spring Boot에서의 Batch처럼 자동적으로 보안환경설정 및 초기화 작업이 이루어지고(객체관의 연결 과정이 종합되어), 이에 대한 내용이 현재의 Spring Web Application에 연동이 된다는 점이다.
이것이 Spring Security "Framework"라고 불리우는 이유이다.
이에 따라 모든 요청에 대해 인증여부를 검사하고, 기본적으로 인증이 승인되어야 자원 접근이 가능해진다.

이처럼 기본적인 인증 인덱스(뷰)를 제공하며,


이와 같이 기본적인 인증승인 계정을 Spring Security에서 제공해준다(참고로 기본적인 인증 승인계정의 username은 user이다).
이러한 작동이 가능한 이유는 Spring Security Framework에서 SecurityProperties 클래스를 환경설정 빈으로 등록하여, 기본적인 인증승인 계정(user, password)을 제공해주기 때문이다.
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
...
public static class User {
/**
* Default user name.
*/
private String name = "user";
/**
* Password for the default user name.
*/
private String password = UUID.randomUUID().toString();
/**
* Granted roles for the default user name.
*/
private List<String> roles = new ArrayList<>();
private boolean passwordGenerated = true;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
if (!StringUtils.hasLength(password)) {
return;
}
this.passwordGenerated = false;
this.password = password;
}
public List<String> getRoles() {
return this.roles;
}
public void setRoles(List<String> roles) {
this.roles = new ArrayList<>(roles);
}
public boolean isPasswordGenerated() {
return this.passwordGenerated;
}
}
}
이 승인계정을 User 객체로 전달하여, 최종적으로 메모리(InMemory)에 저장하여 사용할 수 있게 된다.
이러한 User 정보는 Spring Security에서 기본적으로 제공하는 계정이며, 권한/역할에 대한 체계는 DB에서 별도로 생성하고 관리할 수 있다(사실 그래야만 한다).
Spring Security Framework의 출발점은 SpringBootWebSecurityConfiguration 클래스이다.
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {
/**
* The default configuration for web security. It relies on Spring Security's
* content-negotiation strategy to determine what sort of authentication to use. If
* the user specifies their own {@link SecurityFilterChain} bean, this will back-off
* completely and the users should specify all the bits that they want to configure as
* part of the custom security configuration.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
}
}
/**
* Adds the {@link EnableWebSecurity @EnableWebSecurity} annotation if Spring Security
* is on the classpath. This will make sure that the annotation is present with
* default security auto-configuration and also if the user adds custom security and
* forgets to add the annotation. If {@link EnableWebSecurity @EnableWebSecurity} has
* already been added or if a bean with name
* {@value BeanIds#SPRING_SECURITY_FILTER_CHAIN} has been configured by the user, this
* will back-off.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = BeanIds.SPRING_SECURITY_FILTER_CHAIN)
@ConditionalOnClass(EnableWebSecurity.class)
@EnableWebSecurity
static class WebSecurityEnablerConfiguration {
}
}
이 환경설정을 빈으로 등록하면, FilterChain 정보가 Spring Framework의 흐름을 강제하여 폼로그인 방식 및 httpBasic 로그인을 통해 인증여부를 검사한다.
인증여부를 통과해야만(anyRequests().authenticated()), 요청을 수행한다(http.build()).
이때, defaultSecurityFilterChain은 무조건적으로 적용되는 것은 아니고 @ConditionalOnDefaultWebSecurity의 조건에 따라 조건 부합 시 WebSecurity 환경설정을 초기화한다.
이때의 조건은 DefaultWebSecurityCondition 클래스의 두가지 조건이며, 아래 조건을 모두 만족할 시 FilterChain에 Spring Security이 적용된다(참고로, 사용자 정의에 의한 SecurityBean 생성 시 아래 MissingBean 조건은 부합하지 않게 되어 기본 설정에 의한 Spring Security은 동작하지 않는다. 사용자 정의에 의한 SecurityBean 생성이 이루어지지 않았을 경우에만 아래 조건이 부합하여 기본 설정에 의한 Spring Security가 동작한다).
class DefaultWebSecurityCondition extends AllNestedConditions {
DefaultWebSecurityCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnClass({ SecurityFilterChain.class, HttpSecurity.class })
static class Classes {
}
@ConditionalOnMissingBean({ SecurityFilterChain.class })
static class Beans {
}
}
아래 로그를 살펴보면 Spring Security 적용 후, DefaultSecurityFilterChain으로부터 로그가 기록되어있는 것을 확인할 수 있는데, SpringBootWebSecurityConfiguration 측에서 조건 부합여부를 확인하여 모든 요청에 Spring Security를 적용하기 위해 해당 클래스가 발동한 것으로 보면 되겠다.

또한, Spring Security을 사용하고, 나아가 Web Application의 기본적인 보안 설계를 위해 반드시 알아야 하는 개념인 인증과 인가에 대해 짚고 넘어가보고자 한다.
사용자가 진짜 누구인지 검증하는 단계, "문으로 들어가기 위한 최초의 확인 작업"이라 할 수 있으며, 쉽게 말하자면 로그인 시 이루어지는 과정이다.
username / password, OAuth2 Token, JWT 등으로 인증 절차를 진행할 수 있다.
SecurityContext 에 인증 정보 저장 (SecurityContextHolder)할 수 있으며, 인증은 신분확인 단계라 보면 되겠다.
인증이 끝난 사용자에게 무엇을 할 수 있는지 제한하고, 제한조건을 넘어선 사람에게 특별한 권한을 부여해주는 단계이다.
예를 들어,
의 과정으로 해당 페이지 혹은 영역에 들어갈 수 있는 특별한 권한을 부여 및 확인하여 접근을 인가한다.
Spring Security 환경을 구성한다는 것은 기본적으로 WAS의 Servlet Filter Chaining에 Security Chaining을 연결하겠다는 의미이다.
"Spring Security" 환경구성, 즉 최종적으로 Spring Security Filter Chaining이 발생(initializaing)하기위한 필요과정을 크게 3단계로 나누어 분석해보고자 한다.
위 체계에 대해 단계적으로 살펴보도록 한다.
이에 대해 살펴보기전에, 세부적으로 먼저 파고들면서 이해하면 더 혼란스러워질 수 있기에 일단 큰 흐름부터 살펴보도록 하자.
이 흐름을 이해한다면 WebSecurity/HttpSecurity, 나아가 FilterChainProxy까지의 기초를 다질 수 있다.
일단 Spring Security의 이해를 좀 더 용이하게 하기 위해 큰 흐름에 대해 먼저 파악하는 것이 중요하다.
SecurityBuilder는 말 그대로 "최종적인 보안객체"를 만드는 빌더 클래스이다.
SecurityConfigurer는 이러한 빌더 클래스 내부 환경을 설정하는 설정 클래스이다. 구체적으로는 필터를 생성하며, init을 통해 Builder에 이러한 환경설정들을 등록하고 최종적으로 configure를 통해 Security Filter 및 Rule을 적용하고 해당 객체를 생성한다.
정말 큰 흐름을 먼저 살펴보자.
[자동 설정 시작]
↓
WebSecurity 생성 (SecurityBuilder)
↓
WebSecurity에 여러 SecurityConfigurer 등록
↓
WebSecurity.build()
↓
→ 내부에서 HttpSecurity 생성 (또 다른 Builder)
→ HttpSecurity에 여러 SecurityConfigurer 등록
→ HttpSecurity.build()
↓
SecurityFilterChain 생성
↓
DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain 반영
1) @EnableWebSecurity에 의해, 스프링 컨테이너의 빈객체 정의 및 refresh 시점 어노테이션 리플렉스 시 Spring Security 환경설정을 시작한다.
2) 먼저 SecurityBuilder의 구현체 중 하나인 WebSecurity를 생성, 여러 SecurityConfigurer 객체를 등록한다.
3) WebSecuirty build를 통해 또 다른 SecurityBuilder 구현체인 HttpSecurity 객체를 생성하고, 이 구현체 역시 다양한 SecurityConfigurer를 등록하고 빌더클래스를 등록한다.
4) 여기서 생긴 SecurityFilter 객체 정보들을 엮어 SecurityFilterChain을 생성한다.
WebSecurity 내부에는
와 같은 Configurer 클래스 목록이 존재, "어떠한 요청을 SecurityFilterChain이 처리할 것인가"에 대한 내용이 담겨진 가장 상위의 SecurityBuilder 구현체이다.
이후 build()를 통해 HttpSecurity 구현체를 생성한다.
HttpSecurity는 실질적인 Filter객체를 등록하는, SecurityConfigurer 등록을 통해 SecurityFilterChain을 구축하고 초기화하는 핵심 구현체이다.
@Bean(HTTPSECURITY_BEAN_NAME)
@Scope("prototype")
HttpSecurity httpSecurity() throws Exception {
LazyPasswordEncoder passwordEncoder = new LazyPasswordEncoder(this.context);
AuthenticationManagerBuilder authenticationBuilder = new DefaultPasswordEncoderAuthenticationManagerBuilder(
this.objectPostProcessor, passwordEncoder);
authenticationBuilder.parentAuthenticationManager(authenticationManager());
authenticationBuilder.authenticationEventPublisher(getAuthenticationEventPublisher());
HttpSecurity http = new HttpSecurity(this.objectPostProcessor, authenticationBuilder, createSharedObjects());
WebAsyncManagerIntegrationFilter webAsyncManagerIntegrationFilter = new WebAsyncManagerIntegrationFilter();
webAsyncManagerIntegrationFilter.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
// @formatter:off
http
.csrf(withDefaults())
.addFilter(webAsyncManagerIntegrationFilter)
.exceptionHandling(withDefaults())
.headers(withDefaults())
.sessionManagement(withDefaults())
.securityContext(withDefaults())
.requestCache(withDefaults())
.anonymous(withDefaults())
.servletApi(withDefaults())
.apply(new DefaultLoginPageConfigurer<>());
http.logout(withDefaults());
// @formatter:on
applyCorsIfAvailable(http);
applyDefaultConfigurers(http);
return http;
}
참고로 HttpSecurity를 생성하는 시점에는 위와 같이 csrf, addFilter, formLogin 등 각각의 빌더들을 통해 Configurer 클래스를 호출, 각 필터 객체들의 "설정정보"를 전달받는다.
init() → 필요한 Security 객체 생성 및 HttpSecurity에 등록
configure() → 등록된 객체들을 실제 Filter로 변환하고 FilterChain에 추가
HttpSecurity에 의해 호출되는 Configurer들은 내부적으로 init 메소드를 호출하여, 본인들이 필요한 Security 객체를 생성하고 HttpSecurity에 등록한다.
그 후 configure 메소드를 통해 등록한 객체들을 실질적인 filter 객체로 변환하고, 이를 securityFilterChain에 등록하여 filterchain을 구축한다.
WebSecurityConfiguration은 이러한 WebSecurity/HttpSecurity를 build()하여 최종적으로 List<Filter>, 즉 SecurityFilterChain이 생성된다.
이 과정으로 생긴 SecurityFilterChain은 FilterChainProxy가 보관한다(이후에 다시 다룰 예정).
SecurityBuilder/SecurityConfigurer의 요지는 SecurityFilterChain의 구축과 생성이다.
(SecurityBuilder) (SecurityBuilder)
WebSecurity.build() ─────────► HttpSecurity.build()
│ │
│ │
[Web Security Configurers] [Http Security Configurers]
│ init() │ init()
│ configure() │ configure()
│ │
▼ ▼
(어떤 요청이 보안을 탈지) (보안 필터들이 어떻게 동작할지)
↓
SecurityFilterChain 생성
↓
DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain → Filters
SecurityBuilder를 통해 "Security 객체"를 만드는데, 이 구현체는 WebSecurity, HttpSecurity 두가지가 존재한다.
또한 이 빌더클래스를 구현하기 위해 내부적인 설정클래스인 Configurer 클래스를 생성하고 등록하며, HttpSecurity가 내부설정을 조합하는 시점에 Configurer 클래스들이 filter 객체를 생성하고 filterchain에 등록, "구축"한다.
최종적으로 HttpSecurity.build()이후 Filter객체를 FilterChain을 "생성"한다.
기본적으로 이 두 구현체들은 WebSecurityConfiguration에서 구현된다.
개념적으로 상위 클래스인 HttpSecurity가 먼저 "생성호출", 이 WebSecurity 구현체를 구현하기 위해 HttpSecurity가 필요하여 순서상 그 이후에 HttpSecurity 객체를 "생성호출"한다.
위에서 살펴보았듯이,
HttpSecurity는 Filterchain을 구축한다. 순서상 이후 WebSecurity는 이러한 Filterchain을 "생성"하여 SecurityBuilder에 저장한다.
WebSecurity 구현체가 build()를 할 시점에는 이 SecurityBuilder에 저장된 SecurityFilterChian을 꺼내어 FilterChianProxy 생성자에 전달한다.
SecurityFilterChain은 쉽게말해, WebSecurity, HttpSecurity 구현체의 "역할"을 실행하기 위한 "실행주체"라 보면 되겠다.
public interface SecurityFilterChain {
boolean matches(HttpServletRequest request);
List<Filter> getFilters();
}
의 구조로 되어있는 인터페이스이다.
getFilters를 통해 말 그대로 HttpSecurity 구현체가 Configurer의 init(), configure()를 통해 filter 객체를 등록한 리스트, 즉 filterchain을 반환할 수 있다.
matches를 통해 WebSecurity 구현체가 "어떤 요청에 Security를 적용할 것인지"에 대한 정책부합여부를 판단하고 Security 로직을 주입한다(예를 들어, 요청을 검사하고 FilterChain 대상 요청일 경우 true를 반환하는 방식).
참고로, 이 인터페이스의 구현체는 DefaultSecurityFilterChain이다.
최종적으로 "생성"한 SecurityFilterChain을 꺼내어 FilterChainProxy에 전달, 내부적으로 FilterChain이 필요한 경우 이 FilterChainProxy 객체가 응답하여 전달된다.
즉, Proxy 객체를 만드는 것이 현 단계의 요점이다.
위의 두 과정이 최초 Spring Security 환경초기화 시점(Spring Container의 Bean Definition / refresh)이라면, 이 객체는 런타임 요청을 필터링하는 요소이다.
이에 대해 살펴보기전에 먼저 Spring Security의 본질에 대해 이해해야 한다.
Spring Security는 Filter chaining에 기반한 보안주입이라는 것은 알고있을텐데, 문제는 이 filter chaining의 작동원리이다.
핵심부터 말하자면 WAS에서 작동하는 Servlet filter에서 요청을 미리 필터링하고, 여기서 요청을 개발자 단계에서 조절하기 위해 Spring 특징(IoC, DI, AOP)을 사용할 수 있도록 그 책임을 위임하는 것이다.
와닿지가 않는다면, 먼저 아래 client 요청이 WAS에 전달되었을때 Spring MVC에 어떻게 전달되는지 살펴보자.
┌────────────────────────────┐
│ Servlet Container │ ex) Tomcat
│ │
│ [Filter Chain] │
│ ↑ ↑ │
│ │ └─ (등록됨) DelegatingFilterProxy
│ │ │
└─────┬─────────────────────┘
│ Delegates
▼
┌────────────────────────────┐
│ springSecurityFilterChain (FilterChainProxy) ← Spring Security
└─────┬─────────────────────┘
│ (인증/인가 통과)
▼
┌────────────────────────────┐
│ DispatcherServlet ← Spring MVC Front Controller (Spring Bean)
└─────┬─────────────────────┘
│ Handler Mapping
▼
┌────────────────────────────┐
│ @Controller / @RestController ← Spring IoC Container Bean
└────────────────────────────┘
위와 같이, 최초 WAS(Servlet Container)에 전달된 요청은 내부적인 Filter를 거쳐 Spring Container로 넘어오게 되고, Spring Container의 Dispatcher Servlet이 최종적으로 요청을 핸들링하여(HandlerMapping) 넘겨준다.
Spring Security를 사용하려면 말 그대로 Spring의 특징이 있어야 하고, 하지만 요청을 Spring Container로 넘기기전에 필터링해야 하기에 WAS(Servlet Container) 내부에 Spring Security 로직을 적용해야 한다.
이 딜레마를 해결하기 위해, "Security를 WAS 환경에 적용할 수 있고, Spring의 특징을 활용할 수 있도록" WAS의 delegatingFilterProxy가 요청을 감지하여 이 요청에 대한 필터링 책임을 Spring Cotainer의 FilterChainProxy로 넘기는 것이다(=WebSecurity의 최종 생성체).
참고로 이러한 책임위임방식은 WAS의 Servlet에서도 살펴볼 수 있는데, 이 Servlet은 Spring Container의 DispatcherSevlet 매핑정보를 가지고 있어야 요청이 알맞은 MVC를 탈 수 있다.
참고로 WAS의 Filter는 요청을 가장 최전방에서 맞닥뜨리고, 최후방에서 맞닥뜨리는 관문과 같은 요소이다.
public interface Filter {
default void init(FilterConfig filterConfig) throws ServletException {
}
void doFilter(ServletRequest var1, ServletResponse var2, FilterChain var3) throws IOException, ServletException{
//pre doFilter
chain.doFilter();
//post doFilter
}
default void destroy() {
}
}
이와 같이, 필터의 초기화/진행시/제거시 시점에서 마치 AOP와 같이 공통적인 처리로직을 구현할 수 있다.
또한 doFilter 내부에서는 chain.doFilter 전후로 ServletRequest를 수정할 수도 있고 ServletResponse를 수정할 수도 있다. 사실 수정한다기보다는 로그 등 특수작업을 진행하기 위해 많이 사용한다.
WAS에서 Spring으로의 보안책임을 위임
이미 선제된 두 과정을 통해 생성된 FilterChainProxy는 Spring Container 내부에 있는 "SecurityFilterChain"의 빈 객체와 같고, WAS측 delegatingFilterProxy가 책임을 위임할때 이 Proxy 빈 객체가 반응하여 전달된다.
여기서 유의할 점은 DelegatingProxy는 보안에 대한 처리책임은 없고, 단지 위임할 뿐이라는 것을 반드시 기억한다.
이처럼 최초 초기화 과정부터 런타임 시점에서 Security 흐름이 주입되기까지, SecurityBuilder/SecurityConfigurer, WebSecurity/HttpSecurity, DelegatingFilterProxy/FilterChainProxy의 세가지 키워드 단계로 분류하여 파악해보았다.
이 전체적인 흐름과 키워드만 파악하고 있어도 Spring Security의 생태계를 이해하는데 큰 도움이 될 것이다.
그리고 이러한 구축단계를 살펴보았을때, framework가 너무 잘되어있기에 기본설정만으로 충분한 것 아닐까하는 생각이 들 수 있다. 그러나 Spring Security의 기본적인 구성만으로는 복잡한 보안요구사항을 만족할 수 없다.
기본 구성으로는 승인 계정 1개만 제공 및 역할별 권한 부여를 지원하지 않기에 복잡한 요구사항을 구현하기에는 많이 부족하며, 따라서 사용자 정의에 의한 Spring Security 환경설정은 필수불가결하다.