읽기 전에 : 해당 글은 스프링 시큐리티 6.1.2버전 기준으로, 읽는 시점에 따라 다른(deprecate됐다든지) 내용일 수도 있습니다.


어떠한 요청을 보냈을 때, 요청을 보낸 이에 대한 인가/인증이 수반돼야 한다. 이때

 

인증(Authentication) : 자원에 접근하는 사용자의 신원을 검증하는 것(너 누군데?)

인가(Authorization) : 사용자가 그 자원에 대해 접근할 수 있는지 확인하는 것(누군지 알겠는데, 너가 그런 권한이 있어?)

 

그렇다면 스프링 진영에서 보안을 담당하는 대표적인 스프링 하위 프레임워크인 스프링 시큐리티에서는 인증과 인가를 어떤 방식으로 해줄까? 이에 대해 알아보자


그 전에 알고 가면 좋은 지식

SecurityContextHolder

 

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontextholder

 

스프링 시큐리티가 인증된 사용자의 정보(Authentication)을 SecurityContext에 저장하는데 이 놈을 가지고 있는 바구니가 바로 SecurityContextHolder다. 한 마디로 여기에 사용자의 정보(Authentication)이 있으면 인증된 것으로 취급하며, 나중에 이렇게 담은 정보(Authentication)을 통해 인가를 한다. 참고로 어떻게 넣는지는 상관없이 걍 여기에 정보(Authentication)가 들어있기만 하면 된다.

 

 

Authentication

Spring Security에서 발급해주는 통행증이라고 생각하면 된다. 이 통행증은 다음과 같은 내용들을 가진다

 

  • Principal : 보안 주체에 해당하는 개념으로, 쉽게 말해 "누구냐"에 해당. 보통 UserDetails라는 "사용자의 정보가 담긴 문서"가 이 역할을 한다.
  • Credential : 주로 비밀번호
  • Authorities : Principal이 뭘 할 수 있는지에 대한 권한, 즉 Principal에게 부여된 권한들이며 GrantedAuthority들이 이 역할을 한다

 


인증

우선 사용자를 인증하는 일반적인 방식은 사용자로부터 하여금 이름과 비밀번호를 입력하도록 요구하는 것이다. 이를 통해 인증이 수행되면 ID(세션 ID)를 발급하고, 이를 통한 인증을 수행할 수 있다. 스프링 시큐리티 역시 이름과 비밀번호를 통한 인증 방식을 지원하며, 다음과 같은 과정을 거쳐 진행된다.

 

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html

 

 

  1. BasicAuthenticationFilter(폼로그인의 경우, UsernamePasswordAuthenticationFilter가 이 역할을 함)가 클라이언트로부터 온 HttpServletRequest로부터 username과 password를 뽑아낸다
  2. 뽑아낸 내용을 바탕으로 UsernamePasswordAuthenticationToken(Authentication의 일종)이란 걸 만든다
  3. 사용자를 인증하기 위해 이 토큰을 AuthenticationManager란 놈에게 보낸다
  4. Authenticatoin이 이 토큰을 바탕으로 "인증"을 시도한다
  5. 인증에 실패하면 -> SecurityContextHolder가 비워지고, RememberMeServices의 loginFail이 호출되며, AuthenticationEntryPoint가 호출된다
  6. 인증에 성공하면 -> 해당 인증 정보가 SecurityContextHolder에 설정 즉 저장되고, RememberMeServices의 loginSuccess가 호출되고 filterChain.doFilter를 호출해 남은 로직(다음 필터들에서 진행될 것들)을 수행한다

 

근데 생각해보면.. 매번 요청을 보낼 때마다 이름과 비밀번호를 함께 보내는 방식으로 인증받는 건 당연히 번거롭다. 최초 로그인할 때만 이렇게 하고 SecurityContext를 세션(HttpSession)에 저장한뒤 세션 id를 클라이언트에게 발급한다. 이후 다시 접속하면 세션으로부터 SecurityContext를 가져와 SecurityContextHolder에 꽂아준다. 따라서 해당 SecurityContext가 Authentication을 갖고 있으면 인증상태가 유지된다. (앞서 말했듯, SecurityContext에 Authentication이 들어있어야 인증된 상태라고 취급하기 때문이다)

 

 

여기서 내가 의문이 든 것은, 로그인 이후의 요청에 대해 왜 다시 SecurityContext를 다시 SecurityContextHolder에 꽂아주는가였다. 이미 로그인할 때 Authentication이 들어있는 SecurityContext를 SecurityContextHolder에 넣어줬을텐데? 

하지만 이유는 간단했다. 사용자가 보낸 request를 처리하는 스레드가 종료되면 SecurityContextHolder를 비워주기 때문이다. 그래서 다시 세션으로부터 SecurityContext를 가져와 SecurityContextHolder에 꽂아주는거다.

 

코드레벨에서 보면 쉽게 파악할 수 있다.

 

// 간단히 말하면 필터체인의 앞단에서 SecurityContext를 로드하는 걸 담당하는 필터
public class SecurityContextHolderFilter extends GenericFilterBean {
	// ..생략
	private final SecurityContextRepository securityContextRepository;
	// ..생략

	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		// 대충 처음 접속하면 이 if조건문의 doFilter가 실행됨
		if (request.getAttribute(FILTER_APPLIED) != null) {
			chain.doFilter(request, response);
			return;
		}
		request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
		// SecurityContext를 가져오는 모습
		Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
		try {
			this.securityContextHolderStrategy.setDeferredContext(deferredContext);
			chain.doFilter(request, response);
		}
		finally {
			// 마지막에 SecurityContextFilter를 비워내는 모습!
			this.securityContextHolderStrategy.clearContext();
			request.removeAttribute(FILTER_APPLIED);
		}
	}
    
	// ..생략

 

다음과 같이 Session에서 SecurityContext를 가져오는 모습도 코드레벨에서 훔쳐볼 수 있다

 

public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
	// ..생략

	@Override
	public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
		// 세션으로부터 SecurityContext를 가져오는 모습
		Supplier<SecurityContext> supplier = () -> readSecurityContextFromSession(request.getSession(false));
		return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
	}
    
	// ..생략

 

 

참고로, SecurityContextHolder는 ThreadLocal을 사용한다. 즉 SecurityContextHolder는 여러 쓰레드가 공유하는 요소가 아니다.

 

// SecurityContextHolder의 ThreadLocalSecurityContextHolderStrategy 코드 일부
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();
	}
    
    // ... 생략

 

보다시피 ThreadLocal을 내부적으로 사용하는 모습을 볼 수 있다. 각 쓰레드별로 서로 다른 내용물을 갖는 SecurityContextHolder를 쓰게 되는 것이며, 이를 통해 사용자별로 SecurityContextHolder내에 서로 다른 인증객체(Authentication)을 가지게 되는 효과를 준다! 동일한 쓰레드 내에선 언제든지 SecurityContext를 참조가능하다.

 

ThreadLocal : 쓰레드마다 할당되는 자신만의 고유한 공간. 멀티쓰레드 환경이어도 ThreadLocal은 다른 쓰레드로부터 안전한 공간이다.

 

 

즉 정리하자면

 

  1. 사용자별로 보내는 요청들을 쓰레드를 통해 처리한다
  2. username, password기반으로 이런 요청들에 대한 인증 작업을 한다. 인증이 완료되면 SecurityContext에 Authentication을 꽂아넣으며, Spring Security는 SecurityContext에 Authentication이 들어가있는지 여부를 통해 인증됐는지 아닌지를 판단한다.
  3. 근데 매번 username, password를 함께 보낼 순 없으니 세션을 활용한다. 세션id를 통해 세션으로부터 SecurityContext를 가져와 이를 SecurityContextHolder에 꽂아넣는 식이다.
  4. 사용자별 Authentication 구분은 SecurityContextHolder가 ThreadLocal을 사용함으로써 가능하다.

 


인가

인증 과정이 끝났다면, SecurityContextHolder에 SecurityContext가 들어있고 이 안에 Authentication이란 "통행증"이 들어있으며 이를 통해 인가를 진행할 수 있다. Authentication은 내부적으로 Principal(보안 주체에 해당하는 개념)의 authorities(권한)들을 가지며, 구체적으론 GrantedAuthority인터페이스의 구현체들이 Collection형태로 들어가 있다. 

 

GrantedAuthority의 getAuthority()메서드를 통해 권한 정보들을 읽을 수 있으며, AccessDicisionManager라는 놈이 이 권한 정보들을 읽어서 접근 가능한지 아닌지를 판단해준다.

 

인가는 다음 과정을 통해 진행된다.

 

https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html

 

  1. 필터체인의 AuthorizationFilter가 SecurityContextFilter로부터 Authentication(통행증)을 받아온다. 
  2. 만약 Authentication을 받아오는데 실패하면 AuthenticationException을 던진다.
  3. AuthorizationManager(얘가 AccessDicisionManager역할도 해준다)에게 받아온 Authentication과 HttpServletRequest를 전달한다
  4. AuthorizationManager가 해당 request를 등록된 패턴과 match되는지를 판단해준다
  5. 거부됐다면 -> AuthorizationDeniedEvent가 발행되고 AccessDeniedException을 던진다
  6. 승인됐다면 -> AuthorizationGrantedEvent가 발행되고 필터체인을 마저 진행한다

 

AuthorizationFilter에서 Authentication을 받아오는 메서드의 코드는 다음과 같으며, Authentication을 못 받아오면 예외를 던지는 모습을 직관적으로 바로 확인가능하다.

 

private Authentication getAuthentication() {
	Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
	if (authentication == null) {
		throw new AuthenticationCredentialsNotFoundException(
			"An Authentication object was not found in the SecurityContext");
	}
	return authentication;
}

 

참고로 AuthenticationCredentialsNotFoundException은 AuthenticationException을 상속받은 클래스다.

 

또한 디폴트 설정은 필터체인의 마지막 필터가 AuthorizationFilter이니 알아두면 좋다.

 


인증/인가 실패 시에 대한 예외처리

인증에 실패하면 AuthenticationException이, 인가에 실패하면 AccessDeniedException이 던져진다. 이들은 어떻게 처리되는가?

바로 필터체인의 ExceptionTranslationFilter에서 처리된다.

 

 

이 필터에서 doFilter를 통해 필터체인의 로직들을 수행하다가, AuthenticationException이나 AccessDeniedException이 발생하면 그에 대한 처리를 해주는거다.

 

유저가 인증되지 않았거나 AuthenticationException이 발생하면

 

  1. SecurityContextHolder를 비움
  2. 인증 성공 후 다시 replay가 가능하게끔 클라이언트에게 온 request를 저장
  3. AuthenticationEntryPoint라는 놈을 이용해 클라이언트에게 자격 증명을 요청(즉 클라이언트에게 너 누군지 신원을 대라고 하는 거다. 여기선 AuthenticationEntryPoint가 인증 실패시 써먹는 놈이라고만 알고 넘어가면 된다)

 

AccessDeniedException이 발생하면, 심플하다. 등록된 AccessDeniedHandler를 호출한다.

 

이를 수도 코드로 보면 다음과 같다고 보면 된다.

 

try {
	filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
	if (!authenticated || ex instanceof AuthenticationException) {
		// 클라이언트에게 자격 증명을 요청(인증 실패했으니까)
		startAuthentication();
	} else {
		// AccessDeniedHandler 호출
		accessDenied();
	}
}

 

실제로 ExceptionTranslationFilter 내부를 뜯어보면 다음과 같은 코드를 볼 수 있다.

 

private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
	FilterChain chain, RuntimeException exception) throws IOException, ServletException {
	if (exception instanceof AuthenticationException) {
		handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
	}
	else if (exception instanceof AccessDeniedException) {
		handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
	}
}

 

handleAuthenticationException메서드는 내부적으로 sendStartAuthentication메서드를 호출하며, 코드는 다음과 같다.

 

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
	AuthenticationException reason) throws ServletException, IOException {
	// SEC-112: Clear the SecurityContextHolder's Authentication, as the
	// existing Authentication is no longer considered valid
	SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
	this.securityContextHolderStrategy.setContext(context);
	this.requestCache.saveRequest(request, response);
	this.authenticationEntryPoint.commence(request, response, reason);
}

 

request를 저장하고, AuthenticationEntryPoint의 commence라는 메서드를 호출하는 모습을 볼 수 있다.

handleAccessDiniedException메서드는 AccessDiniedHandler의 handle메서드를 호출한다. 

 

나중에 본인이 커스텀한 AuthenticationEntryPoint와 AccessDeniedHandler를 이용해, 인증/인가를 실패했을 때에 대한 처리방식을 직접 지정해줄 수도 있어서 이렇게 설명해봤다.

 

 


스프링 시큐리티 원리에 대한 내용을 계속 구글링하면서 뒤져봤는데, 구글링해서 보는 페이지마다 다 같은 내용들만 써져 있어서 내가 궁금한 내용들에 대한 서치가 힘들었다. 각 용어들에 대한 개념은 블로그들을 보면서 어느 정도 알겠는데, 어떤 과정으로 진행되는지에 대한 궁금증들이 있었다. 그러던 중 그냥 공식 문서를 파보자는 생각으로 공식 문서들을 파봤다.

 

사실 이전에 React 공부할 때를 빼면, 공식문서를 이렇게까지(물론 지금도 엄청나게 한 것도 아니지만) 공부한 적은 없었다. 개발자로 산다는 사람이 공식문서를 진득하게 본 적 없다는게 사실 생각해보면 부끄러운 일인 것 같다. 처음부터 공식문서를 보는건 개념 잡기가 어렵다는 생각이 들어 지금껏 새로운 걸 공부할 때마다 공식문서로 시작하는 걸 기피해왔던 것 같기도.. 근데 이번에 느낀게, 구글링해서 블로그들 보면서 어느 정도 용어들에 대한 개념만 잡고 그 후엔 공식문서로 공부하는게 되게 좋은 방법 같다. 오피셜한 문서인 만큼 내용에 대한 의심이 안 들고, 정리가 잘 돼있다. 지금 다룬 스프링 시큐리티만 해도 글을 작성하는 현재를 기준으로 구글링해서 나오는 블로그들은 다 옛날 버전이라 deprecate된 내용들이 많다. 예를 들면 인가 과정에서 FilterSecurityInterceptor를 쓴다고들 하는데 6.1.2버전 기준 FilterSecurityInterceptor는 deprecate됐다. 또 SecurityContextPersistenceFilter도 deprecate됐다. 이들 대신에 AuthorizationFilter와 SecurityContextHolderFilter를 현재는 사용중이다.

 

암튼 앞으로 내가 모르는 기술들을 공부할 땐, 구글링해서 개념만 대강 잡고 공식문서로 시작하는게 꽤나 좋은 방법인 듯. 나만의 공부방법을 정립해가야할 때다.

 

 

 

참고

https://catsbi.oopy.io/f9b0d83c-4775-47da-9c81-2261851fe0d0

 

스프링 시큐리티 주요 아키텍처 이해

목차

catsbi.oopy.io

https://docs.spring.io/spring-security/reference/index.html

 

Spring Security :: Spring Security

If you are ready to start securing an application see the Getting Started sections for servlet and reactive. These sections will walk you through creating your first Spring Security applications. If you want to understand how Spring Security works, you can

docs.spring.io

 

웹 서비스는 기본적으로 HTTP 통신 위에서 동작한다. 이 때 HTTP의 큰 특징 중 하나는 바로 stateless하다는 것이다. 간단히 말하면 서버가 클라이언트의 예전 상태를 보존하지 않는다는 뜻으로 비유하자면 클라이언트가 바로 직전에 한 말조차도 기억하지 못한다는 것! 예를 들면..

 

1번째 교신)

클라 : 제주도 가는 비행기 얼마에요?

서버 : 편도 7만원이요

 

2번째 교신)

클라 : 4장 주세요

서버 : ? 뭘요?

 

이런 느낌이다. 좀 더 자세한 얘기는 예전에 쓴 글 참조.

https://jofestudio.tistory.com/57

 

HTTP의 특징(stateless 등..)

1. 클라이언트 - 서버 구조 : HTTP는 클라이언트가 request를 보내면 서버가 response를 보내는 구조이다. 즉 클라이언트와 서버가 개념적으로 분리된 형태이며, 클라이언트로부터 request가 안오면 서버

jofestudio.tistory.com

 

 

암튼 이런 식으로 각 통신의 상태를 저장하지 않기 때문에, 인증 / 인가와 관련한 문제점이 생긴다. 로그인 이후 마이페이지같은 걸 요청할 때, 서버가 내 쪽의 상태를 모르니 매번 새 페이지를 요청할때마다 로그인을 해야 한다. 

 

이런 번거로움을 넘어서 비효율적인 일을 느낌있게 처리해야 한다. 기존에 이미 로그인한 사용자에 대한 정보를 저장해야 할 필요가 있는 것이다. 이를 위한 대표적인 방식들로 세션(Session)과 토큰(Token)을 기반으로 하는 인증방식들이 있다. 둘 다 유저가 로그인 시도 시 일치하는 유저 정보를 찾았다면 인증(Authentication) 완료의 표시로 일종의 확인증을 클라이언트에게 끊어주고, 클라이언트는 이후 로그인한 유저 정보가 필요한 곳에는 발급받은 확인증을 서버에게 건네어 인가(Authorization)를 받는다는 공통점이 있다. 그러나 둘은 차이점이 있고 서로간의 장단점이 있다. 이 둘에 대해 좀 더 뜯어보자.

 

 

세션 (Session)

일정 시간 동안 같은 사용자(정확히는 브라우저)로부터 들어오는 요구들을 하나의 상태로 보고 그 상태를 일정하게 유지시키는 기술을 말한다. 쉽게 말해 방문자가 웹서버에 접속해있는 상태를 하나의 단위로 보는 것. 컨셉은 다음과 같다.

 

  1. 유저가 로그인을 시도하면, 서버 쪽에서 DB를 뒤져서 일치하는 유저 정보를 찾는다.
  2. 찾았다면 그 클라이언트에 대한 세션id(일종의 확인증)을 발급해주고, 서버 쪽에서 갖는 세션 스토리지(Session Storage)에도 해당 정보를 저장한다. (세션id가 일종의 식별key)
  3. 클라이언트는 발급받은 세션id를 쿠키에 저장한다. (2번의 response로부터 set-cookie가 옴)
  4. 이후 클라이언트는 request를 보낼 때 쿠키를 헤더에 넣어 함께 전송(세션id가 들어있음)
  5. 서버는 클라이언트로부터 받은 세션id와 자신의 세션 스토리지에 있는 세션id를 대조해 인증 상태를 판단하고, 인가여부를 내린다.

 

서버 쪽에서 클라이언트의 세션정보를 본인의 메모리나 디스크..뭐 이런 곳에 저장하는 방식으로, 결국은 클라이언트의 상태를 계속해서 서버가 갖고 있고 이를 서비스에 이용하는 stateful방식이다.

 

※ 장점

서버가 클라이언트의 상태를 유지하고 있으니 로그인 여부 확인이 매우 용이하다. 맘만 먹으면 강제 로그아웃 등의 제재를 가하는 것도 쉽다. 

 

※ 단점

서버가 각 클라이언트들의 상태를 모두 갖고있는 셈이니, 당연히 메모리나 디스크 등에 부하가 걸리기 쉽다. 가장 치명적인 단점은 MSA(Micro Service Architecture)문제점이다.  사용자가 많아지면 서버를 여러 개로 늘려 확장한 다음, 로드밸런서(Load Balancer)를 둬서 각 클라이언트들의 보내는 request들을 처리할 서버를 이곳 저곳으로 전달해야 한다. 이 때 내가 로그인을 요청했던 서버와 이후 요청을 보낸 서버가 달라지면, 그 서버에는 내가 아까 로그인을 할 때 저장된 세션 정보가 없다는 문제가 발생하게 되는 것. 이렇게 되면 세션 정보가 없는 다른 서버에 접속할 때마다 계속 로그인을 해야 하는 참사가 생겨버린다. 이를 위해 각 서버들의 세션 스토리지를 동기화해주는 세션매니저(Session Manager)를 두는 방법 등이 있긴 하지만 추가적인 cost가 발생하게 된다..

 

 

토큰(Token)

세션 기반 인증 빙삭이 세션 정보(즉 인증 정보)를 서버에 저장하고 사용하는 식이라면, 토큰 기반 인증 방식은 서버가 아닌 클라이언트가 인증 정보를 직접 들고 있는 방식이다. 대표적으로 JWT(Json Web Token)방식이 가장 유명하다.

 

저작권 문제시 삭제하겠습니다..

 

컨셉은 다음과 같다

 

  1. 유저가 로그인을 시도하면, DB를 뒤져서 일치하는 유저 정보를 찾는다
  2. 찾았다면, 서버 쪽에서 가지고 있는 Secret key로 토큰을 발급한다.
  3. 클라이언트는 발급된 토큰을 저장하고, 이후 요청부터 토큰을 HTTP header에 실어 함께 보낸다.
  4. 서버는 단순히 전달받은 토큰이 유효한지 검증하고, 유효하면 인가해준다.

 

※ 장점

서버 쪽에서 저장하는 게 아니니 서버 쪽의 메모리가 확보된다. 또한 서버를 여러 개로 확장한 경우에도 모든 서버컴퓨터가 유저 정보를 기억하게 할 필요가 없다. 즉 확장성 쪽에서 엄청난 장점을 가진다. 

 

※ 단점

세션 인증 방식의 경우 세션 id만 실어서 보낸다. 반면 토큰의 경우는 사용자 인증 정보 뿐만 아니라 토큰의 발급 시각, 토큰의 id등의 정보도 가지고 있다. 즉 세션id보다 토큰의 사이즈가 더 크기 때문에 훨씬 더 많은 네트워크 트래픽이 유발된다.

뿐만 아니라 세션 인증 방식은 인증 정보를 서버에서 관리하는 반면 토큰은 클라이언트가 모든 인증 정보를 들고 있는 셈이라 보안 측면에서 좀 아쉽다. 세션id는 해커들에게 탈취돼도 서버측에서 해당 세션을 무효 처리하면 되는 반면 토큰은 해커들도 탈취한 다음 지들 맘대로 사용할 수 있다. 

+ Recent posts