
스프링 시큐리티를 공부하면서 필터에 대한 궁금증이 생겨 공부를 해보았습니다.
스프링 시큐리티에서 HTTP 필터는 HTTP 요청에 적용되는 다양한 책임을 위임합니다. 스프링 시큐리티의 HTTP 필터는 일반적으로 요청에 적용해야 하는 각 책임을 관리하고, 책임의 체인을 형성합니다. 필터는 요청을 수신하고 그 논리를 실행하며 최종적으로 체인의 다음 필터에 요청을 위임합니다.
만약 해외여행을 간다고 가정해보면, 여행객은 비행기를 바로 탑승하지 않습니다. 공항에 들어가 비행기에 탑승 하기전까지 여러 과정(필터)을 거쳐야 합니다. 먼저 비행기표를 예매한 후, 비행기 표를 가지고 여권 검사를 진행합니다. 여권 검사가 마무리 되면 보안대를 지납니다. 공항마다 게이트에는 더 많은 과정(필터)가 있을 수 있습ㄴ디ㅏ. 예를 들면 비행기 타기 직전에 비자와 비행기표를 한번 더 검사 할수도 있습니다.
이 과정들이 스프링 시큐리티의 필터 체인과 매우 유사합니다. 이와 같이 HTTP 요청에 작용하는 스프링 시큐리티를 이용하여 필터 체인의 필터를 유연하게 맞춤 구성합니다.

필터 체인은 요청을 받습니다. 각 필터는 관리자를 이용하여 특정 논리를 요청에 적용하고, 최종적으로 체인의 다음 필터에 요청을 위임합니다.
책임의 HTTP 필터체인을 맞춤 구성하는 벙법을 알면 매우 유용합니다. 실제 어플리케이션에는 다양한 요구사항이 있습니다. 이러한 요구사항들은 기본 구성으로는 부족할 때가 많기 때문에 체인에 구성 요소를 추가하거나 기존 구성 요소를 대체해야합니다.
기본 구현에서는 사용자 이름, 암호에 의존하는 HTTP Basic 인증 방식을 이용합니다.
하지만 실제 시나리오에서는 이보다 더 많은 구성 요소가 필요합니다.
스프링 시큐리티 아키텍처의 필터는 일반적인 HTTP 필터입니다. 필터를 만들려면 javax.servlet 패키지의 Filter 인터페이스를 구현합니다. 다른 HTTP 필터와 마찬가지로 doFilter() 메서드를 재정의하여 논리를 구현해야 합니다.
ServletRequest, ServletResponse, FilterChain 매개 변수를 받습니다.
ServletRequest
-> HTTP 요청을 나타냅니다. ServletRequest 객체를 이용하여 요청에 대한 세부 정보를 얻습니다.
ServletResponse
-> HTTP 응답을 나타냅니다. ServletResponse 객체를 이용하여 응답을 클라이언트로 다시 보내기 전에 또는 더 나아가 필터 체인에서 응답을 변경합니다.
FilterChain
-> 필터 체인을 나타냅니다. FilterChain 객체는 체인의 다음 필터로 요청을 전달합니다.
필터 체인은 필터가 작동하는 순서가 정의된 필터 모음입니다.
애플리케이션이 필터 체인에 이러한 모든 필터의 인스턴스를 반드시 가질 필요는 없습니다. 필터 체인은 애플리케이션을 구성하는 방법에 따라 길어지거나 짧아질 수 있습니다.
각 필터에는 순서 번호가 있습니다. 순서 번호에 따라 요청에 필터가 적용되는 순서가 결정됩니다. 스프링 시큐리티가 제공하는 필터와 함께 맞춤형 필터를 추가할 수 있습니다.
필터 체인에서 여러 필터가 같은 순서 값을 가질 수 있지만, 이 경우 스프링 시큐리티는 이들 필터가 호출되는 순서를 보장하지 않습니다.
-RequestValidationFilter 구현-
public class RequestValidationFilter implements Filter {
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
var httpRequest = (HttpServletRequest) request;
var httpResponse = (HttpServletResponse) response;
String requestID = httpRequest.getHeader("Request-ID");
if(requestID == null || requestID.isBlank()){
httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
//만약 헤더가 없다면 HTTP 상태가 400으로 바뀌고, 다음 필터로 넘어가지않는다
}
filterChain.doFilter(request,response);
//헤더가 있으면 요청을 필터 체인의 다음 필터로 전달한다.
}
}
doFilter() 메서드에는 필터의 논리를 작성합니다. 여기서 Request-Id 헤더가 있는지 확인하고, 헤더가 있으면 doFilter() 메서드를 호출하여 체인의 다음 필터로 요청을 전달하고, 없다면 체인의 다음 필터로 요청을 전달하지 않고, 응답으로 HTTP 400을 반환합니다.
-SecurityConfiguratoin-
@Configuration
public class SecurityConfiguraiotn {
@Autowired
private StaticKeyAuthenticationFilter filter;
@Bean
public SecurityFilterChain filterchain(HttpSecurity http) throws Exception{
//필터 체인에서 인증 필터 앞에 맞춤형 필터의 인스턴스를 추가한다.
http.
addFilterBefore(
new RequestValidationFilter(),
BasicAuthenticationFilter.class)
.addFilterAt(filter,
BasicAuthenticationFilter.class)
.authorizeRequests()
.anyRequest().permitAll();
return http.build();
}
맞춤형 필터를 인증 전에 실행하도록 HttpSecurity 객체의 addFilterBefore() 메서드를 이용합니다.
-test Controller-
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "hello";
}
}
curl -v http://localhost:8080/hello
...
<HTTP/1.1 400
...
curl -H "Request-Id:12345" http://localhost:8080/hello
hello
헤더 값을 추가하여 요청하면 HTTP 200 성공입니다. hello가 잘나옵니다.
-AuthenticationLoggingFilter 구현-
public class AuthenticationLoggingFilter implements Filter {
private final Logger logger = Logger.getLogger(AuthenticationLoggingFilter.class.getName());
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain filterChain)
throws IOException, ServletException {
var httpRequest
= (HttpServletRequest) request;
var RequestID =
httpRequest.getHeader("Request-ID");
logger.info("Successfully authenticated request with id "+ RequestID);
filterChain.doFilter(request,response);
}
}
-SecurityConfiguratoin-
@Configuration
public class SecurityConfiguraiotn {
@Autowired
private StaticKeyAuthenticationFilter filter;
@Bean
public SecurityFilterChain filterchain(HttpSecurity http) throws Exception{
//필터 체인에서 인증 필터 앞에 맞춤형 필터의 인스턴스를 추가한다.
http.
addFilterBefore(
new RequestValidationFilter(),
BasicAuthenticationFilter.class)
.addFilterAt(filter,
BasicAuthenticationFilter.class)
// 추가된 부분
.addFilterAfter(
new AuthenticationLoggingFilter(),
BasicAuthenticationFilter.class)
//추가된 부분
.authorizeRequests()
.anyRequest().permitAll();
return http.build();
}
맞춤형 필터를 인증 전에 실행하도록 HttpSecurity 객체의 addFilterBefore() 메서드를 이용합니다.
-test Controller-
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "hello";
}
}
curl -H "Request-Id:12345" http://localhost:8080/hello
hello
INFO 5876 ---[nio-8080-exec-2] c.l.s.f.AuthenticationLoggingFilter:Successfully authenticated request with id 12345
만약 HTTP Basic인증 흐름 대신 다른 인증을 구현하여 애플리케이션이 사용자를 인증하기 위한 자격 증명으로 사용자 이름과 암호 대신 다른 방식을 적용한다면? 3가지 시나리오를 예로 들 수 있습니다.
- 인증을 위한 정적 헤더 값에 기반을 둔 식별
- 대칭 키를 이용하여 인증 요청 서명
- 인증 프로세스에 OTP(일회용 암호) 이용
-인증을 위한 정적 헤더 값에 기반을 둔 식별-
정적 키에 기반을 둔 식별에서 클라이언트는 HTTP 요청의 헤더에 항상 동일한 문자열 하나를 앱으로 전달합니다. 애플리케이션은 이러한 값을 예를 들어 DB나 비밀 볼트에 저장합니다. 애플리케이션은 이 정적 값을 바탕으로 클라이언트를 식별합니다.
-대칭 키를 이용하여 인증 요청 서명-
대칭 키로 요청에 서명하고 검증하며 클라이언트와 서버가 모두 키의 값을 알고 있습니다. 즉, 클라이언트와 서버가 키를 공유합니다. 클라이언트는 이 키로 요청의 일부에 서명하고 서버는 같은 키로 서명이 유효한지 확인합니다. 서버는 각 클라이언트 개별 키를 DB나 비밀 볼트에 저장할 수 있습니다. 비슷하게 비대칭 키 쌍을 이용할 수 있습니다.
-인증 프로세스에 OTP(일회용 암호) 이용-
OTP 이용하는 인증 프로세스 입니다. 사용자는 문자 메시지를 통해 Google Authenticator와 같은 인증 공급자 앱으로 OTP를 받습니다.
필터 클래스인 StaticKeyAuthenticationFilter를 구현해봅시다.
-StaticKeyAuthenticationFilter 구현-
@Component // 속성 파일에서 값을 주입할 수 있도록 스프링 컨텍스트에 클래스의 인스턴스 추가
public class StaticKeyAuthenticationFilter implements Filter {
@Value("${authentication.key}")
private String authenticationKey;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain filterChain)
throws IOException, ServletException {
var httpRequest = (HttpServletRequest) request;
var httpResponse = (HttpServletResponse) response;
String authentication =
httpRequest.getHeader("Authentication");
if(authenticationKey.equals(authentication)){
filterChain.doFilter(request,response);
}else{
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}
필터를 정의한 후 BasicAuthenticationFilter클래스의 위치에 추가해보겠습니다.
이때, 특정 위치에 필터를 추가해도 스프링 시큐리티는 이 위치에 필터가 하나라고 가정하지 않습니다. 필터 체인의 같은 위치에 필터를 더 추가 가능하며, 이경우 스프링 시큐리는 필터가 실행되는 순서를 보장하지 않습니다. 즉, 개발자 사이에서 혼동이 올 수 있으며, 기존 필터와 대체되는 것이 아니기때문에 필터 체인에 필요 없는필터는 아예 추가하지 않도록 해야합니다.
-SecurityFilterChain -
@Bean
public SecurityFilterChain filterchain(HttpSecurity http) throws Exception{
//필터 체인에서 인증 필터 앞에 맞춤형 필터의 인스턴스를 추가한다.
http.
addFilterBefore(
new RequestValidationFilter(),
BasicAuthenticationFilter.class)
// 추가된 부분
.addFilterAt(filter,
BasicAuthenticationFilter.class)
// 추가된 부분
.addFilterAfter(
new AuthenticationLoggingFilter(),
BasicAuthenticationFilter.class)
.authorizeRequests()
.anyRequest().permitAll();
return http.build();
}
authorization.key=SD8QWIcqe2
curl -H "Authorization=SD8QWIcqe2" http://localhost:8080/hello
hello
결과 반환 성공!
헤더가 없다면 401 에러가 뜹니다!
스프링 시큐리티는 Filter인터페이스를 구현하는 여러 추상 클래스가 있습니다. 이를 위해 필터 장의를 확장할 수 있스빈다.
OncePerRequestFilter가 있습니다. GenericFilterBean을 확장하는 더 유용한 클래스이며, 이름을 보면 알 수 있듯이 요청당 한 번만 실행하도록 논리를 구현합니다.
public class AuthenticationLoggingFilterOnce extends OncePerRequestFilter {
private final Logger logger = Logger.getLogger(AuthenticationLoggingFilterOnce.class.getName());
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String RequestID= request.getHeader("Request-ID");
logger.info("Successfully authenticated request with ID" + RequestID);
filterChain.doFilter(request,response);
}
OncePerRequestFilter를 확장하였고, 여기서 재정의할 메서드는 doFilterInternal입니다.
-SecurityFilterChain -
@Bean
public SecurityFilterChain filterchain2(HttpSecurity http) throws Exception{
//필터 체인에서 인증 필터 앞에 맞춤형 필터의 인스턴스를 추가한다.
http
.addFilterAt(filter,
BasicAuthenticationFilter.class)
.addFilterBefore(new StaticKeyAuthenticationLoggingFilter(),
BasicAuthenticationFilter.class)
.authorizeRequests()
.anyRequest().permitAll();
return http.build();
}
}