Django-Rest-Framework(DRF)로 소셜 로그인 API 구현해보기(Google, KaKao, Github)

Edit. 5월 25일

Chanjong Park
Chan’s Programming Diary
24 min readFeb 10, 2021

--

전체 코드는 아래에서 볼 수 있다.

글을 수정하기 전 사용했던 django-rest-auth중단된 프로젝트이고, dj-rest-auth에서 fork가 진행되고 있다. 따라서 dj-rest-auth를 대신 사용하고 JWT 기반 인증으로 진행될 것이다. 글의 이해를 위해 JWT Authentication의 흐름을 숙지하고 진행하거나, 아래 필자가 작성한 튜토리얼을 보고 진행하면 좋을 것이다. (튜토리얼과 초기 환경 세팅은 같다.)

SPA(react.js)를 DRF(Django-Rest-Framework)와 연동하여 진행하는 프로젝트의 일환으로 소셜 로그인을 구현해 보았다.

django-allauth 패키지에서 Template Tags를 사용하여 구현하는 글은 많았지만, 필자는 REST API 형태로 백엔드를 구축하고 프론트와 구별돼 있기 때문에 환경에 맞춰 만들 필요가 있었다.

생각보다 DRF 환경에서의 소셜 로그인은 글들이 많이 없어서 처음에 애를 먹었다. 결국 약 1주간의 삽질과 Stackoverflow에 직접 질문해 해답을 얻었다. 그 과정에서 겪었던 이슈들이 몇 개 있었는데, 혹여나 나와 같은 문제로 고충을 겪을 다른 개발자들의 부담을 덜어주기 위해 글을 작성하게 되었다.

초기환경 세팅

프로젝트 생성과 가상환경(필자의 가상환경은 virtualenv다)까지 설치되었다고 가정하고 시작하겠다.

pip install django
pip install djangorestframework
pip install djangorestframework-simplejwt
pip install dj-rest-auth
pip install django-allauth
pip install mysqlclient # MySQL 유저만

Secret 키 분리하기

## settings.pyfrom pathlib import Path
import os
import json
import sys
BASE_DIR = Path(__file__).resolve().parent.parentROOT_DIR = os.path.dirname(BASE_DIR)
SECRET_BASE_FILE = os.path.join(BASE_DIR, 'secrets.json')
secrets = json.loads(open(SECRET_BASE_FILE).read())
for key, value in secrets.items():
setattr(sys.modules[__name__], key, value)

실제 product 하는건 아니지만 만약을 대비해 중요한 key값들을 분리해서 저장할 필요가 있다. 나중에 연습한 프로젝트를 Github에 올릴 때 실수로 key값들이 노출될 수 있으니 중요한 값들을 분리하는 것을 습관화 하도록 하자.

필자는 최상위 디렉토리에 secrets.json로 두었다. STATE는 나중에 url 요청 시 사용되는 값이다. 안에 SECRET KEY 처럼 스트링을 넣어주면 된다.

{
"KAKAO_REST_API_KEY" : "kakao_key",
"SECRET_KEY" : "secret_key"
"SOCIAL_AUTH_GITHUB_CLIENT_ID" : "github_client_id",
"SOCIAL_AUTH_GITHUB_SECRET" : "secret",
"SOCIAL_AUTH_GOOGLE_CLIENT_ID" : "google_client_id",
"SOCIAL_AUTH_GOOGLE_SECRET" : "google_secret_key",
"STATE" : "random_string",
"DATABASES" : {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": "db_name",
"USER": "db_user",
"PASSWORD": "db_password",
"HOST": "localhost",
"PORT" : "3306",
}}

}

settings.py

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# my app
'accounts',
# django-rest-framework
'rest_framework',
'rest_framework_simplejwt.token_blacklist',
# dj-rest-auth
'dj_rest_auth',
'dj_rest_auth.registration',
# django-allauth
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.kakao',
'allauth.socialaccount.providers.github',
'allauth.socialaccount.providers.google',
]
SITE_ID = 1AUTH_USER_MODEL = 'accounts.User'

내가 만든 accounts 앱과 자신이 원하는 third party들을 settings에 설정해줘야한다. allauth에서 다양하게 제공해주니 다른 플랫폼을 사용하려면 Docs에서 참고하면 된다.

Ddjango-Rest-Framework 설정

REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
),
}

DEFAULT_PERMISSION_CLASSES는 선택이다. API들에 대한 접근 권한을 설정할 수 있다. 원하는 API에 따로 권한을 주거나, 커스터마이징 할 수 있다.

USER 필드 설정에 맞게 변경

ACCOUNT_USER_MODEL_USERNAME_FIELD = None # username 필드 사용 x
ACCOUNT_EMAIL_REQUIRED = True # email 필드 사용 o
ACCOUNT_USERNAME_REQUIRED = False # username 필드 사용 x
ACCOUNT_AUTHENTICATION_METHOD = 'email'

기존 username 필드가 있던 User 모델을 email만 사용하도록 커스터마이징 했기 때문에, 약간의 설정이 필요하다. 여기를 참고하면 더 자세하게 설명되어 있다.

JWT 환경 설정

마지막으로 JWT 환경을 세팅해준다.

REST_USE_JWT = Truefrom datetime import timedeltaSIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': True,
}

Setting에서의 설정은 끝났다.

urls.py

urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('dj_rest_auth.urls')),
path('accounts/', include('allauth.urls')),
path('accounts/', include('accounts.urls')),
]

아까 settings에서 추가한 라이브러리들, 그리고 Custom User Model을 만든 앱인 accounts에 매핑 시켜준다.

models.py

장고에서 제공하는 기본 User Model은 username이 필수로 들어가기 때문에, AbstractUser를 상속받아 이메일만 사용할 수 있도록 커스터마이징했다. 아래 사이트에서 참고할 수 있다.

Client id, Secret key 등록하기

이제 서버를 실행시킨 후에 admin 사이트에서 각 사이트에서 발급받은 키와 id를 등록해 줘야한다. 발급받는 방법은 아래에 각 사이트 별로 간단하게 소개하겠다.

제대로 migration 되었다면 왼쪽에 sties 메뉴가 있을 것이다. 들어가서 로컬호스트로 바꿔주자.

그리고 SOCIAL ACCOUNTS 메뉴에서 third-party들을 추가해준다.

client_id와 secret_key얻는 방법은 아래에 각 플랫폼별로 설명해 놓았다.

Google

Secret Key와 Client id를 발급받으려면 일단 여기로 접속해서 새 프로젝트를 생성해야 한다. 후에 왼쪽 메뉴바에 OAuth 동의 화면을 클릭해서 기본 정보를 입력한다.

애플리케이션 홈페이지는 연습용이기 때문에 localhost로 지정했다.

그다음에 사용자 인증 정보를 들어가서, Callback URI를 지정해 준다. 실제 Django에서 redirect url을 만들어서 urls.py에 매핑 시켜줄 것과 같게 입력해 준다.

이제 구글 로그인을 위한 초기 과정은 모두 마쳤다. Django로 들어와 실제 API를 만들어 보자. 구글 공식 OAuth2.0 document에 친절하게 설명되어 있다.

urls.py

(10/5 오타수정)

urlpatterns = [
path('google/login', views.google_login, name='google_login'),
path('google/callback/', views.google_callback, name='google_callback'),
path('google/login/finish/', views.GoogleLogin.as_view(), name='google_login_todjango'),
]

views.py

코드가 많이 복잡하고, Medium에서는 더욱 보기 힘들테니 각자 IDE에 복사, 붙여넣기로 확인해보자. 아래 코드 흐름을 단계별로 나누었다. 하나하나씩 설명하면서 진행할 예정이니 살펴보도록 하자.

  • google_login 실행 후 로그인 성공 시, Callback 함수로 Code 값 전달받음
  • 받은 Code로 Google에 Access Token 요청
  • Access Token으로 Email 값을 Google에게 요청
  • 전달받은 Email, Access Token, Code를 바탕으로 회원가입/로그인 진행
from django.conf import settings
from accounts.models import User
from allauth.socialaccount.models import SocialAccount
from django.conf import settings
from dj_rest_auth.registration.views import SocialLoginView
from allauth.socialaccount.providers.google import views as google_view
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from django.http import JsonResponse
import requests
from rest_framework import status
from json.decoder import JSONDecodeError
state = getattr(settings, 'STATE')BASE_URL = 'http://localhost:8000/'
GOOGLE_CALLBACK_URI = BASE_URL + 'accounts/google/callback/'
def google_login(request):
"""
Code Request
"""

scope = "https://www.googleapis.com/auth/userinfo.email"
client_id = getattr(settings, "SOCIAL_AUTH_GOOGLE_CLIENT_ID")
return redirect(f"https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}&response_type=code&redirect_uri={GOOGLE_CALLBACK_URI}&scope={scope}")
def google_callback(request):
client_id = getattr(settings, "SOCIAL_AUTH_GOOGLE_CLIENT_ID")
client_secret = getattr(settings, "SOCIAL_AUTH_GOOGLE_SECRET")
code = request.GET.get('code')
"""
Access Token Request
"""

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}")
token_req_json = token_req.json()
error = token_req_json.get("error")
if error is not None:
raise JSONDecodeError(error)
access_token = token_req_json.get('access_token')
"""
Email Request
"""

email_req = requests.get(
f"https://www.googleapis.com/oauth2/v1/tokeninfo?access_token={access_token}")
email_req_status = email_req.status_code
if email_req_status != 200:
return JsonResponse({'err_msg': 'failed to get email'}, status=status.HTTP_400_BAD_REQUEST)
email_req_json = email_req.json()
email = email_req_json.get('email')
"""
Signup or Signin Request
"""

try:
user = User.objects.get(email=email)
# 기존에 가입된 유저의 Provider가 google이 아니면 에러 발생, 맞으면 로그인
# 다른 SNS로 가입된 유저
social_user = SocialAccount.objects.get(user=user)
if social_user is None:
return JsonResponse({'err_msg': 'email exists but not social user'}, status=status.HTTP_400_BAD_REQUEST)
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}accounts/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:
# 기존에 가입된 유저가 없으면 새로 가입
data = {'access_token': access_token, 'code': code}
accept = requests.post(
f"{BASE_URL}accounts/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)
class GoogleLogin(SocialLoginView):
adapter_class = google_view.GoogleOAuth2Adapter
callback_url = GOOGLE_CALLBACK_URI
client_class = OAuth2Client

google_login 실행

def google_login(request):
"""
Code Request
"""
scope = "https://www.googleapis.com/auth/userinfo.email"
client_id = getattr(settings, "SOCIAL_AUTH_GOOGLE_CLIENT_ID")
return redirect(f"https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}&response_type=code&redirect_uri={GOOGLE_CALLBACK_URI}&scope={scope}")
###
이 함수와 매핑된 url로 들어가면, client_id, redirect uri 등과 같은 정보를 url parameter로 함께 보내 리다이렉트한다. 그러면 구글 로그인 창이 뜨고, 알맞은 아이디, 비밀번호로 진행하면 Callback URI로 Code값이 들어가게 된다.

받은 Code로 Google에 Access Token 요청

"""
Access Token Request
"""
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}")
token_req_json = token_req.json()error = token_req_json.get("error")if error is not None:
raise JSONDecodeError(error)
access_token = token_req_json.get('access_token')
###
Google API Server에 응답받은 Code, client_secret, state와 같은 url parameter를 함께 Post 요청을 보낸다. 문제없이 성공하면, access_token을 가져올 수 있다.

Access Token으로 Email 값을 Google에게 요청

"""
Email Request
"""
email_req = requests.get(
f"https://www.googleapis.com/oauth2/v1/tokeninfo?access_token={access_token}")
email_req_status = email_req.status_codeif email_req_status != 200:
return JsonResponse({'err_msg': 'failed to get email'},status=status.HTTP_400_BAD_REQUEST)
email_req_json = email_req.json()email = email_req_json.get('email')
###
응답받은 Access Token을 로그인된 사용자의 Email을 응답받기 위해 url parameter에 포함하여 요청 - Access Token이 틀렸거나, 에러 발생으로 200 OK 코드를 응답받지 못하면 400으로 Response

전달받은 Email, Access Token, Code를 바탕으로 회원가입/로그인 진행

"""
Signup or Signin Request
"""
try:
user = User.objects.get(email=email)
# 기존에 가입된 유저의 Provider가 google이 아니면 에러 발생, 맞으면 로그인
# 다른 SNS로 가입된 유저
social_user = SocialAccount.objects.get(user=user)
if social_user is None:
return JsonResponse({'err_msg': 'email exists but not social user'}, status=status.HTTP_400_BAD_REQUEST)
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}accounts/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:
# 기존에 가입된 유저가 없으면 새로 가입
data = {'access_token': access_token, 'code': code}
accept = requests.post(
f"{BASE_URL}accounts/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)

###
1. 전달받은 email과 동일한 Email이 있는지 찾아본다.
2-1. 만약 있다면?
- FK로 연결되어있는 socialaccount 테이블에서 이메일의 유저가 있는지 체크
- 없으면 일반 계정이므로, 에러 메세지와 함께 400 리턴
- 있지만 다른 Provider로 가입되어 있으면 에러 메세지와 함께 400 리턴
- 위 두개에 걸리지 않으면 로그인 진행, 해당 유저의 JWT 발급, 그러나 도중에
예기치 못한 오류가 발생하면 에러 메세지와 함께 오류 Status 응답
2-2. 없다면 (신규 유저이면)
- 회원가입 진행 및 해당 유저의 JWT 발급
- 그러나 도중에 예기치 못한 오류가 발생하면 에러 메세지와 함께 오류 Status응답

이로써 Django에서의 구글 소셜 로그인이 완성됐다. JWT 인증의 로그아웃 방법, JWT 사용방법 등은 필자가 작성한 다른 글에서 연계되기 때문에 여기서 확인해보았으면 한다.

다음 포스트에서 Github, Kakao 로그인 또한 실습해 보기로 하자. OAuth2.0의 흐름을 다 똑같고 각 Provider마다 약간씩 다르기 때문에, 이 포스트에서 흐름을 대충 이해했다면, 충분히 금방 할 것이라고 생각한다.

콜백 함수에서 로그인 로직을 구현하는 데 있어서 많은 어려움과 삽질이 있었다. 계속 부족한 점이 보여서 수정하고, 또 수정하고 그랬지만 끝이 없는 것 같다.

오타, 틀린 부분, 피드백 등 댓글 또는 이메일로 연락주시면 감사하겠습니다.

--

--