스프링 시큐리티는 스프링 컨테이너 외부인 서블릿에 대해 필터를 추가함으로써 그 기능을 수행합니다. 개발자가 직접 이 필터들을 서블릿 컨테이너에 개발하는 대신 FilterChainProxy를 이용해 스프링 컨테이너 내에 존재하는 빈을 필터로 등록할 수 있도록 했습니다.
이번 포스팅에서는 더 나아가 이 필터 중 인증 관련 필터를 스프링 시큐리티가 어떻게 구성했는지, 개발자는 이를 어떻게 확장할 수 있을지에 대해 알아보겠습니다.
스프링 시큐리티 인증 처리 과정
첫번째로, 요청에 대한 인증입니다. HTTP의 Stateless특성으로 HTTP를 사용하는 서버는 매 순간의 접속이 어떤 클라이언트의 요청인지에 대한 상태를 저장하지 않습니다. 서버는 이 요청이 리소스에 대한 권한을 가지고 있는지만을 판단함으로써 접근을 허용할지, 말지를 결정합니다. 따라서, 클라이언트는 HTTP Request 메시지에 자신을 증명할 수 있는 데이터( Credential Data )를 포함하여 요청합니다. 이를통해 서버는 메시지에 포함된 헤더를 이용해 현재 요청 메시지가 리소스에 접근할 권한이 있는지를 파악할 수 있게 됩니다.
정리하자면, 클라이언트의 HTTP 요청을 처리하기 위해선 다음과 같은 과정을 필요로 합니다.
- HTTP 요청 메시지에서 Credential 데이터를 추출합니다.
- 추출한 Credential 데이터를 이용해 해당 사용자에게 어떤 권한이 있는지를 확인하고, 확인된 권한을 내부적으로 저장합니다.
- 현재 요청을 처리하는데 필요한 권한과 2번 단계에서 저장한 권한을 비교하여 일치할때, 요청을 성공적으로 처리합니다.
위 단계에 대해 알아보기 전에, 사전지식이 필요합니다.
기본개념 - SecurityContextHolder
요청에 대해 할당되는 스레드는 이를 통해 현재 요청에 대한 정보를 기록합니다
요청에 대해 일련의 인증 및 인가 과정을 거치기 위해서는 하나의 요청 즉, 쓰레드가 요청에 대한 정보를 가지고 있을 필요가 있습니다.
시큐리티는 요청의 Credential 데이터 및 Authorized 정보를 저장하기 위한 SecurityContextHolder를 가지고 있습니다. 모든 Spring Security의 기능들은 이를 바탕으로 이루어지며 보안 기능을 구현하기 위한 핵심적인 자료구조입니다.
SecurityContextHolder가 가지는 SecurityContext는 Thread-Local로 관리되어짐으로, 실행되는 스레드 안에서 언제 어디서든 접근이 가능하며, SecurityContext는 사용자의 Principal 정보, Credential 정보, Authorities 정보를 가지는 Authentication 객체을 가집니다. 이 Authentication 객체를 이용하여 개발자는 자기만의 인증 및 인가를 구현할 수 있습니다.
Thread-local이란, 각각의 스레드만이 접근가능한 데이터를 말합니다. 하나의 스레드는 하나의 요청을 처리함으로, 각각의 요청에 대해 본인만이 접근할 수 있는 자료구조를 가짐으로써 더 안전하게 사용자 데이터를 관리할 수 있게 됩니다.
Spring Security는 SecurityContext내의 Authentication이 어떤 Authentication이 사용되는지 상관하지 않습니다. 개발자는 그저 Authentication 인터페이스를 구현한 커스텀 객체를 사용할 수 있습니다.
Authentication 객체를 필터 내에 구현하고 이를 AuthenticationManger에게 넘겨 이 요청이 권한을 가질지, 가지지 못할지를 결정하게 됩니다.
아래 코드는 SecurityContextHolder를 이용해 Authentication을 새로 등록하는 코드입니다.
// SecurityContextHolder 로부터 새로운 SecurityContext 생성
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 사용자 커스텀 Authentication 객체 사용 사용된 파라미터는 임의로 사용한 principal, credential, authorities 입니다.
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER");
// SecurityContext에 Authentication 설정
context.setAuthentication(authentication);
// SecurityContextHolder에 SecurityContext 설정
SecurityContextHolder.setContext(context);
SecurityContextHolder가 가지는 Thread-local 데이터 SecurityContext가 다중 스레드로 인해 RaceCondition에 빠질 수 있기 떄문에 아래와 같은 방법은 사용하기 권장되지 않습니다.
SecurityContextHolder.getContext().setAuthentication(authentication)
기본개념 - Authentication Manager
AuthenticationManager는 인증 작업을 수행하는 Security Filter에 API를 제공하여, SecurityFilter의 인증 작업을 돕습니다. 이는 하나의 인터페이스 이며, 이 인터페이스를 구현한 ProviderManager를 대부분 사용합니다. ProviderManager는 내부적으로 Authentication 객체를 처리하기 위한 여러개의 AuthenticationProvider 객체를 가지고 있습니다. 각각의 AuthenticationProvider는 파라미터로 넘겨지는 Authentication객체의 클래스 타입을통해 자신이 처리할 수 있는 Authentication객체인지 확인하고, 이를 처리합니다. 처리를 수행할 AuthenticationProvider객체를 찾지 못하면, ProviderNotFoundException이 발생합니다.
1. 요청 메시지에서 데이터 추출하기
이 과정은 HttpServletRequest 메시지를 다루는 필터를 통해 구현합니다. 필터는 HttpServletRequest메시지에서 Credential 정보를 추출합니다. 이를 통해 만든 Authentication을 ProviderManager를 통해 검증 할 수 있도록 합니다. ProviderManager 로 부터 받은 Authentication을 SecurityContext에 등록하고 이를 SecurityContextHolder에 저장하여 스레드 내에서 사용할 수 있도록 합니다.
private static final String HEADER_ID = "id";
private static final String HEADER_PWD = "pwd";
@Bean
public OncePerRequestFilter customFilter() {
return new OncePerRequestFilter() {
@Autowired
ProviderManager providerManager;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String id = request.getHeader(HEADER_ID);
String pwd = request.getHeader(HEADER_PWD);
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
Authentication authentication = new TestingAuthenticationToken(id, pwd);
// TestingAuthenticationToken 은 스프링 시큐리티에서 제공하는 Authentication을 구현한 클래스입니다.
// 해당 예시에서는 편의를 위해 사용했으며,Authentication객체를 언제든 새로 구성하여 사용하실 수 있습니다.
securityContext.setAuthentication(providerManager.authenticate(authentication));
SecurityContextHolder.setContext(securityContext);
filterChain.doFilter(request, response);
}
};
}
2. ProviderManger 구성
AuthenticationProvider과 이를 내부에서 관리하는 ProviderManger 이용해 authentication의 유효성을 검사합니다.
파라미터로 받은 authentication 객체가 유효할 시, provider는 새로운 Authentication객체를 리턴합니다.
@Bean
ProviderManager providerManager(AuthenticationProvider authenticationProvider) {
// ProviderManager는 여러 AuthenticationProvider를 가질 수 있습니다. 이 예시에서 더 확장하여 여러 AuthenticationProvider를 가지도록 할 수 있습니다.
return new ProviderManager(authenticationProvider);
}
@Bean
AuthenticationProvider authenticationProvider() {
return new AuthenticationProvider() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String id = (String) authentication.getPrincipal();
String pwd = (String) authentication.getCredentials();
if (id.equals("heejong") && pwd.equals("1234")) {
return new TestingAuthenticationToken(id, pwd, "ROLE_USER");
}
return authentication;
}
@Override
public boolean supports(Class<?> authentication) {
// 파라미터로 받은 authentication객체를 현재 provider객체에서 처리할 수 있는지 여부를 확인하는 용도입니다.
// 이 예시는 구성을 이해하기 위함으로 항상 true를 리턴하도록 구성했습니다.
return true;
}
};
}
3. SecurityFilterChain 구성
필터를 구성합니다. 모든 경로로의 요청의 Authentication 객체가 USER 권한이 있는지를 확인하고 있다면 이를 허용하도록 구성했습니다.( 이에 대한 자세한 설명은 Authorization에 대해 다루며 자세히 설명하겠습니다. )
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.addFilterBefore(customFilter(), AuthorizationFilter.class)
.authorizeHttpRequests(http ->
http.anyRequest().hasRole("USER"))
.build();
}
전체 코드는 다음과 같습니다.
package com.example.security.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@EnableWebSecurity
@Configuration
public class SecurityConfig {
private static final String HEADER_ID = "id";
private static final String HEADER_PWD = "pwd";
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.addFilterBefore(customFilter(), AuthorizationFilter.class)
.authorizeHttpRequests(http ->
http.anyRequest().hasRole("USER"))
.build();
}
@Bean
public OncePerRequestFilter customFilter() {
return new OncePerRequestFilter() {
@Autowired
ProviderManager providerManager;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String id = request.getHeader(HEADER_ID);
String pwd = request.getHeader(HEADER_PWD);
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
Authentication authentication = new TestingAuthenticationToken(id, pwd);
securityContext.setAuthentication(providerManager.authenticate(authentication));
SecurityContextHolder.setContext(securityContext);
filterChain.doFilter(request, response);
}
};
}
@Bean
ProviderManager providerManager(AuthenticationProvider authenticationProvider) {
return new ProviderManager(authenticationProvider);
}
@Bean
AuthenticationProvider authenticationProvider() {
return new AuthenticationProvider() {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String id = (String) authentication.getPrincipal();
String pwd = (String) authentication.getCredentials();
if (id.equals("heejong") && pwd.equals("1234")) {
return new TestingAuthenticationToken(id, pwd, "ROLE_USER");
}
return authentication;
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
};
}
}
긴 글 읽어주셔서 감사합니다.
참고 : https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
'SpringBoot' 카테고리의 다른 글
[SpringBoot] 스프링의 에러처리 탐구 (0) | 2024.01.17 |
---|---|
[Spring] 다수의 SecurityFilterChain 구성 방법 (0) | 2024.01.01 |
[Spring] Spring Security Architecture (1) | 2023.12.29 |
[Spring] RestTemplate (1) | 2023.12.29 |
SpringBoot의 작동원리를 직접 구현 해보며 이해하자(1) - Servlet (0) | 2023.12.22 |