지난 미션 리뷰
"/admin" URL 접근에 대한 접근 권한 검사를 SpEL 표현식 방식에서 voter 방식으로 변경해보기 (OddAdminVoter 클래스)
- AccessDecisionVoter<FilterInvocation> 인터페이스를 구현하는 OddAdminVoter 클래스 추가
- Admin 사용자의 로그인 아이디 끝 숫자가 홀수 인 경우 접근 허용
- URL이 "/admin" 이 아닌 경우 접근을 승인함
- expressionHandler(expressionHandler()) 부분 삭제 — 기본 expressionHandler를 사용함
- 표현식에서 oddAdmin 부분 삭제 (삭제 후 표현식은 아래와 같음)
antMatchers("/admin").access("isFullyAuthenticated() and hasRole('ADMIN')")
- AccessDecisionManager 인터페이스 구현체 중 UnanimousBased를 사용하도록 설정하고, 아래 voter를 추가
- WebExpressionVoter
- OddAdminVoter
솔루션
- OddAdminVoter 구현
- RequestMatcher — URL이 "/admin" 이 아닌 경우를 확인하고 접근을 승인 처리함
- 그 외 구현은 기존과 동일
public class OddAdminVoter implements AccessDecisionVoter<FilterInvocation> { static final Pattern PATTERN = Pattern.compile("[0-9]+$"); private final RequestMatcher requiresAuthorizationRequestMatcher; public OddAdminVoter(RequestMatcher requiresAuthorizationRequestMatcher) { this.requiresAuthorizationRequestMatcher = requiresAuthorizationRequestMatcher; } @Override public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) { HttpServletRequest request = fi.getRequest(); if (!requiresAuthorization(request)) { return ACCESS_GRANTED; } User user = (User) authentication.getPrincipal(); String name = user.getUsername(); Matcher matcher = PATTERN.matcher(name); if (matcher.find()) { int number = toInt(matcher.group(), 0); if (number % 2 == 1) { return ACCESS_GRANTED; } } return ACCESS_DENIED; } private boolean requiresAuthorization(HttpServletRequest request) { return requiresAuthorizationRequestMatcher.matches(request); } @Override public boolean supports(ConfigAttribute attribute) { return true; } @Override public boolean supports(Class<?> clazz) { return FilterInvocation.class.isAssignableFrom(clazz); } }
- HttpSecurity 설정
- AccessDecisionManager 구현체로 UnanimousBased 구현체를 사용
- 순차적으로 AccessDecisionVoter 추가
- WebExpressionVoter
- OddAdminVoter — 생성자 인자로 해당 voter가 처리해야 하는 URL 패턴을 넘김
@Bean public AccessDecisionManager accessDecisionManager() { List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>(); decisionVoters.add(new WebExpressionVoter()); decisionVoters.add(new OddAdminVoter(new AntPathRequestMatcher("/admin"))); return new UnanimousBased(decisionVoters); } @Override protected void configure(HttpSecurity http) throws Exception { http // ... 생략 ... .authorizeRequests() .antMatchers("/me").hasAnyRole("USER", "ADMIN") .antMatchers("/admin").access("isFullyAuthenticated() and hasRole('ADMIN')") .anyRequest().permitAll() .accessDecisionManager(accessDecisionManager()) .and() // ... 생략 ... }
- user 계정으로 로그인 하면 어떻게 될까?
- UnanimousBased 구현에서는 순차적으로 실행되는 voter 중 접근 거부(ACCESS_DENIED)가 발생하면 즉시 AccessDeniedException 예외를 발생시킴
- voter 목록 중 WebExpressionVoter가 먼저 실행되며, ROLE_ADMIN 권한 검사가 먼저 이루어짐
- 이 과정에서 접근 거부되고, 예외가 발생함
- 따라서, OddAdminVoter는 실행 조차 되지 않음
Spring Security 인증 이벤트
인증 성공 또는 실패가 발생했을 때 관련 이벤트(ApplicationEvent)가 발생하고, 해당 이벤트에 관심있는 컴포넌트는 이벤트를 구독할 수 있다.
주의해야 할 부분은 Spring의 이벤트 모델이 동기적이라는 것이다. 따라서 이벤트를 구독하는 리스너의 처리 지연은 이벤트를 발생시킨 요청의 응답 지연에 직접적인 영향을 미친다.
그렇다면 왜 이벤트 모델을 사용해야 할까? 이벤트 모델은 컴포넌트 간의 느슨한 결합을 유지하는데 도움을 준다. 예를들어 로그인 성공 시 사용자에게 이메일을 발송해야 하는 시스템을 생각해보자. 우리는 이제 Spring Security의 인프라스트럭처를 잘 이해하고 있으므로 최대한 이를 이용하려 할 것이다.
- AbstractAuthenticationProcessingFilter 추상 클래스를 상속하고, 인증이 성공했을 때 수행되는 successfulAuthentication 메소드를 override 함
- 또는 AuthenticationSuccessHandler를 재정의할 수 있음
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { sendEmail(authResult); super.successfulAuthentication(request, response, chain, authResult); }
그런데 어느날, 로그인 성공 시 이메일 뿐만 아니라 SMS 전송도 함께 이루어져야 한다는 요구사항을 받았다. 우리는 앞서 만들었던 successfulAuthentication 메소드를 수정하기로 한다.
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { sendEmail(authResult); sendSms(authResult); super.successfulAuthentication(request, response, chain, authResult); }
이 처럼 요구사항이 변화할 때 관련 코드를 지속해서 수정해야하는 것은 해당 코드가 높은 결합도를 가지고 있고, 확장에 닫혀 있기 때문이다. 이 문제를 이벤트 발생-구독 모델로 접근한다면 Spring Security의 인프라스트럭처 위에서 수정해야 하는 것은 아무것도 없다. 단지 인증 성공 이벤트를 구독하는 리스너를 추가만 하면된다.
- 이메일 발송 리스너 — 로그인 성공 이벤트를 수신하고, 이메일을 발송함
- SMS 발송 리스너 — 로그인 성공 이벤트를 수신하고, SMS를 발송함
또 다른 발송 채널을 추가해야 한다면 기존 코드는 수정할 필요가 없다. 그저 필요한 리스너를 추가하면된다.
AuthenticationEventPublisher
- 인증 성공 또는 실패가 발생했을 때 이벤트를 전달하기 위한 이벤트 퍼블리셔 인터페이스
- 기본 구현체로 DefaultAuthenticationEventPublisher 클래스가 사용됨
public interface AuthenticationEventPublisher { void publishAuthenticationSuccess(Authentication authentication); void publishAuthenticationFailure(AuthenticationException exception, Authentication authentication); }
이벤트의 종류
- AuthenticationSuccessEvent — 로그인 성공 이벤트
- AbstractAuthenticationFailureEvent — 로그인 실패 이벤트 (실패 이유에 따라 다양한 구체 클래스가 정의되 있음)
이벤트 리스너
- @EventListener 어노테이션을 이용하여 리스너 등록
@Component public class CustomAuthenticationEventHandler { private final Logger log = LoggerFactory.getLogger(getClass()); @EventListener public void handleAuthenticationSuccessEvent(AuthenticationSuccessEvent event) { Authentication authentication = event.getAuthentication(); log.info("Successful authentication result: {}", authentication.getPrincipal()); } @EventListener public void handleAuthenticationFailureEvent(AbstractAuthenticationFailureEvent event) { Exception e = event.getException(); Authentication authentication = event.getAuthentication(); log.warn("Unsuccessful authentication result: {}", authentication, e); } }
- 주의해야 할 점은 Spring의 이벤트 모델이 동기적이기 때문에 이벤트를 구독하는 리스너에서 처리가 지연되면, 이벤트를 발행하는 부분 처리도 지연됨
- @EnableAsync로 비동기 처리를 활성화하고, @Async 어노테이션을 사용해 이벤트 리스너를 비동기로 변경할 수 있음
그 밖의 필터들
HeaderWriterFilter
- 응답 헤더에 보안 관련 헤더를 추가함
- 관련 이슈에 대해 기본적인 방어 기능만 제공하는것으로 완벽하게 방어되지 않음
- 또한 브라우저마다 다르게 동작할 수 있으므로 유의해야함
- XContentTypeOptionsHeaderWriter — MIME sniffing 공격 방어
- 브라우서에서 MIME sniffing을 사용하여 Request Content Type 을 추측 할 수 있는데 이것은 XSS 공격에 악용될 수 있음
- 지정된 MIME 형식 이외의 다른 용도로 사용하고자 하는 것을 차단
X-Content-Type-Options: nosniff
- XSS — 웹 상에서 가장 기초적인 취약점 공격 방법의 일종으로, 악의적인 사용자가 공격하려는 사이트에 스크립트를 넣는 기법을 말함
- 일반적으로 브라우저에는 XSS공격을 방어하기 위한 필터링 기능이 내장되어 있음
- 물론 해당 필터로 XSS공격을 완벽하게 방어하지는 못하지만 XSS 공격의 보호에 많은 도움이 됨
X-XSS-Protection: 1; mode=block
- 브라우저 캐시 설정에 따라 사용자가 인증 후 방문한 페이지를 로그 아웃한 후 캐시 된 페이지를 악의적인 사용자가 볼 수 있음
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
CsrfFilter
- CSRF (Cross-site request forgery) — 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 공격을 말함
- 방법 방법
- Referrer 검증 — Request의 referrer를 확인하여 domain이 일치하는지 확인
- CSRF Token 활용
- 사용자의 세션에 임의의 토큰 값을 저장하고 (로그인 완료 여부와 상관없음), 사용자의 요청 마다 해당 토큰 값을 포함 시켜 전송
- 리소스를 변경해야하는 요청(POST, PUT, DELETE 등)을 받을 때마다 사용자의 세션에 저장된 토큰 값과 요청 파라미터에 전달되는 토큰 값이 일치하는 지 검증
- 브라우저가 아닌 클라이언트에서 사용하는 서비스의 경우 CSRF 보호를 비활성화 할 수 있음

- CsrfFilter는 요청이 리소스를 변경해야 하는 요청인지 확인하고, 맞다면 CSRF 토큰을 검증함 (기본적으로 활성화됨)
- CsrfTokenRepository — CSRF 토큰 저장소 인터페이스이며 기본 구현체로 HttpSessionCsrfTokenRepository 클래스가 사용됨
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); }
BasicAuthenticationFilter
- Basic 인증을 처리함
- HTTPS 프로토콜에서만 제한적으로 사용해야 함 (보통은 사용하지 않음)
- HTTP 요청 헤더에 username과 password를 Base64 인코딩하여 포함
- "dXNlcjp1c2VyMTIz" Base64 decode — user:user123
Authorization: Basic dXNlcjp1c2VyMTIz
- Form 인증과 동일하게 UsernamePasswordAuthenticationToken을 사용함
- httpBasic() 메소드를 호출하여 활성화 시킴 (기본 비활성화)
http.httpBasic()
WebAsyncManagerIntegrationFilter
- 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; }
- 아래 실행 로그를 살펴보면, Callable 실행 로직이 다른 쓰레드에서 실행되었음에도 SecurityContext를 제대로 참조했음
- MVC 핸들러 쓰레드 — XNIO-1 task-2
- Callable 실행 쓰레드 — task-1

- 앞에서 살펴본 바에 의하면, SecurityContext는 ThreadLocal 변수를 이용하고 있고, 따라서 다른 쓰레드에서는 SecurityContext를 참조할수 없어야 함
- WebAsyncManagerIntegrationFilter는 MVC Async Request가 처리될 때, 쓰레드간 SecurityContext를 공유할수 있게 해줌
- SecurityContextCallableProcessingInterceptor 클래스를 이용함
- beforeConcurrentHandling() — HTTP 요청을 처리하고 있는 WAS 쓰레드에서 실행
- 해당 메소드 구현의 SecurityContextHolder.getContext() 부분은 ThreadLocal의 SecurityContext 정상적으로 참조함
- 즉, ThreadLocal의 SecurityContext 객체를 SecurityContextCallableProcessingInterceptor 클래스 멤버변수에 할당함
- preProcess(), postProcess() — 별도 쓰레드에서 실행
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(); } //... 생략 ... }
- 단, 위 기능은 Spring MVC Async Request 처리에서만 적용되며 (즉, Controller 메소드) @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 으로 변경
- 다른 쓰레드(task-1)에서도 SecurityContext를 참조할 수 있게됨
- SecurityContextHolderStrategy 인터페이스 구현체를 기본값 ThreadLocalSecurityContextHolderStrategy 에서 InheritableThreadLocalSecurityContextHolderStrategy 으로 변경함
- SecurityContext 저장 변수를 ThreadLocal 에서 InheritableThreadLocal 타입으로 변경하게됨
- InheritableThreadLocal — 부모 쓰레드가 생성한 ThreadLocal 변수를 자식 쓰레드에서 참조할 수 있음
public WebSecurityConfigure() { SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); }

- DelegatingSecurityContextAsyncTaskExecutor
- MODE_INHERITABLETHREADLOCAL을 설정하여 이용하는 것은 그다지 권장할 만한 방법이 아님
- Pooling 처리된 TaskExecutor와 함께 사용시 ThreadLocal의 clear 처리가 제대로되지 않아 문제될 수 있음 (예 — ThreadPoolTaskExecutor)
- Pooling 되지 TaskExecutor와 함께 사용해야 함 (예 — SimpleAsyncTaskExecutor)
- 내부적으로 Runnable을 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); }
미션
- Spring Security의 주요 개념과 필터들에 대해 정리해보기