Spring Security Internals -3

김재현·2022년 8월 29일
1

Programmers

목록 보기
22/28

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 메소드를 수정하기로 한다.
  • 이처럼 요구사항이 변화할 때 관련 코드를 지속해서 수정해야하는 것은 해당 코드가 높은 결합도를 가지고 있고, 확장에 닫혀 있기 때문이다. → 결합도가 높다는 것은 OOP관점에서 전혀 좋은 것이 아니다.
  • 이 문제를 이벤트 발생-구독 모델로 접근한다면 Spring Security의 인프라스트럭처 위에서 수정해야 하는 것은 아무것도 없다. 단지 인증 성공 이벤트를 구독하는 리스너를 추가만 하면된다.
  • 이메일 발송 리스너 — 로그인 성공 이벤트를 수신하고, 이메일을 발송함
  • SMS 발송 리스너 — 로그인 성공 이벤트를 수신하고, SMS를 발송함
  • 또 다른 발송 채널을 추가해야 한다면 기존 코드는 수정할 필요가 없다. 그저 필요한 리스너를 추가하면된다.

AuthenticationEventPublisher

  • 인증 성공 또는 실패가 발생했을 때 이벤트를 전달하기 위한 이벤트 퍼블리셔 인터페이스
  • 기본 구현체로 DefaultAuthenticationEventPublisher 클래스가 사용됨

이벤트의 종류

  • 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 형식 이외의 다른 용도로 사용하고자 하는 것을 차단한다.
  • 예시) 공격자가 .jpg 형식의 이미지 파일을 올렸는데, 실제로 그 파일은 이미지 파일이 아닌 java scripts 파일이었다. 브라우저의 요청에 웹 서버는 이 파일을 image/jpg로 응답을 반환하게 된다.
    이제 공격자가 이 파일을 자바스크립트로 사용하려고 할 때 X-Content-Type-Options : nosniff Header를 같이 전송하게되면 웹 브라우저는 파일의 형식이 image/jpg이고 (자바)스크립트를 가리키는 것이 아니란 것을 인식하여 파일을 자바 스크립트로 실행하는 것을 차단하게 된다.
  • X-Content-Type-Options: nosniff
    MIME형식 해석 제한... X-Content-Type-Options: nosniff 헤더

XXssProtectionHeaderWriter — 브라우저에 내장된 XSS(Cross-Site Scripting) 필터 활성화

  • XSS : 웹 상에서 가장 기초적인 취약점 공격방법의 일종.
    악의적인 사용자가 공격하려는 사이트에 스크립트를 넣는 기법.
  • 일반적으로 브라우저에는 XSS공격을 방어하기 위한 필터링 기능이 내장.
  • 물론 해당 필터가 XSS 공격을 완전히 방어하지는 못하지만, XSS 공격의 보호에 많은 도움이 됨.
  • X-XSS-Protection: 1; mode=block
    공격이 탐지되면 웹 페이지 자체를 보여주지 말라는 의미.
    X-XSS-Protection HTTP헤더: 외부 자바스크립트 실행 방어

CacheControlHeadersWriter — 캐시를 사용하지 않도록 설정

  • 브라우저 캐시 설정에 따라 사용자가 인증 후 방문한 페이지를 로그 아웃한 후 캐시된 페이지를 악의적인 사용자가 볼 수 있다.
  • 공용 PC 등에서 민감정보를 확인했을 때 캐시가 남게되면, 다른 사람의 정보를 확인할 수 있게됨. 실수에서 끝날수도 있지만, 악의적인 공격자가 캐시를 취득하면 곤란해질수도.
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 설정 시 관련 헤더 추가됨)

  • 서비스가 HTTPS로 서비스되고 있다면 https:/가 URL에 추가된다.
  • Strict-Transport-Security: max-age=31536000; includeSubDomains
    1년동안 HTTPS로 접속하며, 서브도메인에도 모두 적용된다.
    Strict-Transport-Security

CsrfFilter

  • CSRF (Cross-site request forgery, 사이트 간 요청위조) : 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 공격을 말함
  • '웹 서비스는 인증된 사용자의 웹 브라우저를 신용하고 있다.'는 점을 노리는 공격.
    • CSRF를 통해 악의적인 공격자는 사용자의 권한을 도용하여 중요 기능을 실행하는 것이 가능해짐 (아래 2개 조건을 만족해야 함)
      • 위조 요청을 전송하는 서비스에 사용자가 로그인 상태
      • 사용자가 해커가 만든 피싱 사이트에 접속
    • XSS는 자바스크립트를 실행시키는 것이고, CSRF는 특정한 행동을 시키는 것으로 XSS과 CSRF는 다른 공격 기법임
  • 사이트 간 요청위조

공격 과정

  1. 이용자는 웹사이트에 로그인하여 정상적인 쿠키를 발급받음.
  2. 공격자는 다음과 같은 링크를 이메일이나 게시판 등의 경로를 통해 이용자에게 전달.
    http://www.geocities.com/attacker
  3. 공격용 HTML 페이지는 다음과 같은 이미지 태그를 갖음.
    <img src= "https://travel.service.com/travel_update?.src=Korea&.dst=Hell">
  • 해당 링크는 클릭 시 정상적인 경우 출발지와 도착지를 등록하기 위한 링크.
    위의 경우에는 도착지를 변조하였음.
  1. 이용자가 공격용 페이지를 열면, 브라우저는 이미지 파일을 받아오기 위한 공격용 URL을 연다.
  2. 이용자의 승인이나 인지 없이 출발지와 도착지가 등록됨으로 공격이 완료된다. 해당 서비스 페이지는 등록 과정에 대해 단순히 쿠키를 통한 본인확인 밖에 하지 않으므로 공격자가 정상적인 이용자의 수정이 가능하게 된다.

방어 방법

  • Referrer 검증 — Request의 referrer를 확인하여 domain이 일치하는지 확인
    • 요청을 생성하는 페이지, 즉 피싱사이트의 CSRF 코드가 담긴 페이지가 정상적으로 웹 서비스에서 제공한 페이지인지 검증하는 방법.
  • CSRF Token 활용
    • Referrer 검증보다 확실한 방법
    • 사용자의 세션에 임의의 토큰 값을 저장하고 (로그인 완료 여부와 상관없음), 사용자의 요청 마다 해당 토큰 값을 포함 시켜 전송
    • 리소스를 변경해야하는 요청(POST, PUT, DELETE 등. GET등 읽기 전용 메서드는 제외)을 받을 때마다 사용자의 세션에 저장된 토큰 값과 요청 파라미터에 전달되는 토큰 값이 일치하는 지 검증.
      일치하지 않는다면 오류를 발생시킴.
    • 브라우저가 아닌 클라이언트에서 사용하는 서비스의 경우 CSRF 보호를 비활성화 할 수 있음
  • CsrfFilter는 요청이 리소스를 변경해야 하는 요청인지 확인하고, 맞다면 CSRF 토큰을 검증함 (기본적으로 활성화됨)
    • CsrfTokenRepository — CSRF 토큰 저장소 인터페이스이며 기본 구현체로 HttpSessionCsrfTokenRepository 클래스가 사용됨
  • ThymeleafView가 자동적으로 CSRF 토큰을 추가해주기 때문에 ThymeleafView를 만들 때 CSRF 토큰의 존재에 크게 신경쓰지 않아도 된다.
  • 주의점은 form태그를 선언할 때 action부분에 th라는 타임리프 프리픽스를 붙여주어야 한다. 또한 타임리프 형식에 맞게 코드를 만들어주어야 한다.
    fomr th:action
    만약 프리픽스 부분을 빼고 일반 HTML문서를 만들듯이 하면 인풋 히든 필드가 없어진다.

BasicAuthenticationFilter

  • Basic 인증을 처리함
    • HTTPS 프로토콜에서만 제한적으로 사용해야 함 (보통은 사용하지 않음)
    • HTTP 요청 헤더에 username과 password를 세미콜론으로 결합.
      Base64 인코딩하여 포함.
      • dXNlcjp1c2VyMTIz" Base64 decode — user;user123
      • 🔒 Authorization: Basic dXNlcjp1c2VyMTIz
    • 헤더 키는 Authorization, 헤더 밸류는 Basic키워드와 함께 결합하여 Base64 인코드 된 값을 보내게 됨.
    • HTTP 요청 헤더에 username과 password가 노출되기 때문에 반드시 HTTPS 프로토콜과 함께 사용하여야 함.
    • 많이 사용되지 않고, 특히 B2C 같은 불특정 다수를 대상으로 하는 서비스에서는 거의 사용되지 않음.
      내부 서버간 통신을 위한 제한적인 환경에서 BasicAuthentication이 사용될 수 있다.
  • Form 인증과 동일하게 UsernamePasswordAuthenticationToken을 사용함
  • httpBasic() 메소드를 호출하여 활성화 시킴 (기본 비활성화)
    http.httpBasic()

WebAsyncManagerIntegrationFilter

  • Spring MVC Async Request (반환 타입이 Callable) 처리에서 SecurityContext를 공유할 수 있게 함

Thread For Request 모델과 Thread Local 변수

  • 스프링 웹 MVC는 Thread For Request 모델을 기반으로 하고 있음. 하나의 HTTP요청이 완료될 때까지 모두 동일한 쓰레드에서 실행을 함.
  • Thread Local 변수는 개별 쓰레드들의 독립된 저장공간. 동일 쓰레드 내에서 실행되고 있는 코드라면 코드의 어느 부분에서든지 SecurityContextHolder를 통해서 쓰레드 로컬 변수에 저장된 SecurityContext를 참조할 수 있음.
  • 스프링 웹 MVC에서는 HTTP요청 처리를 별도의 쓰레드로 분리하여 처리할 수 있게하는 기능이 있는데, 그것이 바로 Spring Web Async Request.
@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() — 별도 쓰레드에서 실행
  • 단, 위 기능은 Spring MVC Async Request 처리에서만 적용되며 (즉, Controller 메소드) @Async 어노테이션을 추가한 Service 레이어 메소드에는 해당 안됨

  • 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);
}

0개의 댓글