728x90

이렇게 글을 쓰게 된 이유는 사실 django에서 소셜로그인용 라이브러리를 사용하면 쉽게 구현이 가능합니다.

but, 아무래도 매번 이렇게 사용하다보니 내부 동작이 어떻게 이뤄지고 있는지 확인이 안되었기 때문에 이번에 middleware부터 authentication, jwt 발급까지 최대한 라이브러리 사용없이 글을 작성해보려합니다.

 

카카오 소셜로그인의 경우, 위와 같이 크게
1. 인가 코드 발급
2. 토큰(kakao)
3. 사용자 로그인 처리(server의 주된 로직)

과정을 거쳐 진행됩니다.

 

이번에 이 글에서 다룰 방식은 기본적으로 1,2 번의 경우, 프론트에서 모두 발급한 이후 토큰(kakao)을 서버에 전달하여 인증 처리하는것을 다룰 것입니다. 간단하게 2번단계의 결과물인 토큰(kakao)을 발급받는 방식은 아래의 방식을 따라 가면 됩니다.

https://kauth.kakao.com/oauth/authorize?client_id={kakao 서비스 restapi key}&redirect_uri=http://127.0.0.1:8000&response_type=code
위 url을 직접 접속하여 kakao id, pass를 입력 후 로그인합니다.

http://127.0.0.1:8000/?code={code}
당연하게도, 페이지는 404를 띄어 주겠지만 상단 주소창에 있는 code 값을 복사하여 줍니다.
이것이 바로 인가코드 입니다. 이 인가코드를 통해 kakao 인증용 토큰을 발급 받을 수 있습니다.

headers = {"Content-type": "application/x-www-form-urlencoded;charset=utf-8"}
data = {
    "grant_type": "authorization_code",
    "client_id": {kakao 서비스 restapi key},
    "redirect_uri":"http://127.0.0.1:8000",
    "code": {code},
}

response = requests.post("https://kauth.kakao.com" + f"/oauth/token", data=data, headers=headers)

print(response.json())

 

이후 response의 access_token값을 서버로 보내준다가 전제입니다.

이제 이 access_token을 통해 서버에서의 전반적인 처리 과정은

1. 클라이언트로 부터 kakao access_token을 전달 받아 이 토큰을 통해 kakao의 내서비스에 회원가입 or 로그인 시킨다.

    - 인증된 사용자라면 social_id값을 저장 한다.

    - 내 서비스 인증을 위한 jwt 토큰을 생성 및 발급해준다.

2. auth api외의 다른 api에서 접근시, 헤더의 jwt 토큰을 확인할 수 있도록 authentication 및 middleware를 설정해준다.

입니다.

 

1. 클라이언트로 부터 kakao access_token을 전달 받아 이 토큰을 통해 kakao의 내서비스에 회원가입 or 로그인 시킨다.

토큰을 받는 형식은 아래와 같습니다.

POST /auth/kakao
{"access_token": "~~"}

 

- models.py

from django.contrib.auth.base_user import AbstractBaseUser

class User(AbstractBaseUser):
    nickname = models.CharField(max_length=255)
    social_id = models.CharField(max_length=255)

- views.py

from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from datetime import datetime
from datetime import timedelta
from django.conf import settings
import jwt


def generate_access_jwt(user_id):
    iat = datetime.now()
    expired_date = iat + timedelta(weeks=2)

    payload = {
        "user_id": user_id,
        "expired": expired_date.strftime("%Y-%m-%d %H:%M:%S"),
        "iat": iat.timestamp(),
    }

    return jwt.encode(payload, settings.SECRET_KEY, 'HS256')


@api_view(['POST'])
def kakao(self, request):
    data = request.data.copy()
    access_token = data.get('access_token')

    headers = {"Authorization": f"Bearer ${access_token}",
               "Content-type": "application/x-www-form-urlencoded;charset=utf-8"}
    response = requests.get("https://kapi.kakao.com/v2/user/me", headers=headers)
	
    if response.status_code == 200:
        kakao_data = response.json()
        kakao_id = kakao_data['id']
        nickname = kakao_data['kakao_account']['profile']['nickname']
        kakao_info = {"id": kakao_id, "nickname": nickname}
        
        try:
            user = User.objects.get(social_id=kakao_info["id"])
            status_code = status.HTTP_201_CREATED
        except User.DoesNotExist:
            user = User.objects.create(
            	nickname=kakao_info["nickname"],
            	social_id=kakao_info["id"],
        	)
            status_code = status.HTTP_200_OK
    	return Response({'message': '인증에 성공하였습니다.', 'access_token': generate_access_jwt(user.id)}, status=status_code)
        
    else:
        return Response({'message': '유효하지 않은 access_token입니다.'}, status=status.HTTP_400_BAD_REQUEST)

위 코드의 경우, 

1. https://kapi.kakao.com/v2/user/me api를 통해 전달받은 access_token을 body에 담아 request를 보냅니다.

2. 토큰이 유효했다면 아래와 같이 반환이 됩니다.(설정을 nickname만 받도록 해서 아래와 같이 반환되지만 추가적으로 정보 요청을 할경우, 유저의 정보가 더 추가되어 반환 됩니다.)

{
'id': 2819922431, 
'connected_at': '2023-06-04T15:43:11Z', 
'properties': {'nickname': '이형준'}, 
'kakao_account': {
'profile_nickname_needs_agreement': False, 
'profile': {'nickname': '이형준'}
}
}

3. 반환값의 id의 경우, 유일한 값이므로 이값이 만약 서비스의 social_id값과 일치하는 데이터가 존재한다면 "로그인" 아니라면, "회원가입"이 되게 됩니다.

4. 모든 인증 절차가 완료 되었다면 서비스내에서 jwt 토큰을 발급하여 반환합니다.

5. 액세스 토큰을 통한 kakao 인증 및 jwt 토큰 발급이 모두 마무리 됩니다.

이 단계를 통해 기본적으로 로그인, 회원가입이 완료 되었다면, 이젠 다른 api에서 정상적으로 유저를 식별 할 수 있는 방식을 준비해야합니다.

 

2.auth api외의 다른 api에서 접근시, 헤더의 jwt 토큰을 확인할 수 있도록 authentication 및 middleware를 설정해준다.

기본적으로 이 단계를 진행하기 전에 앞서 middleware와 authentication이 어떤것인지 간단히 알아봐야합니다.

미들웨어란 http 혹은 요청에 대한 응답에 있어 공통된 전후 처리 작업을 담당하는 역할을 합니다.

authentication의 경우, 쉽게 말해 등록한 인증 방법에 맞춰 유저를 식별해내는 방법이며, 더 쉽게 말해 기본적으로 django에서는 request.user를 통해 유저를 식별해낼수 있는 방법입니다.

자세한 내용은 아래의 내용을 참고해주시면 됩니다.

 

Django

The web framework for perfectionists with deadlines.

docs.djangoproject.com

- views.py 추가

def decode_jwt(token):
    try:
        return jwt.decode(token, settings.SECRET_KEY, 'HS256')
    except:
        return None


def check_jwt_expired_date(now_date, expired_date):
    now_date = datetime.strptime(now_date, "%Y-%m-%d %H:%M:%S")
    expired_date = datetime.strptime(expired_date, "%Y-%m-%d %H:%M:%S")

    return True if expired_date <= now_date else False

 

- middleware.py

from datetime import datetime

from jwt import ExpiredSignatureError
from rest_framework.exceptions import PermissionDenied

from .views import decode_jwt, check_jwt_expired_date


class JsonWebTokenMiddleWare(object):

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        try:
            if (
                request.path != "/auth/kakao"
                and "admin" not in request.path
                and "swagger" not in request.path
            ):
                access_token = request.headers.get("Authorization", None)
                if not access_token:
                    raise PermissionDenied()

                auth_type, token = access_token.split(' ')
                if auth_type == "Bearer":
                    payload = decode_jwt(token)
                    if not payload:
                        raise PermissionDenied()

                    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    token_expired = payload.get('expired')

                    if check_jwt_expired_date(now, token_expired):
                        raise ExpiredSignatureError()

                    user_id = payload.get("user_id", None)
                    if not user_id:
                        raise PermissionDenied()

                else:
                    raise PermissionDenied()

            response = self.get_response(request)
            return response

        except (PermissionDenied, User.DoesNotExist):
            return JsonResponse(
                {"error": "Authorization Error"}, status=status.HTTP_401_UNAUTHORIZED
            )

        except ExpiredSignatureError:
            return JsonResponse(
                {"error": "Expired token. Please log in again."},
                status=status.HTTP_403_FORBIDDEN,
            )

1. 기본적으로 인증을 진행해야하는 /auth/kakao와 swagger, admin 페이지의 경우, token이 없어도 접속이 가능해야함을 전제로 하여 middleware에서 바로 통과하도록 설정하였습니다.

2. header의 Autorization에 담겨있는 token을 가져와 유효성을 증명하고 문제없다면 통과 아니라면 middleware단에서 오류 반환 합니다.

 

- authentication.py

from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed

from .views import decode_jwt
from .models import User


class JsonWebTokenAuthentication(BaseAuthentication):

    def authenticate(self, request):
        access_token = request.headers.get("Authorization", None)
        if not access_token:
            return None

        auth_type, token = access_token.split(' ')
        payload = decode_jwt(token)

        user_id = payload.get("user_id", None)
        try:
            user = User.objects.get(id=user_id)
            return user, None
        except User.DoesNotExist:
            raise AuthenticationFailed({"message": "INVALID_TOKEN"})

1. 기본적으로 이미 middleware를 통해 token이 유효하다는것이 증명 되었으므로 authentication에서는 다른 유효성 검증 절차를 진행하지 않습니다.

2. access_token에 해당하는 user값을 반환해줍니다.

 

위의 과정을 거쳐 인증이 진행되며 작성한 코드의 경우, settings.py에서 등록해줘야합니다.

- settings.py

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        '<app_name>.<file_name>.<class_name>',
    ]
}

MIDDLEWARE = [
    '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',
    '<app_name>.<file_name>.<class_name>',
]

 

모든 인증 관련 로직 준비가 완료되었습니다.

추가로 생각해봐야할점은

1. 클라이언트와 서버의 인증에 있어 역할의 분리를 클라이언트 = access_token까지 발급으로 나눴는데 과연이게 정답일지? 아무래도 키값을 클라이언트에서 가지고 있다보니 탈취의 위험도가 있는데 이것이 과연 괜찮은 방법일지는 고려를 해봐야합니다.

2. middleware단에서 로직을 처리할때 물론 개발에 있어 편의성을 굉장히 늘어나지만, 일부 로직에서는 의미 없는 리소스가 소모 된다는 단점 또한 존재합니다. 이것이 middleware에서 처리하기 적합한 과정일지는 요구사항에 맞춰 결정해야합니다.

 

이글의 경우, 글 작성의 편의성을 위해 대부분의 코드가 분리 되어있지않아 있으며 최소한의 예외처리가 진행되었지만 전체적인 흐름 파악의 의미에서 참고를 한후 개발을 진행하면 좋을것 같습니다.

감사합니다 :)

728x90

+ Recent posts