(우분투 기준) nginx설치 후 /etc/nginx로 가보면 nginx.conf라는 파일이 있고, 여기엔 nginx의 설정 파일인 nginx.conf가 있다

 

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
	worker_connections 768;
	# multi_accept on;
}

http {

	##
	# Basic Settings
	##

	sendfile on;
	tcp_nopush on;
	types_hash_max_size 2048;
	# server_tokens off;

	# server_names_hash_bucket_size 64;
	# server_name_in_redirect off;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	##
	# SSL Settings
	##

	ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
	ssl_prefer_server_ciphers on;

	##
	# Logging Settings
	##

	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;

	##
	# Gzip Settings
	##

	gzip on;

	# gzip_vary on;
	# gzip_proxied any;
	# gzip_comp_level 6;
	# gzip_buffers 16 8k;
	# gzip_http_version 1.1;
	# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

	##
	# Virtual Host Configs
	##
	include /etc/nginx/conf.d/*.conf;
	include /etc/nginx/sites-enabled/*;

}


#mail {
#	# See sample authentication script at:
#	# http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
#
#	# auth_http localhost/auth.php;
#	# pop3_capabilities "TOP" "USER";
#	# imap_capabilities "IMAP4rev1" "UIDPLUS";
#
#	server {
#		listen     localhost:110;
#		protocol   pop3;
#		proxy      on;
#	}
#
#	server {
#		listen     localhost:143;
#		protocol   imap;
#		proxy      on;
#	}
#}

 

여기서 http블록 안에 직접 server 블록을 작성해줌으로써 웹 서버에서 특정 도메인, 호스트 이름, 포트 또는 IP 주소와 관련하여 요청을 어떻게 처리할지를 정의할 수 있다. 또는 위에 적혀있듯이 /etc/nginx/sites-enabled나 /etc/nginx/conf.d에 작성한 conf파일에 적힌 내용을 include하는 식으로도 server 블록을 정의할 수 있다(즉 직접 nginx.conf안에 작성하든가, 아니면 외부에서 작성한 후 include로 끌고 올 수 있다)

 

이 server 블록은 각 블록별로 하나의 웹사이트 또는 가상 호스트(Virtual Host)를 의미한다. 즉 여러 개의 server 블록을 작성해 다수의 도메인을 하나의 서버에서 가동할 수 있는 것이고, 이는 다수의 도메인에 대해 하나의 서버에서 각기 다른 페이지를 서비스할 수 있다는 말이다. server 블록은 다음과 같이 작성 가능하다.

server {
    listen 80;
    server_name example.com;

    location / {
        # example.com에 대한 설정 ...
    }
}
  • listen : nginx server가 클라이언트로부터 요청을 수신하는 방법을 정의. 걍 어느 ip 어느 포트로 올 건지를 말 그대로 listen한다는 것을 설정하는 부분
  • server_name : 해당 server 블록이 처리할 도메인이나 호스트네임 또는 ip주소를 지정. 클라이언트로부터 request가 오면 header에 있는 Host값과 이 server_name값이 일치하면 해당 server블록을 활성화하는 것임. server_name을 없애고 listen에 example.com:80으로도 작성 가능함
  • location 블록 : 특정 URL 경로 또는 패턴에 대한 요청을 처리하는 방법을 정의. 해당 경로에 대한 프록시 패스를 처리한다든지..그런 것들을 설정해줄 수 있다. 위 예제에선 example.com의 모든 요청(/이니까~)에 대한 모든 요청에 대해 location블록에 설정한 내용이 동작할 것임. 필요에 따라 한 server 블록에 여러 location블록을 둘 수 있음
  • 기타 다른 설정도 가능..

 


여기서 내가 처음에 헷갈렸던 건, "server_name으로 해당 server블록이 처리할 도메인을 적으라는 것". 아니 그러면 naver 도메인같은 걸 여기 적어줘도 되는거야? 란 생각이 들었다. 그러면 말이 안 되지 않나? 저 문장대로면 브라우저에 naver치고 들어가는 걸 naver서버가 아니라 내가 띄운 nginx가 처리해야 한다는 거 아닌가? 

 

그리고 애당초 난 처음엔 server_name을 프록시 대상의 도메인이나 ip주소를 적어줘야 하는 줄 알고 있었다.. (nginx를 리버스 프록시로 쓸 생각으로 설치했었고 자료를 찾아봤어서...) 그래서 nginx 처음 쓰는데 server_name에 프록시 대상으로 was의 주소(nginx를 웹서버, spring boot를 was로 사용해서 was의 주소를 적어줬었음)를 줬는데 잘만 동작하길래 내가 이해한 게 맞다 싶었다. 근데 알고 보니 server_name으로는 server블록이 처리할 도메인과 같이 웹서버에 관한 뭔가를 쓰는 거여서 뭐지? 싶던 거.

 

"난 server_name으로 웹서버가 아닌 was에 대한 걸 적어줬는데 왜 프록시 패스가 잘 되는거지?"

 

그래서 실험을 좀 했다.

우선 server 블록은 다음과 같이 1개만 작성된 상태

server_name에 was(spring boot)의 사설 ip를 박아뒀었다. nginx는 public subnet에 띄웠었고, was랑 같은 vpc안에 있으니 사설 ip를 통해서도 접근이 가능하기 때문에, was의 사설 ip를 server_name에 박아줘도 된다고 생각했음. 그래서 proxy_pass에 server_name에 준 주소를 그대로 써먹은 모습을 확인 가능하다.

 

암튼 이렇게 두면, 잘 된다. nginx쪽에 request를 보내면 잘만 was쪽으로 request를 던져준다. 근데 왜 잘 되는 건지는 몰랐다. 일단 server_name에 10.0.1.88을 박아뒀으니 reqeust의 Host와 매칭되진 않을텐데? 근데 왜 프록시 패스가 잘 되지?

 

바보같은 생각이지만(?) Host와 server_name이 매칭이 안 되면 10.0.1.88로 뭔지 모르겠지만 보내나,,? 싶었다. 그래서 server 블록을 하나 더 추가해줬다.

이렇게 하니, 안된다. nginx쪽으로 보낸 request가 was쪽으로 던져지지 않는다(즉 was에 request가 오지 않음). 매칭이 안 된다고 10.0.1.88로 무조건 보내는 건 아닌 듯 하다. 근데 이상하게 두 server 블록의 순서를 바꿔주면 됐었음. (이때까진 이유를 몰랐다)

 

사실 이때까지도 server_name을 프록시 대상의 ip를 줘야 하지 않을까란 의심(?)을 버리지 못한 상태라,, server_name이랑 request의 Host가 정확히 어떻게 매칭이 되는거지라는 생각이 들었다.. server_name을 아예 nginx가 띄워져있는 ec2의 public ip를 주면 어떨까?란 생각이 들어서 바꿔봤고..실험해봤다

ㅇㅇ. 이렇게 하니 nginx로 보낸 request가 was로 잘 전달된다.

 

음 그러면 확실하게 server_name은 프록시 대상과는 아무런 연관이 없다. server_name과 request의 Host가 매칭돼야 하는거라면, nginx가 띄워진 ec2의 public ip를 server_name으로 줬을 때 해당 ip로 보내는 request의 Host와 매칭되는건 당연하다. 그러면 아까 들었던 의문으로 돌아가서.. server_name으로 아무 도메인(네이버 등)이나 막 줘도 request의 Host랑 매칭만 되면 동작하겠다는 생각이 들었다. 그러나 주소창에 naver를 치고 들어가면 당연히 내 nginx가 이를 캐치할 리는 없고..엇? nginx로 보내는 request에 담긴 Host값만 잘 조작하면 프록시 패스가 되겠네?란 생각이 들었다. 당장 실험에 옮기기로 했다

server_name에 www.naver.com 입력하고

nginx로 보내는 request의 Host를 www.naver.com적어서 보내본다. 

결과는..!

response가 왔다는거 = nginx에서 was로 request를 던져줬다. 라는 것.. 즉 예측에 성공했다.

 

아하! server 블록을 여러 개 둔 뒤 블록별로 server_name을 이것저것 주면, 클라이언트에서 request의 Host를 직접 조작해서 보내는 것을 통해 이런저런 조작을 할 수 있구나~! 이렇게 활용하나보다~!

 

싶었으나! 이내 깨달았다.

 

"하나의 ip주소에 여러 도메인을 연결한다면"..!

 

즉..nginx는 물리적으로 하나의 콤퓨타 위에서 돌아가고 있으니, 해당 컴퓨터에 대한 하나의 ip로 요청을 보낼 건데.. 그 ip가 여러 도메인에 연결돼있다면, 우리 킹갓 nginx가 그 도메인들 별로 각기 다른 서비스를 제공해줄 수 있다는 거다. 뭐 클라이언트에서 직접 request의 Host를 직접 조작해서 보내는..그런 이상한 짓(?)은 안 하는거(물론 원한다면 할 순 있겠지만..)

 

근데 그러면 아까 server블록을 하나만 작성했는데 server_name이 request의 Host와 매칭이 안 됐는데도 동작했던 이유는 뭘까? 그리고 server블록을 여러 개 뒀었는데 순서에 따라 어떨 땐 동작하고 어떨 땐 동작하지 않던 이유는 뭘까?

 

간단하다. Host와 매칭되는 server블록이 하나도 없으면, 기본으로 설정된 server 블록 또는 제일 처음 작성된 server 블록이 활성화된다고 한다. 그래서 그랬던 거..따라서 server 블록의 server_name은 필요에 따라 굳이 작성할 필요가 없다. (server블록이 하나만 있다거나 등등..)

 

 

이런 삽질과 구글링을 통해 배운 걸 정리하면..

  • nginx설정은 nginx.conf를 편집해서 가능
  • server블록에 대한 걸 설정하려면 -> nginx.conf에 직접 작성 또는 /etc/nginx/sites-enabled에 심볼릭 링크로 걸어둔 파일을 통해(nginx.conf에서 여깄는 것들을 include로 땡겨오니까)
  • server_name은 nginx가 띄워진 ip 또는 그 ip에 묶인 도메인들을 작성. ip에 묶인 도메인이 여러 개면 각 도메인별로 다른 페이지 등을 제공 가능
  • server 블록이 여러 개일때 request의 Host와 server_name이 매칭되는게 하나도 없으면 기본 또는 젤 처음 작성된 server블록을 활성화

 

 

 

 

 

읽기 전에 : 해당 글은 스프링 시큐리티 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

 

프로젝트를 진행하면서, 개의 중성화여부를 나타내는 컬럼을 추가해달라는 요청을 받았다.

true / false값을 저장하는 is_neutered필드를 추가하기로 했고, 찾아보니 MySQL에서는 boolean타입을 별도로 지원하지 않는다고 해서 TINYINT(1)타입으로 필드를 만들어줬다. 1은 true, 0은 false를 나타낼 수 있게끔.

 

 

// Dog entity
    @Column(columnDefinition = "TINYINT(1)", nullable = false)
    private boolean isNeutered;

 

그리고 강아지를 등록할 때 사용하는 dto들에도 마찬가지로 isNeutuerd라는 필드들을 추가해줬다.

 

@Getter
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class SignupRequestDto {
    private String email;
    private String name;
    private String dogName;
    private String dogType;
    private String dogGender;
    private boolean isNeutered;
    private int dogAge;
    
    // ... 생략

 

근데 이윽고 잠시 후, 문제가 생겼다. 실제 DB에 쌓이는 데이터들을 보는데, dog테이블의 is_neutered컬럼이 죄다 0만 저장되고 있던 거다.

 

참고로 프론트(플러터 단)에서는 요로코롬 true값을 담아서 request를 보내고 있던 상황이었음

 

      var res = await dio.post(
        "http://localhost:8080/api/auth/signup?provider=$oauth2Provider",
        data: {
          "email": email,
          "name": _userName,
          "dog_name": _dogName,
          "dog_type": _dogType,
          "dog_gender": _dogGender,
          "is_neutered": true,
          "dog_age": _dogAge,
        },
      );

 

뭘까 싶어서 컨트롤러에서 SignupRequestDto의 값을 찍어봤는데, 이럴 수가!! (ㅎㅎ..내가 봐도 오글거리는 말투다)

isNeutered필드 값이 false가 찍히고 있었다!

 

단순히 이 현상만 보자면 프론트에서는 true를 넘겼는데 백엔드에서 받고 보니까 false다..? 중간에 해커가 탈취해서 조작했나..? 오만가지 생각이 다 들었다.

 

하지만 구글링을 통해 비교적 쉽게 문제를 해결할 수 있었다. 바로 dto에서 boolean이 아니라 Boolean으로 타입을 변경해주는 것을 통해서!

 

 

일단 원인부터 정리하자면 다음과 같았다

 

  • 롬복(Lombok)은 @Getter나 @Setter를 붙이면 필드들에 대해 get뭐시기, set뭐시기같은 게터 & 세터 메소드들을 만들어준다.
  • 근데 만약 필드가 boolean타입이면서 is로 시작하는 필라면(ex: isExpired), getIsExpired나 setIsExpired같은 이름으로 메서드를 만들어주는게 아니라 isExpired, setExpired란 이름의 게터와 세터를 만들어준다

 

결국 내가 SignupRequestDto에 작성한 isNeutered라는 필드에 대한 게터가 getIsNeutered가 아닌 isNeutered로 작성된 것. 근데 이게 왜 문제가 되는가? 바로 컨트롤러에 있는 @RequestBody에서 문제가 된다.

 

    @PostMapping("/signup")
    public BaseResponse<JwtResponseDto> signup(@RequestParam("provider") String oauth2ProviderName,
                                               @RequestBody SignupRequestDto signupRequestDto) {
        Oauth2Provider oauth2Provider =
                Oauth2Provider.getOauth2ProviderByName(oauth2ProviderName);
        
        // ... 생략

 

@RequestBody가 어떻게 동작하길래 dto의 게터가 isNeutered인게 문제가 되는 걸까?

 

알다시피 이 놈은 client가 보낸 request의 json데이터를 Dto에 매핑해주는 역할(역직렬화)을 한다.

MappingJackson2HttpMessageConverter가 내부적으로 ObjectMapper를 활용해서 값을 dto에 바인딩해주는데, 이 때 dto에 게터들이 있어야 한다. 즉 get뭐시기라는 형식의 이름을 가진 메서드들이 있어야 하는데, 롬복선생께서 isNeutered의 게터를 getIsNeutered가 아니라 isNeutered로 만들어줘서 문제가 생기는 것. (적절한 게터를 찾게 되지 못하는 것이기 때문) 이때 boolean타입의 기본값은 false기 때문에 자동으로 false가 들어가져있던 거다.

 

즉 이를 해결하기 위해선 이리 볶든 저리 볶든 Dto클래스에 getIsNeutered라는 게터가 있으면 되는거다.

물론 내가 직접 게터를 dto클래스에 만들어주면 되나! 필드의 타입을 boolean이 아닌 Boolean으로 작성(즉 래퍼클래스를 쓰는 형태로 작성)하면, is라는 접두사로 시작하는 필드여도 get뭐시기 라는 형태로 만들어진다고 한다.

 

@Getter
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class SignupRequestDto {
    private String email;
    private String name;
    private String dogName;
    private String dogType;
    private String dogGender;
    private Boolean isNeutered;
    private int dogAge;
    
    // ... 생략

 

 


✅ 참고한 글들

 

https://jungguji.github.io/2020/12/31/RequestBody-Annotation-%EC%82%AC%EC%9A%A9-%EC%8B%9C-boolean-%EB%B3%80%EC%88%98-%EB%B0%94%EC%9D%B8%EB%94%A9-%EC%97%90%EB%9F%AC/

 

RequestBody Annotation 사용 시 boolean 변수 바인딩 에러

서론토이프로젝트 중 @Reqeust 어노테이션을 적용한 DTO에서 boolean 데이터를 제대로 전달 받지 못하는 문제가 발생하여 이를 정리한다. 문제 발생vue.js에서 넘어온 데이터를 @RequestBody 어노테이션을

jungguji.github.io

https://sedangdang.tistory.com/305

 

@RequestBody 공식문서를 읽어보자 + HttpMessageConverter

의역/오역이 있을 수 있고 잘못된 부분이 존재할 수 있습니다 요약 1. @RequestBody는 HttpMessageConverter가 HTTP Request Body 내의 데이터를 객체로 변환(역직렬화)하도록 시키는 애노테이션이다. 2. @Valid를

sedangdang.tistory.com

https://velog.io/@soyeon207/Spring-Lombok-%EC%82%AC%EC%9A%A9%EC%8B%9C-is-prefix-%EB%8A%94-%EC%99%9C-%EC%97%86%EC%96%B4%EC%A7%88%EA%B9%8C

 

Lombok 사용시 is prefix 는 왜 없어질까 ?

isNew 라는 신상품 여부를 나타내는 변수가 있었는데 response 로 DTO 내려줄 때 is 가 사라지고 new 라고만 계속 보여졌다.

velog.io

 

등장배경

어쩌다보니 꽤나 잘 그린 것 같은 그림이 나왔다

우리가 만든 서비스가 유저를 대신해서 구글에서 제공하는 서비스에 뭔가를 하고 싶은 일들이 생겼던 거다. 가령 구글 캘린더에 일정등록을 우리가 만든 서비스가 해준다든가, 등등.. 이를 위해서는 유저로부터 그가 사용하는 구글에 대해 접근할 수 있다는 허락을 받아야 한다.

 

가장 쉬운 방법은 당연히 유저로부터 구글ID와 PW를 받는 것. 우리가 만든 서비스가 유저가 준 ID, PW를 기억하면서 적재적소에 써먹으면 된다. 상당히 쉽고 강력한 방법이다.

하지만 당연히 이걸 실제로 써먹을 수는 없다. 유저입장에선 우리가 만든 서비스를 신뢰할 수 없을 것이며, 구글 입장에서도 유저가 아니라 제 3자인 우리 앱이 유저의 ID와 PW를 가지게 되니 여간 골치아픈게 아니다. 결국 보안적으로 가당치도 않은 상황이 된다.

 

이런 문제에 대한 해결책으로 등장한 것이 본 포스트에서 다룰 OAuth다.

 

 

OAuth?

Open Authorization의 줄임말. 사용자(user)와 구글과 같은 플랫폼 사이에서 제 3자에 해당하는 우리의 서비스가 해당 플랫폼에 있는 사용자의 데이터에 접근할 수 있는 권한을 위임받을 수 있는 표준 프로토콜이다. 이 프로토콜을 통해 사용자는 우리가 만드는 서비스에 ID, PW를 맡길(?) 필요가 없고, 구글 등에 있는 사용자의 데이터에 대해 접근할 수 있는 권한을 우리 서비스가 부여받을 수 있게 된다.

 

 

OAuth의 원리

한 문장으로 요약하자면

 

사용자의 요청을 통해 구글이 access token을 발급해주고, 그 토큰을 통해서 우리가 구글에 존재하는 사용자의 데이터에 접근이 가능해지는 것

 

이다. 이제 이 페이지를 닫으셔도 됩니다

좀만 더 원리를 디테일하게 설명하기 전, 용어 정리를 한 번 하고 가야 한다.

 

 

Resouce Owner

우리가 만든 서비스를 이용하면서 구글 등에 데이터를 가지고 있는 사람. 즉 사용자를 말한다

 

Resouce Server

구글과 같이 사용자의 리소스를 가지고 있는 서버, 즉 우리가 만든 서비스가 제어하고자 하는 리소스를 가지고 있는 애를 말한다. 인증 관련된 서버와 자원 관련된 서버로 구분하기 위해 Authorization Server와 Resource Server 2개로 분리하기도 하는데, 본 포스트에선 Resource Server 하나로 뭉탕치도록(?) 하겠다. 

 

Client

Resource Server의 리소스를 이용하고자 하는 서비스. 즉 우리가 만든 서비스를 말한다

 

 

그럼 이제 본격적으로 OAuth의 동작순서 및 원리에 대해 좀 더 알아보자.

 


동작순서는 다음과 같다

 

  1. Resource Owner(사용자)가 Client(우리가 만든 서비스)의 [구글 계정으로 로그인] 과 같은 버튼을 누른다
  2. Client는 이를 접수(?)하고 Resource Server(구글 등)에게 전달
  3. Resource Server는 Resource Owner에게 로그인 페이지를 보여주고, Resource Owner가 로그인한다
  4. Resouce Server는 인증이 성공되면 Resource Owner에게 Client가 특정 리소스에 접근해도 되냐는 질의를 한다
  5. Resouce Owner가 허락한다면, Resouce Owner가 Authorization code를 Resource Owner에게 전달하면서 Resource Owner를 사전에 약속(Client와 Resource Server가 사전에 약속한 것임)된 Redirect URI로 리다이렉트시킴 (Authorization code: 일종의 임시 암호)
  6. 이를 통해 Client도 Resouce Owner가 Resource Server로부터 전달받은 Authorization code를 알게 됨
  7. Client는 사전에 Resource Server와 합의해서 가지고 있던 client secret이란 걸 가지고 있음. 이걸 Authorization code와 함께 Resource Server에게 전달.
  8. Resoruce Server가 이에 대한 인증이 끝나면, Client에게 access token(허가증)을 발급!
  9. 이후 Client는 Resource Server에 존재하는 Resource Owner의 리소스에 접근할 때는 아까 받았던 access token을 활용

 

그럼 각 단계를 좀 더 뜯어보자. 그 전에, 위 순서에서 보면 사전에 약속된, 합의된 이런 말이 나온다. 그것도 포함해서 각 단계를 뜯어보자.

 


0. 일단 우리가 만든 서비스를 등록

우선 Client, 즉 우리가 만드는 서비스가 구글 즉 Resource Server를 이용하기 위해선 Resource Server에 우리가 널 쓸거라고 사전에 등록을 해야 한다. 이 방법은 구글, 카카오, 애플 등 플랫폼별로 조금씩 다르다.

 

플랫폼 별로 방법이야 당연히 다른데 공통적으로 수행하는 작업이 있다. 바로 Redirection URI를 등록하는 것! 이 URI는 구글과 같은 플랫폼이 인증이 성공한 사용자(구글로 로그인을 눌러서 자신의 구글 계정으로 로그인한..위 순서에서 3 ~ 5번 참조)를 리다이렉트 즉 이동시킬 URI다. 위 순서에서 알 수 있듯, 이는 Resource Server로부터 Authorization code를 받은 Resource Owner가 오게 되는 URI다. (CallBack URL로도 부르는 듯)

 

(음 내가 이해한 대로 설명하자면..유저한테 "유저야, 우리 서비스를 통해 구글에 접근하고 싶지? 그럼 너가 구글에 들러서 걔네한테 받은 임시 허가증을 우리 집 창문으로 들고 와!" 라고 하는 상황이다. 구글이 Resource Server고, 임시 허가증이 Authorization Code다. 그리고 우리 집 창문이 Redirect URI, 즉 사용자가 구글에서 임시 허가증을 받은 뒤 와야 하는 "지정된 장소"인 것. 근데 구글이 친절하게도 직접 택시를 태워서 사용자를 우리 집 창문 앞으로 보내주는 것, 즉 리다이렉트 시켜주는 거다)

 

암튼 이렇게 등록이 끝나면 Client Id와 Client Secret(위 순서에서 7번을 참조)를 발급받는다

 

  • Client Id : 등록된 우리 서비스를 Resource Server가 식별할 수 있는 식별자
  • Client Secret : Client Id에 대한 비밀번호. 외부에 노출되면 절대 안 된다

 

즉 이런 등록과정, 즉 사전협의를 통해 client와 resource server는 client id 및 client secret, 그리고 redirect uri를 아는 상태에서 시작한다.

 


1. Resource Owner가 Client의 [구글 계정으로 로그인] 과 같은 버튼을 누른다

걍 이거 말하는 거임

 

ChatGPT 로그인 화면

 

여기서 구글로 로그인 이런걸 유저가 누른다는 말!

 


2. Client가 이를 Resource Server로 전달

이때 전달하는 주소는 다음과 같은 형식이다 (물론 플랫폼 별로 조금씩 차이가 있을 수도..?)

 

https://resource.server/?client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}

 

아까 사전 협의를 통해 Client가 Client Id와 Redirect URI를 알고 있음을 상기하자. scope는 client가 resource server로부터 인가받을 권한의 범위를 말한다고 생각하면 된다(구글의 모든 리소스에 접근할 수 있는 것보단 딱 필요한 것에만 접근할 수 있게끔 하는 것이 당연히 좋다)

 


3. Resource Server가 로그인 페이지를 보여주고 Resource Owner가 로그인한다

이거 말하는 거임

 


4. Resource Server는 인증이 성공하면 Resource Owner에게 Client가 특정 리소스들에 접근해도 되냐는 질의를 함

인증이 성공(즉 Resource Owner가 로그인에 성공하면)하면, Resource Server는 쿼리스트링 형식으로 넘어온 파라미터들을 보며 Client가 본인이 아는 그 놈이 맞는지 검사한다(즉 사전에 협의된 녀석이 맞는지 검사). Resource Server 역시 Client Id와 Redirect URI를 알고 있음을 다시 한 번 상기하자.

 

검사하는 내용은,

 

  1. 파라미터로 전달된 client id값과 동일한 값을 내가(Resource Server가) 가지고 있는가?
  2. 가지고 있다면 그에 대한 redirect uri가 파라미터로 전달된 redirect uri와 동일한가?

 

이렇게 검사한 후에, 특정 리소스들에 접근해도 되냐는 질의를 하는데 그건 이런 거 말하는 거임

 

 


5. Resource Owner가 허락한다면, Resource Server는 Authorization code를 Resource Owner에게 전달하며 Redirect URI로 리다이렉트시킨다

특정 리소스들에 대한 접근 질의에 Resource Owner가 허락한다면, Resource Server는 해당 Client Id에 대해 특정 user_id를 갖는 Resource Owner가 특정 scope에 대한 행동을 허가했다는 사실을 기록한다. (즉 특정 유저가 우리 서비스에 대해 A, B라는 행동을 해도 된다고 허락했다는 것을 기억하는 것)

 

 

그 후, Resource Owner에게 Authorization code라는 임시 암호를 발급해주면서 어떤 uesr에게 해당 Authorization code를 발급했는지 기록한다. 그와 동시에, 사전에 합의했던 Redirect URI로 Resource Owner를 리다이렉트시킨다.

 


6. 이를 통해 Client도 Resource Owner가 Resource Server로부터 발급받은 Authorization Code를 알게 됨

별도의 부연설명은 생략

 

 


7. Client는 사전에 합의한 후 받았던 Client Secret과 함께 Authorization code를 Resource Server에게 전달

 

 

이 때 다음과 같은 형식으로 보내게 된다

 

https://resource.server/token?
	grant_type=authorization_code&
	code={authorization_code}&
	redirection_uri={redirect_uri}&
	client_id={client_id}&
	client_secret={client_secret}

 

각각에 대해 설명하자면

 

  • grant_type : 항상 "authorization_code"라는 문자열로 설정
  • code : 전달받은 authorization code를 넣으면 됨
  • redrection_uri : 사전에 합의한 바로 그 redirection_uri 넣으면 됨
  • client_id : 사전에 합의하고 받은 바로 그 client id 넣으면 됨
  • client_secret : 사전에 합의하고 받은 바로 그 client secret 넣으면 됨

 


8. Resource Server가 이에 대한 인증이 끝나면, Client에게 access token을 발급함

Resource Server는 Client에게 전달받은 code(= authorization code)값과 자신이 아까 기록한(5번 참조) Authorization code를 대조하며 인증을 함. 이 과정이 성공적으로 끝나면, Resource Server는 아까 자신이 기록했던 Authorization code를 지우고 Client에게 Access Token을 발급하며 해당 토큰을 어떤 user_id에게 발급했는지를 기록한다.

 

 


9. 이후 Client는 발급받은 Access token을 이용해 활용

자세한 설명은 생략한다.

 

 


참고로, Refresh token을 발급해주기도 한다고 한다. Access token이 만료되면 Refresh token을 통해서 Access token을 재발급받는 것.

형제간 margin병합 현상

형제간 margin충돌이 일어날 때, 각각의 margin값들이 반영되는게 아니라 더 큰 margin으로 병합되는 현상.

음 말로 적으니까 뭔 말인지 어렵다..

 

  <div id="box1"></div>
  <div id="box2"></div>
    #box1 {
      width: 100%;
      height: 200px;
      background-color: yellow;
      margin-bottom: 100px;
    }  
    
    #box2 {
      width: 100%;
      height: 200px;
      background-color: pink;
      margin-top: 50px;
    }

 

요로코롬 HTML, CSS가 작성돼있다고 하자. box1, box2는 형제 관계고 box는 밑 쪽으로 100px만큼의 margin을, box2는 위쪽으로 50px만큼의 margin을 가지고 싶어 한다. 상식적으론 둘의 의견을 반영해 150px의 margin이 생기는게 맞다고 생각되지만, 

 

 

실제론 이렇게 100만큼의 margin만 가지게 된다. 이게 형제간 margin 병합 현상. 충돌이 일어날 때 더 큰 margin값이 채택된다는 얘기다.

 

 

부모 자식간 margin병합 현상

자식만 margin을 두고 싶지만 부모도 함께 margin이 적용되는 현상을 말한다.

 

  <div id="div">  
    <article></article>
  </div>
    #div {
      width: 100%;
      height: 400px;
      background-color: yellow;
    }
    
    article {
      width: 100px;
      height: 100px;
      background-color: red;
    }

 

요로코롬 HTML, CSS가 작성돼있다고 하자. 현재 화면은 다음과 같다.

 

 

여기서 노란색은 놔두고 빨간색 박스만 밑으로 50px만큼 움직이고 싶어서 빨간색 박스(article태그)에 margin-top을 다음과 같이 먹였다고 하자.

 

    #div {
      width: 100%;
      height: 400px;
      background-color: yellow;
    }
    
    article {
      width: 100px;
      height: 100px;
      background-color: red;
      margin-top: 50px;
    }

 

하지만 결과는,

 

 

이렇게 부모도 같이 움직이게 된다. 이를 부모 자식간 margin병합 현상이라 한다. 자식에 쓰인 margin이 부모에게도 적용되는 것!

 

참고로 이건 자식에게 position값으로 absolute를 멕이는 것으로 해결 가능하다. absolute가 부모를 기준으로 적용되는 position값이기 때문!

+ Recent posts