[Django] 6. 로그인 기능 구현(1) - 자체 로그인

김성산·2020년 11월 24일
1

안녕하세요! 레오입니다🤗 오랜만에 비가 추적추적 오고 있습니다. 비가 온 뒤엔 보통 추워지기 마련이어서 겨울이 이제 시작되려는건지 걱정스럽네요🥶 오늘은 회원 관리 API 중 꽃이라고 할 수 있는 로그인 기능을 구현해보겠습니다! 로그인 기능을 개발하기 전에는 그저 아이디, 비밀번호 투다다닥 입력한 뒤 로그인 버튼만 누르면, 로그인이 되거나 또는 비밀번호 오류가 발생하는 매우 간단한 작업인 줄 알았습니다. 이렇게 2주가 넘게 걸리고 머리가 지끈지끈 할 줄은 몰랐네요🤕🤕 로그인을 주제로는 회원가입된 계정에 대한 자체 로그인 기능과 카카오 로그인 API 기능을 이용한 카카오 로그인 기능 두 편에 걸쳐 포스팅 하겠습니다!🚌🚌

JsonWebToken(JWT) 방식


코드를 작성하기 전에 프로젝트에서 사용하려는 JWT 인증 방식을 간단하게 설명하고자 합니다. HTTP 통신은 요청과 응답의 한 사이클을 끝내면 연결을 끊고 통신을 종료합니다. 즉 이전에 했던 통신에 대한 정보를 전혀 담지 않습니다. 따라서 사용자가 로그인을 통해 인증 절차를 마쳤더라도 그 다음 통신에서는, 예를 들어 마이페이지 조회를 요청하여도 HTTP는 사용자가 인증된 사용자인지 아닌지 모르고 관심조차 없습니다. 결국 사용자가 인증된 사용자라는 것을 알려줄만한 방법이 필요하고 이러한 방법으로 쿠키/세션 방식과 JWT 방식이 있습니다. 본 포스트에서는 로그인 기능 구현에 집중할 것이고, 인증 방식에 관하여는 추후에 자세하게 다룰 예정이므로 지금 당장 궁금하신 분들은 정보의 바다에서 찾아보는 것을 추천드립니다! 인증 방식은 네트워크에서 매우 중요한 이론이므로 꼭 숙지하여야 하는 내용입니당!!😉😉

Settings.py 코드 작성

먼저 DRF의 JWT 패키지를 설치하겠습니다!

pip3 install djangorestframework-jwt

djangorestframework-simplejwt 패키지도 있는데 이 패키지는 이미 만들어져있는 API에 유저 정보를 보내면 token을 반환하는 패키지이고 제가 사용할 djangorestframework-jwt는 유틸쪽을 더욱 활용하여 직접 만들고 수정할 수 있는 특징이 있습니다. 비유적으로 표현하자면 simplejwt는 가공된 식품, jwt는 많은 재료들이 모여있는 주방으로 생각할 수 있습니다. 그럼 이제부터 jwt 재료들이 모여있는 주방에서 요리를 시작하겠습니다👨‍🍳👨‍🍳

가장 먼저 settings.py에 코드를 작성하겠습니다.

### myproject/settings.py
from datetime import timedelta
REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        'rest_framework.permissions.IsAuthenticated',
    ],
    "DEFAULT_AUTHENTICATION_CLASSES": (
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
    )
}

JWT_AUTH = {
  'JWT_ENCODE_HANDLER':
  'rest_framework_jwt.utils.jwt_encode_handler',
  'JWT_DECODE_HANDLER':
  'rest_framework_jwt.utils.jwt_decode_handler',
  'JWT_PAYLOAD_HANDLER':
  'rest_framework_jwt.utils.jwt_payload_handler',
  'JWT_PAYLOAD_GET_USER_ID_HANDLER':
  'rest_framework_jwt.utils.jwt_get_user_id_from_payload_handler',
  'JWT_RESPONSE_PAYLOAD_HANDLER':
  'rest_framework_jwt.utils.jwt_response_payload_handler',
 
  'JWT_SECRET_KEY': 'SECRET_KEY',
  'JWT_GET_USER_SECRET_KEY': None,
  'JWT_PUBLIC_KEY': None,
  'JWT_PRIVATE_KEY': None,
  'JWT_ALGORITHM': 'HS256',
  'JWT_VERIFY': True,
  'JWT_VERIFY_EXPIRATION': True,
  'JWT_LEEWAY': 0,
  'JWT_EXPIRATION_DELTA': timedelta(hours=1),
  'JWT_AUDIENCE': None,
  'JWT_ISSUER': None,
  'JWT_ALLOW_REFRESH': False,
  'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7),
  'JWT_AUTH_HEADER_PREFIX': 'Bearer',
  'JWT_AUTH_COOKIE': None,
}

세팅할 변수들이 많은데, 이번 포스트에서는 로그인에서 쓰인 변수만 다루겠습니다.

  • DEFAULT_PERMISSION_CLASSES: serializer의 디폴트 permission class를 정의합니다. 이는 view의 permission class 변수를 정의하지 않을 시 참조됩니다. 'IsAuthenticated'는 인증된 사용자에게 요청을 허락하는 객체입니다.
  • DEFAULT_AUTHENTICATION_CLASSES: 인증을 어떤 방식으로 진행할지 정의합니다.
  • JWT_ENCODE_HANDLER: 인자로 받은 payload를 secret key 또는 public/private key 를 이용하여 인코딩을 진행합니다.
  • JWR_PAYLOAD_HANDLER: 유저의 정보를 이용하여 payload로 만들어주며 만들어진 payload는 딕셔너리 형식입니다. 위의 'JWT_ENCODE_HANDLER'와 'JWR_PAYLOAD_HANDLER'는 serializer.py에서 더욱 자세히 다루겠습니다!
  • JWT_SECRET_KEY: 사용할 시크릿 키를 정의합니다. 이전 포스트에서 환경변수로 설정했던 개인이 할당받은 SECRET_KEY를 사용합니다.
  • JWT_ALGORITHM: 사용할 알고리즘을 선택합니다.
  • JWT_EXPIRATION_DELTA: 만들어진 Token을 만료시킬 시간을 정합니다. 이 시간은 timestamp 형식으로 표현됩니다.

작성할 코드가 많지만 대부분 정형화되어있는 코드들입니다. 여기서 refresh를 하거나 해싱 알고리즘을 다른 것으로 쓰거 싶거나 할 때 해당 부분만 수정하면 됩니다! 이제는 시리얼라이저 코드를 알아보겠습니다🚶🚶

serializers.py 코드 작성

#user/serializers.py

from .models import User
from rest_framework_jwt.settings import api_settings

from django.contrib.auth import authenticate
from django.contrib.auth.models import update_last_login

JWT_PAYLOAD_HANDLER = api_settings.JWT_PAYLOAD_HANDLER
JWT_ENCODE_HANDLER = api_settings.JWT_ENCODE_HANDLER

class UserLoginSerializer(serializers.Serializer):

    email = serializers.CharField(max_length=255)
    password = serializers.CharField(max_length=128, write_only=True)
    token = serializers.CharField(max_length=255, read_only=True)

    def validate(self, data):
        email = data.get("email", None)
        password = data.get("password", None)
        user = authenticate(email=email, password=password)
        if user is None:
            raise serializers.ValidationError(
        "A user with this email and password is not found."
        )
        try:
            payload = JWT_PAYLOAD_HANDLER(user)
            payload['nickname'] = User.objects.get(email=user).nickname
            jwt_token = JWT_ENCODE_HANDLER(payload)
            update_last_login(None, user)

        except User.DoesNotExist:
            raise serializers.ValidationError(
        "User with given email and password does not exists"
        )
        return {
            'email': user.email,
            'token': jwt_token
        }
  • JWT_PAYLOAD_HANDLER: settings.py에서 정의한 PAYLOAD HANDLER를 불러옵니다. 깃허브 문서를 보면서 어떻게 작동하는지 알아보겠습니다.
    -L33: 기본 모델의 username field를 불러옵니다. 이 프로젝트에서는 이전에 지정했다시피 email이 username field입니다.
    👉USERNAME_FIELD 포스트 보러가기!
    -L42-46: payload에 넣을 유저의 정보입니다. uuid로 정의된 user의 id와 username(email)을 넣습니다. 그리고 만료기한인 exp를 payload에 넣을 정보로 추가합니다.
    -L47-48: user에 email 속성(필드)이 있으면 payload에 key와 value를 추가합니다.
    -L49-50: user의 primary key가 uuid type이면 이를 string으로 바꿔줍니다. uuid가 저장될 당시의 type은 string이 아닌 uuid.UUID입니다.
    -L52: L33-34에서 정의했던 username_field와 username은 email이고 이미 L47-48에서 저장되었으므로 이 경우는 덮어씁니다.
    -L56-65: refresh, audience, issuer는 FALSE와 None으로 저장했으므로 'orig_iat', 'aud', 'iss'는 생성되지 않습니다. 참고로 audience는 토큰 발급 대상자, issuer는 토큰 발급자입니다.
    return되는 payload의 형태는 {'user_id': 'UUID', 'username': 'EMAIL', 'exp': DATETIME, 'email': 'EMAIL'} 입니다. username field와 email이 겹치지만 오류가 없으므로 그냥 넘어가겠습니다!
  • JWT_ENCODE_HANDLER:settings.py에서 정의한 ENCODE HANDLER를 불러옵니다. 이 또한 깃허브 문서를 보면서 어떻게 작동하는지 알아보겠습니다.
    -L91: key를 설정합니다. 이번 프로젝트에서는 private key 대신 secret key를 사용합니다.
    -L92-96: payload와 key, algorithm을 이용하여 인코딩합니다. 인코딩된 값은 byte형태이기때문에 디코딩을 진행하여 string 형태로 return 합니다.

    혹시 encode handler에서 secret key를 가져오는 방법을 궁금해하실 수 있을 것 같아서 이 메소드도 가져와 봤습니다.
    -L24: 만약 JWT_GET_USER_SECRET_KEY 변수를 설정하였다면 각각의 개인마다 secret key를 생성합니다. 이번 프로젝트에서는 해당 변수의 값이 None이기때문에 if문이 돌아가지 않고 바로 JWT_SECRET_KEY가 return됩니다. 참고로 JWT_GET_USER_SECRET_KEY의 값에는 function이 들어와야 한다고 합니다. 아마 키를 생성해주는 function으로 설정해야할 것 같습니다.
  • email, password, token: 필드를 정의합니다. email과 password는 CRUD 할 필요가 없기때문에 ModelSerializer 대신 기본 시리얼라이저를 상속받습니다(email과 password 일치 확인은 하단의 authenticate 메소드로 할 예정).
  • authenticate: 인자로 받은 email과 password를 이용하여 확인합니다. check_password 메소드로 해싱된 password와 일치하는지 확인해줍니다. user의 쿼리셋을 반환합니다.
  • payload['nickname']: payload에 user의 nickname에 대한 정보를 담고싶어서 커스터마이징 해보았습니다. get 메소드와 user의 email을 이용하여 nickname을 받아서 payload에 넣어주었습니다.
  • update_last_login: 인자로 받은 user의 'last_login' 필드를 현재 시간으로 update합니다. update_last_login 메소드는 다소 쉬운 편이기에 설명은 생략하고 이에 관한 문서 링크만 게시하겠습니다!👇👇
    👉update_last_login 코드 문서
    👉save()의 update_field 인자 코드 문서

지금까지 어려운 코드들을 작성하느라 수고많으셨습니당💯💯 JWT를 직접 생성해보려니까 여간 복잡한게 아니지만 이렇게 모든 과정을 직접 살펴보니 이해가 잘 되기도 하고 에러가 발생해도 잡아낼 수 있을 것 같은 자신감이 듭니다😝 이제 마무리를 짓기 위해 view와 url을 손보도록 하겠습니다!

views.py 코드 작성


from .serializers import UserRegistrationSerializer, UserLoginSerializer

class UserLoginView(GenericAPIView):
    permission_classes = (AllowAny,)
    serializer_class = UserLoginSerializer

    def post(self, request):
        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        response = {
            "success": "True",
            "status_code": status.HTTP_200_OK,
            "message": "User Logged in successfully",
            "token": serializer.data['token'],
        }
        status_code = status.HTTP_200_OK

        return Response(response, status=status_code)
  • is_valid(): 앞서 만들었던 validate() 함수를 이용하여 validation을 진행합니다.
  • serializer.data['token']: is_valid()로 입력된 data의 token 키를 이용하여 token을 불러옵니다.

urls.py 코드 작성

from django.conf.urls import url
from django.urls import path

from .views import UserRegistrationView, UserLoginView

urlpatterns = [
    path('signup/', UserRegistrationView.as_view()),
    path('signin/', UserLoginView.as_view()),
]

이제 모든 코드의 작성이 완료되었습니다! runserver를 통해 서버를 열어서 테스트 해보겠습니당

view에서 작성한 return의 형태대로 응답이 나왔습니다. token의 길이가 상당한데 어떤 정보를 담고있는지도 확인해보겠습니다!

jwt 홈페이지에 접속하면 디코딩을 할 수 있는 칸이 있습니다. 여기에 반환된 token을 붙여 넣고 payload를 확인해보면 serializer에서 넣었던 정보 그대로 들어가 있는 것을 알 수 있습니다 ㅎㅎㅎ

에러가 발생했던 부분

  1. 401 인증 에러

처음에는 permission_classes를 지정하지 않았습니다. 그로 인해 seetings.py의 permission default 값인 IsAuthenticated가 작동되고 인증 에러를 반환하였습니다. 따라서 permission_classes를 AllowAny로 정의해주고 난 뒤 에러를 수정할 수 있었습니다.

  1. 400 로그인 에러: "A user with this email and password is not found."

가장 속썩었던 에러였습니다. 디버깅을 통해 authenticate에서 None을 반환하는 것을 우선 파악하였습니다. 이에 비밀번호가 일치하지 않는 것으로 판단하고 새로 계정을 만들어서 다시 시도하였는데 그대로였습니다. 분명 3초 전에 만든 계정인데,,,🐠🐠 그래서 authenticate를 파보기로 결정하고 코드 문서를 찾아보았습니다.

  • L69, L71: backend_signature.bind(request, **credentials)까지는 잘 되는 것을 확인하였습니다.
  • L76: backend.authenticate를 찾으러 갔습니다.
  • L48:check_password() 함수와 user_can_authenticate() 함수가 True가 되어야 user가 return이 됩니다.
    user_can_authenticate 함수는 바로 아래 정의되어있는데 is_active를 판단하는 함수입니다. 읽어내려가면서 이마를 탁 쳤습니다,,😵😵 is_active가 True(1)거나 아예 없다면 True를 반환하는데, 제가 이메일 인증을 연습하면서 is_active의 default값을 False로 만들어 놓았기때문에 여기서 None이 반환되었던 것입니다.


이렇게 5시간을 쥐잡듯이 뒤져서 알아낸 것은 is_active가 False이면 authenticate()를 쓸 수 없다,,

궁금해서 찾아본 부분

  1. JSONWebTokenAuthentication
    'IsAuthenticated'를 어떤 방식으로 인증할 것인지 설정합니다.
  • L82: get_jwt_value의 get_authorization_header라는 메소드를 통해 header의 jwt 토큰 정보를 얻습니다. header에 prefix인 Bearer와 JWT을 실어 보내면 get_authorization_header 함수가 이를 받아 Bearer와 JWT 두 개의 값이 담긴 리스트를 반환합니다(length는 2).
  • L93-99: 잘못된 Authorization 헤더를 예외처리한 뒤 이를 통과한 auth 변수의 JWT를 return합니다. 이 때 auth[0]은 'Bearer', auth[1]에는 Token이 담겨있습니다.
  1. is_valid()
    view에서 serializer 변수의 is_valid()가 어떻게 동작되는지 자세하게 알아보고 싶었습니다.

만들었던 LoginSerializer는 serializers.Serializer를 상속받았고, serializers.Serializer는 BaseSerializer를 상속받았습니다. 위의 is_valid는 BaseSerializer 클래스의 함수입니다.

  • L213-215: initial_data를 가지고 있는지 확인하고 없으면 assert error를 반환합니다. 이를 통해 is_valid()함수는 data keyword가 통과되어야 실행할 수 있다는 것을 알 수 있습니다.
  • L219-225: validate_data 속성값에 넣을 validation을 run_validation 함수를 이용하여 진행합니다. 그리고 문제가 생기면 errors 속성에 에러에 대한 정보를, 문제가 없으면 errors 속성에 {}값을 넣고 return값으로 True를 반환하게 됩니다('not bool({})'은 True).

이제 run_validation이 어떻게 동작하는지 알아보겠습니다.

  • L415: validate_empty_values() 메소드를 통해 data가 비어있는지 확인하고 두 개의 값을 반환합니다. data가 read_only, empty, 또는 None이면 is_empty_value에는 True가 할당되고, 이외의 경우에는 False가 할당됩니다.
  • L416: is_empty_value가 True면 바로 data가 return이 되고, False면 아래의 validation을 진행합니다.
  • L419: to_internal_value는 데이터를 Orderdict() 형태로 변환해줍니다.
  • L421: run_validators는 read_only field를 추가해주는 메소드입니다.
  • L422: 여기서 우리가 만든 def validate()가 실행이 됩니다.

이상으로 자체 로그인 기능 구현에 대해서 알아보았습니다. 워낙 길고 고려해야 할 것들이 많아서 코딩하는 시간도 오래 걸렸고 포스팅 작성하는 시간도 오래 걸렸네용💦💦 하지만 걸린 시간만큼 인증, 보안 등에 대해 얻은 것도 많았던 기능이었습니다 ㅎㅎ 다음 시간에는 카카오를 이용한 로그인 기능을 구현해 보겠습니다! 긴 글 읽어주셔서 감사합니다!!

profile
비단같은 마음씨

0개의 댓글