프론트와 백엔드 분리된 환경에서의 DRF 소셜로그인 구현하기(feat. JWT)

이주환·2023년 11월 10일

파이썬 냉장고

목록 보기
2/3

들어가며

구글에 장고 소셜로그인만 검색 해도 많은 글들을 읽을 수 있지만 제가 겪은 상황과 달리 오래된 글들을 읽으며 코드를 작성하기 어려웠습니다. 몇 날 며칠의 삽질 결과 스니펫 조각 모음 하듯 코드가 구현 되며 작동 됐을 때의 기분은 아직도 잊을 수 없네요.

그래서 이 프로젝트와 비슷한 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"

프로젝트 설정

1. 환경변수 파일

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
}

2. 기초 환경변수 설정

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 탭에서 플랫폼 서버를 등록 해야 하는데, 이 때 필요한 정보는 플랫폼 서버의 클라이언트 아이디, 클라이언트 시크릿 정보입니다.

  • 작성해야 할 항목
    <구글 홈페이지 설정 이라고 가정>
  1. 제공자 선택
  2. 이름: google-login
  3. 클라이언트 아이디: xxx
  4. 비밀 키: xxx
  5. sites : 이미지엔 짤려서 안 보이지만 밑에 사이트를 > 화살표 버튼을 눌러 옮긴 후 저장하면 됩니다.

어플리케이션 설정

유저 어플리케이션 설정

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 클래스로 이 곳에서 정의 된 기능들을 플랫폼 서버 마다 필요한 정보가 달랐기 때문에 오버라이딩 하기 위한 클래스입니다.

오버라이딩 메서드 설명

  • 설명 하기 전, 클래스 변수 설정
    - platform: 어느 플랫폼 서버를 사용 하고 있는지
    • token_url: 유저 정보 요청을 위한 플랫폼 url 주소
  1. request_user_profile
  • 액세스 토큰을 발급 받은 후 플랫폼 서버에게 유저 정보를 요청하는 단계입니다. 코드를 보면 구글, 카카오, 네이버 모두 다른 호출 방식 또는 url값으로 호출을 하기 때문에 그에 맞게 변경 되어있습니다.
  1. get_account_user_primary_key
  • 이메일을 사용 하는 경우, uid를 사용하는 경우 모두 존재 했는데 우선 네이버와 구글은 이메일 정보를 제공 해주기 때문에 받아서 사용 할 수 있었지만 카카오는 현재 사용자 닉네임, 프로필 이미지 정도 필수로 제공 받을 수 있는데 이메일은 선택사항으로도 받을 수 없었습니다. 그래서 uid를 만들어 이메일 형태로 가공 하여 강제로 이메일 느낌을 만들어냈는데 이 것은 임시 방편에 불과 하기 때문에 이 부분은 수정해서 작성 하시면 됩니다.
  1. simple_registration
  • 해당 본인의 프로젝트는 일정 관리가 포함 되어 있어 달력의 날짜를 전달 받았을 때 페이지가 생성 되어 안에 필드 들을 사용자가 채울 수 있었어야 했었습니다. 그렇기 때문에 유저의 정보가 있을 때 페이지 정보를 가져오거나, 생성하는 메서드를 사용하여 에러를 방지한 경우의 메서드입니다.

accounts/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())
]

소셜로그인 관련 된 내용은 이 정도가 될 것 같고, 나머지 폴더구조에 있는 어플리케이션은 소셜로그인과 별개의 로직입니다.

끝맺음

감사합니다.

profile
안녕하새우

0개의 댓글