DRF(Django REST Framework)를 사용해서 Google계정으로 회원가입, 로그인을 하는 방법을 알아보자.
최종 수정 : 2022/06/08 (OAuth2.0 흐름 수정)
Google API 등록
1. 접속 후 새 프로젝트 생성
2. 사용자 인증정보 만들기
윗 부분의 + 사용자 인증 정보 만들기 클릭
OAuth 클라이언트 ID 클릭
3. OAuth 동의
* 표시된 항목을 적는다.
도메인을 설정해준다 = 사용할 url을 적어준다.
4. 클라이언트 ID확인 및 Callback uri 설정
위에서 했듯이 좌측 사용자 인증 정보 탭에 들어간다.
가운데를 보면 방금 만들었던 OAuth 앱이 생성되어 있음을 알 수 있다.
이름을 눌러서 들어가보자
리디렉션 uri가 있음을 인지하고 그냥 넘어가자.
나중에 소셜로그인에서 필요한 콜백함수의 엔드포인트를 적어주면 된다.
여기까지 Google 소셜로그인을 위한 API 구성이 적당히 마무리 되었다.
구글의 참고자료
이제 실제로 사용한 API를 만들어 보자
아래 API를 모두 이해한다면 카카오, 네이버 등과 같은 소셜로그인은 앞으로 문제 없을 것이다!
OAuth2.0 흐름
OAuth2.0은 권한 부여를 위해 광범위하게 사용되는 공개 표준이다.
용어 정리
- 권한 부여 서버(Authorization server) : 사용자를 인증하고 사용자의 승인을 받아서 클라이언트 애플리케이션에게 접근 토큰을 발급해준다.
- 클라이언트 애플리케이션 : Djnago 서버라고 생각하면 된다. 권한 부여 서버에게 요청을 하는 입장이므로 클라이언트라고 부르게 된다.
OAuth2.0 사양에서는 접근 토큰(access token) 발급을 위한 권한 승인 흐름을 4가지로 분류한다.
- 권한 코드 승인 흐름
- 묵시적 승인 흐름
- 자원 소유자 암호 자격 증명 승인 흐름
- 클라이언트 자격 증명 승인 흐름
그 중 가장 안전하지만 가장 복잡한 흐름인 권한 코드 승인 흐름을 적용하였다.
- http://${hostname}:${port}/login/google 을 GET method로 요청하면 위와 같은 페이지로 이동한다.
클라이언트(Django)가 웹 브라우저를 통해 사용자를 구글 서버(권한 부여 서버)로 넘겨주고 권한 승인 흐름을 시작하는 부분이다. - 구글 서버(권한 부여 서버) 는 사용자를 인증하고 사용자의 동의를 구한다. (구글 로그인은 이러한 과정이 딱히 없지만 카카오 로그인을 생각해보면 무엇인가를 동의하는 부분이 항상 있었을 것이다.)
이때 로그인 시 어떤 정보를 구글 서버로부터 받을 지는 django 서버 내부에서 scope 파라미터로 정해줄 수 있다. scope를 지정해주면 해당 정보까지 받을 수 있는 접근토큰을 받을 수 있다. - 클라이언트(django)가 인증을 요청하고(-> 계정 버튼을 눌러 로그인 시도) 로그인에 성공 시, 구글 서버는 우리가 지정해준 Callback uri로 접근 토큰을 받을 수 있는 인증 코드를 전송한다.
인증 코드는 브라우저의 uri 쿼리를 통해 전달되는데, 악의적인 javascript 코드가 인증 코드를 가로챌 수 있으므로 짧은 시간에 걸쳐 단 한 번만 허용된다.
GOOGLE_OAUTH2_CLIENT_ID 는 위에서 봤던 Callback uri를 지정해주는 페이지에 나와있다. 중요한 정보니 따로 저장해두자. (.env 파일을 사용하는것을 추천한다.) - uri에서 추출한 인증 코드를 사용해 구글 서버에 접근 토큰(access token)을 요청한다.(POST)
이때 GOOGLE_OAUTH2_CLIENT_ID 와 GOOGLE_OAUTH2_CLIENT_SECRET 을 함께 전송한다. (CLIENT_ID, CLIENT_SECRET) - 접근 토큰(access token)을 받은 후 유저 정보를 가지고 올 수 있는 url로(권한 부여 서버마다 다름) 파라미터에 access token을 담아서 GET요청을 보낸다.
아래와 같은 정보를 받게 되고, 전달 받은 정보를 바탕으로 로그인을 진행하면 된다.
코드
urls.py
# urls.py
from django.urls import path, include
from auth.apis import (
LoginApi,
LogoutApi,
GoogleLoginApi,
GoogleSigninCallBackApi
)
from auth.googleapi import *
login_patterns = [
path('google', GoogleLoginApi.as_view(), name='google_login'),
path('google/callback', GoogleSigninCallBackApi.as_view(), name='google_login_callback'),
]
urlpatterns = [
path('logout', LogoutApi.as_view(), name="logout"),
path('login/', include(login_patterns)),
]
google이 적힌 endpoint만 보면 된다.
- http://${hostname}:${port}/login/google
- http://${hostname}:${port}/login/google/callback
googleapi.py
# googleapi.py
from rest_framework.views import APIView
from django.shortcuts import redirect
from django.conf import settings
from django.contrib.auth import get_user_model
from api.mixins import PublicApiMixin
from auth.utils import social_user_get_or_create
from auth.services import google_get_access_token, google_get_user_info
from auth.authenticate import jwt_login
User = get_user_model()
class GoogleLoginApi(PublicApiMixin, APIView):
def get(self, request, *args, **kwargs):
app_key = settings.GOOGLE_OAUTH2_CLIENT_ID
scope = "https://www.googleapis.com/auth/userinfo.email " + \
"https://www.googleapis.com/auth/userinfo.profile"
redirect_uri = settings.BASE_BACKEND_URL + "/api/v1/auth/login/google/callback"
google_auth_api = "https://accounts.google.com/o/oauth2/v2/auth"
response = redirect(
f"{google_auth_api}?client_id={app_key}&response_type=code&redirect_uri={redirect_uri}&scope={scope}"
)
return response
class GoogleSigninCallBackApi(PublicApiMixin, APIView):
def get(self, request, *args, **kwargs):
code = request.GET.get('code')
google_token_api = "https://oauth2.googleapis.com/token"
access_token = google_get_access_token(google_token_api, code)
user_data = google_get_user_info(access_token=access_token)
profile_data = {
'username': user_data['email'],
'first_name': user_data.get('given_name', ''),
'last_name': user_data.get('family_name', ''),
'nickname': user_data.get('nickname', ''),
'name': user_data.get('name', ''),
'image': user_data.get('picture', None),
'path': "google",
}
user, _ = social_user_get_or_create(**profile_data)
response = redirect(settings.BASE_FRONTEND_URL)
response = jwt_login(response=response, user=user)
return response
URL에 직접적으로 대응되는 view 코드이다.
GoogleLoginApi는 OAuth2.0 FLow의 1, 2 번 부분을 담당하는 코드이다.
GoogleSigninCallBackApi는 Flow의 3,4,5번을 담당하는 코드이다.
services.py
# services.py
import requests
from django.conf import settings
from django.core.exceptions import ValidationError
GOOGLE_ACCESS_TOKEN_OBTAIN_URL = 'https://oauth2.googleapis.com/token'
GOOGLE_USER_INFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
def google_get_access_token(google_token_api, code):
client_id = settings.GOOGLE_OAUTH2_CLIENT_ID
client_secret = settings.GOOGLE_OAUTH2_CLIENT_SECRET
code = code
grant_type = 'authorization_code'
redirection_uri = settings.BASE_BACKEND_URL + "/api/v1/auth/login/google/callback"
state = "random_string"
google_token_api += \
f"?client_id={client_id}&client_secret={client_secret}&code={code}&grant_type={grant_type}&redirect_uri={redirection_uri}&state={state}"
token_response = requests.post(google_token_api)
if not token_response.ok:
raise ValidationError('google_token is invalid')
access_token = token_response.json().get('access_token')
return access_token
def google_get_user_info(access_token):
user_info_response = requests.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
params={
'access_token': access_token
}
)
if not user_info_response.ok:
raise ValidationError('Failed to obtain user info from Google.')
user_info = user_info_response.json()
return user_info
구글로그인에 성공해서 받은 인증 코드(code)를 가지고 접근 토큰(access_token)을 받는 기능과
access_token을 통해서 유저 정보를 가져오는 기능을 분리했다.
jwt_login() 추가 설명
social_user_get_or_create()
# utils.py
from django.db import transaction
from django.utils import timezone
from django.contrib.auth import get_user_model
from users.models import Profile
User = get_user_model()
@transaction.atomic
def social_user_create(username, password=None, **extra_fields):
user = User(username=username, email=username)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.full_clean()
user.save()
profile = Profile(user=user)
try:
profile.image = extra_fields['image']
except:
pass
if extra_fields['nickname'] == '':
profile.nickname = username
else:
profile.nickname = extra_fields['nickname']
try:
try:
user.first_name = extra_fields['first_name']
user.last_name = extra_fields['last_name']
except:
try:
user.first_name = extra_fields['name']
except:
pass
except:
pass
try:
path = extra_fields['path']
profile.signup_path = f"{path}"
profile.image = f"profile_image/{path}_basic.png"
except:
pass
profile.save()
return user
@transaction.atomic
def social_user_get_or_create(username, **extra_data):
user = User.objects.filter(email=username).first()
if user:
return user, False
return social_user_create(username=username, **extra_data), True
'Back-End > Django' 카테고리의 다른 글
[Django] ORM 쿼리 최적화 (select_related, annotate, aggregates) (0) | 2021.10.11 |
---|---|
[Security] XSS(Cross Site Scripting) 취약점 Django (0) | 2021.09.02 |
[Django] 회원정보 모델 설계 UserModel design #1 (0) | 2021.05.17 |
[Django project #2] conda + Django + mysql 개발 환경 구축하기 (2) for Mac (2) | 2021.04.28 |
[Django project #1] conda + Django + mysql 개발 환경 구축하기 (1) for Mac (2) | 2021.04.26 |