[DRF] JWT 인증을 사용한 회원가입&로그인

김재연·2022년 7월 15일
19

Watti

목록 보기
1/10
post-thumbnail

2022-07-22 추가 => dj-rest-auth를 활용한 더 편한 jwt 회원가입&로그인 방법은 여기로

시작하기

작년에 멋사에서 이음 프로젝트를 하면서 가장 절실하게 깨달았던 점.. 유저는 무조건 시작단계에 해놔야 한다. 다른거 다 만들고 유저를 만들거나 고치려고 하면 너무 복잡하게 얽혀있어서 공사가 엄청 크다. 그래서 이번에는 어쨌든 프로젝트 기획도 완전하지 않으니 겸사겸사 유저 작업을 먼저 시작했다.

이번 유저 작업에서 해야할 것들은 우선 다음과 같다.

  1. 일반회원가입 & 로그인
  2. JWT 토큰 인증 방식
  3. 이메일 인증
  4. 소셜로그인(구글, 카카오, 네이버 등)

완전 무지렁이 상태에서 했던 유저 작업이 너무너무너무 힘들었어서 사실 겁부터 나는데.. 😥 차근차근 해보자..

JWT란?

  • 유저를 인증하고 식별하기 위한 토큰 기반 인증
  • 토큰은 서버가 아닌 클라이언트에 저장됨 => 서버의 부담을 덜 수 있음
  • HTTP 헤더에 토큰을 첨부해서 데이터 요청/응답받기 가능
  1. 클라이언트가 아이디와 패스워드를 통해 웹서비스 인증
  2. 서버에서 (signed) JWT를 생성하여 클라이언트에게 응답으로 돌려줌
  3. 클라이언트가 HTTP 헤더에 JWT를 첨부해서 서버에 데이터를 요구
  4. 서버에서 클라이언트로부터 온 JWT 검증
  • Header, Payload, Signature로 구성되고 각 요소는 .으로 구분됨
    • Header : 토큰 타입(무조건 JWT)과 해시 알고리즘
    • Payload : 서버에서 첨부한 사용자 권한 정보와 데이터
    • Signature : Header와 Payload를 조합하고 비밀키로 서명
    • JWT = B64(Header).B64(Payload).B64(Sig)
  • accessToken & refreshToken
    • accessToken : 매번 인가를 받을 때 사용하는 토큰, 수명 짧음
    • refreshToken : accessToken의 수명이 다했을 때 accessToken을 재발행받기 위한 토큰, 수명 김

JWT 인증방식으로 일반회원가입 & 로그인 구현하기

simplejwt 공식문서

1. JWT 기반 인증 초기 환경 세팅

pip install djangorestframework
pip install djangorestframework-simplejwt

기존에는 djangorestframework-jwt를 사용했지만, 이 패키지는 더이상 업데이트가 진행되지 않아서 그 대신 djangorestframework-simplejwt을 사용한다.

# settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # 생성한 앱
    'user',

    # 설치한 라이브러리
    'rest_framework',
    'rest_framework_simplejwt',
]

# user 앱에서 내가 설정한 User를 사용하겠다고 설정한다.
AUTH_USER_MODEL = 'user.User'

# jwt 토큰은 simplejwt의 JWTAuthentication으로 인증한다.
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
}

# 추가적인 JWT 설정, 다 쓸 필요는 없지만 혹시 몰라서 다 넣었다.
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': False,
    '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': ('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',
    '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),
}

REST_FRAMEWORKDEFAULT_AUTHENTICATION_CLASSES는 API가 호출됐을 때 session이나 token을 인증할 클래스들을 정의한다. DRF의 APIView에 의해 호출되는데, 명시적으로 정의하지 않으면 기본값은 아래와 같이 설정된다.

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
    	'rest_framework.authentication.SessionAuthentication',
        'rest_framework.authentication.BasicAuthentication'
    ),
}

❗위쪽에 있는 인증 클래스부터 실행되므로 순서 주의❗
simplejwt에서는 인증클래스로 JWTAuthentication만 쓰는 것 같다. 찾다 보면 JSONWebTokenAuthentication이 많이 나오는데(이음에서도 얘를 썼었다) 얘는 simplejwt가 아니라 jwt에서 제공하는 것이므로 이 프로젝트에는 맞지 않다.

여기서 세트로 DEFAULT_PERMISSION_CLASSES도 많이 설정해준다.

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': (
        # 'rest_framework.permissions.IsAuthenticated', # 인증된 사용자만 접근
        # 'rest_framework.permissions.IsAdminUser', # 관리자만 접근
        'rest_framework.permissions.AllowAny', # 누구나 접근
    ),

}

DEFAULT_PERMISSION_CLASSES는 API에 접근할 때 헤더에 access_token을 포함한, 유효한 유저만이 접근할 수 있도록 default로 설정해주는 부분이다. 여기서 설정을 하면 API에 일일이 접근권한을 설정하지 않아도 된다. 하지만 아직은 필요하지 않아서 설정해주지는 않았다.

2. 커스텀유저 (AbstractBaseUser)

# 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'

AbstractBaseUser 모델을 상속한 User 커스텀 모델은 로그인 아이디로 이메일 주소를 사용하거나 Django 로그인 절차가 아닌 다른 인증 절차를 직접 구현할 수 있다. 그리고 PermissionsMixin을 다중상속하면 Django의 기본 그룹, 허가권 관리 기능을 재사용할 수 있다고 해서 쓰긴 했는데, 아직은 잘 모르겠다.

그리고 유저를 생성하는 건 UserManager()라는 헬퍼 클래스를 통해 이루어진다. 일반유저를 만들 때는 create_user()를, 관리자 계정을 만들 때는 create_superuser()를 타서 유저가 만들어진다.

3. 유저 시리얼라이저

본격적인 회원가입과 로그인 코드를 만들기 전에 유저 시리얼라이저를 만들어준다.

# serializers.py

from .models import User
from rest_framework import serializers

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

    def create(self, validated_data):
        user = User.objects.create_user(
            email = validated_data['email'],
            password = validated_data['password']
        )
        return user

유효성 검증을 통과한 값인 validated_data를 이용해서 입력값을 검증하고 유저 객체를 만들어준다. 어차피 회원가입이나 로그인이나 똑같은 시리얼라이저를 사용하길래 (create 오버라이딩의 유무만 차이) 하나로 통일시켜버렸다.

4. 회원가입

# views.py
from rest_framework.views import APIView
from .serializers import *
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework import status
from rest_framework.response import Response

class RegisterAPIView(APIView):
    def post(self, request):
        serializer = UserSerializer(data=request.data)
        if serializer.is_valid():
            user = serializer.save()
            
            # jwt 토큰 접근
            token = TokenObtainPairSerializer.get_token(user)
            refresh_token = str(token)
            access_token = str(token.access_token)
            res = Response(
                {
                    "user": serializer.data,
                    "message": "register successs",
                    "token": {
                        "access": access_token,
                        "refresh": refresh_token,
                    },
                },
                status=status.HTTP_200_OK,
            )
            
            # jwt 토큰 => 쿠키에 저장
            res.set_cookie("access", access_token, httponly=True)
            res.set_cookie("refresh", refresh_token, httponly=True)
            
            return res
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# urls.py

urlpatterns = [
    path("register/", RegisterAPIView.as_view()), # post - 회원가입
]

시리얼라이저를 사용해서 유저를 저장하고(=회원가입), jwt 토큰을 받아서 쿠키에 저장한다. 쿠키에 저장할 때 httponly=True 속성을 줬는데, 이는 JavaScript로 쿠키를 조회할 수 없게 하기 위한 것이다. 따라서 XSS로부터 안전해지는데, 대신 CSRF로부터 취약해져서 CSRF 토큰을 같이 사용해야 한다.

5. 로그인/로그아웃

# views.py

import jwt
from rest_framework.views import APIView
from .serializers import *
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, TokenRefreshSerializer
from rest_framework import status
from rest_framework.response import Response
from django.contrib.auth import authenticate
from django.shortcuts import render, get_object_or_404
from watti_backend.settings import SECRET_KEY

class AuthAPIView(APIView):
    # 유저 정보 확인
    def get(self, request):
        try:
            # access token을 decode 해서 유저 id 추출 => 유저 식별
            access = request.COOKIES['access']
            payload = jwt.decode(access, SECRET_KEY, algorithms=['HS256'])
            pk = payload.get('user_id')
            user = get_object_or_404(User, pk=pk)
            serializer = UserSerializer(instance=user)
            return Response(serializer.data, status=status.HTTP_200_OK)

        except(jwt.exceptions.ExpiredSignatureError):
            # 토큰 만료 시 토큰 갱신
            data = {'refresh': request.COOKIES.get('refresh', None)}
            serializer = TokenRefreshSerializer(data=data)
            if serializer.is_valid(raise_exception=True):
                access = serializer.data.get('access', None)
                refresh = serializer.data.get('refresh', None)
                payload = jwt.decode(access, SECRET_KEY, algorithms=['HS256'])
                pk = payload.get('user_id')
                user = get_object_or_404(User, pk=pk)
                serializer = UserSerializer(instance=user)
                res = Response(serializer.data, status=status.HTTP_200_OK)
                res.set_cookie('access', access)
                res.set_cookie('refresh', refresh)
                return res
            raise jwt.exceptions.InvalidTokenError

        except(jwt.exceptions.InvalidTokenError):
            # 사용 불가능한 토큰일 때
            return Response(status=status.HTTP_400_BAD_REQUEST)

    # 로그인
    def post(self, request):
    	# 유저 인증
        user = authenticate(
            email=request.data.get("email"), password=request.data.get("password")
        )
        # 이미 회원가입 된 유저일 때
        if user is not None:
            serializer = UserSerializer(user)
            # jwt 토큰 접근
            token = TokenObtainPairSerializer.get_token(user)
            refresh_token = str(token)
            access_token = str(token.access_token)
            res = Response(
                {
                    "user": serializer.data,
                    "message": "login success",
                    "token": {
                        "access": access_token,
                        "refresh": refresh_token,
                    },
                },
                status=status.HTTP_200_OK,
            )
            # jwt 토큰 => 쿠키에 저장
            res.set_cookie("access", access_token, httponly=True)
            res.set_cookie("refresh", refresh_token, httponly=True)
            return res
        else:
            return Response(status=status.HTTP_400_BAD_REQUEST)

    # 로그아웃
    def delete(self, request):
        # 쿠키에 저장된 토큰 삭제 => 로그아웃 처리
        response = Response({
            "message": "Logout success"
            }, status=status.HTTP_202_ACCEPTED)
        response.delete_cookie("access")
        response.delete_cookie("refresh")
        return response
# urls.py

urlpatterns = [
    path("auth/", AuthAPIView.as_view()),
    # post - 로그인, delete - 로그아웃, get - 유저정보
]

APIView 하나에 메소드만 다르게 해서(post-로그인, delete-로그아웃, get-유저정보) 구현했다. 로그인은 회원가입이랑 시리얼라이저를 save 하느냐 get 하냐만 다르고 jwt 토큰에 접근해서 쿠키에 저장하는 로직은 똑같다.

- ❗ JWT 로그아웃 ❗

JWT는 서버 쪽에서 로그아웃을 할 수 없고, 한번 발급된 JWT 토큰을 강제로 삭제하거나 무효화할수도 없다. 그래서 보통 JWT 토큰의 유효기간을 짧게 하거나, 토큰을 DB에 보존하고 매 요청마다 로그아웃으로 인해 DB에서 삭제된 토큰인지 아닌지를 확인하는 방법을 쓴다고 한다. 그런데 이 방법들은 너무 귀찮기도 하고 복잡해서, 🍪쿠키를 삭제하면 서버에서 유저를 확인할 방법이 없어지니까 단순하게 쿠키에 담긴 토큰을 지워주는 방향으로🍪 갔다. (개발하는 입장에서나 쿠키에서 지운 토큰도 가지고 있지)

유저정보는 별도로 authorization에 jwt 토큰을 포함시켜주지 않아도 쿠키에 저장된 토큰으로 해당 사용자의 정보를 보여준다.

6. 토큰 재발급받기

# urls.py

from rest_framework_simplejwt.views import TokenRefreshView

urlpatterns = [
    path("auth/refresh/", TokenRefreshView.as_view()), # jwt 토큰 재발급
]

simplejwt에 내장된 기능 사용한다.

7. 테스트용 viewset

# views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .serializers import *

# jwt 토근 인증 확인용 뷰셋
# Header - Authorization : Bearer <발급받은토큰>
class UserViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthenticated]
    queryset = User.objects.all()
    serializer_class = UserSerializer
from django.urls import path, include
from rest_framework import urls
from .views import *
from rest_framework import routers

router = routers.DefaultRouter()
router.register('list', UserViewSet) # 유저리스트 (테스트용)

urlpatterns = [
    path("", include(router.urls)),
]

IsAuthenticated가 적용된 뷰셋에 접근하려면 Header의 Authorization 항목에 Bearer <jwt토큰>를 같이 넘겨줘야 인증 에러가 안뜬다.

테스트 (Postman)

관리자 계정 만들기

is_superuser, is_active, is_staff 필드가 모두 true이다.

일반 회원가입

회원가입 단계에서도 jwt 토큰을 발급받아서 cookies에 저장된다.

일반 로그인

위에서 회원가입한 계정 정보대로 로그인 요청을 보낸다.

쿠키에 토큰들이 저장되는 것을 볼 수 있다.

psql에서 확인해보면 관리자 계정과는 다르게 is_active 필드만 true고 is_superuser, is_staff 필드는 false로 만들어졌다.

일반 유저정보

별도로 Header에 Authorization을 넣지 않아도 쿠키에 있는 정보로 현재 요청을 보낸 유저가 누구인지 판별한다. (쿠키를 지우면 에러남)

IsAuthenticated

Header의 Authorization에 Bearer <jwt토큰> 을 넣어서 요청을 보내야 오류 없이 제대로 받아진다.

일반 로그아웃

쿠키에 있던 토큰들을 지운다.

🔆 JWT 토큰 decode

발급받은 토큰을 디코딩해보면 (JWT Debugger)

Payload 부분에 해당 access token을 발급받은 유저의 아이디(pk)와 access token 만료기한이 나와있다. 2022-07-20 13:23:59에 로그인했고, 기한은 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30)으로 설정되어있으므로, jwt 토큰에는 2022-07-20 13:53:59로 나오는게 맞다.

이렇게 사용자가 아이디와 패스워드를 입력해서 로그인하고 서버가 access token을 발급해서 사용자에게 전달하면, 나중에 로그인이 필요한 API를 호출했을 때 Header에 이 jwt 토큰을 담아 전송해서, 서버에서 JWT 토큰을 디코드해서 사용자 정보를 획득할 수 있는 것이라고 한다.

profile
일기장같은 공부기록📝

5개의 댓글

comment-user-thumbnail
2023년 4월 11일

postman으로는 실행이 잘되는데 localhost로 로그인을 하게된다면 access = request.COOKIES['access'] 이 부분에서 에러나네요

답글 달기
comment-user-thumbnail
2023년 4월 24일

재연님~ 해당 코드 잘보고 배워가요~ 한가지 궁금한게 있는데요
simple jwt 다큐먼트에도 view(회원가입, 로그인, 로그아웃 등)에 대한 예제는 없는데 혹시 어디서 보고 참고하신건지 알수있을까요?

답글 달기
comment-user-thumbnail
2023년 8월 14일

잘 보고 갑니다! 많이 배웠어요. watti_backend는 뭔가요? django.conf.settings에서 SECRET_KEY를 가져오지 않는데 어떻게 하신거에요? 그리고 회원가입 할때 비밀번호를 하나만 입력해도 되는데 재연님도 그러신가요?

1개의 답글
comment-user-thumbnail
2023년 11월 23일

너무 상세하고 좋은 실습안입니다! 감사합니다 ㅠㅠㅠ
5. 로그인/로그아웃 예제에서 따로 정의한 User가 import가 빠진것 같습니다!

답글 달기