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 를 더 자세히 읽어보는 것을 추천한다. 물론 나부터 읽어야겠지만..