본문 바로가기
개발/사이드 프로젝트

Django + React 소셜 로그인 구현

by Dahna 2022. 4. 7.

구현 목표

프론트 서버와 백엔드 서버를 구축할 때, 유저 인증에 소셜 로그인을 활용해 본다.

구현 목표 요약

[프론트] 유저 로그인 시 먼저 Javascript SDK를 통해 OAuth provider에게 토큰을 발급받고, 이를 장고 서버에 넘긴다.

[백엔드] 유저 로그인 시 전달받은 토큰(각 provider별 식별자)을 REST API 방식으로 OAuth provider에게 전달해서 유저 정보를 얻는다. 유저 정보를 활용하여 회원가입 혹은 로그인 처리를 진행하고, JWT 토큰을 발급하여 응답한다.

+ 우리는 백엔드 서버에 자체적으로 유저 관리 시스템을 가지고 있다. 따라서 우리 서버에서 사용하는 별도의 토큰 발급 과정이 필요하다.

흐름도

[프론트] 구현 계획

1. SDK를 스크립트에 직접 삽입하여 적용하는 방식과 react로 컴포넌트로 감싸진 라이브러리 중 선택할 수 있는데, JS인 SDK를 TS로 효과적으로 감싸는 구조가 궁금해서 라이브러리를 사용하는 방식을 선택했다.

2. 구글과 카카오를 제공하기로 결정하여 react-kakao-login, react-google-login를 사용했다.

3. 각 플랫폼 별 라이브러리마다 다를수 있지만 대체로 secret key정보, 성공시 콜백함수, 실패시 콜백함수는 공통적으로 필요하다.

4. 카카오, 구글 로그인 성공시 응답을 받아 장고 서버로 로그인 및 토큰 발급을 요청하는 api함수를 작성한다.

5. 유저 인증 후처리를 실행한다. Redux를 사용하여 유저 인증 상태를 관리한다. 성공, 실패 여부에 따라 각각의 Redux Action을 실행시키는 함수를 실행시킨다.

[프론트] 구현 내용

1. 필요 라이브러리를 설치한다.

npm install react-google-login
npm install react-kakao-login

2. 로그인 페이지 구현

  • GoogleLogin, KakaoLogin 컴포넌트를 불러온다
  • env에 secret key를 설정하고 컴포넌트에 넘겨준다.
  • 성공, 실패 함수, 정보제공동의범위, 버튼 스타일, 문구 등을 각 플랫폼별로 설정해준다.
  • 로그인 성공시 각각 kakaoLogin, googleLogin 함수를 실행시켜주고 실패시 에러 처리는 동일하게 해준다. (서버 에러 응답 형식 통일은 보완할 예정)
// pages/login
import { LoginForm } from 'components/forms/auth/LoginForm'
import { useAppDispatch, useAppSelector } from 'hooks'
import { NextPageWithLayout } from 'type/ui'
import KakaoLogin from 'react-kakao-login'
import GoogleLogin from 'react-google-login'
import { kakaoLogin, googleLogin } from 'api/auth/socialLogin'
import { loginFail, logout } from 'api/auth/login'
import Link from 'next/link'
import { useRouter } from 'next/router'

const Login: NextPageWithLayout = () => {
  const KAKAO_KEY = process.env.NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY
  const GOOGLE_KEY = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID

  const dispatch = useAppDispatch()
  const handleKakaoLoginSuccess = (response: any) => {
    dispatch(kakaoLogin(response))
  }
  const handleGoogleLoginSuccess = (response: any) => {
    dispatch(googleLogin(response))
  }
  const handleLoginFail = (err: any) => {
    if (typeof err === 'string') {
      dispatch(loginFail(err))
    } else if ('error_description' in err) {
      dispatch(loginFail(err.error_description))
    } else {
      dispatch(loginFail('로그인에 실패했습니다.'))
    }
  }
  const handleLogout = () => {
    dispatch(logout())
  }

  // 이미 로그인 된 유저의 경우 메인으로 보내기
  const user = useAppSelector((state) => state.user)
  const router = useRouter()

  useEffect(() => {
    if (user.user) {
      router.replace('/')
    }
  }, [user, router])

  return (
    <div>
      <p className="mb-4 text-slate-500 md:text-sm">
        로그인 정보를 입력해주세요.
      </p>

      <LoginForm />
      <div className="mt-2 flex justify-between text-gray-600 md:text-xs">
        <Link href="/signup">이메일로 회원가입</Link>
        <button>비밀번호 찾기</button>
      </div>
      <div className="my-5 w-full border-t border-gray-300">
        <p className="my-4 text-slate-500 md:text-sm">
          소셜 계정으로 3초만에 로그인/회원가입 하기
        </p>
      </div>
      <div className="flex space-x-2">
        {GOOGLE_KEY && (
          <GoogleLogin
            clientId={GOOGLE_KEY}
            buttonText="로그인"
            onSuccess={handleGoogleLoginSuccess}
            onFailure={handleLoginFail}
            className="flex w-1/2"
          />
        )}

        {KAKAO_KEY && (
          <KakaoLogin
            token={KAKAO_KEY}
            onSuccess={handleKakaoLoginSuccess}
            onFail={handleLoginFail}
            onLogout={handleLogout}
            scopes={['id', 'kakao_account']}
            render={({ onClick }) => (
              <a
                href="#"
                onClick={(e) => {
                  e.preventDefault()
                  onClick()
                }}
                className="flex w-1/2 items-center justify-center rounded-md bg-[url(//k.kakaocdn.net/14/dn/btroDszwNrM/I6efHub1SN5KCJqLm1Ovx1/o.jpg)] py-2 px-2 font-semibold text-amber-900 md:text-sm"
              />
            )}
          />
        )}
      </div>
    </div>
  )
}

export default Login

- 로그인 성공함수는 응답에서 플랫폼 별 유저 식별 토큰을 헤더에 담아 장고 소셜로그인 콜백 함수를 호출하고 응답에 따라 후처리를 한다.

유저 식별시 카카오는 access token을, 구글은 tokenId를 받는다.

// api/auth/socialLogin

import { anonymousInstance } from 'api/setupAxios'
import { Dispatch } from 'react'
import { GoogleLoginResponse } from 'react-google-login'
import { userLoginStart } from 'store/modules/auth/user'
import { loginSuccess, loginFail } from './login'

export const kakaoLogin =
  (response: any) => async (dispatch: Dispatch<object>) => {
    try {
      dispatch(userLoginStart())
      const loginResponse = await anonymousInstance.get(
        '/common/kakao-callback/',
        {
          headers: {
            Authorization: response.response.access_token,
          },
        },
      )
      const { result, message, success } = loginResponse.data
      if (success) {
        dispatch(loginSuccess(result))
      } else {
        dispatch(loginFail(message || '로그인에 실패했습니다.'))
      }
    } catch {
      dispatch(loginFail('로그인에 실패했습니다.'))
    }
  }

  export const googleLogin =
  (response: GoogleLoginResponse) => async (dispatch: Dispatch<object>) => {
    try {
      dispatch(userLoginStart())
      const loginResponse = await anonymousInstance.get(
        '/common/google-callback/',
        {
          headers: {
            Authorization: response.tokenId,
          },
        },
      )
      const { result, message, success } = loginResponse.data
      if (success) {
        dispatch(loginSuccess(result))
      } else {
        dispatch(loginFail(message || '로그인에 실패했습니다.'))
      }
    } catch {
      dispatch(loginFail('로그인에 실패했습니다.'))
    }
  }

- 로그인 성공, 실패시 상태관리 함수

// api/auth/login

import { Dispatch } from '@reduxjs/toolkit'
import {
  removeAccessToken,
  removeRefreshToken,
  setAccessToken,
  setRefreshToken,
} from 'store/modules/auth/token'
import {
  userLogin,
  catchError,
  userLoginStart,
  userLogout,
} from 'store/modules/auth/user'
import { openToast } from 'store/modules/ui/toast'
import { User } from 'type/user'

interface LoginParams {
  user: User
  access_token: string
  refresh_token: string
}

export const loginSuccess =
  ({ user, access_token, refresh_token }: LoginParams) =>
  async (dispatch: Dispatch) => {
    dispatch(userLogin(user)) // user store에 유저 정보를 기록
    dispatch(setAccessToken(access_token)) // token store에 토큰 정보를 기록
    dispatch(setRefreshToken(refresh_token)) // token store에 토큰 정보를 기록
  }

export const loginFail = (message: string) => async (dispatch: Dispatch) => {
  dispatch(catchError(message))
  dispatch(openToast(message))
}

export const logout = () => async (dispatch: Dispatch) => {
  dispatch(userLogout()) // store에서 유저, 토큰 삭제
  dispatch(removeAccessToken())
  dispatch(removeRefreshToken())
}

https://github.com/darae07/client-matzip

 

 

이 프로젝트에서는 유저, 토큰을 Redux store에서 관리하고 있다. 각자 유저 상태를 관리하던 대로 설정해주면 된다. store 코드까지 다루면 내용이 너무 길어질것 같아 stroe 설정 코드가 궁금하다면 전체 코드에서 확인해 볼수 있다.

[백엔드] 구현계획

0. Django + DRF 조합을 기준으로 작성하였다.

1. AbstactUser 모델을 상속하여 CommonUser라는 커스텀 유저 모델을 생성한다. 여기서 기본 username 필드를 email로 설정하고, 가입 방법을 저장한다.

2. 프로젝트 설정의 기본 유저 모델을 커스텀한 유저 모델로 지정한다.

3. 프로젝트의 기본 토큰을 설정한다. 여기서는 simpleJWT를 이용했다.

4. 카카오, 구글 로그인 콜백 함수를 만든다.

[소셜 로그인 콜백 함수가 하는일]

  • 토큰 혹은 인가 코드로 OAuth 서버에 유저 정보를 요청한다.
  • 제공받은 유저 정보 중 이메일을 가지고 DB 조회하여 가입 여부를 판별한다.
  • 신규 유저이면 유저를 생성한다.
  • 로그인 처리하고 토큰을 발급한다.

[백엔드] 구현내용

// settings.py

INSTALLED_APPS = [
    ...
    'rest_framework_simplejwt.token_blacklist',
    'dj_rest_auth',
    'dj_rest_auth.registration',
    'rest_framework.authtoken',
]

REST_FRAMEWORK = {
    ...
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
        'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
    ]
}

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=3),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': False,
    'BLACKLIST_AFTER_ROTATION': True,
    'UPDATE_LAST_LOGIN': False,

    'ALGORITHM': 'HS256',
    # 'SIGNING_KEY': settings.SECRET_KEY,
    'VERIFYING_KEY': None,
    'AUDIENCE': None,
    'ISSUER': None,

    'AUTH_HEADER_TYPES': ('Bearer',),
    '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',

    'JTI_CLAIM': 'jti',

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

REST_USE_JWT = True

AUTH_USER_MODEL = 'common.CommonUser' // 커스텀 유저모델로 설정
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_AUTHENTICATION_METHOD = 'email'
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_USER_MODEL_USERNAME_FIELD = None

추가 필드를 커스텀하기 편하도록 유저 모델을 상속받아 이메일 필드를 유저네임 필드로 설정한다.

// common/models.py
from django.contrib.auth.models import AbstractUser

class CommonUser(AbstractUser):
    username = None
    email = models.EmailField(_('email address'), unique=True)

    nickname = models.CharField(max_length=100, null=True, blank=True, unique=True)
    ...

    LOGIN_EMAIL = 'email'
    LOGIN_KAKAO = 'kakao'
    LOGIN_GOOGLE = 'google'
    LOGIN_CHOICES = (
        (LOGIN_EMAIL, 'email'),
        (LOGIN_GOOGLE, 'google'),
        (LOGIN_KAKAO, 'kakao')
    )

    login_method = models.CharField(
        max_length=6, choices=LOGIN_CHOICES, default=LOGIN_EMAIL
    )

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    def __str__(self):
        return self.email

카카오, 구글 로그인 콜백 함수를 만든다.

# common/views.py

from .models import CommonUser
from rest_framework.response import Response
from rest_framework.decorators import action, api_view, renderer_classes

from dj_rest_auth import views
from dj_rest_auth.serializers import JWTSerializer
from rest_framework_simplejwt.tokens import RefreshToken

SITE_DOMAIN = os.environ.get('SITE_DOMAIN')
KAKAO_CLIENT_ID = os.environ.get('KAKAO_ID')
KAKAO_REDIRECT_URI = SITE_DOMAIN + '/api/common/kakao-callback/'
KAKAO_SECRET = os.environ.get('KAKAO_SECRET')
RESPONSE_TYPE = 'code'
GOOGLE_CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID')
GOOGLE_CLIENT_SECRET = os.environ.get('GOOGLE_CLIENT_SECRET')
GOOGLE_REDIRECT_URI = SITE_DOMAIN + '/api/common/google-callback/'
GOOGLE_STATE = os.environ.get('GOOGLE_STATE')

class AlertException(Exception):
    pass


class TokenException(Exception):
    pass
    
@api_view(('GET',))
def kakao_login_callback(request):
    try:
        access_token = request.META.get('HTTP_AUTHORIZATION') # 요청 헤더에서 토큰 읽기
        headers = {'Authorization': f'Bearer {access_token}'} 
        profile_request = requests.post('https://kapi.kakao.com/v2/user/me',
                                        headers=headers,
                                        ) # 토큰으로 유저정보 얻기
        profile_json = profile_request.json()
        kakao_account = profile_json.get('kakao_account')
        profile = kakao_account.get('profile')

        nickname = profile.get('nickname', None)
        email = kakao_account.get('email', None)
        if email is None:
            AlertException('카카오계정(이메일) 제공 동의에 체크해 주세요.')

        try: # 이메일로 가입된 유저인지 판별
            user = CommonUser.objects.get(email=email)
        except CommonUser.DoesNotExist:
            user = None
        if user is not None:
            if user.login_method != CommonUser.LOGIN_KAKAO: # 가입 방법으로 로그인 하도록
                AlertException(f'{user.login_method}로 로그인 해주세요')
        else: # 없으면 유저 생성
            user = CommonUser(
                email=email,
                nickname=nickname,
                login_method=CommonUser.LOGIN_KAKAO
            )

            user.set_unusable_password()
            user.save()
        messages.success(request, f'{user.email} 카카오 로그인 성공')
        login(request, user, backend="django.contrib.auth.backends.ModelBackend", )

        token = RefreshToken.for_user(user) #simple jwt로 토큰 발급
        data = {
            'user': user,
            'access_token': str(token.access_token),
            'refresh_token': str(token),
        }
        serializer = JWTSerializer(data)
        return Response({'message': '로그인 성공', **serializer.data}, status=status.HTTP_200_OK)
    except AlertException as e:
        print(e)
        messages.error(request, e)
        # 유저에게 알림
        return Response({'message': str(e)}, status=status.HTTP_406_NOT_ACCEPTABLE)
    except TokenException as e:
        print(e)
        # 개발 단계에서 확인
        return Response({'message': str(e)}, status=status.HTTP_400_BAD_REQUEST)


@api_view(('GET',))
@renderer_classes((JSONRenderer,))
def google_login_callback(request):
    try:
        token_id = request.META.get('HTTP_AUTHORIZATION')
        profile_request = requests.get(f'https://www.googleapis.com/oauth2/v3/tokeninfo?id_token={token_id}')
        profile_json = profile_request.json()

        nickname = profile_json.get('name', None)
        email = profile_json.get('email', None)
        try:
            user = CommonUser.objects.get(email=email)
        except CommonUser.DoesNotExist:
            user = None
        if user is not None:
            if user.login_method != CommonUser.LOGIN_GOOGLE:
                AlertException(f'{user.login_method}로 로그인 해주세요')
        else:
            user = CommonUser(email=email, login_method=CommonUser.LOGIN_GOOGLE, nickname=nickname)
            user.set_unusable_password()
            user.save()
        messages.success(request, f'{user.email} 구글 로그인 성공')
        login(request, user, backend="django.contrib.auth.backends.ModelBackend",)

        token = RefreshToken.for_user(user)
        data = {
            'user': user,
            'access_token': str(token.access_token),
            'refresh_token': str(token),
        }
        serializer = JWTSerializer(data)
        return Response({'message': '로그인 성공', **serializer.data}, status=status.HTTP_200_OK)
    except AlertException as e:
        print(e)
        messages.error(request, e)
        # 유저에게 알림
        return Response({'message': str(e)}, status=status.HTTP_406_NOT_ACCEPTABLE)
    except TokenException as e:
        print(e)
        # 개발 단계에서 확인
        return Response({'message': str(e)}, status=status.HTTP_400_BAD_REQUEST)

Django 전체 소스코드는 여기서 확인할수 있다.

https://github.com/darae07/server-matzip

 

GitHub - darae07/server-matzip

Contribute to darae07/server-matzip development by creating an account on GitHub.

github.com

도움이 되셨다면 스타 부탁드려요:D

 

구현 후기

유저 인증 절차에 대해서 공부할 수 있는 계기가 되었다. 개발하면서 OAuth provider에서 발행하는 토큰과 장고 서버에서 발행하는 토큰을 구별하지 못하고 OAuth 토큰을 그대로 인증에 사용했다가 인증에 실패하게 되었다. 그후에 살펴보니프로젝트에서는 simple jwt를 사용하면서 토큰으로 인증하도록 설정되어 있었고, 소셜 로그인 후에도 동일한 방식으로 토큰을 발행하도록 수정해주니 정상 작동하게 되었다.  

댓글