[Django] OAuth 2.0과 구글 소셜로그인 활용하기

우노·2023년 9월 26일
0

Django의 장점이자 단점은 코드가 간단하다는 것이다.
사용자가 실제로 적는 코드는 짧아서 기능을 구현하는 것은 쉽지만 내부 실행 로직을 이해하거나 커스텀하기는 쉽지 않은 편이다.
그래서 Django 쓰면서 실행 과정 따라서 코드를 꽤 많이 까본 것 같다.

소셜로그인도 마찬가지다. 구글 소셜로그인 코드를 쓰고 기능을 구현하는 것은 어렵지 않다.
근데 내가 엔드포인트를 써놓고 내부에서 어떻게 실행되는지도 모르겠다🫠
그래서 오늘도 자료를 찾아보고 양파같은 코드를 까봤다 !

OAuth 2.0

OAuth를 이야기하기 전에 인증/인가를 간단하게 정리하자면

인증 : 너 누군데?? (로그인 등을 통한 사용자 식별)
인가 : 너 이 글 봐도 되는 사람이야?? (허가된 권한에 따라 리소스 접근 제한)

이걸 확실하게 이해하고 넘어가야한다. OAuth 2.0은 인가 프레임워크다 !

Google API는 인증과 승인에 OAuth 2.0 프로토콜을 사용합니다.

그래서 RFC 6749를 통해 OAuth 2.0 프로토콜이 뭔지 알아보면음 그만 알아보도록 하자 가 아니라 일단 OAuth 2.0에는 4가지 역할이 있다.

  1. Resource owner
    보호된 리소스에 대한 액세스 권한을 부여할 수 있는 개체
    사람이면 최종 사용자

  2. Resource server
    보호된 리소스를 호스팅할 수 있는 서버
    access token을 통해 보호된 리소스 요청 수락 및 응답

  3. Client
    리소스 소유자를 대신하여 보호된 리소스를 요청하는 애플리케이션
    ⚠️ 보편적으로 개발에서 지칭하는 Client-Server를 의미하지 않는다.

  4. Authorization server
    리소스 소유자를 인증하고 인가 권한을 획득한 후 클라이언트에 액세스 토큰 발급

위 역할들의 예시를 들자면 아래와 같다.

구글 (Authorization Server)
구글 계정 정보를 이용하는 서비스A (Client)
서비스A 사용자B (Resource Owner)
구글의 DB (Resource Server)

권한 부여 방식도 4가지가 있는데 글이 너무 길어질 것 같아서 아래 글을 참고하면 좋을 것 같다.
OAuth 2.0 동작 방식의 이해

간편로그인 소셜로그인에서 쓰는 방식은 인가 코드 부여 방식이다.

Authorization Code Grant

위의 참고글에서 가져온 사진이다.
이 사진을 바탕으로 다시 초록으로 돌아와서 이야기해보면

OAuth 2.0로 인증 서버가 발급한 승인 코드와 access_token을 통해
사용자B가 직접 HTTP 서비스에 승인 요청을 하지 않을 수 있고,
서비스 A에서의 인가를 통한 접근 제한을 구현할 수 있다.

마지막으로 “구글 소셜로그인은 OAuth 2.0 인가 프레임워크 구조/규칙으로 구현되었다“ 고 정리하면 된다!

그럼 이제 Django 쪽으로 넘어가보자.

패키지

django-rest-auth dj-rest-auth

django.contrib.auth - 로그인 관련 인증 기능 (django 기본 내장)
django-rest-auth - 로그인 및 소셜로그인이 가능하지만 유지/보수가 되지 않아 비추천
django-allauth - 로그인 및 소셜로그인
dj-rest-auth - allauth 의존 + 엔드포인트 제공

django-allauth와 dj-rest-auth 등 필요한 것을 골라쓰면 좋을 것 같다 !

소셜로그인 관련 패키지 비교는 링크글 참고 !

우리는 django-allauthdj-rest-auth를 써서 구글 소셜로그인을 통한 통합로그인을 구현해보려고 한다.

pip install dj-rest-auth
pip install django-allauth
pip install djangorestframework-simplejwt

dj-rest-auth가 allauth에 의존하기 때문에
allauth 배경에 필요한 부분은 dj-rest-auth를 쓰는 느낌이다.
구글의 데이터를 가져와서 우리 서비스의 jwt를 구현하려면 simplejwt도 필요하다.

실행 로직

제일 중요한 건 역시 어떻게 소셜로그인이 실행되는지를 이해하는 것이다.

큰 흐름은 사진과 같다. 앞서 OAuth 2.0에서 언급했던 인증 코드 부여 방식이다.

Django에서 구글 소셜로그인을 구현하려면 어떤 코드를 작성해야하느지 차례로 알아보자 !

settings.py - config

# THIRD_PARTY_APPS에 사용할 패키지 추가
THIRD_PARTY_APPS = [
    'rest_framework',
    'rest_framework.authtoken',
    'rest_framework_simplejwt',
    'dj_rest_auth',
    'dj_rest_auth.registration',

    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    'allauth.socialaccount.providers.google',
]

MIDDLEWARE = [
	# allauth를 쓰기 위해 middleware 추가
    'allauth.account.middleware.AccountMiddleware',  
]

AUTH_USER_MODEL = '...'  # 사용할 User 모델 설정

REST_USE_JWT = True    # JWT 사용

# ACCOUNT 필드 사용 여부에 따라 설정
ACCOUNT_USER_MODEL_USERNAME_FIELD = None # username 필드 사용 x
ACCOUNT_EMAIL_REQUIRED = True            # email 필드 사용 o
ACCOUNT_USERNAME_REQUIRED = False        # username 필드 사용 x
ACCOUNT_AUTHENTICATION_METHOD = 'email'

# JWT 인가 사용 설정
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
}

urls.py - config

urlpatterns = [
	# User 모델이 있는 url 설정
    path('users/', include('<app_with_User_model>.urls')),
    path('users/', include('allauth.urls')),
]

앱을 쓰기 위해서는 프로젝트 urls.py에 추가해야하고 allauth의 엔드포인트를 쓰기 위해서 allauth.urls도 추가해준다.

자 그럼 app 내부 코드에서는 어떻게 소셜로그인을 할까 !
일단 우리가 만들 서버에서 작성한 엔드포인트부터 시작해보자.
추가로 아래 내용은 구글 로그인을 모두 백엔드에서 한다는 가정하에 작성되었다. 프론트와의 협업은 맨 아래에서 확인..!

urlpatterns

urlpatterns = [
    # 구글 소셜로그인
    path('google/login/', google_login, name='google_login'),
    path('google/callback/', google_callback, name='google_callback'),
    # path('google/login/finish/', GoogleLogin.as_view(), name='google_login_todjango'),
]

google/login/

프론트엔드측에서 접근하는 엔드포인트이자 백엔드가 구글에서 인가코드를 받아오는 엔드포인트이다. 이 부분은 프론트엔드가 직접 인가 코드를 받아서 백엔드 측에 넘겨준다면 불필요한 과정이다.

def google_login(request)

def google_login(request):
    scope = GOOGLE_SCOPE_USERINFO + \   # 자율 !
            GOOGLE_SCOPE_DRIVE
    client_id = CLIENT_ID
    return redirect(f"{GOOGLE_REDIRECT}?client_id={client_id}&response_type=code&redirect_uri={GOOGLE_CALLBACK_URI}&scope={scope}")

서비스에 필요한 구글 계정 정보를 scope에 지정하고 사전에 부여받은 client_id를 통해 우리 서비스를 위한 구글 로그인 페이지로 넘어간다 !

당연하지만 redirect_uri도 구글에 등록해둬야한다 !
구글도 이상한 곳에 개인정보 넘겨줬다가 고소미 먹고 싶지 않을테니까 해주자
redirect_uri와 관련된 내용은 프론트와의 협업 여부에 따라 다르므로 아래에 더 상세히 기술했다.

⇒ 이게 위 사진의 1번 과정이다.
➕ response_type=code라서 로그인이 끝나면 callback(redirect_uri)으로 코드를 넘겨준다. 2번까지 끝 !

google/callback/

def google_callback(request)

def google_callback(request):
    client_id = os.environ.get("SOCIAL_AUTH_GOOGLE_CLIENT_ID")
    client_secret = os.environ.get("SOCIAL_AUTH_GOOGLE_SECRET")
    code = request.GET.get('code')

    # 1. 받은 코드로 구글에 access token 요청
    token_req = requests.post(f"https://oauth2.googleapis.com/token?client_id={client_id}&client_secret={client_secret}&code={code}&grant_type=authorization_code&redirect_uri={GOOGLE_CALLBACK_URI}&state={state}")

    ### 1-1. json으로 변환 & 에러 부분 파싱
    token_req_json = token_req.json()
    error = token_req_json.get("error")

    ### 1-2. 에러 발생 시 종료
    if error is not None:
        raise JSONDecodeError(error)

    ### 1-3. 성공 시 access_token 가져오기
    access_token = token_req_json.get('access_token')

위에서 callback_uri로 넘어온 code를 받아오고 사전에 프로젝트를 등록할 때 받은 client_id와 client_secret을 가져온다.

grant_type=authorization_code로 OAuth 2.0 방식을 지정해준다.
응답으로는 access_token, expires_in, token_type, scope, refresh_token가 들어온다.
이를 json으로 변환하고 access_token을 가져와서 구글 계정 정보 인가에 활용한다.

이걸로 scope에 허용해준 범위 내에서 우리 서비스의 사용자는 자신의 구글 계정 정보를 사용할 수 있다 (물론 우리가 정보 가져오는 건 따로 만들어줘야 함)

3번 과정 끝 !

구글 이메일 요청

위의 내용을 모두 이해하고 보면 제대로 보일 것이라고 예상한다 !

    # 2. 가져온 access_token으로 이메일값을 구글에 요청
    email_req = requests.get(f"https://www.googleapis.com/oauth2/v1/tokeninfo?access_token={access_token}")
    email_req_status = email_req.status_code

    ### 2-1. 에러 발생 시 400 에러 반환
    if email_req_status != 200:
        return JsonResponse({'err_msg': 'failed to get email'}, status=status.HTTP_400_BAD_REQUEST)

    ### 2-2. 성공 시 이메일 가져오기
    email_req_json = email_req.json()
    email = email_req_json.get('email')

    # return JsonResponse({'access': access_token, 'email':email})

구글 access_token이 있으니 서비스에 회원가입/로그인을 할 구글 이메일 계정을 요청한다.
여기서 서비스에 구글 이메일, access_token 이외에 필요한 게 없다면 JSON으로 넘겨주면 끝이다.

회원가입/로그인

이 과정에서는 구글에서 얻어온 정보를 바탕으로 우리 서비스의 회원가입/로그인을 구현한다.

provider

Authorization Server + Resourc Server를 묶어서 OAuth Provider라고 지칭한다.
인가 코드 발급하는 것도 구글, 이메일 넘겨주는 것도 구글 ⇒ 구글이 OAuth Provider

allauth

회원가입/로그인 과정을 자세히 보기 전에 allauth 패키지 코드를 잠깐 이해해보자

  • class SocialAccount(models.Model)
    ⇒ 서비스에 있는 계정 중 소셜로그인으로 가입된 계정을 모아두는 모델
    코드에서는 adapter 연결 및 연동, uid 등 데이터 추출해서 필드(클래스 변수)에 저장

    # 3. 전달받은 이메일, access_token, code를 바탕으로 회원가입/로그인
    try:
        # 전달받은 이메일로 등록된 유저가 있는지 탐색
        # 없다면 Exception 발생
        user = User.objects.get(email=email)    
		token = RefreshToken.for_user(user)         # 자체 jwt 발급
		refresh_token = str(token)
        access_token = str(token.access_token)

		if user.is_active == True:
            # return JsonResponse HTTP_200_OK
        else:
 			# 활성화되지 않은 회원, Exception 발생
            raise Exception('Signup Required')

	except Exception:
        # 가입이 필요한 회원
        # return JsonResponse HTTP_202_ACCEPTED
        

이렇게 하면 가장 간단하게 구글로그인을 이용한 회원가입/로그인을 구현할 수 있다 👀
하지만 좀 더 확장성이 있으려면 SocialAccount 등의 활용이 필요하다.
그래서 /finish/를 통해서 소셜로그인 제공자마다 회원가입/로그인 과정을 분리할 수 있다.

google/login/finish/

위에서는 allauth까지만 사용하며 여기서부터 dj-rest-auth가 등장한다.

dj-rest-auth

💡 allauth에 의존하여 회원가입/로그인을 아주아주 간편하게 할 수 있도록 도와주는 엔드포인트를 제공한다.

위의 views.py의 email로 user 검증 코드 아래에 해당 코드를 작성한다.

        # FK로 연결되어 있는 socialaccount 테이블에서 해당 이메일의 유저가 있는지 확인
        social_user = SocialAccount.objects.get(user=user)

        # 있는데 구글계정이 아니어도 에러
        if social_user.provider != 'google':
            return JsonResponse({'err_msg': 'no matching social type'}, status=status.HTTP_400_BAD_REQUEST)

        # Google 로그인으로 연결
        data = {'access_token': access_token, 'code': code}
        accept = requests.post(f"{BASE_URL}api/user/google/login/finish/", data=data)
        accept_status = accept.status_code

google/login/finish/를 호출한다.

class GoogleLogin(SocialLoginView)

위에 두가지 엔드포인트는 메소드 연결하고 이건 SocialLoginView 상속이 필요해서 class로 연결한다.

class GoogleLogin(SocialLoginView):
    adapter_class = google_view.GoogleOAuth2Adapter
    callback_url = GOOGLE_CALLBACK_URI
    client_class = OAuth2Client

이제부터는 dj-rest-auth의 패키지 코드를 살짝 파보자.

  • class SocialLoginView(LoginView)
    class used for social authentications - 인증에 사용하는 클래스 !
    내부에서 로그인을 진행하므로 코드가 매우 복잡하다. 코드별로 설명을 정리해두었으나 글의 요점을 흐릴 것 같아 생략한다.
💡 간단하게 해당 View 내부에서 adapter가 SocialAccount 연동 및 로그인을 진행해준다는 것까지만 이해해도 코드를 쓰기에는 충분하다.

다시 전체 코드를 보면

    # 3. 전달받은 이메일, access_token, code를 바탕으로 회원가입/로그인
    try:
        # 전달받은 이메일로 등록된 유저가 있는지 탐색
        user = User.objects.get(email=email)

        # FK로 연결되어 있는 socialaccount 테이블에서 해당 이메일의 유저가 있는지 확인
        social_user = SocialAccount.objects.get(user=user)

        # 있는데 구글계정이 아니어도 에러
        if social_user.provider != 'google':
            return JsonResponse({'err_msg': 'no matching social type'}, status=status.HTTP_400_BAD_REQUEST)

        # 이미 Google로 제대로 가입된 유저 => 로그인 & 해당 우저의 jwt 발급
        data = {'access_token': access_token, 'code': code}
        accept = requests.post(f"{BASE_URL}api/user/google/login/finish/", data=data)
        accept_status = accept.status_code

        # 뭔가 중간에 문제가 생기면 에러
        if accept_status != 200:
            return JsonResponse({'err_msg': 'failed to signin'}, status=accept_status)

        accept_json = accept.json()
        accept_json.pop('user', None)
        return JsonResponse(accept_json)

    except User.DoesNotExist:    # DoesNotExist -> Django Model에서 기본 지원
        # 전달받은 이메일로 기존에 가입된 유저가 아예 없으면 => 새로 회원가입 & 해당 유저의 jwt 발급
        data = {'access_token': access_token, 'code': code}
        accept = requests.post(f"{BASE_URL}api/user/google/login/finish/", data=data)
        accept_status = accept.status_code

        # 뭔가 중간에 문제가 생기면 에러
        if accept_status != 200:
            return JsonResponse({'err_msg': 'failed to signup'}, status=accept_status)

        accept_json = accept.json()
        accept_json.pop('user', None)
        return JsonResponse(accept_json)

	except SocialAccount.DoesNotExist:
    	# User는 있는데 SocialAccount가 없을 때 (=일반회원으로 가입된 이메일일때)
        return JsonResponse({'err_msg': 'email exists but not social user'}, status=status.HTTP_400_BAD_REQUEST)

따란-!
try except는 예외 처리와 err_msg 나누는 용도고 엔드포인트 요청은 똑같다.

dj-rest-auth의 엔드포인트 제공

그래서 dj-rest-auth가 엔드포인트를 제공한다는 게 무슨 의미야?
라고 되물을 수도 있을 것 같아 all-auth와 비교해보겠다.

django-allauth에서는 아래처럼 view에 직접 adapter를 연결하여 로그인과 데이터 반환을 수행한다.

oauth2_login = OAuth2LoginView.adapter_view(GoogleOAuth2Adapter)
oauth2_callback = OAuth2CallbackView.adapter_view(GoogleOAuth2Adapter)

이 부분이 상당히 복잡하기 때문에 adapter를 사용하는 부분을 추상화시켜서 쉽게 사용할 수 있는 엔드포인트 로 만들어둔 것이다 !

프론트와의 협업

위 과정은 전부 백엔드에서 이루어진다. 하지만 프론트엔드와 협업하는 과정에서 이 방법을 사용하면 문제가 발생하기 시작한다.

redirect_uri

구글의 redirect_uri에 백엔드의 callback uri를 등록한다면 백엔드 테스트에서는 문제가 없겠지만 실제 서비스 운영에서는 문제가 생긴다.
보통 프론트와 협업하면 웹 서버와 WAS 서버로 다른데 사용자가 보는 서버는 웹서버, 이동하는 서버는 WAS서버? 이러면 사용자는 갑자기 백엔드 서버의 주소로 이동해버린다.

프론트 주소로 이동해서 따로 콜백 요청

이를 해결할 방법은 redirect_uri를 프론트의 주소로 설정하고 프론트가 따로 백의 callback 엔드포인트를 호출하는 방법이 있다.

  • 프론트에서 구글 로그인 창으로 연결
  • 로그인이 끝나면 인가코드와 함께 redirect_uri로 설정된 프론트의 주소로 이동
  • 해당 주소에서 axios 등으로 백의 callback 엔드포인트 호출로 회원가입/로그인

이 때 주의할 점은 프론트가 백에 요청할 때 쿼리, 바디 등으로 인가코드를 넘겨줘야한다는 것..!
위에서 google/login/이 프론트와 협업한다면 필요없다는 이야기가 이 부분에서 나온 것이다.

전체 코드

그래서 가장 간단하게 구현한 구글 소셜로그인 아래와 같으며 views.py의 내용이다.
프론트의 협업으로 google/login/ 엔드포인트를 삭제했으며 구글 로그인만을 구현하여 제공자마다 분기처리를 해줄 google/login/finish/도 삭제하였다.

def google_callback(request):
    client_id = get_secret("GOOGLE_CLIENT_ID")
    client_secret = get_secret("GOOGLE_SECRET")
    # body = json.loads(request.body.decode('utf-8'))
    # code = body['code']
    code = request.GET.get('code')

    token_req = requests.post(f"https://oauth2.googleapis.com/token?client_id={client_id}&client_secret={client_secret}&code={code}&grant_type=authorization_code&redirect_uri={GOOGLE_CALLBACK_URI}")
    token_req_json = token_req.json()
    error = token_req_json.get("error")

    if error is not None:
        raise JSONDecodeError(error)

    google_access_token = token_req_json.get('access_token')

    email_response = requests.get(f"https://www.googleapis.com/oauth2/v1/tokeninfo?access_token={google_access_token}")
    res_status = email_response.status_code

    if res_status != 200:
        return JsonResponse({'status': 400,'message': 'Bad Request'}, status=status.HTTP_400_BAD_REQUEST)
    
    email_res_json = email_response.json()
    email = email_res_json.get('email')

    try:
        user = User.objects.get(email=email)
        token = RefreshToken.for_user(user)         # 자체 jwt 발급

        if user.is_active == True:
            return JsonResponse({
            # JSON에 들어갈 내용
            }, status=status.HTTP_200_OK)
        else:
            raise Exception('Signup Required')
    except Exception:
        print(email)
        # 가입이 필요한 회원
        return JsonResponse(
        	# JSON에 들어갈 내용
        }, status=status.HTTP_202_ACCEPTED)

# 회원가입은 구글 email과 입력받은 이름으로 진행 
class RegisterView(APIView):
    def post(self, request):
        body = json.loads(request.body.decode('utf-8'))
        email = body['email']
        name = body['name']
        if email == "" or name == "":
            return JsonResponse({
                # JSON에 들어갈 내용
            }, status=status.HTTP_400_BAD_REQUEST)
        
        # user 생성
        # 에러 확인

        # 회원가입 완료 -> jwt 토큰 발급
        token = RefreshToken.for_user(user)         # 자체 jwt 발급
        return JsonResponse({
            # JSON에 들어갈 내용
        }, status=status.HTTP_201_CREATED)


결론은 소셜로그인을 제대로 이해하려면 OAuth 2.0을 이해하고 코드를 뜯어보자. 그런 의미에서 RFC 6749 를 더 자세히 읽어보는 것을 추천한다. 물론 나부터 읽어야겠지만..

profile
기록하는 감자

0개의 댓글