HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🔒
Spring Boot — Spring Security Essentials
/
5️⃣
05. Spring Security Internals Part. 3
05. Spring Security Internals Part. 3
5️⃣

05. Spring Security Internals Part. 3

github.com
https://github.com/prgrms-be-devcourse/w16-SpringBoot_Part_C/tree/class_5

지난 미션 리뷰

"/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); }
AuthenticationEventPublisher 구현 발췌
이벤트의 종류
  • 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
        MIME 형식의 보안위협 완화: X-Content-Type-Options 헤더
        bullet; 웹 해킹 훈련장 172.16.15.116 40080/tcp 대상 공개용 도구 기반의 홈페이지 취약점 점검 실습 (20200507) &bullet; Kali Linux 2020.1b 64bit 설치 설명서 (MS 윈도우 10, VMware 플레이어) (20200325) &bullet; VirtualBox 가상머신으로 GSM CE 6.0.2 설치 설명서 (OpenVAS) (20200125) &bullet; Kali Linux 2019.4 64bit Light 배포판 설치 설명서 (권장) (20191202) &bullet;
        MIME 형식의 보안위협 완화: X-Content-Type-Options 헤더
        https://webhack.dynu.net/?idx=20161120.001
        MIME 형식의 보안위협 완화: X-Content-Type-Options 헤더
    • XXssProtectionHeaderWriter — 브라우저에 내장된 XSS(Cross-Site Scripting) 필터 활성화
      • XSS — 웹 상에서 가장 기초적인 취약점 공격 방법의 일종으로, 악의적인 사용자가 공격하려는 사이트에 스크립트를 넣는 기법을 말함
      • 일반적으로 브라우저에는 XSS공격을 방어하기 위한 필터링 기능이 내장되어 있음
      • 물론 해당 필터로 XSS공격을 완벽하게 방어하지는 못하지만 XSS 공격의 보호에 많은 도움이 됨
      • X-XSS-Protection: 1; mode=block
        X-XSS-Protection헤더 시험 페이지
        bullet; 웹 해킹 훈련장 172.16.15.116 40080/tcp 대상 공개용 도구 기반의 홈페이지 취약점 점검 실습 (20200507)
        X-XSS-Protection헤더 시험 페이지
        https://webhack.dynu.net/?idx=20161119.001
    • CacheControlHeadersWriter — 캐시를 사용하지 않도록 설정
      • 브라우저 캐시 설정에 따라 사용자가 인증 후 방문한 페이지를 로그 아웃한 후 캐시 된 페이지를 악의적인 사용자가 볼 수 있음
      • Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0
    • XFrameOptionsHeaderWriter — clickjacking 공격 방어
      • 웹 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 어떤 것을 클릭하게 속이는 악의적인 기법
      • 보통 사용자의 인식 없이 실행될 수 있는 임베디드 코드나 스크립트의 형태
      • X-Frame-Options: DENY
        클릭재킹 - 위키백과, 우리 모두의 백과사전
        클릭재킹(Clickjacking, User Interface redress attack, UI redress attack, UI redressing)은 웹 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 어떤 것을 클릭하게 속이는 악의적인 기법으로써 잠재적으로 공격자는 비밀 정보를 유출시키거나 그들의 컴퓨터에 대한 제어를 획득할 수 있게 된다. 이것은 다양한 웹 브라우저들과 컴퓨팅 플랫폼들에서의 취약점으로서 브라우저 보안 이슈이다.
        클릭재킹 - 위키백과, 우리 모두의 백과사전
        https://ko.wikipedia.org/wiki/%ED%81%B4%EB%A6%AD%EC%9E%AC%ED%82%B9
        클릭재킹 - 위키백과, 우리 모두의 백과사전
    • HstsHeaderWriter — HTTP 대신 HTTPS만을 사용하여 통신해야함을 브라우저에 알림 (HTTPS 설정 시 관련 헤더 추가됨)
      • Strict-Transport-Security: max-age=31536000 ; includeSubDomains
        Strict-Transport-Security - HTTP | MDN
        HTTP Strict-Transport-Security response header (종종 로 약칭) 는 HTTP 대신 HTTPS만을 사용하여 통신해야한다고 웹사이트가 브라우저에 알리는 보안 기능. Strict-Transport-Security: max-age= Strict-Transport-Security: max-age= ; includeSubDomains Strict-Transport-Security: max-age= ; preload 이 사이트가 HTTPS 로만 접근되어야 한다고 기억되어야 하는 시간(초). includeSubDomains Optional 이 옵션이 적용되면, 이 사이트의 모든 서브도메인에 규칙이 적용된다는 것을 의미한다.
        Strict-Transport-Security - HTTP | MDN
        https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Strict-Transport-Security
        Strict-Transport-Security - HTTP | MDN
CsrfFilter
  • CSRF (Cross-site request forgery) — 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 공격을 말함
    • CSRF를 통해 악의적인 공격자는 사용자의 권한을 도용하여 중요 기능을 실행하는 것이 가능해짐 (아래 2개 조건을 만족해야 함)
      • 위조 요청을 전송하는 서비스에 사용자가 로그인 상태
      • 사용자가 해커가 만든 피싱 사이트에 접속
      • https://rusyasoft.github.io/java/2019/02/15/spring-security-csrf-from-context/
    • XSS는 자바스크립트를 실행시키는 것이고, CSRF는 특정한 행동을 시키는 것으로 XSS과 CSRF는 다른 공격 기법임
  • 방법 방법
    • Referrer 검증 — Request의 referrer를 확인하여 domain이 일치하는지 확인
    • CSRF Token 활용
      • 사용자의 세션에 임의의 토큰 값을 저장하고 (로그인 완료 여부와 상관없음), 사용자의 요청 마다 해당 토큰 값을 포함 시켜 전송
      • 리소스를 변경해야하는 요청(POST, PUT, DELETE 등)을 받을 때마다 사용자의 세션에 저장된 토큰 값과 요청 파라미터에 전달되는 토큰 값이 일치하는 지 검증
      • 브라우저가 아닌 클라이언트에서 사용하는 서비스의 경우 CSRF 보호를 비활성화 할 수 있음
      • 로그인 페이지를 보면 _csrf 라는 이름으로 hidden input이 있는것을 확인할 수 있다
        로그인 페이지를 보면 _csrf 라는 이름으로 hidden input이 있는것을 확인할 수 있다
  • 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); }
      CsrfFilter 구현 일부 발췌
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
notion image
  • 앞에서 살펴본 바에 의하면, 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(); } //... 생략 ... }
      SecurityContextCallableProcessingInterceptor 구현 일부 발췌
  • 단, 위 기능은 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; } }
      SimpleService 의 메소드에서 SecurityContext 참조 결과가 null 이라는 것을 확인할 수 있다.
      SimpleService 의 메소드에서 SecurityContext 참조 결과가 null 이라는 것을 확인할 수 있다.
  • SecurityContextHolderStrategy 설정값을 기본값 MODE_THREADLOCAL 에서 MODE_INHERITABLETHREADLOCAL 으로 변경
    • 다른 쓰레드(task-1)에서도 SecurityContext를 참조할 수 있게됨
    • SecurityContextHolderStrategy 인터페이스 구현체를 기본값 ThreadLocalSecurityContextHolderStrategy 에서 InheritableThreadLocalSecurityContextHolderStrategy 으로 변경함
    • SecurityContext 저장 변수를 ThreadLocal 에서 InheritableThreadLocal 타입으로 변경하게됨
      • InheritableThreadLocal — 부모 쓰레드가 생성한 ThreadLocal 변수를 자식 쓰레드에서 참조할 수 있음
      public WebSecurityConfigure() { SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); }
      notion image
  • 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); }
      Using strategy MODE_INHERITABLETHREADLOCAL is dangerous with thread pools · Issue #6856 · spring-projects/spring-security
      You can't perform that action at this time. You signed in with another tab or window. You signed out in another tab or window. Reload to refresh your session. Reload to refresh your session.
      Using strategy MODE_INHERITABLETHREADLOCAL is dangerous with thread pools · Issue #6856 · spring-projects/spring-security
      https://github.com/spring-projects/spring-security/issues/6856
      Using strategy MODE_INHERITABLETHREADLOCAL is dangerous with thread pools · Issue #6856 · spring-projects/spring-security

미션

  • Spring Security의 주요 개념과 필터들에 대해 정리해보기