개발

[django + react] social login 장고 + 리액트 + 카카오 로그인

deo2kim 2022. 12. 26. 20:11
반응형

미흡한 부분이 많습니다. 많은 지적 부탁드립니다. 😀

이번에 사이드 프로젝트를 진행하며 로그인하는 부분이 필요했고, 카카오 로그인만 진행하기로 했다.

전체적으로 진행했던 과정은 다음과 같다.

  1. [react] kakao 에 로그인 요청 - 카카오 로그인페이지로 이동됨
  2. 카카오 로그인 페이지에서 로그인
  3. [django] code 를 받음
  4. [django] 받은 code 를 사용하여 kakao 에 token 요청
  5. [django] 받은 token 을 사용하여 kakao 에 유저정보 요청
  6. [django] 유저 정보를 가지고
    1. 디비에 유저가 등록되어 있지 않으면 회원가입 후 로그인
    2. 디비에 유저가 등록되어 있으면 로그인
  7. [django] 로그인 할 때 access token 과 refresh token 을 발급하고, 쿠키에 저장하면서 react 로 redirect
  8. [react] 이후 django 에 요청할 때마다 쿠키의 access token 을 헤더에 설정해두고 요청
  9. access token 만료시
    1. [react] django 에서 보낸 401에러를 확인하고
    2. [react] axios interceptor 를 이용하여 django로 토큰 재발급 요청
    3. [django] refresh token 을 확인하여 access token 재발급 후 쿠키에 저장
    4. [react] 재발급 확인 후 이전 요청을 다시 django 에 요청
  10. logout
    1. [django] refresh token 을 blacklist 에 추가
    2. [django] 브라우저의 access token, refresh token 쿠키 삭제

 

jwt 를 간편하게 사용하기 위해 rest_framework_simplejwt 라이브러리를 사용했다.

0. 초기 세팅

# setting.py
INSTALLED_APPS = [

    # my_app
    'accounts',

    # DRF
    'rest_framework',
    'rest_framework.authtoken',

    # rest_auth
    'rest_auth',

    # registration
    'django.contrib.sites',

    # AUTH
    'rest_framework_simplejwt',
    'rest_framework_simplejwt.token_blacklist',

]

AUTH_USER_MODEL = 'accounts.User'

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

REST_USE_JWT = True

ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = 'email'
ACCOUNT_EMAIL_VERIFICATION = 'none'


# jwt 토큰은 simplejwt의 JWTAuthentication으로 인증한다.
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.AllowAny', # 누구나 접근
    ),
}

SITE_ID = 1

CORS_ALLOW_CREDENTIALS = True

# 추가적인 JWT 설정
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=2), #
    'REFRESH_TOKEN_LIFETIME': timedelta(days=14), #
    'ROTATE_REFRESH_TOKENS': True, # 
    'BLACKLIST_AFTER_ROTATION': False, #
    'UPDATE_LAST_LOGIN': False,

    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    # 'VERIFYING_KEY': None,
    # 'AUDIENCE': None,
    # 'ISSUER': None,
    # 'JWK_URL': None,
    # 'LEEWAY': 0,

    'AUTH_HEADER_TYPES': ('JWT',),
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',

    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',
    'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',

    'JTI_CLAIM': 'jti',

    # 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    # 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    # 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

# accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin

# 헬퍼 클래스
class UserManager(BaseUserManager):
    def create_user(self, email, password, **kwargs):
        """
        주어진 이메일, 비밀번호 등 개인정보로 User 인스턴스 생성
    	"""
        if not email:
            raise ValueError('Users must have an email address')
        user = self.model(
            email=email,
        )
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email=None, password=None, **extra_fields):
        """
        주어진 이메일, 비밀번호 등 개인정보로 User 인스턴스 생성
        단, 최상위 사용자이므로 권한을 부여
        """
        superuser = self.create_user(
            email=email,
            password=password,
        )
        
        superuser.is_staff = True
        superuser.is_superuser = True
        superuser.is_active = True
        
        superuser.save(using=self._db)
        return superuser

# AbstractBaseUser를 상속해서 유저 커스텀
class User(AbstractBaseUser, PermissionsMixin):
    
    email = models.EmailField(max_length=30, unique=True, null=False, blank=False)
    is_superuser = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

	# 헬퍼 클래스 사용
    objects = UserManager()

	# 사용자의 username field는 email으로 설정 (이메일로 로그인)
    USERNAME_FIELD = 'email'
    
    
# accounts/serializer.py
from .models import User
from rest_framework import serializers

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = '__all__'

 

1. 카카오에 로그인 요청하기

const REST_API_KEY = process.env.REACT_APP_KAKAO_LOGIN_API_KEY;
const REDIRECT_URI = `${process.env.REACT_APP_BASE_URL}${process.env.REACT_APP_KAKAO_LOGIN_CALLBACK_URL}`;
export const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code`;

<a style={{ color: "white" }} href={KAKAO_AUTH_URL}>카카오로 로그인</a>
  • 카카오 디벨로퍼에서 앱을 추가하고
  • Redirect URI 를 설정한다.
    • 나의 경우 django 에서 받을 것이므로 http://localhost:8000/api/accounts/kakao/callback 으로 설정
    • 나중에 django 에서 accounts 앱의 urls 에 '/kakao/callback' 과 연결된 view가 실행될 것이다.
  • redirect uri 와 카카오에서 발급된 rest api key를 가지고 kakao_auth_url 을 만들어서 이동한다.
  • 그렇게 하면 로그인 페이지가 나온다.
  • 로그인을 한다.

2. django 에서 code -> token, token -> user info 받기

# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback

app_name = 'accounts'

urlpatterns = [
  path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),
]
# accounts/views.py
KAKAO_TOKEN_API = "https://kauth.kakao.com/oauth/token"
KAKAO_USER_API = "https://kapi.kakao.com/v2/user/me"
KAKAO_CALLBACK_URI = BASE_URL + "/api/accounts/kakao/callback"
# BASE_URL = 'http://localhost:8000'

class KakaoLoginCallback(generics.GenericAPIView, mixins.ListModelMixin):
    def get(self, request, *args, **kwargs):
        code = request.GET["code"]
        if not code:
            return Response(status=status.HTTP_400_BAD_REQUEST)

        # kakao에 access token 발급 요청
        data = {
          "grant_type": "authorization_code",
          "client_id": os.environ.get('SOCIAL_AUTH_KAKAO_CLIENT_ID'), # 카카오 디벨로퍼 페이지에서 받은 rest api key
          "redirect_uri": KAKAO_CALLBACK_URI,
          "code": code,
        }
        token = requests.post(KAKAO_TOKEN_API, data=data).json() # 받은 코드로 구글에 access token 요청하기
        access_token = token['access_token'] # 받은 access token
        if not access_token:
            return Response(status=status.HTTP_400_BAD_REQUEST)

        # kakao에 user info 요청
        headers = {"Authorization": f"Bearer ${access_token}"}
        user_infomation = requests.get(KAKAO_USER_API, headers=headers).json() # 받은 access token 으로 user 정보 요청

        data = {'access_token': access_token, 'code': code}
        kakao_account = user_infomation.get('kakao_account')

3. 회원가입 로그인 또는 로그인

# accounts/views.py
class KakaoLoginCallback(generics.GenericAPIView, mixins.ListModelMixin):
    def get(self, request, *args, **kwargs):
		# ...
        # ... 대충 2번로 로직
        # ...

        email = kakao_account.get('email')  

        # 1. 유저가 이미 디비에 있는지 확인하기
        try:
            user = User.objects.get(email=email)
            token = create_token(user=user)
            res = redirect(CLIENT_URL)
            set_cookie(res, token.get('access'), token.get('refresh'))
			# 쿠키설정은 res.set_cookie('쿠키이름', '쿠키값')
            return res

        except User.DoesNotExist:
        # 2. 없으면 회원가입하기
            data = {
              'email': email,
              'password': 'test'
              # 비밀번호는 없지만 validation 을 통과하기 위해서 임시로 사용
              # 비밀번호를 입력해서 로그인하는 부분은 없으므로 안전함
            }
            serializer = UserSerializer(data=data)
            if not serializer.is_valid():
                return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST)

            user = serializer.validated_data
            serializer.create(validated_data=user)

            # 2-1. 회원가입 하고 토큰 만들어서 쿠키에 저장하기
            try:
                user = User.objects.get(email=email)
                token = create_token(user=user)
                res = redirect(CLIENT_URL)
                set_cookie(res, token.get('access'), token.get('refresh'))

                return res
            except:
                return Response(status=status.HTTP_400_BAD_REQUEST)

4. react 에서 로그인 했는지 항상 확인하는 부분

# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback, AuthAPIView

app_name = 'accounts'

urlpatterns = [
  path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),
  path('auth/', AuthAPIView.as_view(), name="auth"),
]
# accounts/views.py
# 로그인 확인
class AuthAPIView(APIView):

  def get(self, request):
    try:
      access = request.auth
      if not access:
        return Response({'message': '토큰 없음'}, status=status.HTTP_200_OK)

      payload = jwt.decode(str(access), SECRET_KEY, 'HS256')
      pk = payload.get('user_id')
      user = get_object_or_404(User, pk=pk)
      serializer = UserSerializer(instance=user)
      data = {
        'id': serializer.data['id'],
        'email': serializer.data['email'],
        'is_staff': serializer.data['is_staff'],
      }
      return Response(data, status=status.HTTP_200_OK)
        
    except(jwt.exceptions.InvalidTokenError):
      print('[사용불가토큰]')
      res = Response({'message': '사용불가토큰'}, status=status.HTTP_400_BAD_REQUEST)
      delete_cookie(res)
      return res

5. 토큰 만료 시 401에러 대처하기

const initAxiosConfig = () => {
  axios.defaults.baseURL = process.env.REACT_APP_BASE_URL;
  const accessToken = getCookie("access");
  setAuthorizationHeaders(accessToken);
	
  axios.interceptors.response.use(
    (res) => res,
    async (error) => {
      const {
        config,
        response: { status },
      } = error;

      if (status === 401) {
      	// django 에서 401 에러를 뱉고
        // 다시 django 로 토큰 리프레쉬 요청을 함
        // 토큰이 리프레쉬되면 이전에 실패했던 요청을 재요청
        setAuthorizationHeaders("");
        await refreshAccessToken();
        const accessToken = getCookie("access");
        setAuthorizationHeaders(accessToken);
        config.headers.Authorization = `JWT ${accessToken}`;
        return axios(config);
      }
      return Promise.reject(error);
    }
  );
};
# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback, AuthAPIView, RefreshTokenAPIView

app_name = 'accounts'

urlpatterns = [
  path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),
  path('auth/', AuthAPIView.as_view(), name="auth"),
  path('refresh-token/', RefreshTokenAPIView.as_view(), name="refresh_token"),
]
# accounts/views.py
class RefreshTokenAPIView(generics.GenericAPIView, mixins.ListModelMixin):

  def post(self, request):
    
    refresh_token = request.COOKIES.get('refresh')
    try:
        token = jwt.decode(refresh_token, SECRET_KEY, 'HS256')
    except jwt.ExpiredSignatureError:
        return Response({'message': 'Token signature has expired'}, status=status.HTTP_400_BAD_REQUEST)

    try:
        token = update_token(refresh_token)
        res = Response({'message': 'Success token refresh'})
        set_cookie(res, token.get('access'), token.get('refresh'))
        return res
    except:
        return Response({'message': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST)

6. 로그아웃

# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback, AuthAPIView, LogoutAPIView, RefreshTokenAPIView

app_name = 'accounts'

urlpatterns = [
  path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),

  path('auth/', AuthAPIView.as_view(), name="auth"),
  path('logout/', LogoutAPIView.as_view(), name="logout"),
  path('refresh-token/', RefreshTokenAPIView.as_view(), name="refresh_token"),
]
# accounts/views.py
class LogoutAPIView(generics.GenericAPIView, mixins.ListModelMixin):

  def post(self, request):
      res = Response({'message': 'Success Logout'}, status=status.HTTP_202_ACCEPTED)
      refresh_token = request.COOKIES.get('refresh')
      token = RefreshToken(refresh_token)
      token.blacklist()
      delete_cookie(res)
      return res

 

참고: https://medium.com/chanjongs-programming-diary/django-rest-framework%EB%A1%9C-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-api-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0-google-kakao-github-2-cf1b4059b5d5

반응형