[Security] OAuth2 - RESTful architecture

얄루얄루·2023년 1월 27일
0

Spring

목록 보기
12/14

Background

Spring의 OAuth2-Client를 이용해 OAuth2 프로토콜을 이용하는 로그인은 구현을 했다.

로컬 테스트를 성공적으로 마치고 안심하고 있었다.

그러다가 프론트팀과 함께 통합테스트를 했는데...ㅋㅋ 별의별 문제가 다 생긴다.

그래서 CORS 테스트를 안 했었구나 하는 깨달음을 얻고, 기존 프로젝트에서 페이지 렌더링을 하는 모듈을 따로 만들고 5173 포트에서 동작하게 했다.

// 이거랑 index.mustache만 따로 뺌
@Controller
public class FrontController {

    @GetMapping("/")
    public String index() {
        return "index";
    }
}

자세한 코드는 Github에 있다.

문제점

파트를 분리하고 나니 오류가 터지면서 내가 SSR 원툴용의 설계를 했었구나 하는 걸 깨달았다.

CORS 이슈는 <a> 태그 활용으로 손쉽게 해결했는데, 진짜 문제가 생겼다.

변경 전에는 로그인 하면 화면에 닉네임 등이 잘 출력되며 내가 로그인 했다는 걸 알 수가 있었는데, 이제 보니 이게 세션을 생성해서 받는 식으로 표시가 되고 있었던 거다.

우리 팀이 정한 소셜 로그인 방식은 프론트에서 인증 요청을 보내면 백에서 알아서 리디렉션을 하고 여차저차 한 다음 우리쪽 api에 접근할 수 있는 JWT를 주는 거였다.

왜 이렇게 했냐면 인증 방식으로 JWT를 사용하고 있었고, 이를 이용해 로컬 로그인과 공존시키기 위해서였다.

그 때문에 굳이 세션을 생성시킬 필요가 없어 세션 정책을 STATELESS로 해놓고 있는데, 이 때문에 세션이 생성되지 않는다.

문제는 SecurityContext는 인증 후에 HttpSession에 저장되는 방식이라는 것. 이 때문에 SecurityContext가 자꾸 증발해버리는 문제가 발생했다.

정책을 바꾸면 간단히 해결이 되지만 나는 삘이 꽂히면 삽질을 시작하기 때문에 또다시 삽을 들었다.

다시 처음부터 디버깅을 하며 함수의 흐름을 따라갔다.

알아낸 것은, 얘도 결국 똑같다는 거.

Filter Manager Provider 의 구성으로 이루어져 있다.

UserDetailsService 대신 OAuth2UserService를 사용하는 걸 제외하면 구조상 동일하다.

특이한 점은 Filter가 2가지가 있다.

  • OAuth2AuthorizationRequestRedirectFilter
  • OAuth2LoginAuthenticationFilter

이름만 봐도 위는 특정 패턴의 요청을 OAuth2 인증 url로 리디렉션 해주고,
밑은 인증과 그 후처리를 담당한다.

// OAuth2AuthorizationRequestRedirectFilter의 일부
OAuth2AuthorizationRequest authorizationRequest = 
											this.authorizationRequestResolver.resolve(request);
	if (authorizationRequest != null) {
		this.sendRedirectForAuthorization(request, response, authorizationRequest);
		return;
	}
/* OAuth2LoginAuthenticationFilter의 일부 */

// 접수된 패러미터 기반으로 redirectUri 생성
OAuth2AuthorizationResponse authorizationResponse = 
				OAuth2AuthorizationResponseUtils.convert(params, redirectUri);
                
// request 생성
Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(
				clientRegistration,
				new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(authenticationDetails);

// 내부에서 code와 토큰을 교환하고, 그 토큰을 사용자 정보로 교환
// 즉 authenticationResult 안에 지금 닉네임, 이메일 등이 있음
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
				.getAuthenticationManager().authenticate(authenticationRequest);
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
				.convert(authenticationResult);
Assert.notNull(oauth2Authentication, "authentication result cannot be null");

// authenticationResult 안에는 Principal만 있고, access token, refresh token 등이 없다
// 이 때문에 아까 생성해두었던 authenticationDetails를 이용해 주입
oauth2Authentication.setDetails(authenticationDetails);

// authorizedClientRepository에 담는 용도
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
				authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
				authenticationResult.getAccessToken(), authenticationResult.getRefreshToken());
this.authorizedClientRepository.saveAuthorizedClient(
				authorizedClient, oauth2Authentication, request, response);

// 이후로는 AbstractAuthenticationProcessingFilter로 이동해서
// Success / Failure 처리를 한다
return oauth2Authentication;

이 흐름은 AbstractAuthenticationProcessingFilter로 넘어가는데, 이렇게 던져진 정보가 null이라면 그냥 패스, 예외가 발생했다면 예외처리, 둘 다 아니면 성공처리가 된다.

try {
	// 위 코드가 바로 이 호출 때문에 실행 된 것이다
	Authentication authenticationResult = attemptAuthentication(request, response);
	if (authenticationResult == null) {
		// 인증 정보 없음
		return;
	}
    // 인증 정보 있으면 세션 정책에 따른 처리를 한다
	this.sessionStrategy.onAuthentication(authenticationResult, request, response);
	// 설정에 따라 인증 성공 처리 전에 필터 돌리기도 함
	if (this.continueChainBeforeSuccessfulAuthentication) {
		chain.doFilter(request, response);
	}
    // 이 부분이 인증 정보를 SecurityContextHolder에 집어넣는다
	successfulAuthentication(request, response, chain, authenticationResult);
}

successfulAuthentication 메소드 내부에서는 SecurityContext를 생성해 인증 정보를 집어넣고, SecurityContextHolderSecurityContext를 넣는다.

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, 
			FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // SecurityContext 생성
		SecurityContext context = SecurityContextHolder.createEmptyContext();
        // 인증 정보 투입
		context.setAuthentication(authResult);
        // SecurityContext 넣는다
		SecurityContextHolder.setContext(context);
		this.securityContextRepository.saveContext(context, request, response);
		if (this.logger.isDebugEnabled()) {
			this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
		}
		this.rememberMeServices.loginSuccess(request, response, authResult);
		if (this.eventPublisher != null) {
			this.eventPublisher.publishEvent(
            		new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
		}
        // successHandler 호출
		this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

문제는 successHandler 내에서까지도 살아있던 SecurityContext가 그 뒤로 던져진 url의 컨트롤러에서는 사라져버린다는 것이다.

이상한 부분

세션이 없어서 SecurityContext가 유지가 되지 않는다? 그래, 좋다. 그렇다 치자.

근데 이건 왜 됨?

if(StringUtils.hasText(token) && jwtAuthenticationProvider.validateToken(token)){
    Authentication auth = jwtAuthenticationProvider.getAuthentication(token);
    SecurityContextHolder.getContext().setAuthentication(auth);
}

jwt 인증을 담당하는 필터의 일부분이다.

이 경우에는 SecurityContext가 잘 살아서 컨트롤러까지 온다.

추측

처음에는 JwtFilter는 Filter에서 즉, Spring Context 밖에서 처리를 하기에 Spring Security의 세션 정책의 영향을 받지 않는다고 생각했다.

그런데 살펴보니까 OAuth2Filter도 역시 AbstractAuthenticationProcessingFilter를 통해 Filter에서 처리를 한다. 참고로 이 추상 필터는 OAuth2 뿐만 아니라 Form, LDAP 등 다른 로그인 방식도 따로 처리를 하지 않으면 기본적으로 적용된다.

그러면 OAuth2-Client 내부에서 제대로 된 저장을 하지 못했을 가능성이 생긴다.

AbstractAuthenticationProcessingFilter 분석

인증 정보를 SecurityContextHolder에 넣는 부분이 해당 필터에 있기 때문에 여기부터 분석을 진행했다.

위에서 썼던 코드의 일부분을 다시 보자.

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, 
			FilterChain chain, Authentication authResult) throws IOException, ServletException {
		...
		this.securityContextRepository.saveContext(context, request, response);
        ...
}

이 부분은 인증 정보를 담은 SecurityContextSecurityContextRepository에 담고 있다.

SecurityContextRepository 연관된 부분은 아래와 같이 초기화, 설정 두 가지가 있다.

private SecurityContextRepository securityContextRepository = new NullSecurityContextRepository();

public void setSecurityContextRepository(SecurityContextRepository securityContextRepository) {
	Assert.notNull(securityContextRepository, "securityContextRepository cannot be null");
	this.securityContextRepository = securityContextRepository;
}

NullSecurityContextRepository

Null? 이름이 심상치 않아서 내부를 살펴봤다.

public final class NullSecurityContextRepository implements SecurityContextRepository {

	@Override
	public boolean containsContext(HttpServletRequest request) {
		return false;
	}

	@Override
	public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
		return SecurityContextHolder.createEmptyContext();
	}

	@Override
	public void saveContext(SecurityContext context, 
    			HttpServletRequest request, HttpServletResponse response) {
	}

}

예상대로 더미 클래스였다. save 시에 아무것도 하지 않고, context 저장 여부를 물으면 반드시 false를 답한다.

이 놈이 원래 뭘 하는 친구인지 쫓아가서 알아보자.

SecurityContextRepository

인터페이스인 SecurityContextRepository를 찾아갔다.

Strategy used for persisting a SecurityContext between requests.

Used by SecurityContextPersistenceFilter to obtain the context which should be used for the current thread of execution and to store the context once it has been removed from thread-local storage and the request has completed.

The persistence mechanism used will depend on the implementation, but most commonly the HttpSession will be used to store the context.

See Also:
SecurityContextPersistenceFilter,
HttpSessionSecurityContextRepository,
SaveContextOnUpdateOrErrorResponseWrapper

클래스 설명에 있는 원문이다.

요청 간 SecurityContext를 유지하기 위한 방책이라고 한다.

SecurityContextPersistenceFilter에 의해 사용되는데,

  1. 현재 실행 중인 스레드에서 이용 할 context를 얻을 때
  2. 스레드의 로컬 스토리지에서 사라지고 요청이 완료될 때 저장할 때

사용된다고 한다.

그러고 보니 SecurityContext는 스레드 단위로 유지가 된다.

그러니까 원래는 매 요청마다 SecurityContext가 유지될 수 없는데, Repository를 이용하는 전략으로 따로 저장을 해서 필터를 통과 할 때마다 정책에 따른 아이덴티티(e.g. 세션ID)를 통해 일치하는 context를 가지고 와서 SecurityContextHolder에 넣어주는 식으로 동작하는 것이다.

JWT 인증 방식에 세션 정책을 STATELESS로 하는 이유는 토큰 자체가 저장소의 역할을 하기 때문이다.

토큰을 해석하면 Principal(ID or email)과 Authority가 나오니까 이를 바탕으로 인증 정보를 생성하고, SecurityContext에 넣어 현 스레드 내에서 사용하는 것이다.

요청이 완료되면 SecurityContext가 유지되지 않기 때문에 그 안에 담겨 있던 인증 정보도 같이 사라진다.

그렇기 때문에 매 요청마다 새로 인증 정보를 만들 필요가 있고, 그래서 JWT 인증 filter를 구현할 때 대개 OncePerRequestFilter를 상속받는 것이다.

이쯤 되니 SecurityContextHolder가 정확히 어떤 식으로 동작하는 것인지 궁금해진다.

SecurityContextHolder

이 녀석은 일종의 Wrapper class이다.

내부에 아래와 같은 타입의 인터페이스를 가지고 있다.

private static SecurityContextHolderStrategy strategy;

strategy에는 기본적으로 MODE_THREADLOCAL(기본값) MODE_INHERITABLETHREADLOCAL MODE_GLOBAL MODE_PRE_INITIALIZED가 있고, 커스텀 strategy를 만들 수도 있다.

save이던 get이던 간에 해당 strategy의 구현체에 구현된 메소드를 콜하는 식으로 동작한다.

고로 진짜 봐야 하는 건 SecurityContextHolderStrategy의 구현체이다.

ThreadLocalSecurityContextHolderStrategy

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

해당 클래스의 멤버 변수가 눈에 띈다.

여기서 Context holder의 정체가 SecurityContext를 제네릭 타입으로 하는 ThreadLocal라는 것을 알 수 있다.

ThreadLocal은 내부에 ThreadLocalMap을 가지고 있다. 이 친구는 흔히 알고 있는 Map<K,V>와 동일하다.

이 Map은 기본적으로 현 스레드 객체를 해시코드화 해서 키로 사용한다. 중복키에 대해서는 Open addressing 기법으로 해결한다.

기본 사이즈는 16으로 매 사이즈의 2/3를 넘어가면 Doubling strategy를 따라 맵의 사이즈를 2배씩 늘린다.

조금 잡스런 정보가 많았는데,

지금까지의 조사를 통해 SecurityContextHolder는 기본 전략인 MODE_THREADLOCAL 하에서는 통합저장소가 아닌 각 스레드 별 로컬저장소라는 것을 알았다.

그리고 security context의 통합저장소는 security repository라는 것도 알았다.

이제 해당 repository를 사용하는 filter 클래스에 대해 알아보자.

SecurityContextHolderFilter

SecurityContextPersistenceFilter를 찾아봤더니 Deprecated 돼 있어서 이쪽을 찾아봤다.

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
    		FilterChain filterChain) throws ServletException, IOException {

		SecurityContext securityContext = 
        				this.securityContextRepository.loadContext(request).get();
		try {
			SecurityContextHolder.setContext(securityContext);
			filterChain.doFilter(request, response);
		}
		finally {
			SecurityContextHolder.clearContext();
		}
	}

ContextRepository로부터 context를 load한다.

ContextHolder에 context를 set한다.

해당 context 내에 인증 정보를 넣는 것은 filterChain의 나머지 필터들에서 이루어질 것이다.

해당 과정이 끝나면 ContextHolder를 clear한다.

세부 구현은 전부 서브 클래스에 이루어져서 별로 볼 게 없다.

Repository 검증

지금까지의 확인으로 몇 가지 정보를 얻었다.

  • SecurityContextHolder는 기본값 기준으로 Thread<SecurityContext>라는 것.
  • 그렇기 때문에 SecurityContextHolder는 동일 스레드 내에서만 유지가 된다는 것.
  • 그 안에 담겨 있는 SecurityContext를 유지하기 위해서는 저장소를 이용해야 한다는 것.
  • 그 저장소의 아이덴티티는 세션ID 등이 될 수 있으며, 정책에 따라 달라진다는 것.

필요한 정보들을 알았으니 다시 검증을 이어가 보자.

Session이던 ContextRepository던 간에 각각의 strategy를 따른다는 것을 알았다.

먼저 Filter에서 사용하는 session strategy는 다음과 같다.

public class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategy {
	private final Log logger = LogFactory.getLog(getClass());
	private final List<SessionAuthenticationStrategy> delegateStrategies;
}

이 녀석은 실질적으로 하는 게 없다. SessionAuthenticationStrategy의 리스트를 가지고 돌리면서 처리를 위임하는 게 역할이다.

계속 따라가보면 ChangeSessionIdAuthenticationStrategy 클래스로 이어진다.

해당 클래스의 onAuthentication 메소드의 일부분이다.

HttpSession session = request.getSession();
if (hadSessionAlready && request.isRequestedSessionIdValid()) {
	...
}

세션은 가지고 오는데, 유효한 세션이 아닌 판정을 받아서 세션을 처리하는 로직을 타지 못한다.

그리고 success handler를 타기 전, 인증 성공 처리의 이 부분.

this.securityContextRepository.saveContext(context, request, response);

디버깅 결과, this.securityContextRepository가 여전히 NullSecurityContextRepository이기 때문에 save를 해도 실제로 save되는 게 아무것도 없다.

그 말은 요청 간에 SecurityContext를 유지할 수 없다는 것이다.

Thread 검증

SecurityContext는 Repository뿐만 아니라 기본적으로 SecurityContextHolder에도 저장이 된다. 그리고 이는 스레드 그 자체이기 때문에 스레드가 이어진다면 값은 남아있을 것이다.

값이 사라진 것으로 봐서 동일 스레드가 아닐 것이라고 추측이 되는데, 확인을 해 보자.

먼저 리디렉션 전에 success handler에서 확인한 스레드이다.

그리고 리디렉션 후에 컨트롤러에서 확인한 스레드이다.

이 둘은 다른 스레드이다.

JwtFilter에서 넣은 SecurityContext가 잘 살아서 온 건 리디렉션이 없었기 때문에 재요청이 없었고, 그래서 스레드가 유지되었던 것이다.

해결책

  1. HttpSession이나 Redis 등 외부 저장소를 이용해 SecurityContext를 저장한다.
  2. success handler에서 JWT를 발급한다.

1번 해결책의 배경은 단순하다. request 간에 SecurityContext를 유지시킬 수 있게끔 저장소를 이용하면 된다는 것. 단점은 저장소를 이용하기 때문에 추가적인 I/O와 저장공간이 필요해진다는 것이다. 그리고 세션 정책을 STATELESS로 설정한 의미도 없어지는 듯.

2번 해결책은 OAuth2-Client의 구조 때문이다.

이 친구는 인증 -> code -> access token -> protected user resource를 얻는 순차적 과정을 연달아 진행하기 때문에 마지막에 리디렉션을 해서 우리쪽 JWT 발급으로 이어줄 필요가 있다.

그 과정에서 리디렉션에 의한 재요청이 발생하며 스레드가 새로 생성되고 인증 정보가 날아간다.

그렇다면 아직 동일 스레드 내에 있을 때 success handler에서 JWT 생성을 해버리면 어떨까?

그리고 response의 PrintWriter를 이용해서 응답을 주면 추가적인 리디렉션이 필요없어진다.

결과는...ㄷㄱㄷㄱ

토큰 발급 성공했다.

마지막으로 조사도 했고 문제 해결에 필요한 지식을 쌓는데 도움도 줬지만, 직접적인 연관은 없어서 위 설명에서 제외했던 클래스 하나를 소개하며 글을 마친다.

HttpSessionSecurityContextRepository

SecurityContextRepository의 기본 구현체이다.

A SecurityContextRepository implementation which stores the security context in the HttpSession between requests.

SecurityContextHttpSession에 저장한다고 한다.

그리고 유효한 SecurityContext를 추출할 수 없으면, SecurityContextHolder.createEmptyContext() 메소드를 통해 빈 context를 내준다고 한다.

추가적으로 SecurityContext를 저장할 때, 그게 빈 context가 아니고 + 세션이 없으면, 새로 생성하는데 최대 1개까지만 생성한다고 한다.

그리고 만약 HttpSession이 생성되지 않아야 경우(e.g Basic Authentication, JWT Authentication) allowSessionCreationfalse로 놓으라고 한다.

다만, 이에 대한 붙임말이 있는데 서버 메모리를 아끼고 싶을 경우 그렇게 하고, 더불어 SecurityContextHolder를 이용하는 모든 클래스가 요청 간에 SecurityContext의 영속성이 필요없도록 설계했는지 확실히 확인하라고 한다.

saveContext 메소드를 살펴보자.

public void saveContext(SecurityContext context, 
				HttpServletRequest request, HttpServletResponse response) {
        // response가 주어진 클래스 타입이라면 그대로 리턴, 상위 타입이라면 한 단계씩 하위 타입으로 내린다
        // 내부가 재귀호출로 되어 있기 때문에 결국 주어진 클래스 타입으로 변환된다
		SaveContextOnUpdateOrErrorResponseWrapper responseWrapper = 
        					WebUtils.getNativeResponse(response, 
                            				SaveContextOnUpdateOrErrorResponseWrapper.class);
		if (responseWrapper == null) {
        	// 존재하는 HttpSession이 있는지 확인한다
            // getSession의 패러미터는 세션이 없을 경우 생성할 것인지의 여부를 결정한다
			boolean httpSessionExists = request.getSession(false) != null;
            // 빈 security context를 생성한다
			SecurityContext initialContext = SecurityContextHolder.createEmptyContext();
            // response wrapper에 wrappedSession을 저장한다
            // wrappedSession에는 request, response, 세션존재여부, context, 인증정보가 들어간다
			responseWrapper = new SaveToSessionResponseWrapper(response, request, 
            								httpSessionExists, initialContext);
		}
        // response wrapper에 context를 저장한다
		responseWrapper.saveContext(context);`
}
profile
시간아 늘어라 하루 48시간으로!

0개의 댓글