설명이 부족하고 업데이트 되지 않은 문서입니다.
해당 문서를 스킵하고 시리즈의 [OAuth] 소셜로그인을 위한 OAuth 2.0 부터 읽어주세요!!
Django의 장점이자 단점은 코드가 간단하다는 것이다.
사용자가 실제로 적는 코드는 짧아서 기능을 구현하는 것은 쉽지만 내부 실행 로직을 이해하거나 커스텀하기는 쉽지 않은 편이다.
그래서 Django 쓰면서 실행 과정 따라서 코드를 꽤 많이 까본 것 같다.
소셜로그인도 마찬가지다. 구글 소셜로그인 코드를 쓰고 기능을 구현하는 것은 어렵지 않다.
근데 내가 엔드포인트를 써놓고 내부에서 어떻게 실행되는지도 모르겠다🫠
그래서 오늘도 자료를 찾아보고 양파같은 코드를 까봤다 !
OAuth를 이야기하기 전에 인증/인가를 간단하게 정리하자면
인증 : 너 누군데?? (로그인 등을 통한 사용자 식별)
인가 : 너 이 글 봐도 되는 사람이야?? (허가된 권한에 따라 리소스 접근 제한)
이걸 확실하게 이해하고 넘어가야한다. OAuth 2.0은 인가 프레임워크다 !
Google API는 인증과 승인에 OAuth 2.0 프로토콜을 사용합니다.
그래서 RFC 6749를 통해 OAuth 2.0 프로토콜이 뭔지 알아보면음 그만 알아보도록 하자 가 아니라 일단 OAuth 2.0에는 4가지 역할이 있다.
Resource owner
보호된 리소스에 대한 액세스 권한을 부여할 수 있는 개체
사람이면 최종 사용자
Resource server
보호된 리소스를 호스팅할 수 있는 서버
access token을 통해 보호된 리소스 요청 수락 및 응답
Client
리소스 소유자를 대신하여 보호된 리소스를 요청하는 애플리케이션
⚠️ 보편적으로 개발에서 지칭하는 Client-Server를 의미하지 않는다.
Authorization server
리소스 소유자를 인증하고 인가 권한을 획득한 후 클라이언트에 액세스 토큰 발급
위 역할들의 예시를 들자면 아래와 같다.
구글 (Authorization Server)
구글 계정 정보를 이용하는 서비스A (Client)
서비스A 사용자B (Resource Owner)
구글의 DB (Resource Server)
권한 부여 방식도 4가지가 있는데 글이 너무 길어질 것 같아서 아래 글을 참고하면 좋을 것 같다.
OAuth 2.0 동작 방식의 이해
간편로그인 소셜로그인에서 쓰는 방식은 인가 코드 부여 방식이다.
위의 참고글에서 가져온 사진이다.
이 사진을 바탕으로 다시 초록으로 돌아와서 이야기해보면
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-allauth와 dj-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에서 구글 소셜로그인을 구현하려면 어떤 코드를 작성해야하느지 차례로 알아보자 !
# 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',
)
}
urlpatterns = [
# User 모델이 있는 url 설정
path('users/', include('<app_with_User_model>.urls')),
path('users/', include('allauth.urls')),
]
앱을 쓰기 위해서는 프로젝트 urls.py에 추가해야하고 allauth의 엔드포인트를 쓰기 위해서 allauth.urls도 추가해준다.
자 그럼 app 내부 코드에서는 어떻게 소셜로그인을 할까 !
일단 우리가 만들 서버에서 작성한 엔드포인트부터 시작해보자.
추가로 아래 내용은 구글 로그인을 모두 백엔드에서 한다는 가정하에 작성되었다. 프론트와의 협업은 맨 아래에서 확인..!
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'),
]
프론트엔드측에서 접근하는 엔드포인트이자 백엔드가 구글에서 인가코드를 받아오는 엔드포인트이다. 이 부분은 프론트엔드가 직접 인가 코드를 받아서 백엔드 측에 넘겨준다면 불필요한 과정이다.
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번까지 끝 !
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으로 넘겨주면 끝이다.
이 과정에서는 구글에서 얻어온 정보를 바탕으로 우리 서비스의 회원가입/로그인을 구현한다.
Authorization Server + Resourc Server를 묶어서 OAuth Provider라고 지칭한다.
인가 코드 발급하는 것도 구글, 이메일 넘겨주는 것도 구글 ⇒ 구글이 OAuth Provider
회원가입/로그인 과정을 자세히 보기 전에 allauth 패키지 코드를 잠깐 이해해보자
# 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/를 통해서 소셜로그인 제공자마다 회원가입/로그인 과정을 분리할 수 있다.
위에서는 allauth까지만 사용하며 여기서부터 dj-rest-auth가 등장한다.
위의 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/를 호출한다.
위에 두가지 엔드포인트는 메소드 연결하고 이건 SocialLoginView 상속이 필요해서 class로 연결한다.
class GoogleLogin(SocialLoginView):
adapter_class = google_view.GoogleOAuth2Adapter
callback_url = GOOGLE_CALLBACK_URI
client_class = OAuth2Client
이제부터는 dj-rest-auth의 패키지 코드를 살짝 파보자.
다시 전체 코드를 보면
# 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가 엔드포인트를 제공한다는 게 무슨 의미야?
라고 되물을 수도 있을 것 같아 all-auth와 비교해보겠다.
django-allauth에서는 아래처럼 view에 직접 adapter를 연결하여 로그인과 데이터 반환을 수행한다.
oauth2_login = OAuth2LoginView.adapter_view(GoogleOAuth2Adapter)
oauth2_callback = OAuth2CallbackView.adapter_view(GoogleOAuth2Adapter)
이 부분이 상당히 복잡하기 때문에 adapter를 사용하는 부분을 추상화시켜서 쉽게 사용할 수 있는 엔드포인트 로 만들어둔 것이다 !
위 과정은 전부 백엔드에서 이루어진다. 하지만 프론트엔드와 협업하는 과정에서 이 방법을 사용하면 문제가 발생하기 시작한다.
구글의 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 를 더 자세히 읽어보는 것을 추천한다. 물론 나부터 읽어야겠지만..