Notion - Spring security에서 DeferredResult 처리 시 401 Unauthorized
문제
401 - Full authentication is required to access this resource
org.springframework.security.access.AccessDeniedException: Access Denied
최근에 팀 프로젝트 진행하면서 Long polling을 이용하여 알림기능을 구현하였다.
이 과정에서 DeferredResult와 Spring security 같이 사용하게 되었는데, 요청 시에는 정상적으로 인증이 되었어도 응답 시에 401 Unauthorized가 반환되는 상황이 발생했다.
이슈가 발생했던 코드는 아래와 같다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal) {
// 사용자 정보 가져오기
Member member = memberService.getMemberByUsername(principal.getName());
// DeferredResult 생성 및 저장소에 저장
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
deferredResult.setErrorResult(new NoMoreNotificationException());
});
return deferredResult;
}
해결 시도
원인을 파악하기 위해 일주일이라는 정말 많은 시간이 소요 되었다.
그만큼 원인을 파악하기 힘들었는데, 그 과정을 조금 공유해볼까 한다.
비동기 처리 시 SecurityContext 미전파
처음에는 단순히 비동기 처리 시 SecurityContext가 자식 스레드에 전파되지 않아서 발생한 문제로 파악했었다.
DeferredResult는 비동기로 요청을 처리해야할 때 사용되는데, 이로 인해 중간에 SecurityContext를 잃어버려 발생하는 이슈로 짐작을 하고 있었다.
DelegatingSecuritContextRunnable/Callable
DelegatingSecurityContextRunnable
를 사용하여 비동기 처리 시에도 해당 스레드에도 SecurityContext를 전파할 수 있다.
그렇기에 아래와 같이 코드를 작성하여 SecurityContext 전파를 시도하였지만, 해결책이 되지는 못했다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal) {
SecurityContext context = SecurityContextHolder.getContext();
Member member = memberService.getMemberByUsername(principal.getName());
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
Runnable timeoutTask = new Runnable() {
@Override
public void run() {
SecurityContextHolder.setContext(context);
deferredResult.setErrorResult(new NoMoreNotificationException());
}
};
DelegatingSecurityContextRunnable wrappedTimeoutTask = new DelegatingSecurityContextRunnable(timeoutTask, context);
wrappedTimeoutTask.run();
});
deferredResult.onCompletion(() -> {
Runnable completionTask = new Runnable() {
@Override
public void run() {
SecurityContextHolder.setContext(context);
}
};
DelegatingSecurityContextRunnable wrappedCompletionTask = new DelegatingSecurityContextRunnable(completionTask, context);
wrappedCompletionTask.run();
});
return deferredResult;
}
SecurityContextHolder Mode 변경
다음 해결책으로는 SecurityContextHolder의 스레드 전파 방식을 변경하는 방법이 있었다.
- ThreadLocalStrategy
- 기본 전략으로, 각 스레드별로 SecurityContext를 분리하여 저장한다.
- InheritableThreadLocalStrategy
- 부모 스레드에서 생성된 자식 스레드가 부모의 SecurityContext를 상속받을 수 있도록 한다.
- GlobalStrategy
- 애플리케이션 내 모든 스레드가 동일한 SecurityContext를 공유한다.
이 중 InheritableThreadLocalStrategy
과 GlobalStrategy
방식을 둘 다 시도해보았지만, 해결되지 않았다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal) {
Member member = memberService.getMemberByUsername(principal.getName());
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
deferredResult.setErrorResult(new NoMoreNotificationException());
});
return deferredResult;
}
원인
원인은 비동기 요청 처리 시의 로직에 있었다.

비동기 요청 처리 시 사용되는 DeferredResult
는 위와 같은 로직으로 동작한다.
단순히 요청 시에만 인증정보를 확인하는 게 아니라 응답 시에도 인증 정보를 한번 더 검사한다.
이는 보안을 위한 요소로 Spring security는 보통 SecurityContext
를 저장소에 저장하여 관리한다.
Spring Security 6 버전 부터 SecurityContext
를 관리하는 기본 방식인 SecurityContextHolderFilter
를 사용하게 되었다.
SecurityContextHolderFilter 는 이전 버전과는 다르게 SecurityContextRepository
의 구현체를 등록해주지 않으면 인증 객체를 저장할 수 없다는 점이 문제였다.
이로 인해 응답 시에 다른 스레드에서 인증을 한 번 더 진행하는 DeferredResult
의 경우는 SecurityContext
를 불러올 방법이 없어 401 Unauthorized 를 반환 할 수 밖에 없었던 것이다.
그렇기에 위에서 시도했던 해결책들이 도움이 되지 않았던 것은 응답을 반환하는 부분이 아닌 처리하는 부분에 SecurityContext
전파를 시도했기 때문이다.
Spring Security 6 버전에서의 인증 영속성과 세션 관리에 대한 내용은 아래에서 더 자세히 정리해두었으니 참고하면 좋을 것 같다.
Spring Security 6 - Authentication Persistence and Session Management
해결 방법
그렇다면 이제는 해결 방법에 대해서 알아보자.
Spring Security 설정 변경 (SecurityConfig 수정)
Spring security에 대한 Config 설정 시 아래와 같이 어떤 SecurityContextRepository
를 사용하여 저장할 지 설정해줄 수 있다.
http.securityContext((securityContext) -> securityContext
.securityContextRepository(new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
))
);
- DelegatingSecurityContextRepository
SecurityContextRepository
구현체를 체인으로 연결해준다.- 연결된 목록을 순회하며
SecurityContext
를 저장하거나 복원하는데 사용된다.
- RequestAttributeSecurityContextRepository
SecurityContext
를 HTTP 요청의 속성으로 저장하고 복원할 때 사용된다.- 현재 요청을 처리하는 동안 스레드 간 SecurityContext를 올바르게 전파하여 비동기 작업의 문제점을 해결 할 수 있다.
- HttpSessionSecurityContextRepository
- SecurityContext를 세션에 저장하고 복원한다.
- 현재 세션을 공유하는 모든 스레드에서 SecurityContext가 올바르게 전파될 수 있도록 한다.
- 하지만 이는 Session을 사용하지 않는 애플리케이션에서는 사용할 수 없다.
위 설명한 Repository 중에서 상황에 맞게 추가하여 사용하면 된다.
그렇기에 RequestAttributeSecurityContextRepository
만을 사용해도 이를 해결 할 수 있었다.
요청 내부에서 명시적으로 저장 (요청 내부 수정)
전체적인 변경이 필요한 것이 아니라 요청 하나에 대해서만 SecurityContextRepository
를 사용한 저장을 하기를 원한다면 아래와 같이 수정할 수 있다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal, HttpServletRequest request, HttpServletResponse response) {
Member member = memberService.getMemberByUsername(principal.getName());
// Create a new SecurityContext and set the authentication
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(principal);
// Save the SecurityContext to the request
RequestAttributeSecurityContextRepository requestAttributeSecurityContextRepository = new RequestAttributeSecurityContextRepository();
requestAttributeSecurityContextRepository.saveContext(securityContext, request, response);
// DeferredResult 생성 및 저장소에 저장
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
deferredResult.setErrorResult(new NoMoreNotificationException());
});
return deferredResult;
}
필요한 부분에 HttpServletRequest
, HttpServletResponse
를 매개변수로 추가해야한다.
이후 RequestAttributeSecurityContextRepository
를 선언하여 saveContext()
를 통하여 SecurityContext를 요청, 응답과 함께 명시적으로 저장해주었다.
위와 같이 요청, 응답 간의 SecurityContext를 유지함으로써 문제를 해결할 수 있었다.
인증 미진행 (RequestMatchers의 PermitAll())
마지막 방법으로는 프로젝트를 진행하면서 우회 방법으로 선택하게 되었던 내용이다.
해당 요청에 대한 인증을 거치지 않게 함으로써 문제를 해결할 수 있었다.
http.authorizeHttpRequests(auth -> {
auth.requestMatchers(HttpMethod.GET, "/subscribe").permitAll();
auth.anyRequest().authenticated();
};
구현했던 알림기능은 타 기능들에 대해 중요한 정보를 포함하고 있지도 않으며, 원인 파악에 너무 오랜시간을 소요하는 것이 부담스러웠기 때문에 우회 방법으로 선택하게 되었다.
물론, 이는 온전한 해결책이 아닌 우회하는 방법이기 때문에 중요한 정보가 포함되어 있다거나 내키지 않는다면 다른 방법을 선택하는 게 좋을 것 같다.
참고
https://github.com/spring-projects/spring-security/issues/11962
https://docs.spring.io/spring-security/reference/5.8/migration/servlet/session-management.html
https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
'트러블슈팅' 카테고리의 다른 글
[오류를 잡아보자] @Transactional Duplicate entry 이슈 (0) | 2024.05.14 |
---|---|
[오류를 잡아보자] NoClassDefFoundError / ClassNotFoundException: org.hibernate.dialect.MySQL57Dialect (2) | 2024.01.28 |
Kakao Login 시 401 Unauthorized (1) | 2024.01.26 |
CreatedDate, LastModifiedDate 사용 시 값이 들어가지 않는 이슈 (0) | 2024.01.21 |
[오류를 잡아보자] 생성자 바인딩 이슈 (Cannot resolve parameter names for constructor) (0) | 2024.01.17 |
Notion - Spring security에서 DeferredResult 처리 시 401 Unauthorized
문제
401 - Full authentication is required to access this resource
org.springframework.security.access.AccessDeniedException: Access Denied
최근에 팀 프로젝트 진행하면서 Long polling을 이용하여 알림기능을 구현하였다.
이 과정에서 DeferredResult와 Spring security 같이 사용하게 되었는데, 요청 시에는 정상적으로 인증이 되었어도 응답 시에 401 Unauthorized가 반환되는 상황이 발생했다.
이슈가 발생했던 코드는 아래와 같다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal) {
// 사용자 정보 가져오기
Member member = memberService.getMemberByUsername(principal.getName());
// DeferredResult 생성 및 저장소에 저장
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
deferredResult.setErrorResult(new NoMoreNotificationException());
});
return deferredResult;
}
해결 시도
원인을 파악하기 위해 일주일이라는 정말 많은 시간이 소요 되었다.
그만큼 원인을 파악하기 힘들었는데, 그 과정을 조금 공유해볼까 한다.
비동기 처리 시 SecurityContext 미전파
처음에는 단순히 비동기 처리 시 SecurityContext가 자식 스레드에 전파되지 않아서 발생한 문제로 파악했었다.
DeferredResult는 비동기로 요청을 처리해야할 때 사용되는데, 이로 인해 중간에 SecurityContext를 잃어버려 발생하는 이슈로 짐작을 하고 있었다.
DelegatingSecuritContextRunnable/Callable
DelegatingSecurityContextRunnable
를 사용하여 비동기 처리 시에도 해당 스레드에도 SecurityContext를 전파할 수 있다.
그렇기에 아래와 같이 코드를 작성하여 SecurityContext 전파를 시도하였지만, 해결책이 되지는 못했다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal) {
SecurityContext context = SecurityContextHolder.getContext();
Member member = memberService.getMemberByUsername(principal.getName());
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
Runnable timeoutTask = new Runnable() {
@Override
public void run() {
SecurityContextHolder.setContext(context);
deferredResult.setErrorResult(new NoMoreNotificationException());
}
};
DelegatingSecurityContextRunnable wrappedTimeoutTask = new DelegatingSecurityContextRunnable(timeoutTask, context);
wrappedTimeoutTask.run();
});
deferredResult.onCompletion(() -> {
Runnable completionTask = new Runnable() {
@Override
public void run() {
SecurityContextHolder.setContext(context);
}
};
DelegatingSecurityContextRunnable wrappedCompletionTask = new DelegatingSecurityContextRunnable(completionTask, context);
wrappedCompletionTask.run();
});
return deferredResult;
}
SecurityContextHolder Mode 변경
다음 해결책으로는 SecurityContextHolder의 스레드 전파 방식을 변경하는 방법이 있었다.
- ThreadLocalStrategy
- 기본 전략으로, 각 스레드별로 SecurityContext를 분리하여 저장한다.
- InheritableThreadLocalStrategy
- 부모 스레드에서 생성된 자식 스레드가 부모의 SecurityContext를 상속받을 수 있도록 한다.
- GlobalStrategy
- 애플리케이션 내 모든 스레드가 동일한 SecurityContext를 공유한다.
이 중 InheritableThreadLocalStrategy
과 GlobalStrategy
방식을 둘 다 시도해보았지만, 해결되지 않았다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal) {
Member member = memberService.getMemberByUsername(principal.getName());
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
deferredResult.setErrorResult(new NoMoreNotificationException());
});
return deferredResult;
}
원인
원인은 비동기 요청 처리 시의 로직에 있었다.

비동기 요청 처리 시 사용되는 DeferredResult
는 위와 같은 로직으로 동작한다.
단순히 요청 시에만 인증정보를 확인하는 게 아니라 응답 시에도 인증 정보를 한번 더 검사한다.
이는 보안을 위한 요소로 Spring security는 보통 SecurityContext
를 저장소에 저장하여 관리한다.
Spring Security 6 버전 부터 SecurityContext
를 관리하는 기본 방식인 SecurityContextHolderFilter
를 사용하게 되었다.
SecurityContextHolderFilter 는 이전 버전과는 다르게 SecurityContextRepository
의 구현체를 등록해주지 않으면 인증 객체를 저장할 수 없다는 점이 문제였다.
이로 인해 응답 시에 다른 스레드에서 인증을 한 번 더 진행하는 DeferredResult
의 경우는 SecurityContext
를 불러올 방법이 없어 401 Unauthorized 를 반환 할 수 밖에 없었던 것이다.
그렇기에 위에서 시도했던 해결책들이 도움이 되지 않았던 것은 응답을 반환하는 부분이 아닌 처리하는 부분에 SecurityContext
전파를 시도했기 때문이다.
Spring Security 6 버전에서의 인증 영속성과 세션 관리에 대한 내용은 아래에서 더 자세히 정리해두었으니 참고하면 좋을 것 같다.
Spring Security 6 - Authentication Persistence and Session Management
해결 방법
그렇다면 이제는 해결 방법에 대해서 알아보자.
Spring Security 설정 변경 (SecurityConfig 수정)
Spring security에 대한 Config 설정 시 아래와 같이 어떤 SecurityContextRepository
를 사용하여 저장할 지 설정해줄 수 있다.
http.securityContext((securityContext) -> securityContext
.securityContextRepository(new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
))
);
- DelegatingSecurityContextRepository
SecurityContextRepository
구현체를 체인으로 연결해준다.- 연결된 목록을 순회하며
SecurityContext
를 저장하거나 복원하는데 사용된다.
- RequestAttributeSecurityContextRepository
SecurityContext
를 HTTP 요청의 속성으로 저장하고 복원할 때 사용된다.- 현재 요청을 처리하는 동안 스레드 간 SecurityContext를 올바르게 전파하여 비동기 작업의 문제점을 해결 할 수 있다.
- HttpSessionSecurityContextRepository
- SecurityContext를 세션에 저장하고 복원한다.
- 현재 세션을 공유하는 모든 스레드에서 SecurityContext가 올바르게 전파될 수 있도록 한다.
- 하지만 이는 Session을 사용하지 않는 애플리케이션에서는 사용할 수 없다.
위 설명한 Repository 중에서 상황에 맞게 추가하여 사용하면 된다.
그렇기에 RequestAttributeSecurityContextRepository
만을 사용해도 이를 해결 할 수 있었다.
요청 내부에서 명시적으로 저장 (요청 내부 수정)
전체적인 변경이 필요한 것이 아니라 요청 하나에 대해서만 SecurityContextRepository
를 사용한 저장을 하기를 원한다면 아래와 같이 수정할 수 있다.
@GetMapping(value = "/subscribe")
public DeferredResult<List<NotificationResponse>> getNewNotifications(Principal principal, HttpServletRequest request, HttpServletResponse response) {
Member member = memberService.getMemberByUsername(principal.getName());
// Create a new SecurityContext and set the authentication
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(principal);
// Save the SecurityContext to the request
RequestAttributeSecurityContextRepository requestAttributeSecurityContextRepository = new RequestAttributeSecurityContextRepository();
requestAttributeSecurityContextRepository.saveContext(securityContext, request, response);
// DeferredResult 생성 및 저장소에 저장
DeferredResult<List<NotificationResponse>> deferredResult =
notificationService.getDeferredResult(member.getId().toString());
deferredResult.onTimeout(() -> {
deferredResult.setErrorResult(new NoMoreNotificationException());
});
return deferredResult;
}
필요한 부분에 HttpServletRequest
, HttpServletResponse
를 매개변수로 추가해야한다.
이후 RequestAttributeSecurityContextRepository
를 선언하여 saveContext()
를 통하여 SecurityContext를 요청, 응답과 함께 명시적으로 저장해주었다.
위와 같이 요청, 응답 간의 SecurityContext를 유지함으로써 문제를 해결할 수 있었다.
인증 미진행 (RequestMatchers의 PermitAll())
마지막 방법으로는 프로젝트를 진행하면서 우회 방법으로 선택하게 되었던 내용이다.
해당 요청에 대한 인증을 거치지 않게 함으로써 문제를 해결할 수 있었다.
http.authorizeHttpRequests(auth -> {
auth.requestMatchers(HttpMethod.GET, "/subscribe").permitAll();
auth.anyRequest().authenticated();
};
구현했던 알림기능은 타 기능들에 대해 중요한 정보를 포함하고 있지도 않으며, 원인 파악에 너무 오랜시간을 소요하는 것이 부담스러웠기 때문에 우회 방법으로 선택하게 되었다.
물론, 이는 온전한 해결책이 아닌 우회하는 방법이기 때문에 중요한 정보가 포함되어 있다거나 내키지 않는다면 다른 방법을 선택하는 게 좋을 것 같다.
참고
https://github.com/spring-projects/spring-security/issues/11962
https://docs.spring.io/spring-security/reference/5.8/migration/servlet/session-management.html
https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html
'트러블슈팅' 카테고리의 다른 글
[오류를 잡아보자] @Transactional Duplicate entry 이슈 (0) | 2024.05.14 |
---|---|
[오류를 잡아보자] NoClassDefFoundError / ClassNotFoundException: org.hibernate.dialect.MySQL57Dialect (2) | 2024.01.28 |
Kakao Login 시 401 Unauthorized (1) | 2024.01.26 |
CreatedDate, LastModifiedDate 사용 시 값이 들어가지 않는 이슈 (0) | 2024.01.21 |
[오류를 잡아보자] 생성자 바인딩 이슈 (Cannot resolve parameter names for constructor) (0) | 2024.01.17 |