

웹 요청은 어떻게 FilterChainProxy로 전달될까?
- 웹 요청은 DelegatingFilterProxy라는 객체를 통해 FilterChainProxy로 전달된다.
- 이 과정에서 DelegatingFilterProxy는 웹 요청을 필터 체인으로 넘겨주고, 필터 체인에서 보안 작업을 처리한 후 결과를 반환하게 된다.
- DelegatingFilterProxy Bean은 SecurityFilterAutoConfiguration 클래스에서 자동으로 등록됨
@Bean
@ConditionalOnBean(name = DEFAULT_FILTER_NAME)
public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(SecurityProperties securityProperties) {
DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean(DEFAULT_FILTER_NAME);
registration.setOrder(securityProperties.getFilter().getOrder());
registration.setDispatcherTypes(getDispatcherTypes(securityProperties));
return registration;
}
Spring Security는 정말 다양한 필터 구현을 제공하고, 시큐리티를 잘 이해하고 활용한다는 것은 이들 Filter를 이해하고, 적절하게 사용한다는 것을 의미한다.
참고자료 - 스프링 시큐리티 공식 문서
익명 사용자가 보호 받는 리소스 (예: /me)에 접근할 경우, 접근 권한이 없기 때문에 AccessDecisionManager 에서 접근 거부 예외가 발생한다.
이때, ExceptionTranslationFilter 접근 거부 예외를 처리하고, 현재 사용자가 익명 사용자라면, 보호 받는 리소스로의 접근을 캐시처리하고, 로그인 페이지로 이동 시킨다.
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
authentication), exception);
}
sendStartAuthentication(request, response, chain,
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
if (logger.isTraceEnabled()) {
logger.trace(
LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
exception);
}
this.accessDeniedHandler.handle(request, response, exception);
}
}
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContextHolder.getContext().setAuthentication(null);
this.requestCache.saveRequest(request, response); // 기존 요청을 캐시 처리하여 저장한다.
this.authenticationEntryPoint.commence(request, response, reason);
}
RequestCacheAwareFilter를 통해 위에서 살펴본 캐시된 요청을 처리할 수 있다.
캐시된 요청이 있다면 캐시된 요청을 처리하고, 캐시된 요청이 없다면 현재 요청을 처리한다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest wrappedSavedRequest = this.requestCache.getMatchingRequest((HttpServletRequest) request,
(HttpServletResponse) response);
chain.doFilter((wrappedSavedRequest != null) ? wrappedSavedRequest : request, response);
}
전송 레이어 보안을 위해 SSL 인증서를 생성하고, 이를 Spring Boot 웹 어플리케이션에 적용한다. 이제 웹 어플리케이션은 HTTPS 프로토콜을 통해 서비스 된다.
Http vs Https + SSL
- HTTP(Hyper Text Transfer Protocol)는 인터넷상에서 데이터를 주고 받기 위한 프로토콜
- 클라이언트와 서버가 주고 받는 데이터는 암호화되어 있지 않음
- 따라서, 악의적인 데이터 감청, 데이터 변조의 가능성이 있음
- HTTPS(HyperT ext Transfer Protocol Secure)는 HTTP 프로토콜의 암호화 버전
- 클라이언트와 서버가 주고 받는 모든 데이터는 암호화되어 있음
- 데이터 암호화를 위해 SSL(Secure Sockets Layer)을 사용
- SSL
- SSL은 Netscape가 개발했으며 SSL 3.0부터 TLS라는 이름으로 변경되었다.
- SSL 암호화를 위해 SSL 인증서가 필요하다.
- SSL 인증 방식
- 서버는 SSL인증서를 클라이언트에 전달함
- 클라이언트는 서버가 전달한 SSL 인증서를 검증하고, 신뢰할 수 있는 서버인지 확인함
- 신뢰할 수 있는 서버라면 SSL 인증서의 공개키를 이용해 실제 데이터 암호화에 사용될 암호화키를 암호화하여 서버에 전달함
public class ChannelProcessingFilter extends GenericFilterBean {
private ChannelDecisionManager channelDecisionManager;
private FilterInvocationSecurityMetadataSource securityMetadataSource;
// ...생략...
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
FilterInvocation filterInvocation = new FilterInvocation(request, response, chain);
Collection<ConfigAttribute> attributes = this.securityMetadataSource.getAttributes(filterInvocation);
if (attributes != null) {
this.logger.debug(LogMessage.format("Request: %s; ConfigAttributes: %s", filterInvocation, attributes));
this.channelDecisionManager.decide(filterInvocation, attributes);
if (filterInvocation.getResponse().isCommitted()) {
return;
}
}
chain.doFilter(request, response);
}
// ...생략...
}
+) HttpSecurity 클래스를 통해 ChannelProcessingFilter 세부 설정 가능
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// HTTP 요청을 HTTPS 요청으로 리다이렉트
.requiresChannel()
.anyRequest().requiresSecure()
;
}
해당 필터에 요청이 도달할때까지 사용자가 인증되지 않았다면, 사용자를 null 대신 Anonymous 인증 타입으로 표현한다.
사용자가 null 인지 확인하는것보다 어떤 구체적인 타입으로 확인할수 있도록 한다.
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() == null) { // 인증되지 않은 사용자라면
SecurityContextHolder.getContext().setAuthentication(createAuthentication((HttpServletRequest) req)); // createAuthentication() 호출
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.of(() -> "Set SecurityContextHolder to "
+ SecurityContextHolder.getContext().getAuthentication()));
}
else {
this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
}
}
else {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.of(() -> "Did not set SecurityContextHolder since already authenticated "
+ SecurityContextHolder.getContext().getAuthentication()));
}
}
chain.doFilter(req, res);
}
protected Authentication createAuthentication(HttpServletRequest request) { // Anonymous 인증 타입으로 표현
AnonymousAuthenticationToken token = new AnonymousAuthenticationToken(this.key, this.principal, this.authorities);
token.setDetails(this.authenticationDetailsSource.buildDetails(request));
return token;
}
+) httpSecurity 클래스를 통해 AnonymousAuthenticationFilter 세부 설정 가능
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// AnonymousAuthenticationFilter 세부 설정
.anonymous()
.principal("thisIsAnonymousUser") // username 설정
.authorities("ROLE_ANONYMOUS", "ROLE_UNKNOWN") // 권한 설정
;
}
FilterSecurityInterceptor 바로 위에 위치하며, FilterSecurityInterceptor 실행 중 발생할 수 있는 예외를 잡고 처리함
💡 주의사항
필터 체인 상에서 ExceptionTranslationFilter 의 위치를 주의해서 볼 필요가 있다.
ExceptionTranslationFilter는 필터 체인 실행 스택에서 자기 아래에 오는 필터들에서 발생하는 예외들에 대해서만 처리할 수 있다.
커스텀 필터를 추가해야 하는 경우 이 내용을 잘 기억하고, 커스텀 필터를 적당한 위치에 두어야 한다.
FilterSecurityInterceptor 실행 중 발생 가능한 AuthenticationException, AccessDeniedException 예외에 대한 처리를 담당한다.
AuthenticationEntryPoint

기본 구현은 org.springframework.security.web.access.AccessDeniedHandlerImpl 클래스
구현 내용 - 접근 거부 요청에 대한 로깅 처리 및 HTTP 403 응답 생성
@Bean
public AccessDeniedHandler accessDeniedHandler() { //
return (request, response, e) -> {
/*
Authentication -> 인증 주체 즉, 사용자를 표현하는 객체
Authentication.getPrincipal() -> 인증 전, 인증 후 가리지 않고 사용자의 정보를 Object 타입으로 포괄적으로 표현
Authentication.isAuthenticated() -> 사용자가 인증되었는지 boolean 타입으로 확인 가능
*/
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication != null ? authentication.getPrincipal() : null;
log.warn("{} is denied", principal, e);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("text/plain");
response.getWriter().write("## ACCESS DENIED ##");
response.getWriter().flush();
response.getWriter().close();
};
}
아래 HttpSecurity 클래스의 exceptionHandling() 메소드를 통해 앞서 Bean으로 등록한 AccessDeniedHandler를 설정할 수 있다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 커스터마이징한 AccessDeniedHandler 추가
.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler())
;
}

HTTP GET 요청에 대해 디폴트 로그인 페이지를 생성해주는 필터이다.
http
// ... 생략 ...
.formLogin()
.loginPage("/mylogin")
.permitAll()
.and()
// ... 생략 ...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username : "";
username = username.trim();
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}

관련 이슈에 대해 기본적인 방어 기능만 제공하는것으로 완벽하게 방어되진 않고, 브라우저마다 다르게 동작할 수 있으므로 유의해야 한다.
X-Content-Type-Options: nosniff
참고자료 - MIME 형식의 보안위협 완화: X-Content-Type-Options 헤더
X-XSS-Protection: 1; mode=block
참고자료 - X-XSS-Protection HTTP헤더
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
참고자료 - Strict-Transport-Security
CSRF (Cross-site request forgery) 란
사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 공격을 말한다.
+) XSS는 자바스크립트를 실행시키는 것이고, CSRF는 특정한 행동을 시키는 것으로, XSS과 CSRF는 다른 공격 기법
- CSRF를 통해 악의적인 공격자는 아래 2개 조건을 만족하면 사용자의 권한을 도용하여 중요 기능을 실행하는 것이 가능해진다.
- 위조 요청을 전송하는 서비스에 사용자가 로그인 상태
- 사용자가 해커가 만든 피싱 사이트에 접속


CsrfFilter는 요청이 리소스를 변경해야 하는 요청인지 확인하고, 맞다면 CSRF 토큰을 검증함 (기본적으로 활성화됨)
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
boolean missingToken = (csrfToken == null);
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match "
+ this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
return;
}
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
this.logger.debug(
LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
: new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, exception);
return;
}
filterChain.doFilter(request, response);
}
HTTPS 프로토콜에서만 제한적으로 사용 (보통은 사용 X)
Authorization: Basic dXNlcjp1c2VyMTIzhttp.httpBasic()Spring MVC Async Request (반환 타입이 Callable) 처리에서 SecurityContext를 공유할수 있도록 하는 역할이다.
@GetMapping(path = "/asyncHello")
@ResponseBody
public Callable<String> asyncHello() {
log.info("[Before callable] asyncHello started.");
Callable<String> callable = () -> {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User principal = authentication != null ? (User) authentication.getPrincipal() : null;
String name = principal != null ? principal.getUsername() : null;
log.info("[Inside callable] Hello {}", name);
return "Hello " + name;
};
log.info("[After callable] asyncHello completed.");
return callable;
}

public final class SecurityContextCallableProcessingInterceptor extends CallableProcessingInterceptorAdapter {
private volatile SecurityContext securityContext;
//... 생략 ...
@Override
public <T> void beforeConcurrentHandling(NativeWebRequest request, Callable<T> task) {
if (this.securityContext == null) {
setSecurityContext(SecurityContextHolder.getContext());
}
}
@Override
public <T> void preProcess(NativeWebRequest request, Callable<T> task) {
SecurityContextHolder.setContext(this.securityContext);
}
@Override
public <T> void postProcess(NativeWebRequest request, Callable<T> task, Object concurrentResult) {
SecurityContextHolder.clearContext();
}
//... 생략 ...
}
SecurityContext는 ThreadLocal<T> 변수를 이용하고 있고, 따라서 다른 쓰레드에서는 SecurityContext를 참조할수 없어야 한다.WebAsyncManagerIntegrationFilter는 MVC Async Request가 처리될 때, 쓰레드간 SecurityContext를 공유할수 있게 해준다.SecurityContextCallableProcessingInterceptor 클래스를 이용함beforeConcurrentHandling() — HTTP 요청을 처리하고 있는 WAS 쓰레드에서 실행SecurityContextHolder.getContext() 부분은 ThreadLocal<T>l의 SecurityContext 정상적으로 참조한다.ThreadLocal<T>의 SecurityContext 객체를 SecurityContextCallableProcessingInterceptor 클래스 멤버변수에 할당한다.preProcess(), postProcess() — 별도 쓰레드에서 실행@Async 어노테이션을 추가한 Service 레이어 메소드에는 해당 되지 않는다.@Controller
public class SimpleController {
public final Logger log = LoggerFactory.getLogger(getClass());
private final SimpleService simpleService;
public SimpleController(SimpleService simpleService) {
this.simpleService = simpleService;
}
// ... 생략 ...
@GetMapping(path = "/someMethod")
@ResponseBody
public String someMethod() {
log.info("someMethod started.");
simpleService.asyncMethod();
log.info("someMethod completed.");
return "OK";
}
}
@Service
public class SimpleService {
public final Logger log = LoggerFactory.getLogger(getClass());
@Async
public String asyncMethod() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User principal = authentication != null ? (User) authentication.getPrincipal() : null;
String name = principal != null ? principal.getUsername() : null;
log.info("asyncMethod result: {}", name);
return name;
}
}
SecurityContextHolderStrategy 설정값을 기본값 MODE_THREADLOCAL 에서 MODE_INHERITABLETHREADLOCAL 으로 변경SecurityContext를 참조할 수 있게됨SecurityContextHolderStrategy 인터페이스 구현체를 기본값 ThreadLocalSecurityContextHolderStrategy 에서 InheritableThreadLocalSecurityContextHolderStrategy 으로 변경함SecurityContext 저장 변수를 ThreadLocal 에서 InheritableThreadLocal 타입으로 변경하게됨InheritableThreadLocal — 부모 쓰레드가 생성한 ThreadLocal 변수를 자식 쓰레드에서 참조할 수 있음public WebSecurityConfigure() {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}

MODE_INHERITABLETHREADLOCAL을 설정하여 이용하는 것은 그다지 권장할 만한 방법이 아님TaskExecutor와 함께 사용시 ThreadLocal의 clear 처리가 제대로되지 않아 문제될 수 있음 (예 — ThreadPoolTaskExecutor)TaskExecutor와 함께 사용해야 함 (예 — SimpleAsyncTaskExecutor)DelegatingSecurityContextRunnable 타입으로 wrapping 처리함DelegatingSecurityContextRunnable 객체 생성자에서 SecurityContextHolder.getContext() 메소드를 호출하여 SecurityContext 참조를 획득@Bean
public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(5);
executor.setThreadNamePrefix("task-");
return executor;
}
@Bean
public DelegatingSecurityContextAsyncTaskExecutor taskExecutor(ThreadPoolTaskExecutor delegate) {
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
참고자료 - Using strategy MODE_INHERITABLETHREADLOCAL is dangerous with thread pools