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 어노테이션을 사용해 이벤트 리스너를 비동기로 변경할 수 있음
- 커스터마이징을 해줄 필요가 거의 없음.
- 응답 헤더에 보안 관련 헤더를 추가함.
- 관련 이슈에 대해 기본적인 방어 기능만 제공하는 것으로 완벽하게 방어되지 않음.
- 또한 브라우저마다 다르게 동작할 수 있으므로 유의해야함.
- 브라우저에서 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 헤더
- XSS : 웹 상에서 가장 기초적인 취약점 공격방법의 일종.
악의적인 사용자가 공격하려는 사이트에 스크립트를 넣는 기법.
- 일반적으로 브라우저에는 XSS공격을 방어하기 위한 필터링 기능이 내장.
- 물론 해당 필터가 XSS 공격을 완전히 방어하지는 못하지만, XSS 공격의 보호에 많은 도움이 됨.
X-XSS-Protection: 1; mode=block
공격이 탐지되면 웹 페이지 자체를 보여주지 말라는 의미.
X-XSS-Protection HTTP헤더: 외부 자바스크립트 실행 방어
- 브라우저 캐시 설정에 따라 사용자가 인증 후 방문한 페이지를 로그 아웃한 후 캐시된 페이지를 악의적인 사용자가 볼 수 있다.
- 공용 PC 등에서 민감정보를 확인했을 때 캐시가 남게되면, 다른 사람의 정보를 확인할 수 있게됨. 실수에서 끝날수도 있지만, 악의적인 공격자가 캐시를 취득하면 곤란해질수도.
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
- 웹 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 어떤 것을 클릭하게 속이는 악의적 기법
- 보통 사용자의 인식 없이 실행될 수 있는 임베디드 코드나 스크립트의 형태
X-Frame-Options: DENY
외부 사이트에 의한 프레이밍 원천 금지
클릭재킹
- 서비스가 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는 다른 공격 기법임
- 사이트 간 요청위조
공격 과정
- 이용자는 웹사이트에 로그인하여 정상적인 쿠키를 발급받음.
- 공격자는 다음과 같은 링크를 이메일이나 게시판 등의 경로를 통해 이용자에게 전달.
http://www.geocities.com/attacker
- 공격용 HTML 페이지는 다음과 같은 이미지 태그를 갖음.
<img src= "https://travel.service.com/travel_update?.src=Korea&.dst=Hell">
- 해당 링크는 클릭 시 정상적인 경우 출발지와 도착지를 등록하기 위한 링크.
위의 경우에는 도착지를 변조하였음.
- 이용자가 공격용 페이지를 열면, 브라우저는 이미지 파일을 받아오기 위한 공격용 URL을 연다.
- 이용자의 승인이나 인지 없이 출발지와 도착지가 등록됨으로 공격이 완료된다. 해당 서비스 페이지는 등록 과정에 대해 단순히 쿠키를 통한 본인확인 밖에 하지 않으므로 공격자가 정상적인 이용자의 수정이 가능하게 된다.
방어 방법
- 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);
}