소마 프로젝트를 진행하며, 내가 먼저 담당하게 된 기능은 로그인 및 회원가입 기능들이었다. 그 중에서도 소셜로그인 기능을 만들기로 했는데, 구글 & 카카오 & 애플에 대해서 만들기로 했다.

 

야생형 학습(일단 만들면서 그때그때 필요한 걸 찾아가는)이 좋다는 걸 알지만, Oauth라는 것 자체에 내가 무지했던 만큼 최소한 이게 어떤 건지, 어떤 원리로나마 굴러가는건지 파악을 한 뒤 기능개발에 들어가보기로 했다.

 

아래 포스트는 당시 내가 Oauth에 대해 찾아보며 정리했던 포스트다.

https://jofestudio.tistory.com/105

 

OAuth의 등장배경 및 기본적인 원리 & 과정 - top down형식으로 알아보자

등장배경 우리가 만든 서비스가 유저를 대신해서 구글에서 제공하는 서비스에 뭔가를 하고 싶은 일들이 생겼던 거다. 가령 구글 캘린더에 일정등록을 우리가 만든 서비스가 해준다든가, 등등..

jofestudio.tistory.com

 

Oauth가 굴러가는 원리는 내 머릿속에선 다음과 같이 새겨졌다

 

  1. 각 플랫폼에 들어가서 redirect uri 등등을 세팅
  2. 각 플랫폼(구글 등)에서 지정한 로그인페이지로 들어감
  3. 거기서 해당 플랫폼 계정으로 로그인하면 authorization code를 지급받는데, 사전 설정한 redirect uri를 통해 이걸 백엔드로 보내게 됨
  4. 백엔드에선 해당 code를 통해 플랫폼으로부터 access token을 받음
  5. 그걸 통해서 회원정보도 조회하고.. 등등

 

우리 프로젝트는 자체적으로 구축할 db에 회원정보를 저장할 것이다. 따라서 백엔드에서 4번 과정을 통해 받은 access token을 활용해 플랫폼으로부터 회원정보를 받아오고, 해당 회원정보를 갖는 회원이 실제로 우리 db에 있는지를 판단해서 로그인 및 회원가입을 만들면 되겠다는 그림이 머릿속에 그려졌다. 네이티브 앱은 세션 로그인이 불가능하기 때문에, 해당 회원에 대한 JWT를 만들어서 플러터 단으로 주면 되겠다는 생각도 들었다.

 

"그러면 일단 저 순서대로 만들어가면 되겠네?" 라는 생각이 들었다. 그러나 문제는.. 내가 모든 게 처음이라는 것이었다.

 


앱에서는 authorization code만 받는 걸 어떻게 하지?

"구현방법은 조금씩 달라도, 결국 저 순서를 따르는 식으로들 만들겠지!" 라고 처음에 생각했다. 각 플랫폼 별로 지정한 로그인 페이지를 띄우려면 웹뷰를 띄워야겠다고 생각했고, 웹뷰 띄우는 방법을 먼저 검색해야 한다는 갈피가 잡혔다. 

 

그러나 얼마 지나지 않아.. 구글에서는 보안 정책상의 이유로 웹뷰를 통한 구글로그인을 막아놨다는 걸 알게 됐다. user agent를 변경하여 이를 우회할 수도 있다고 하나, 다른 것도 아니고 앱의 보안과 사용성에 부정적인 영향을 끼칠 수 있어서 막아둔 걸 굳이 우회해서 이용할 필요는 없다고 생각했다.

 

..그러면 다른 방법을 찾아야 한다! 일단 플러터 구글로그인 예제를 검색해봤다. google_sign_in이라는 패키지를 통해 구글로그인을 할 수 있다는 걸 알게 되고, 써봤다. 매우 쉽게 google에서 지급한 access token을 플러터 단에서 받을 수 있었다.

 

여기서 뭔가 이상한 걸 느꼈다. 내가 이해한 Oauth2 프로토콜은 사용자 측에서 redirect uri를 통해 백엔드로 authorization code를 보내면 백엔드가 그걸 통해서 resource server(ex: google)로부터 access token을 받아야 한다. 그러나 지금은 버튼을 딸깍 하고 누르니 바로 플러터 단에서 google의 access token이 받아져(?)버렸다.

 

물론 백엔드로 authorization code를 넘기는 것은 내가 그린 그림이었을 뿐, 프론트 쪽에서 토큰을 받는 식으로 처리해도 상관은 없다. 애당초 Oauth2 프로토콜에서 말하는 "client"라는 것도 엄밀히 말하면 우리가 구축하는 서비스를 말하는 것이지 백엔드만을 말하는 게 아니니까. 따라서 이 상태, 즉 프론트에서 토큰을 받은 상태에서 그대로 백엔드 단으로 access token을 넘긴 뒤 백엔드에서 그걸 통해서 유저 정보를 조회하고 JWT를 만드는 식으로 가도 될 것이다. 그러나 access token자체를 백엔드로 넘긴다? 이거 탈취당하면 어떡할건데? 라는 생각이 머리를 스쳤다.  그리고 우리 서비스에서 사용할 커스텀 JWT를 만들기 위해 access token을 백엔드로 넘기는 거면 백엔드에서 그 access token을 가지고 resource server에서 회원정보 가져올 텐데, 어차피 플러터 쪽에서(즉 프론트에서) access token을 가지고 있는거면 플러터에서 바로 resource server로부터 회원정보를 가져오고 그걸 백엔드로 넘기는게 더 효율적일 수도 있다는 생각도 들었다.

 

결국 access token이 아닌 authorization code를 백엔드로 보내게 되는게 보안상 안전하다는 생각이 들었다. 그래서 어떻게든 authorization code를 받아오는 방법을 찾아봐야겠다는 생각을 하고 여기저기 쑤셔대기 시작했다.

 

그러나 구글링해서 나오는 예제는 죄다 google_sign_in패키지를 활용해서 바로 access token을 받아오는 것들이었고,, 거의 대부분이 firebase와 연계해 인가/인증을 하는 예제들이었다. 나는 따로 회원정보를 자체적으로 구축할 db에 저장하고 이를 활용할 것(외래키로 사용 등..)이기 때문에, 최대한 firebase를 쓰는 예제들은 지양하고 있었다. 물론 firebase를 쓴다 해도 그와는 별개로 우리 플젝에서 쓸 db에도 어찌어찌 회원데이터를 넣을 순 있겠으나, firebase와 자체db의 정보를 계속 동기화하는 것 등등 신경쓸 것이 너무나 많아질 것이란 생각도 들었고, 내가 만들 백엔드를 통해 인가/인증을 하지 못하게 될 거라는 점도 맘에 안 들었다. 

 

결국 authorization code만 따로 쏙 빼고 싶으면 웹뷰를 써야 하는데.. 일단 아까 말했다시피 구글에서는 웹뷰를 통한 로그인을 막아둔 상태. 그리고 이걸 배제한다고 해도 문제가 있었다. redirect uri를 통해 백엔드로 바로 authorization code를 넘겨야 하는데, 웹뷰에서 redirect uri를 통해 백엔드로 보낼 수가 있는가?라는 의문점이 든거다. 즉 나는 로컬환경에서 개발 중인데, 노트북에서 에뮬레이터로 띄운 휴대폰 기기의 웹뷰에서 redirect uri로 localhost뭐시기를 했을 때.. 내 로컬 "노트북"에서 돌아가는 스프링부트로 authorization code가 오는가..의 문제. 당시에는 웹뷰 입장에선 자신을 돌리고 있는건 핸드폰 기기인 셈이니 얘한테 있어서 localhost는 기기를 말하지 않겠냐는 생각이 머리에서 돌아가게 된 거다. 이에 대한 해결책으론 배포를 해서 배포된 주소로 보내든가.. 아니면 내 노트북의 ip주소로 보내든가해야 하는데, 두 방법 모두 썩 좋은 해결책은 아니라는 생각이 들었고.. 무엇보다도 애당초 구글은 웹뷰를 통한 로그인을 막아뒀다!

 

(나중에 에뮬레이터의 웹뷰에서 localhost를 때려도 노트북으로 http를 보낼 수 있다는 걸 알게 되긴 했다.

https://learn.microsoft.com/ko-kr/xamarin/cross-platform/deploy-test/connect-to-local-web-services)

 

 

원래는 이렇게 만들고 싶었다.. (파워포인트로 후루룩 만듦ㅋㅋ)

 

 

머리가 아파졌고,, 그냥 카카오 로그인이나 먼저 만들까란 생각으로 카카오 로그인 공식문서를 열었다.

근데 열자마자..!

 

 

앱 서비스에서는 Redirect 방식 사용불가라는 말이 떡하니 있었다. 게다가 얘도 클라이언트단에서 인가 코드와 토큰 발급을 모두 처리하는 방식을 가지고 있었다.

 

나는 우리 프로젝트에서 사용할 소셜플랫폼인 카카오, 구글, 애플은 동일한 형태의 로그인을 만들고 싶었다(셋 다 토큰 자체를 백으로 넘기든가, 아니면 셋 다 authorization code를 백으로 넘기든가..). 근데 어차피 구글은 웹뷰를 통한 로그인을 막아놨고, 카카오도 공식문서피셜로는 네이티브 앱 환경에서는 Redirect 방식을 사용할 수 없다고 공지를 내렸었다.

 

그러면 결단을 내릴 차례.. 하는 수 없이 그냥 "플러터에서 토큰 넘기자"로 결정하게 됐다. 프로젝트에서 주어진 시간도 많지 않았기 때문에, authorization code를 보내는 방법을 더 알아내는 것보단 일단 로그인 자체를 만드는 것에 더 포커스를 두기로 한 것도 있었다. 

 

 


AWS Cognito와의 사투

그렇게 플러터 단에서 토큰을 받아서 백엔드로 넘기는 로직을 만들었을 때쯤, 연수생 동기와 Oauth에 대한 얘기를 주고 받았었다. 근데 그 친구가 firebase를 써서 소셜로그인을 만들었다는 얘기를 했고, firebase를 사용해서도 자체 백엔드에서 인가인증이 가능하다는 얘기를 해줬다!

 

https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl 

 

ID 토큰 확인  |  Firebase Authentication

Google I/O 2023에서 Firebase의 주요 소식을 확인하세요. 자세히 알아보기 의견 보내기 ID 토큰 확인 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Firebase 클라이

firebase.google.com

 

사실 나는 이전에 firebase를 잠깐 써봤을 때 걔네가 지들이 알아서 인가인증을 다 해줬던 걸로 기억해, firebase를 쓰면 내 백엔드에서 인가인증하는 식으로 못 만든다고 생각해서 지금까지 지양했던건데,, 역시 난 우물 안 개구리였구나라는 생각이 들었다.. ㅎㅎㅎ;;

 

그 때 한창 소마에서 지원해주는 AWS 심화교육(Architection on AWS)를 듣고 있던 터였는데, 우리 얘기를 듣고 있던 강사님께서 AWS에서 제공하는 Cognito란 걸 사용하면 똑같은 걸 할 수 있다고 말씀해주셨다. 그 때 삘이 꽂혀서,, ㅎㅎ; 갑자기 코그니토를 통한 소셜로그인을 엄청나게 검색하고 따라해봤다.

 

뭐. 결론적으로 말하면 이 방법은 성공하지 못 했다. AWS Amplify를 통해 Flutter에서 이것저것 시도를 해봤고,, 공식문서도 그대로 따라하는걸 10번 정도는 한 것 같다. 근데 자꾸 에러가 났다 ㅎㅎ

 

결국 시간 문제에 다시 직면했고, 타협을 해야 할 지점이 왔다. 로그인/회원가입을 만드는 것에서 더 이상 시간을 끌 수 없던 노릇이라, AWS Cognito를 사용하는 방식을 버리기로 했다. 그러면 firebase를 그냥 쓸까?라는 생각이 들었지만, 내가 직접 JWT를 만들어서 인가인증을 만들어보는게 더 낫겠다는 생각이 들었다. 이전에 jwt를 활용한 로그인을 따라하면서 어떻게 만들어본 적은 있지만, 뭔지 알고 만들었던 건 아니었어서 이번에 각잡고 만들어봐야겠다는 마음이 들었고.. 이게 어쨌든 좀 더 공부가 될 것 같았기 때문.

 

그래서 다음과 같은 흐름으로 만들어보기로 했다. 탕탕탕!

 

 


스프링 시큐리티 & JWT로 인가 인증 만들기

member테이블을 만들고, db에 회원정보가 있는지 없는지 조회하는 건 금방 만들었다. 근데 스프링 시큐리티라는 놈이 인가인증을 어떻게 해주는건지, 그리고 스프링 시큐리티가 JWT를 통해서는 어떤 식으로 인가인증을 하는지는 잘 몰랐다. 그래서 일단 이놈들의 이론적인 부분에 대해(?) 먼저 짚고 넘어가기로 했다.

 

https://jofestudio.tistory.com/112

 

[Spring Security] 스프링 시큐리티의 인증/인가 과정(6.1.2버전 기준, with 공식문서)

읽기 전에 : 해당 글은 스프링 시큐리티 6.1.2버전 기준으로, 읽는 시점에 따라 다른(deprecate됐다든지) 내용일 수도 있습니다. 어떠한 요청을 보냈을 때, 요청을 보낸 이에 대한 인가/인증이 수반돼

jofestudio.tistory.com

(스프링 시큐리티 원리에 대해 공부하고 정리한 글. 실제로 개발할 때는 완전 딥하게는 공부하지 못했고, 로그인/회원가입을 일단 만든 뒤에 몰랐던 부분 더 찾아본 뒤에 정리해서 작성한 글이다)

 

 

스프링 시큐리티가 인가인증을 해주는 "기본적인" 방식은 내 머릿속엔 다음과 같이 새겨졌다.

 

  1. 유저가 로그인을 시도(with username & password)
  2. 필터에서 DB에 있는 유저인지 체크함(username을 활용)
  3. 있는 놈이면 "그 유저의 정보를 담은 문서" = UserDetails를 만듦
  4. 그 UserDetails의 내용과 유저가 로그인할 때 보낸 정보(password)를 비교.
  5. 같다면 통행증 = Authentication을 끊어줌. 통행증에는 누군지(=principal이라 부르고, 얘 역할을 UserDetails가 함), 그리고 뭘 할 수 있는 앤지(=GrantedAuthority)가 적혀있음
  6. 그걸 SecurityContextHolder에 저장하면서 세션에도 저장!
  7. 유저에게 세션id를 주고 이후 요청들은 이걸로 인가인증하는 거임

 

한마디로 SecurityContextHolder라는 곳에 Authentication이라는 통행증을 발급해서 넣어둔다. 그 후 나중에 이렇게 넣어둔 Authentication이 있는지를 봐서 "인증"을 하고, 이 통행증에 써져 있는 권한(GrantAuthority)를 보고 "인가"를 하는 식으로 Spring Security가 동작한다.

 

jwt를 활용해 인가인증을 할 거다. 그럼 위 과정을 토큰방식으로 바꾼다면 어떨까? 토큰방식으로 바꿔도 뭐가 됐든 간에 Authentication을 만든 다음 그걸 SecurityContextHolder에 넣는 것은 동일할 것이다. 그 후 SecurityContextHolder에 Authentication이 들어있는지를 보며 "인증"을 하고, 나중에 Authentication을 꺼내 GrantedAuthority를 보고 "인가"를 할 것이다.

 

이렇게 대략적인 흐름이 머리 속에 잡혔고, 그 후 구글링을 통해 스프링 시큐리티와 jwt관련된 예제들을 살폈다. 나름 원리를 알고 나니(?) 예제들을 보면서 "아 여기선 뭘 하고 있는 거구나" 이런 게 느껴졌다. 

 

 

다음과 같은 흐름으로 만들기로 했다

 

  1. 우선 로그인 성공하면 jwt반환(Access token) -> 여긴 유저의 이메일 정보를 담는다
  2. 클라이언트가 헤더에 Access Token을 담아 요청을 보냄
  3. 헤더에서 Access Token 추출
  4. Access Token 검증(유효기간 만료 등)
  5. 이제 Access Token으로부터 Authentication을 만들고 이를 SecurityContext에 꽂아야 한다
  6. 우선 Access Token에서 이메일 추출
  7. 해당 이메일을 갖는 유저의 UserDetails를 만듦
  8. 이 UserDetails를 활용해 Authentication을 만든다(principal은 UserDetails가, grantedAuthority는 UserDetails.getAuthorities를 활용
  9. 그렇게 만든 Authentication을 SecurityContext에 꽂는다

 

그림으로 보면 다음과 같다. jwtFilter, jwtProvider 등등은 내가 정한 클래스들의 이름이다.

 

 

이 과정에서 잠깐 고민했던 부분은 UserDetails의 구현체를 어떤 방식으로 만드는가였다. User(내 프로젝트는 Member) 클래스가 UserDetails를 구현하는 식으로 만들 수도 있고, 정말 아예 User클래스와 CustomUserDetails(UserDetails의 구현체 클래스)를 별도로 둘 수도 있었다.

 

나는 후자로 했다! 물론 UserDetails를 User가 구현하도록 하는게 뭔가 더 편할 것 같긴 하지만, UserDetails의 원래 용도 자체는 "사용자의 정보가 담긴 문서"인 만큼, User가 UserDetails를 구현하면 유저 자체가 통째로 사용되는 것이니 조금 번거롭더라도 UserDetails를 구현하는 별도의 커스텀 클래스를 만드는게 보안상 좀 더 안전하리라 생각했다.

 

아 그리고 UserDetails를 받은 뒤 Authentication을 만드는 과정에서, 요 Authentication이란 놈은 인터페이스여서 구현체를 만들어서 써야 하는데, 이건 UsernamePasswordToken이란 놈이 사용된다. 근데 이 놈은 생성자가 2개다. 

 

public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
	return new UsernamePasswordAuthenticationToken(principal, credentials);
}

public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
	Collection<? extends GrantedAuthority> authorities) {
	return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}

 

전자는 인증되지 않은 상태의 Authentication을 만드는 거고, 후자는 인증된 상태의 Authentication을 만드는 생성자이다. 전자는 도대체 왜 있는거지? 싶었는데, 알고보니 username & password로 하는 전통의 원조(?) 로그인에서는 전자를 통해서 인증되지 않은 상태로 만든 뒤 UserDetails와 비밀번호를 대조하는.. 뭐 그런 과정들을 거친 뒤에 인증된 상태로 바꿔주는 과정이 있는 거였다. ㅇㅇ

 

하지만 지금 내가 하는 과정에서는 굳이 그렇게까지 할 이유가 안 보였다. jwt가 클라이언트로부터 온다는 거 자체가 이미 이전에 로그인을 통한 신원 확인이 끝나서 클라이언트가 서버로부터 jwt를 받은 것이기 때문. 

 


인증/인가 실패 시 처리

인증/인가를 할 수 있도록 Authentication을 SecurityContext에 꽂아넣는 건 만들었다. (비하인드 : 요로코롬 SecurityContext에 Authentication을 꽂아둬야 인증한 것으로 취급된다는 걸 당시엔 명확하게 몰라서 무수히 많은 로그를 찍어봤었 건 비밀). ok. SecurityContext에 Authentication이 없으면 인증에 실패한 것으로 처리되는 것도 알겠다. 그러면 인증이 실패했을 때와 인가에 실패했을 때에 대한 처리는 어떻게 하는가? 예를 들어, Access Token의 검증에 실패하면 그건 인증 실패로 처리해야 할테니까.

 

일단 인증실패는 AuthenticationEntryPoint라는걸 통해서 가능했다! 인증에 실패하면 AuthenticationException이 발생하는데, 이걸 AuthenticationEntryPoint에서 처리한다. 커스텀하게 AuthenticationEntryPoint를 만들어 클라이언트에게로 가는 Response를 조작할 수 있는데, 여기서 고민이 생겼다. 클라이언트로 가는 Response들에 세부적인 메시지를 내가 달아주고 싶은데, 그걸 어떻게 하지? 라는 고민. 이게 무슨 말이냐?

 

public interface AuthenticationEntryPoint {
	/**
	 * @param request that resulted in an AuthenticationException
	 * @param response so that the user agent can begin authentication
	 * @param authException that caused the invocation
	 */
	void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
			throws IOException, ServletException;

}

 

요렇게 AuthenticationEntryPoint의 commence메서드를 통해 AuthenticationException을 처리한다. 그리고 내가 만든 jwtFilter에서는 다양한 이유들로 인증 실패를 낼 수 있다. Access Token의 만료기간이 지났다든지, 유효하지 않는 서명을 사용했다느든지 등등.. 그럴 때마다 단순히 "인증 실패요~"라는 것보다는 클라이언트에게 다양한 메시지(뭐 땜에 실패했어요~)를 전달하고 싶은데, 그 오류메시지(?)들을 어떻게 AuthenticationEntryPoint로 넘기는지를 고민하게 된 거다.

 

AuthenticationException도 추상 클래스이기 때문에, 커스텀하게 이를 구현하는 클래스를 만들어 내가 원하는 오류 문구를 담을 수도 있을 것이다. 그러면 jwtFilter에서 이렇게 커스텀하게 만든 AuthenticationException을 만들어 던진다면 어떨까? 싶었는데.. 일단 지금와서 돌이켜보면 당시에는 이 생각을 못 했다. 지금이야 돌이켜보니 일단 만드는 과정에서 부족하지만 나름대로의 지식이 생기긴 했지만.. 당시엔 뭐 어떻게되는지 모르겠지만 SecurityContext에 Authentication을 안 꽂아두면 뒷단에 있는 필터에서 AuthenticationException이 발생하고, AuthenticationEntryPoint가 이를 처리하네! 정도만 알던 상태라.. 

지금 돌이켜보면 jwtFilter에서 커스텀하게 만든 AuthenticationException을 던져도 그게 ExceptionTranslationFilter에서 처리되지 않는다는 걸 안다. 왜냐하면 ExceptionTranslationFilter는 doFilter를 통해 자기보다 뒷단의 필터(AuthorizationFilter)에서 일어나는 AuthenticationException만 처리하는데, 내가 만든 jwtFilter는 ExceptionTranslationFilter보다 앞단에 위치하기 때문이다. 

 

암튼 그래서 당시엔 다른 사람들이 만든 코드들을 살피면서 방법을 찾다가, 나름 기똥찬(?) 방법을 발견했다. 그건 바로 request에 에러메시지를 저장하는 것! 자세히보면 commence메서드가 AuthenticationException뿐만 아니라 request와 response도 파라미터로 받고 있는걸 볼 수 있다. 즉 jwtFilter쪽에서 request에 에러메시지를 세팅해두면, 나중에 AuthenticationEntryPoitn의 commence메서드에선 파라미터로 받은 request에 들어가있는 에러메시지를 꺼낼 수 있는 거다!

 

// 내가 만든 JwtFilter 내부..
try {
	jwtProvider.validateAccessToken(accessToken);
} catch (CustomJwtException ex) {
	request.setAttribute("exception", ex.getMessage());
	return;
}
// .. 생략

 

// 커스텀 AuthenticationEntryPoint
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response,
						AuthenticationException authException)
			throws IOException, ServletException {
		// request로부터 꺼내기
		String errorMessage = (String) request.getAttribute("exception");
		setResponse(response, errorMessage);
	}
	// ..생략

 

그러면 인가실패는 어떻게 처리하는가? 인가에 실패하면 AccessDeniedException이 던져지며, 이는 AccessDeniedHandler를 통해 처리할 수 있다. 이 핸들러또한 커스텀하게 만들 수 있어서, response를 내가 원하는 식으로 조작 가능하다.

 

인가 실패는 그럼 어떤 상황에서 이뤄지는가? 다음과 같다.

 

  1. AuthorizationFilter에서 진행됨
  2. SecurityContextHolder에서 Authentication을 가져옴. 못 가져오면 AuthenticationException
  3. Authorities들, 즉 사용자의 권한 목록을 꺼냄
  4. 시큐리티 설정에서 hasRole을 사용했다면, 해당 메서드의 인자로 넘긴 값들과 사용자의 권한 목록을 비교. 참고로 hasRole의 인자로 넘긴 값들에 대해 자동으로 ROLE_이라는 prefix를 붙여줌. 예를 들어 hasRole("USER") -> 사용자의 권한 목록에 ROLE_USER가 있어야 한다는 거
  5. 일치하는게 있으면 ok. 없으면 AccessDeniedException

 

참고로 난 원래 Member엔티티들이 가지는 Role을 USER, ADMIN이런 식으로 줬어서.. 급하게 앞에 ROLE_들을 붙이는 방식으로 수정했었다.

 

이후 Configuration파일에서 다음과 같은 설정을 통해 커스텀하게 만든 AuthenticationEntryPoint, AccessDeniedHandler를 등록해줬다. 당연한 소리지만 이렇게 커스텀하게 만든 애들을 등록하지 않으면 디폴트로 있는 애들이 쓰인다.

 

@Configuration
public class SecurityConfig {
    // ... 생략

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // ... 생략
                ).exceptionHandling((exceptionHandling) -> {
                    exceptionHandling.authenticationEntryPoint(new JwtAuthenticationEntryPoint());
                    exceptionHandling.accessDeniedHandler(new JwtAccessDeniedHandler());
                });

        return http.build();
    }
}

 


토큰 재발급 처리

Access Token & Refresh Token을 발급해서 클라이언트에게 넘기는 건 끝났다. 클라이언트(플러터) 단에서는 Access Token과 Refresh Token을 보관소에 가지고 있도록 했는데, 로그인 유지 기능을 위해 앱 접속 시 자동으로 보관소에 들어있는 토큰들을 갱신해주기로 했다. 난 Access Token의 유효기간을 1시간, Refresh Token의 유효기간을 30일로 만들었으니 앱을 접속을 안 하고 있었다고 해도 30일 내로 접속한다면 로그인이 유지된 채로 앱을 이용할 수 있는 거다.

 

일단 당연히 Refresh Token을 서버로 보내면 걔를 검증(유효기간 만료 등)해야 한다. 그럼 이 검증만 하고 재발급을 바로 해주면 되나? 싶었지만 좀 더 보안을 위한 방법이 있었다. 바로 Refresh Token원본을 DB에 저장하고, 클라이언트로부터 온 Refresh Token을 그와 대조하는 것.

 

즉 다음과 같은 흐름이다.

 

소셜로그인 과정.. Resource Server에서 토큰받아오고 그런 과정은 과감하게 생략했다

 

근데 DB에 Refresh Token을 보관상으로 얻는 구체적인 이점들은 도대체 뭘까? 이거 디비에 둘 필요 없이 그냥 검증만 해주면 되는거 아니야? 왜 굳이 DB에 저장해? (라고 생각했던 건 본인)

 

  1. 우선 단순히 Refresh Token을 검증만 하고 재발급하는 수준이라면 -> 악의적 사용자도 Refresh Token 탈취해서 악용이 가능. 하지만 DB에 Refresh Token을 저장하는 구조라면? 악의적 사용이 보고됐을 때 DB에 저장된 Refresh Token을 삭제함으로써, 악의적 사용자가 Refresh Token을 보내도 검증은 되겠지만 DB에 Refresh Token이 없으니 재발급을 안해주는 식으로 대응이 가능하다
  2. Access Token을 발급받을 때 Refresh Token도 함께 발급받으면서 DB에 기존에 있던 Refresh Token을 삭제한 뒤 새로 발급받은 Refresh Token을 저장하는 방식이면 -> 악의적 사용자가 어찌저찌 Refresh Token을 탈취해도, 유저가 다시 로그인하거나 하면 DB에 있던 Refresh Token도 갱신되는 셈 -> 악의적 사용자가 지가 가지고 있던 Refresh Token을 보내도 물론 검증은 통과하겠지만 DB에는 해당 Refresh Token은 삭제됐으니 재발급을 안해주는 식의 대응이 가능해진다

 

 

요 내용은 다음 글에서 좀 더 자세히 확인 가능하다. 영어로 된 글이니 놀라지 말 것..

 

https://betterprogramming.pub/should-we-store-tokens-in-db-af30212b7f22

 

3 Scenarios Where You Can Store JWT Token in Your DB

Know when and why

betterprogramming.pub

 

등장배경

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

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

 

가장 쉬운 방법은 당연히 유저로부터 구글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을 재발급받는 것.

+ Recent posts