백엔드 데브코스 TIL 43일차

Inchang Choi·2022년 5월 28일
0

백엔드 데브코스 TIL

목록 보기
27/30
post-thumbnail

학습목표

강의를 들으며 내가 알고 있는 내용을 점검하고,

새로 배운 내용을 정리하며,

궁금한 내용을 알아가며 학습해나가는 것을 목표로 합니다.

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

HeaderWriterFilter는 응답 헤더에 보안 관련 헤더를 추가합니다.

커스텀 수요가 거의 없는 필터입니다. 특징은 아래와 같습니다.

  • 관련 이슈에 대해 기본적인 방어 기능만 제공 하는 것으로 완벽하게 방어되지 않습니다.
  • 또한 브라우저마다 다르게 동작할 수 있으므로 유의해야 합니다.
  • XContentTypeOptionsHeaderWriter — MIME sniffing 공격 방어
    • 브라우서에서 MIME sniffing을 사용하여 Request Content Type 을 추측 할 수 있는데 이것은 XSS 공격에 악용될 수 있습니다.

    • 지정된 MIME 형식 이외의 다른 용도로 사용하고자 하는 것을 차단합니다.

      X-Content-Type-Options: nosniff

      MIME 형식의 보안위협 완화: X-Content-Type-Options 헤더

  • XXssProtectionHeaderWriter : 브라우저에 내장된 XSS(Cross-Site Scripting) 필터 활성화
    • XSS : 웹 상에서 가장 기초적인 취약점 공격 방법의 일종으로, 악의적인 사용자가 공격하려는 사이트에 스크립트를 넣는 기법을 말합니다.

    • 일반적으로 브라우저에는 XSS공격을 방어하기 위한 필터링 기능이 내장되어 있습니다.

    • 물론 해당 필터로 XSS공격을 완벽하게 방어하지는 못하지만 XSS 공격의 보호에 많은 도움이 됩니다.

      X-XSS-Protection: 1; mode=block

      X-XSS-Protection헤더 시험 페이지

  • CacheControlHeadersWriter : 캐시를 사용하지 않도록 설정합니다.
    • 브라우저 캐시 설정에 따라 사용자가 인증 후 방문한 페이지를 로그아웃 한 후 캐시 된 페이지를 악의적인 사용자가 볼 수 있습니다.

      Cache-Control: no-cache, no-store, max-age=0, must-revalidate
      Pragma: no-cache
      Expires: 0
  • XFrameOptionsHeaderWriter : clickjacking 공격 방어
    • 웹 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 어떤 것을 클릭하게 속이는 악의적인 기법

    • 보통 사용자의 인식 없이 실행될 수 있는 임베디드 코드나 스크립트의 형태

      X-Frame-Options: DENY

      클릭재킹 - 위키백과, 우리 모두의 백과사전

  • HstsHeaderWriter : HTTP 대신 HTTPS만을 사용하여 통신해야함을 브라우저에 알립니다. (HTTPS 설정 시 관련 헤더 추가됨)
    Strict-Transport-Security: max-age=31536000 ; includeSubDomains
    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 보호를 비활성화 할 수 있습니다.
  • 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 인코딩하여 포함 합니다.

💡 Authorization: Basic dXNlcjp1c2VyMTIz
  • "dXNlcjp1c2VyMTIz" Base64 decode — user:user123
  • 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;
}

앞에서 살펴본 바에 의하면, 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;
  }

}
  1. SecurityContextHolderStrategy 설정값을 기본값 MODE_THREADLOCAL 에서 MODE_INHERITABLETHREADLOCAL 으로 변경합니다.

  2. 다른 쓰레드(task-1)에서도 SecurityContext를 참조할 수 있게 됩니다.

  3. SecurityContextHolderStrategy 인터페이스 구현체를 기본값 ThreadLocalSecurityContextHolderStrategy 에서 InheritableThreadLocalSecurityContextHolderStrategy 으로 변경합니다.

  4. SecurityContext 저장 변수를 ThreadLocal 에서 InheritableThreadLocal 타입으로 변경하게 됩니다.

  5. 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);
}

Using strategy MODE_INHERITABLETHREADLOCAL is dangerous with thread pools · Issue #6856 · spring-projects/spring-security

profile
always positive

0개의 댓글