구글에 장고 소셜로그인만 검색 해도 많은 글들을 읽을 수 있지만 제가 겪은 상황과 달리 오래된 글들을 읽으며 코드를 작성하기 어려웠습니다. 몇 날 며칠의 삽질 결과 스니펫 조각 모음 하듯 코드가 구현 되며 작동 됐을 때의 기분은 아직도 잊을 수 없네요.
그래서 이 프로젝트와 비슷한 use case에서 읽을만한 자료로 공유하고 싶었습니다. 이 글에서 코드를 왜 그렇게 작성 했었어야 했으며, 어떤 모델링을 했고 커널에서 입력 해야 할 명령어의 순서 이런 것은 보장되지 않지만 어떤 방식을 사용 했는지 훑어 보시면 좋을 것 같습니다.
프로젝트 상황
프론트엔드에서 플랫폼 서버에 액세스 토큰을 요청 하여 응답 받은 인증된 토큰번호를 백엔드 서버로 전달 했을 때 백엔드 서버에서 플랫폼 서버의 유저 정보를 받아 JWT 토큰을 반환 하는 과정
├── backend
│ ├── accounts
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── migrations
│ │ ├── models.py
│ │ ├── serializers.py
│ │ ├── services.py
│ │ ├── tests.py
│ │ ├── urls.py
│ │ └── views.py
│ ├── config
│ │ ├── __init__.py
│ │ ├── asgi.py
│ │ ├── config.json
│ │ ├── settings.py
│ │ ├── urls.py
│ │ ├── utils.py
│ │ └── wsgi.py
│ ├── db.sqlite3
│ ├── manage.py
│ └── management
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ ├── models.py
│ ├── serializers.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── docker
│ ├── Dockerfile
│ └── entrypoint.sh
├── docker-compose.yml
├── poetry.lock
└── pyproject.toml
pyproject.toml
python = "^3.9"
djangorestframework = "^3.14.0"
django = "^4.2.6"
djangorestframework-simplejwt = "^5.3.0"
python-dotenv = "^1.0.0"
drf-spectacular = "^0.26.5"
dj-rest-auth = "^5.0.1"
django-allauth = "^0.58.2"
django-cors-headers = "^4.3.0"
프로젝트 설정
config/secrets.json
{
"google_token_api": "https://www.googleapis.com/oauth2/v1/tokeninfo",
"google_client_id": "custom your google client id", # custom
"google_client_secret": "custom your google secret", # custom
"base_url": "custom your domain root url", # me: http://127.0.0.1:8000/
"kakao_client_id": "custom", # custom
"kakao_client_secret": "custom", # custom
"kakao_token_api": "https://kapi.kakao.com/v2/user/me",
"naver_client_id": "custom", # custom
"naver_client_secret": "custom", # custom
"naver_token_api": "https://openapi.naver.com/v1/nid/me",
"secret_key": "django secret key" # custom - your settings.py in secret
}
config/utils.py
def Initialize_env_variables(json_file: json, module_name: str):
with open(json_file, "r") as f:
config: Dict = json.load(f)
for key, value in config.items():
setattr(module_name, key, value)
config/settings.py
BASE_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = Path(__file__).resolve().parent
SECRET_JSON = Path(CONFIG_DIR, "config.json")
current_module = sys.modules[__name__]
Initialize_env_variables(SECRET_JSON, current_module)
SECRET_KEY = getattr(current_module, "secret_key")
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
]
THIRD_PARTY = [
"corsheaders",
"rest_framework",
"rest_framework_simplejwt",
"rest_framework_simplejwt.token_blacklist",
"rest_framework.authtoken",
"dj_rest_auth",
"dj_rest_auth.registration",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.kakao",
"allauth.socialaccount.providers.naver",
"drf_spectacular",
]
APPLICATION = [
"accounts",
"management",
]
INSTALLED_APPS += THIRD_PARTY + APPLICATION
AUTH_USER_MODEL = "accounts.CustomUser"
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": (
'rest_framework.authentication.SessionAuthentication',
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
REST_AUTH = {
"USE_JWT": True,
"JWT_AUTH_COOKIE": "access_token",
"JWT_AUTH_REFRESH_COOKIE": "refresh_token",
"JWT_AUTH_HTTPONLY": False,
"JWT_AUTH_RETURN_EXPIRATION": True,
"LOGIN_SERIALIZER": "accounts.serializers.UserLoginSerializer",
"REGISTER_SERIALIZER": "accounts.serializers.UserRegistrationSerializer",
}
LANGUAGE_CODE = "ko-kr"
TIME_ZONE = "Asia/Seoul"
USE_I18N = True
USE_TZ = True
# custom variable
SITE_ID = 1
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_AUTHENTICATION_METHOD = "email"
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
REST_USE_JWT = True
CORS_ALLOW_ALL_ORIGINS = True
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': True,
}
config/urls.py
from django.contrib import admin
from django.urls import path
from django.urls import include
from drf_spectacular.views import SpectacularJSONAPIView
from drf_spectacular.views import SpectacularRedocView
from drf_spectacular.views import SpectacularSwaggerView
from drf_spectacular.views import SpectacularYAMLAPIView
urlpatterns = [
path('admin/', admin.site.urls),
]
user = [
path('accounts/', include('dj_rest_auth.urls')),
path('accounts/', include('allauth.urls')),
path('accounts/', include('accounts.urls')),
]
docs = [
path("docs/json/", SpectacularJSONAPIView.as_view(), name="schema-json"),
path("docs/yaml/", SpectacularYAMLAPIView.as_view(), name="swagger-yaml"),
path("docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema-json"), name="swagger-ui"),
path("docs/redoc/", SpectacularRedocView.as_view(url_name="schema-json"), name="redoc"),
]
app = [
path("api/", include("management.urls")),
]
urlpatterns.extend(user)
urlpatterns.extend(app)
urlpatterns.extend(docs)
프로젝트 설정 세션에서는 django-admin start project config 명령어를 통해 root project 기초 설정과 종합적인 url endpoint 설정들을 담고 있습니다.
SITE 설정
Admin 페이지에 접속 하여 Sites 탭을 들어와 기존 example.com 으로 되어 있던 도메인 명을 본인이 사용하는 도메인 이름으로 변경해야 합니다.
글을 쓰는 본인: localhost:8000
도메인 없이 루프백 아이피를 호출 하고 있으니 커스텀 하는 환경에 맞게 적용하면 됩니다.

소셜 어플리케이션 설정
Social Applications 탭에서 플랫폼 서버를 등록 해야 하는데, 이 때 필요한 정보는 플랫폼 서버의 클라이언트 아이디, 클라이언트 시크릿 정보입니다.


어플리케이션 설정
accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.utils.translation import gettext_lazy as _
class CustomUserManager(BaseUserManager):
def create_user(self, email, password, **extra_fields):
if not email:
raise ValueError(_("The Email must be set"))
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save()
return user
def create_superuser(self, email, password, **extra_fields):
"""
Create and save a SuperUser with the given email and password.
"""
extra_fields.setdefault("is_admin", True)
extra_fields.setdefault("is_active", True)
if extra_fields.get("is_admin") is not True:
raise ValueError(_("Superuser must have is_staff=True."))
return self.create_user(email, password, **extra_fields)
class CustomUser(AbstractBaseUser):
email = models.EmailField(
default="",
max_length=255,
null=False,
blank=False,
unique=True,
verbose_name="이메일",
)
age_choices = [
(f"{str(age)}대", f"{str(age)}대")
for age in range(10, 81, 10)
]
age_choices.append(("선택", "선택"))
age_range = models.CharField(
max_length=50,
choices=age_choices,
default="선택",
verbose_name="연령대"
)
date_joined = models.DateTimeField(auto_now_add=True, verbose_name="가입 일시")
last_login = models.DateTimeField(auto_now=True, verbose_name="최종 로그인 일시")
is_active = models.BooleanField(default=True, verbose_name="활성화 여부")
is_admin = models.BooleanField(default=False, verbose_name="스태프 여부")
USERNAME_FIELD = "email"
objects = CustomUserManager()
class Meta:
verbose_name = "유저"
verbose_name_plural = "유저"
def __str__(self):
return self.email
@property
def is_staff(self):
return self.is_admin
def has_perm(self, perm, obj=None):
return True
def has_module_perms(self, app_label):
return True
serializers.py
from rest_framework import serializers
from django.contrib.auth import get_user_model, authenticate
from .models import CustomUser
class UserRegistrationSerializer(serializers.ModelSerializer):
password = serializers.CharField(
style={"input_type": "password"}, write_only=True, label="비밀번호"
)
password2 = serializers.CharField(
style={"input_type": "password"}, write_only=True, label="비밀번호 확인"
)
class Meta:
model = CustomUser
fields = [
"email",
"age_range",
"password",
"password2",
]
def validate_email(self, value):
User = get_user_model()
if User.objects.filter(email=value).exists():
raise serializers.ValidationError("이미 가입된 이메일입니다.")
return value
def validate(self, data):
if data['password'] != data['password2']:
raise serializers.ValidationError({"password": "비밀번호가 일치하지 않습니다."})
return data
def create(self, validated_data):
user = get_user_model().objects.create_user(
email=validated_data['email'],
password=validated_data['password'],
)
return user
class UserLoginSerializer(serializers.ModelSerializer):
email = serializers.CharField(required=True, allow_blank=False)
password = serializers.CharField(style={"input_type": "password"}, write_only=True)
class Meta:
model = CustomUser
fields = ["email", "password"]
def authenticate(self, **options):
return authenticate(self.context["request"], **options)
def validate(self, attrs):
email = attrs.get("email")
password = attrs.get("password")
if email and password:
user = authenticate(
email=email,
password=password,
)
if not user:
msg = "Incorrect credentials."
raise serializers.ValidationError(msg, code="authorization")
attrs["user"] = user
return attrs
유저 앱의 시리얼라이저는 django rest-auth 의 로그인 및 회원가입 과정을 오버라이딩 하여 사용 하는 경우입니다.
accounts/services.py - 해당 파일은 소셜로그인을 하기 위한 모듈 파일입니다.
from django.http import JsonResponse
from allauth.socialaccount.models import SocialAccount
from rest_framework import status
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
from rest_framework.response import Response
from rest_framework.request import Request
from typing import Union, Dict, Any
from .models import CustomUser
def user_does_not_exist(user: CustomUser, created: bool, ptf: str, uid: str) -> Response:
platform = {
"kakao": "kakao",
"google": "google",
"naver": "naver",
}
try_login_platform: str = platform.get(ptf)
if created:
SocialAccount.objects.create(user_id=user.id, provider=try_login_platform, uid=uid)
access_token: AccessToken = AccessToken.for_user(user)
refresh_token: RefreshToken = RefreshToken.for_user(user)
return Response({'refresh': str(refresh_token), 'access': str(access_token), "msg": "회원가입 성공"},
status=status.HTTP_201_CREATED)
def social_user_login(user: CustomUser) -> Response:
refresh: RefreshToken = RefreshToken.for_user(user)
return Response({'refresh': str(refresh), 'access': str(refresh.access_token), "msg": "로그인 성공"},
status=status.HTTP_200_OK)
def access_token_is_valid(request: Request) -> Union[Dict[str, Any], Response]:
if request.status_code != 200:
error_message = {"err_msg": "Access token이 올바르지 않습니다."}
return JsonResponse(error_message, status=request.status_code)
return request.json()
accounts/views.py
import requests
from typing import Dict, Any
from allauth.socialaccount.models import SocialAccount
from django.contrib.auth import get_user_model
from django.views.decorators.csrf import csrf_exempt
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from rest_framework.request import Request
from . import services
from .models import CustomUser
from config import settings
class BaseSocialLoginView(APIView):
permission_classes = (AllowAny,)
user: CustomUser = get_user_model()
@csrf_exempt # note : if postman testing needs csrftoken
def post(self, request: Request):
access_token: str = request.data.get("access_token")
user_profile_request: Response = self.request_user_profile(access_token)
print(f"request: {user_profile_request}")
user_profile_response: Dict[str, Any] = services.access_token_is_valid(user_profile_request)
print(f"response: {user_profile_response}")
user_key, registration_params = self.get_account_user_primary_key(user_profile_response)
print(f"user key:{user_key}\nregister: {registration_params}")
try:
user: CustomUser = self.user.objects.get(email=user_key)
social_user: SocialAccount = SocialAccount.objects.get(user=user)
print(social_user)
if social_user.provider != self.platform:
response_message = {"error": "no matching social type"}
return Response(response_message, status=status.HTTP_400_BAD_REQUEST)
if social_user:
return services.social_user_login(user)
except self.user.DoesNotExist:
user, created = self.simple_registration(user_key)
return services.user_does_not_exist(user, created, self.platform, registration_params)
except SocialAccount.DoesNotExist:
response_message = {"error": "소셜로그인 유저가 아닙니다."}
return Response(response_message, status=status.HTTP_400_BAD_REQUEST)
def request_user_profile(self):
pass
def get_account_user_primary_key(self):
pass
def simple_registration(self):
pass
class GoogleLogin(BaseSocialLoginView):
platform = "google"
token_url = getattr(settings, "google_token_api")
def post(self, request: Request):
return super().post(request)
def request_user_profile(self, access_token: str) -> Request:
print(f"{self.token_url}?access_token={access_token}")
return requests.get(f"{self.token_url}?access_token={access_token}")
def get_account_user_primary_key(self, user_info_response: Dict[str, Any]):
return user_info_response.get("email"), user_info_response.get("user_id")
def simple_registration(self, email):
return self.user.objects.get_or_create(email=email)
class KakaoLogin(BaseSocialLoginView):
platform = "kakao"
token_url = getattr(settings, "kakao_token_api")
def post(self, request):
return super().post(request)
def request_user_profile(self, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
return requests.post(self.token_url, headers=headers)
def get_account_user_primary_key(self, user_info_response: Dict[str, Any]):
return f"{user_info_response.get('id')}@kakao.com", user_info_response.get("id")
def simple_registration(self, uid):
return self.user.objects.get_or_create(email=uid)
class NaverLogin(BaseSocialLoginView):
platform = "naver"
token_url = getattr(settings, "naver_token_api")
def post(self, request):
return super().post(request)
def request_user_profile(self, access_token):
headers = {"Authorization": f"Bearer {access_token}"}
return requests.post(self.token_url, headers=headers)
def get_account_user_primary_key(self, user_info_response: Dict[str, Any]):
response = user_info_response.get("response")
return response.get("email"), response.get("id")
def simple_registration(self, email):
return self.user.objects.get_or_create(email=email)
소셜로그인 View 파일에서 기능을 맡고 있는 부분은 BaseSocialLoginView 클래스로 이 곳에서 정의 된 기능들을 플랫폼 서버 마다 필요한 정보가 달랐기 때문에 오버라이딩 하기 위한 클래스입니다.
오버라이딩 메서드 설명
request_user_profileget_account_user_primary_keysimple_registrationaccounts/urls.py
from accounts import views
from django.urls import path, include
urlpatterns = [
path("register/", include("dj_rest_auth.registration.urls")),
path("google/login-request/", views.GoogleLogin.as_view()),
path("kakao/login-request/", views.KakaoLogin.as_view()),
path("naver/login-request/", views.NaverLogin.as_view())
]
소셜로그인 관련 된 내용은 이 정도가 될 것 같고, 나머지 폴더구조에 있는 어플리케이션은 소셜로그인과 별개의 로직입니다.
감사합니다.