[django] token options

EMMA·2022년 5월 4일
1
post-thumbnail

token에도 여러 옵션이 있다.

그리하여 작성해보는 token 편 - 3가지 종류에 대해.


프로젝트 기간 동안에는 pyJWT를 배웠고, pyJWT를 당연하게 사용해 왔다.

그러다가 기업 프로젝트를 하면서,
token을 주는 것에도 다양한 옵션이 있고 외부 lib/package를 사용하는 것에 대해 다시 한번 생각해볼 수 있는 계기가 되어, 정리해보기로 했다.

0 Session based authentication

JWT 이전에는, 인증 작업은 session을 통해 이뤄졌다.
사용자의 인증 정보가 서버에 전달되면 서버는 session을 만들고,
클라이언트에 session id를 전달한다(클라이언트는 cookie에 이를 저장). session id가 만료되면 다시 처음부터 인증 - session id 생성 등을 진행한다.

django에서 처음 프로젝트를 생성하고 migrate을 하면, 아래와 같이 django_session 테이블이 생성되어 있음을 알 수 있다.

session을 생성하고 관련 정보를 server가 모두 관리함으로써 stateful한 상태가 되는데, 이 방법에는 몇 가지 단점이 있다

  • session 관련 정보는 database에 저장되어, 규모가 커지고 관련 database가 분산되는 경우 확장성이 어려울 수 있다
  • 사용자가 많아지면 그만큼 메모리 사용이 커진다 (서버 성능 down)
  • 중복 로그인 처리가 되지 않는 등의 문제가 있어 multiple device 기반 web 환경에서는 불리한 점이 있다

반면, 이러한 점 때문에 Netflix처럼 접속인원을 제한한다던가, 강제 로그아웃(세션 삭제) 등으로 활용할 수 있다.

어쨌든 위와 같은 이유로 최근에는 JWT를 대부분 사용한다.
(클라이언트에서 관리하므로 확장성에 문제가 없음)

1 pyJWT

pyJWT 라이브러리를 사용해 토큰 발행하는 방법이다.

pip install pyJWT

사용자가 로그인을 하면, 아이디/비밀번호 검증을 한 후, 토큰을 발행한다. JWT는 3가지 구성요소로 이뤄져 있는데 - header, payload, signature가 그것. 이렇게 구성된 타입을 Bearer 타입이라고 한다.

  • header : 토큰 type, algorithm 등을 포함
  • payload: 사용자 정보 등을 포함 (인코딩만 하기때문에, 민감 정보는 넣을 수 없음 - 그래서 pk대신 uuid를 넣어 발행하기도 함)
  • signature: header, payload, secret key의 조합

실제 작성한 예시는 아래와 같다. (유효기간: 3일)

import pyJWT 

from datetime import datetime, timedelta

access_token = jwt.encode({
	'user_id':user.id , 
    'exp':datetime.utcnow() + timedelta(days=3)},
     settings.SECRET_KEY, settings.ALGORITHM
)

2 simpleJWT

DRF 기반 JWT 생성 library로, JWT 기반이면서 DRF built-in token에 비해 보안이 좋아 많이 사용되고 있다.
(DRF built-in token은 상대적으로 문자열 구성이 단순하기 때문)

사용하려면 djangorestframework-simplejwt를 설치해야 한다.

아직은 사용해본 적이 없어 우선 블로그를 통해 흐름을 이해했다.
blacklist 기능이 있으며, 사용자가 로그아웃하면 해당 token을 더이상 사용할 수 없도록 관리해준다.
블로그 - how to blacklist jwt in django


3 DRF token

이번에 사용한 TokenAuthentication 은 공식 문서에 따르면, 아래와 같이 소개 되어 있다.

The TokenAuthentication class can be used to support REST framework's built-in TokenAuthentication, as well as OAuth and JWT schemes.

DRF token에는 몇 개 옵션이 더 있다.

  • BasicAuthentication
    • 사용자 ID/PW를 base-64로 인코딩/디코딩해 인증하는 것으로, 실제 prod 환경에서 사용한다면 반드시 https 상에서 적용할 것을 권장한다
    • 보안에 취약
  • TokenAuthentication
    • Token header 사용 -> 이번에 로그인 시 사용할 옵션
    • Token을 발행해 Token끼리 비교, 인증한다
  • SessionAuthentication
    • 세션을 통한 인증
  • RemoteUserAuthentication
    • remote user를 관리하기 위한 인증 옵션
    • 공식문서로는 잘 이해가 안돼 찾아보니 다른 서비스에서 user가 관리될 때 사용하는 인증 옵션이라고 한다

TokenAuthentication 기본 설정은 아래와 같다.

#settings.py 

INSTALLED_APPS = [
	#...
    'rest_framework.authtoken',
]
...
...
RESTFRAMEWORK = {
	'DEFAULT_AUTHENTICATION_CLASSES': [        
    	'rest_framework.authentication.TokenAuthentication',
    ],
}

Serializer.py는 아래와 같이 작성했다.

class LoginSerializer(serializers.Serializer):
    email    = serializers.EmailField()
    password = serializers.CharField(max_length = 128, write_only = True)

    def validate(self, data):
        email    = data.get('email', None)
        password = data.get('password', None)

        user = authenticate(username = email, password = password)

        if not user: 
            raise serializers.ValidationError('Invalid User')

        return {'user' : user}
  • serializer로부터 데이터를 받을 때는 request가 아니라 data
  • authenticate()usernamepassword 등을 인자로 받아 동일한 사용자인지 아닌지 확인해 준다. 나는 emailusername으로 설정했으므로, username=email이라고 작성.
  • 모든 검사를 통과하면, user를 반환한다. 해당 user 객체를 반환해야, token 발행이 가능하다.
    (즉, user는 결국 User.objects.get(id=user.id)이다)
  • 만약 통과하지 못하면, None을 반환한다.
    test10@gmail.com을 비활성화 처리하고, shell에 찍었을 때 결과

다음으로, views.py를 작성한다.

#views.py

from rest_framework.authtoken.views import ObtainAuthToken

class LogInView(ObtainAuthToken):
    permission_classes = [AllowAny]
    serializer_class   = LoginSerializer
    
   	def post(self, request):
    	serializer = self.seriazlier_class(data = request.data) 
        serializer.is_valid(raise_exception = True)
        token, is_created = Token.objects.get_or_create(user_id = serializer.validated_data['id'])
        
        return Response({'token' : token.key})
  • login은 모두가 접근 가능해야 하므로,AllowAny로 permission 설정
  • 사용자가 입력한 id(email)/pw가 POST로 전달되면, serializer 을 통해 검증 과정까지 거친다 (is_valid)
  • 1번 이상 로그인한 적 있는 사용자면 token을 get하고, 그렇지 않으면 create 해서 클라이언트에 전달한다

그런데, 이렇게 쓰면 POST를 2번 작성한 것이 된다.
왜냐하면 LoginViewObtainAuthToken을 상속받았고, 여기에는 위에 작성한 내용이 이미 모두 포함되어 있다.

따라서, 아래와 같이 작성하면 끝.

#views.py 

from rest_framework.authtoken.views import ObtainAuthToken

class LogInView(ObtainAuthToken):
    permission_classes = [AllowAny]
    serializer_class   = LoginSerializer

django, django-rest-framework 소스코드는 아래와 같다.

1 authenticate()

class ModelBackend(BaseBackend):
    """
    Authenticates against settings.AUTH_USER_MODEL.
    """

    def authenticate(self, request, username=None, password=None, **kwargs):
        if username is None:
            username = kwargs.get(UserModel.USERNAME_FIELD)
        if username is None or password is None:
            return
        try:
            user = UserModel._default_manager.get_by_natural_key(username)
        except UserModel.DoesNotExist:
            # Run the default password hasher once to reduce the timing
            # difference between an existing and a nonexistent user (#20760).
            UserModel().set_password(password)
        else:
            if user.check_password(password) and self.user_can_authenticate(user):
                return user

2 token

class Token(models.Model):
    """
    The default authorization token model.
    """
    key = models.CharField(_("Key"), max_length=40, primary_key=True)
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL, related_name='auth_token',
        on_delete=models.CASCADE, verbose_name=_("User")
    )
    created = models.DateTimeField(_("Created"), auto_now_add=True)

    class Meta:
        abstract = 'rest_framework.authtoken' not in settings.INSTALLED_APPS
        verbose_name = _("Token")
        verbose_name_plural = _("Tokens")

    def save(self, *args, **kwargs):
        if not self.key:
            self.key = self.generate_key()
        return super().save(*args, **kwargs)

    @classmethod
    def generate_key(cls):
        return binascii.hexlify(os.urandom(20)).decode()

    def __str__(self):
        return self.key

3 ObtainAuthToken

class ObtainAuthToken(APIView):
    throttle_classes = ()
    permission_classes = ()
    parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,)
    renderer_classes = (renderers.JSONRenderer,)
    serializer_class = AuthTokenSerializer

    if coreapi_schema.is_enabled():
        schema = ManualSchema(
            fields=[
                coreapi.Field(
                    name="username",
                    required=True,
                    location='form',
                    schema=coreschema.String(
                        title="Username",
                        description="Valid username for authentication",
                    ),
                ),
                coreapi.Field(
                    name="password",
                    required=True,
                    location='form',
                    schema=coreschema.String(
                        title="Password",
                        description="Valid password for authentication",
                    ),
                ),
            ],
            encoding="application/json",
        )

    def get_serializer_context(self):
        return {
            'request': self.request,
            'format': self.format_kwarg,
            'view': self
        }

    def get_serializer(self, *args, **kwargs):
        kwargs['context'] = self.get_serializer_context()
        return self.serializer_class(*args, **kwargs)

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.validated_data['user']
        token, created = Token.objects.get_or_create(user=user)
        return Response({'token': token.key})


obtain_auth_token = ObtainAuthToken.as_view()


별첨

사실 프로젝트 진행하는 동안에는 이런 저런 외부 lib을 사용하는 것이 공부에 도움되기도 했기에 이에 대해 깊이 고민한 적은 없었다.

회사마다 다양한 컨벤션/규칙/개발 방향성이 존재하고,
현재 내가 프로젝트를 진행하고 있는 회사 또한 이러한 rule이 있다.
그 중 하나가 외부 lib 사용을 자제하는 것인데, 사실 이것 또한 생각해보면 유지/보수/확장과 연관되어 있는 문제였다는 것.

일단 첫 번째로는 용량의 문제다.
어쩌면 아주 일부만 사용하기 위해 그 lib을 통째로 갖고 와야하는 경우가 많은데, 그렇기엔 그 용량을 다 감당해야 해서 비효율적.

두 번째로는 추후 확장과 관련한 문제다.
외부 lib 사용을 남발하다 보면, 나중에 확장할 때 아주 어려워진다. build-up 이 복잡해지고 현재의 경우도 DRF에 이미 자체 token 기능이 있기 때문에 더더욱 외부 token 관련 lib을 갖고올 필요는 없는 것.

내가 처음simpleJWT를 고려했던 이유는 보안이 DRF token보다 좋다고 느꼈기 때문인데, 그것뿐 아니라 위의 사항들도 고려해봐야 한다.

이러한 detail들도 회사 와서 많이 배우고 있다.


참고 자료
https://sherryhsu.medium.com/session-vs-token-based-authentication-11a6c5ac45e4
https://stackoverflow.com/questions/34013299/web-api-authentication-basic-vs-bearer
https://github.com/django/django/tree/271a8e73ee382bb487d15e97ffaa675d78869413
https://github.com/encode/django-rest-framework

profile
예비 개발자의 기술 블로그 | explore, explore and explore

0개의 댓글