안녕하세요. 작성을 시작하는건 8.29일이지만 최종 작성날짜는 언제일지 모르겠습니다.
설명이 많이 어색한 부분도 있고, 좀 정리가 안된 포인트가 몇 군데 존재합니다.
사실 정확하게 이렇게 해야합니다, 라고 하기에는 정확한 해답일지 모르겠습니다만,
그래도 이전 버전(5.7
)과 동일하게 동작을 하도록 맞춰서 해결을 해보았습니다.
5.7 버전과 비교했습니다!. 참고 바랍니다.
public LoginAuthenticationFilter(final String defaultFilterProcessesUrl,
final AuthenticationManager authenticationManager) {
super(defaultFilterProcessesUrl, authenticationManager);
// 로그인 이후 Context 생성 전략 설정
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response)
throws AuthenticationException, IOException {
String method = request.getMethod();
if (!method.equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
ServletInputStream inputStream = request.getInputStream();
LoginRequestDto loginRequestDto = new ObjectMapper().readValue(inputStream, LoginRequestDto.class);
return this.getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(
loginRequestDto.username,
loginRequestDto.password
));
}
public record LoginRequestDto(
String username,
String password
){}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class);
sharedObject.userDetailsService(this.userDetailsService);
AuthenticationManager authenticationManager = sharedObject.build();
http.authenticationManager(authenticationManager);
http
.csrf(AbstractHttpConfigurer::disable)
// .formLogin(Customizer.withDefaults())
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequest ->
authorizeRequest
.requestMatchers(
antMatcher("/auth/**")
).hasRole("MEMBER")
.requestMatchers(
antMatcher("/h2-console/**")
).permitAll()
.anyRequest().permitAll()
)
.addFilterAt(
this.abstractAuthenticationProcessingFilter(authenticationManager),
UsernamePasswordAuthenticationFilter.class)
.headers(
headersConfigurer ->
headersConfigurer
.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin
)
);
return http.build();
}
1) 로그인 요청에 대한 형식을 변경했습니다.
2) UserDetailsService 클래스를 구현했습니다.
마지막으로 사용했던 버전은 5.7
입니다.
그리고 현재 작성중에 사용하는 버전은 6.1.2
버전입니다.
핵심 로직의 주요 변화 포인트는 다음과 같습니다.
1. SecurityContext를 담는 ThreadLocal 의 타입 변화
2. SecurityContextRepository 인터페이스의 실제 활용 코드
번외로 가장 중요한 클래스는 SecurityContextRepository
의 구현체인
DelegatingSecurityContextRepository
클래스 입니다.
5.7 버전의 코드
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
@Override
public void clearContext() {
contextHolder.remove();
}
@Override
public SecurityContext getContext() {
SecurityContext ctx = contextHolder.get();
if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}
return ctx;
}
@Override
public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(context);
}
@Override
public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}
6.1 버전의 코드
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal<>();
@Override
public void clearContext() {
contextHolder.remove();
}
@Override
public SecurityContext getContext() {
return getDeferredContext().get();
}
@Override
public Supplier<SecurityContext> getDeferredContext() {
Supplier<SecurityContext> result = contextHolder.get();
if (result == null) {
SecurityContext context = createEmptyContext();
result = () -> context;
contextHolder.set(result);
}
return result;
}
@Override
public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(() -> context);
}
@Override
public void setDeferredContext(Supplier<SecurityContext> deferredContext) {
Assert.notNull(deferredContext, "Only non-null Supplier instances are permitted");
Supplier<SecurityContext> notNullDeferredContext = () -> {
SecurityContext result = deferredContext.get();
Assert.notNull(result, "A Supplier<SecurityContext> returned null and is not allowed.");
return result;
};
contextHolder.set(notNullDeferredContext);
}
@Override
public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}
아무래도, 점차 Security
의 코드들이 함수형으로 변화해가면서 바뀐 코드가 아닐까 싶습니다. 이 코드에서는 DelegatingSecurityContextRepository
의 영향을 많이 받게됩니다.
기존에는 해당 인터페이스를 여러 추상 인증필터 (ex. AbstractAuthenticationProcessingFilter
) 에서 사용하지 않았습니다.
모두 NullSecurityContextRepository
를 사용했는데요,
제가 본 6.1.2 부터는 직접적으로(?) 사용하게 됐습니다
예를 들면,
AbstractAuthenticationProcessingFilter
나RememberMeAuthenticationFilter
와 같은 인증 필터들의Property
로SecurityContextRepository
의 구현체를 직접 선언해서 쓰고 있는걸 볼 수 있습니다.
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
DeferredSecurityContext deferredSecurityContext = null;
for (SecurityContextRepository delegate : this.delegates) {
if (deferredSecurityContext == null) {
deferredSecurityContext = delegate.loadDeferredContext(request);
}
else {
DeferredSecurityContext next = delegate.loadDeferredContext(request);
deferredSecurityContext = new DelegatingDeferredSecurityContext(deferredSecurityContext, next);
}
}
return deferredSecurityContext;
}
DelegatingSecurityContextRepository
에서 ThreadLocal에 등록할 SecurityContext
를 로딩을 합니다. DelegatingSecurityContextRepository
는 생성자를 만들기 위해서는 SecurityContextRepository
객체를 Collection
으로 받아서 여러개를 가집니다.
그 안에는 기본적으로 HttpSessionSecurityContextRepository
, RequestAttributeSecurityContextRepository
이 2개의 클래스를 가지게 됩니다.
각각 하는 역할은 다음과 같습니다.
HttpSessionSecurityContextRepository
- HttpSession
생성RequestAttributeSecurityContextRepository
- ServletRequest
에 인증객체를 속성에 추가 @Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> readSecurityContextFromSession(request.getSession(false));
return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
}
DelegatingSecurityContextRepository
에서 loadDeferredContext(..)
에 의해 동작하는 코드입니다. 여기서 readSecurityContextFromSession
함수를 넘겨서
SupplierDeferredSecurityContext
객체를 전달합니다.
readSecurityContextFromSession
는 HttpSession 에서 세션을 읽어오는 역할을 하는 메서드입니다.
그 다음으로 DelegatingSecurityContextRepository
에서 deferredSecurityContext
변수값이 선언되었기 때문에 else 구문을 통해서 또 한번의 loadDeferredContext(..)
를 동작하는데 이 떄 구현체는 RequestAttributeSecurityContextRepository
입니다.
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> getContext(request);
return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
}
getContext(..)
함수를 넘겨서 객체를 생성하고 반환합니다.
그러면 최종적으로 생성된 생성자 객체는 아래의 로직을 수행합니다.
SupplierDeferredSecurityContext
객체를 등록해두고 인증객체를 조회할때 사용합니다.
SupplierDeferredSecurityContext(Supplier<SecurityContext> supplier, SecurityContextHolderStrategy strategy) {
this.supplier = supplier;
this.strategy = strategy;
}
이 클래스를 직접 보시면 아시겠지만, 이러한 메서드가 존재합니다.
@Override
public SecurityContext get() {
init();
return this.securityContext;
}
private void init() {
if (this.securityContext != null) {
return;
}
this.securityContext = this.supplier.get();
this.missingContext = (this.securityContext == null);
if (this.missingContext) {
this.securityContext = this.strategy.createEmptyContext();
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Created %s", this.securityContext));
}
}
}
Security 를 어느정도 디버깅을 해보신 분들이라면 익숙한 코드인데요,
이전버전에서의 SecurityContext.get()
과 같은 코드가 됩니다.
즉,
init()
을 통해서DelegatingSecurityContextRepository
의 메서드에 의해 전달한() -> readSecurityContextFromSession(...)
와() -> getContext(..)
메서드를 호출하여 결과를 받고 그 결과에 따라서 SecurityContext 객체가 반환됩니다.
최종적으로는 ThreadLocalSecurityContextHolderStrategy
에 setDeferredContext
메서드를 통해 ThreadLocal
타입 변수에 저장이 됩니다.
그리고 전체 인증프로세스가 끝나는 과정에서 ThreadLocal
에 저장해둔 Supplier
들을 사용해서 인증객체를 구해오고 그 객체들에 따라 익명의 사용자
인지 또는 인증 사용자
인지를 판단합니다.
사실 로그인 요청을 하게되면 먼저 위의 코드들이 설정이 미리 됩니다.
그래서 우리는 이미 설정되어 있는 Security 코드를 따라서 요청이 수행됩니다.
그래도 어느정도 아신다고 가정하고 간단히 설명하면 ProviderManager
의 authenticate
메서드를 통해서 인증이 수행될것입니다.
인증이 성공적으로 완료되면 AbstractAuthenticationProcessingFilter
에서 successfulAuthentication
이 수행됩니다.
이 구간 로직을 요약해봅시다.
1) SecurityContext 객체를 생성
2) SecurityContext 에 인증객체를 저장
3) SecurityContext 객체를 ThreadLocal에 설정한다.
4) SecurityContext를 생성 레포지토리에 저장한다.
아래의 사진은 3번 로직의 동작과정입니다.
기존의 Security
로직과 마찬가지로 contextHolder 에 인증객체를 저장하는것은 동일합니다.
이곳까지는 문제가 없습니다.
문제는 4번입니다.
위에서 작성한 DelegatingSecurityContextRepository
를 보시면 HttpSessionSecurityContextRepository
와 RequestAttributeSecurityContextRepository
의 생성자를 가지고 있으며, 두 클래스는 서로 다른 역할을 하고 있는데요.
현재 간단하게 구현한 설정만으로 동작을 시켰을 경우 saveContext(..)
메서드는 RequestAttributeSecurityContextRepository
의 클래스에서만 동작을 하고 있습니다.
Security 5.7 버전에서는 의외로 AbstractAuthenticationProcessingFiller
에서 securityContextRepository
의 구현체는 기본적으로 NullSecurityContextRepository
를 사용하고 있습니다.
// 5.7버전의
AbstractAuthenticationProcessingFilter
private SecurityContextRepository securityContextRepository = new NullSecurityContextRepository();
// 6.1.2버전의
AbstractAuthenticationProcessingFilter
private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
여담으로 SupplierDeferredSecurityContext
클래스는 5.8버전부터 사용하게 되었으므로 AbstractAuthenticationProcessingFilter
의 securityContextRepository
는 현재 6.1버전에서 나타나는 것 처럼 RequestAttributeSecurityContextRepository
를 쓰고 있을 것으로 추측됩니다.
onAuthentcationSuccess(..)
메서드를 들여다 보면 handle() 과 clearAuthenticationAttributes() 메서드를 사용하는데,
좀 더 내부적으로 살펴보게 되면 다음과 같습니다.
SaveContextOnUpdateOrErrorResponseWrapper
라는 추상 클래스에서 default 메서드로서 saveContext(..)
를 사용하고 있으며
이 메서드는 구현 클래스인 SaveToSessionResponseWrapper
클래스의 메서드를 사용합니다.
이때, 이 클래스는 사실 HttpSessionSecurityContextRepository
의 내부 클래스로서 정의되어 있으며 아래의 로직이 수행되고 HttpSession
에 인증 객체를 저장하는 로직이 수행됩니다.
@Override
protected void saveContext(SecurityContext context) {
if (isTransient(context)) {
return;
}
final Authentication authentication = context.getAuthentication();
if (isTransient(authentication)) {
return;
}
HttpSession httpSession = this.request.getSession(false);
String springSecurityContextKey = HttpSessionSecurityContextRepository.this.springSecurityContextKey;
// See SEC-776
if (authentication == null
|| HttpSessionSecurityContextRepository.this.trustResolver.isAnonymous(authentication)) {
if (httpSession != null && this.authBeforeExecution != null) {
// SEC-1587 A non-anonymous context may still be in the session
// SEC-1735 remove if the contextBeforeExecution was not anonymous
httpSession.removeAttribute(springSecurityContextKey);
this.isSaveContextInvoked = true;
}
if (this.logger.isDebugEnabled()) {
if (authentication == null) {
this.logger.debug("Did not store empty SecurityContext");
}
else {
this.logger.debug("Did not store anonymous SecurityContext");
}
}
return;
}
httpSession = (httpSession != null) ? httpSession : createNewSessionIfAllowed(context);
// If HttpSession exists, store current SecurityContext but only if it has
// actually changed in this thread (see SEC-37, SEC-1307, SEC-1528)
if (httpSession != null) {
// We may have a new session, so check also whether the context attribute
// is set SEC-1561
if (contextChanged(context) || httpSession.getAttribute(springSecurityContextKey) == null) {
HttpSessionSecurityContextRepository.this.saveContextInHttpSession(context, this.request);
this.isSaveContextInvoked = true;
}
}
}
그리고 private
메서드인 saveContextInHttpSession(..)
에서 저장이 이루어집니다.
private void saveContextInHttpSession(SecurityContext context, HttpServletRequest request) {
if (isTransient(context) || isTransient(context.getAuthentication())) {
return;
}
SecurityContext emptyContext = generateNewContext();
if (emptyContext.equals(context)) {
HttpSession session = request.getSession(false);
removeContextFromSession(context, session);
}
else {
boolean createSession = this.allowSessionCreation;
HttpSession session = request.getSession(createSession);
setContextInSession(context, session);
}
}
정리를 해보면 다음과 같습니다.
HttpSessionSecurtiyContextRepository
를 통해서 HttpSession
에 인증객체를 저장한다AbstractAuthenticationProcessingFilter
) 가 기본적으로 RequestAttributeSecurityContextRepository
를 사용한다.이전에 제가 작성해둔 LoginAuthenticationFtiler
와 UserDetailsService
구현 클래스만으로는 인증성공에 대한 결과를 얻을 수 없습니다.
최초로 SecurityFilterChain
을 등록할 떄 아래의 메서드를 수행하는데요, 여기서 분명히 등록을 하고 있습니다. SessionManagementConfigurer
클래스에서 세션 관리전략에 따라서 기본적으로 사용하는 SecurityContextRepository
가 달라집니다.
@Override
public void init(H http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
boolean stateless = isStateless();
if (securityContextRepository == null) {
if (stateless) {
http.setSharedObject(SecurityContextRepository.class, new RequestAttributeSecurityContextRepository());
this.sessionManagementSecurityContextRepository = new NullSecurityContextRepository();
}
else {
HttpSessionSecurityContextRepository httpSecurityRepository = new HttpSessionSecurityContextRepository();
httpSecurityRepository.setDisableUrlRewriting(!this.enableSessionUrlRewriting);
httpSecurityRepository.setAllowSessionCreation(isAllowSessionCreation());
AuthenticationTrustResolver trustResolver = http.getSharedObject(AuthenticationTrustResolver.class);
if (trustResolver != null) {
httpSecurityRepository.setTrustResolver(trustResolver);
}
this.sessionManagementSecurityContextRepository = httpSecurityRepository;
DelegatingSecurityContextRepository defaultRepository = new DelegatingSecurityContextRepository(
httpSecurityRepository, new RequestAttributeSecurityContextRepository());
http.setSharedObject(SecurityContextRepository.class, defaultRepository);
}
}
else {
this.sessionManagementSecurityContextRepository = securityContextRepository;
}
RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache == null) {
if (stateless) {
http.setSharedObject(RequestCache.class, new NullRequestCache());
}
}
http.setSharedObject(SessionAuthenticationStrategy.class, getSessionAuthenticationStrategy(http));
http.setSharedObject(InvalidSessionStrategy.class, getInvalidSessionStrategy());
}
즉, STATELESS
전략이라면 RequestAttributeSecurityContextRepository
그 외의 세션 생성전략이라면 DelegatingSecurityContextRepository
를 공통으로 등록합니다.
우리는 왜 STATELESS
가 아님에도 불구하고 인증시 공통으로 등록된 객체를 쓰지 못하는 걸까요?
여기서 부터는 사실 코드에 근거한 추측입니다.
그 이유를 이해하기 위해서 순수 FormLogin
설정을 통한 Security
동작과정을 보고 비교해봐야 할 거라고 판단했습니다.
기본적으로 formLogin
설정을 하게 될 경우 다음과 같은 로직으로 설정하게 될 것 입니다.
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(Customizer.withDefaults())
위 설정을 했을 때 동작과정을 봅시다.
생성자를 통해서 super
생성자를 호출을 하는데요,
여기서 보아야할 부분은 new UsernamePasswordAuthenticationFilter()
라고 생각합니다.
제 예상으로는
new UsernamePasswordAuthenticationFilter()
를 통해 만든 객체에 기본적으로 생성되는Security
설정을 추가하고 있다고 보고있습니다.
윗 사진 (AbstractAuthenticationFilterConfigurer
) 에서 this.authFilter 에 SecurityContextRepository
를 등록합니다.
이 과정은 밑 사진(SecurityContextConfigurer
) 에서 getSecurityContextRepository()
를 통해
일전에 등록한 SecurityContextRepository
의 공통객체를 뽑아서
new UsernamePasswordAuthenticationFilter()
객체에 등록하는 것이죠.
1) formLogin
은 새로운 UsernamePasswordAuthenticationFilter
를 만들어서 여태까지의 모든 Security
설정을 붙입니다.
2) 우리는 addFilterAt
메서드를 통해서 새롭게 우리가 구현한 AuthenticationFilter
를 사용합니다.
즉, 자동설정에 의해 동작해야하는 UsernamePassowrdAuthenticationFilter
는 소멸되고 우리가 새롭게 커스텀한 AuthenticationFilter
를 따로 등록했기 때문에,
AbstractAuthenticationProcessingFilter
클래스의 기본 속성(변수)인 SecurityContextRepository
인터페이스 -> RequestAttributeSecurityContextRepository
클래스가 동작하게 되었고,
ServletRequest
객체에만 인증 객체가 들어갔기 때문에,
새롭게 변화된 ThreadLocalSecurityContextHolderStrategy
클래스에서 변수로 등록된 ThreadLocal
타입의 변수에 저장된 Supplier
를 뽑아 냈을 때,
최초로 들어간 () -> request.getSession(false)
의 값이 null
을 반환하게 되어 5.7버전에서의 동작과 다르게 AnonymousAuthenticaiton
(익명사용자 객체)을 반환 받게 된 것이라고 볼 수 있습니다.
AnonymousAuthentication
이 발급되는 과정은 AnonymousAuthenticationFilter
에서 확인이 가능합니다.
사실 그냥 봤을때는 이해하기가 쉽지는 않다고 생각합니다.
왜냐하면 5.7
과 6.1.2
버전은 다른 위치에서 동작과정의 이루어지게 변화했기 때문이죠,
하지만 기본 인증필터의 앞단인 AbstractAuthenticationProcessingFilter
에서부터 SecurityContextRepository
의 구현체를 보고 즉시 발견을 할 수 있었기 때문에 디버깅을 잘 하시는 분들이라면 금방 즉시 해결할 수 있는 방안을 모색할 수 있었을 거라고 생각합니다..
LoginAuthenticationFilter
에 DelegatingSecurityContextRepository
를 추가함으로써 일단락 하였습니다.
public LoginAuthenticationFilter(final String defaultFilterProcessesUrl,
final AuthenticationManager authenticationManager) {
super(defaultFilterProcessesUrl, authenticationManager);
// 로그인 이후 Context 생성 전략 설정
setSecurityContextRepository(
new DelegatingSecurityContextRepository(
new HttpSessionSecurityContextRepository(),
new RequestAttributeSecurityContextRepository()
)
);
}
HttpSessionSecurityContextRepository
를 단일로 등록해도 문제는 없지만,
현재 Security
에서 왜 RequestAttributeSecurityContextRepository
를 사용했을지에 대해서는 SessionManagementConfigurer
의 init()
코드가 설명해 주고 있다고 생각합니다.
@Override
public void init(H http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
boolean stateless = isStateless();
if (securityContextRepository == null) {
if (stateless) {
http.setSharedObject(SecurityContextRepository.class, new RequestAttributeSecurityContextRepository());
this.sessionManagementSecurityContextRepository = new NullSecurityContextRepository();
}
else {
HttpSessionSecurityContextRepository httpSecurityRepository = new HttpSessionSecurityContextRepository();
httpSecurityRepository.setDisableUrlRewriting(!this.enableSessionUrlRewriting);
httpSecurityRepository.setAllowSessionCreation(isAllowSessionCreation());
AuthenticationTrustResolver trustResolver = http.getSharedObject(AuthenticationTrustResolver.class);
if (trustResolver != null) {
httpSecurityRepository.setTrustResolver(trustResolver);
}
this.sessionManagementSecurityContextRepository = httpSecurityRepository;
DelegatingSecurityContextRepository defaultRepository = new DelegatingSecurityContextRepository(
httpSecurityRepository, new RequestAttributeSecurityContextRepository());
http.setSharedObject(SecurityContextRepository.class, defaultRepository);
}
}
else {
this.sessionManagementSecurityContextRepository = securityContextRepository;
}
RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache == null) {
if (stateless) {
http.setSharedObject(RequestCache.class, new NullRequestCache());
}
}
http.setSharedObject(SessionAuthenticationStrategy.class, getSessionAuthenticationStrategy(http));
http.setSharedObject(InvalidSessionStrategy.class, getInvalidSessionStrategy());
}
간단하게 말하자면,,
최근에는 SSR 방식의 구현을 잘 하지 않는 추세이기도 하고 Token
방식의 인증이 많아지다보니 stateless
방식을 많이쓰게되고, 이런 부분에 대해 따라가고자 몇몇 Filter
에서 기본적으로 RequestAttributeSecurityContextRepository
를 쓰게끔 설정한게 아닌가 라는 생각이 들었습니다.
6.1.x 버전 Security
가 Release 된지 좀 됐지만, 해당 부분에 대한 글이 없기도 하고, 저 또한 해결을 했어야 했기때문에 해당 글을 작성하게 됐습니다.
제가 직접 볼떄는 좀 더 이해를 해보고자 봤던 코드들이 있지만 너무 많아서 그나마 이해에 도움이 되도록 추려서 올려봤습니다.
읽는분은 얼마 없으시겠지만 도움이 됐으면 좋겠습니다.
세션 저장소가 적용이 안되는 이유
AbstractHttpConfigurer
로 설정된 객체들은 정보들을shareObject
라는 공유 변수로 일괄적으로 적용해줍니다. 그런데 커스텀한 필터만 적용하면 공유 변수로 넣어 줄 수 없습니다.AbstractHttpConfigurer
클래스를 상속받아서 구현해서 httpBuilder에 등록하면 적용을 받을 수 있습니다.