Pure Django로 JWT인증 구현하기 (2)

이전 포스트에서는 Django에서 JWT인증을 위한 미들웨어 작성법을 알아보았다.
이번에는 middleware를 사용하기 위한 인증 토큰을 발행하는 과정을 다루어보겠다.
기본적으로 /login/signup 두 가지의 path에서 토큰을 발행된다.

URL 설정하기

인증과 관련된 로직을 처리하기 위한 user앱을 새로만들었다.
그 후 앞서 얘기했던 것과 같이 로그인과 회원가입에 필요한 두 URL을 설정한다.
아직 구현하진 않았지만 호출할 view로 login_viewsignup_view를 적어주었다.

  • user앱의 urls.py
from django.urls import path
from user.views import login_view, signup_view

app_name = "user"

urlpatterns = [
    path("login", login_view, name="login_view"),    path("signup", signup_view, name="signup_view"),]

프로젝트 폴더의 urls.py를 열어 작성한 user앱의 urls.py를 추가해주었다.

  • 프로젝트의 urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("", include("todo.urls", namespace="todo")),
    path("", include("user.urls", namespace="user")),    path("admin/", admin.site.urls),
]

URL의 prefix가 모두 ""이기 때문에 각각 /login, /signup으로 연결된다.

View구현을 위한 모듈 추가하기

상당히 많은 모듈이 추가되었다.
기본적으로 요청에 대한 반환예외처리, JWT인증토큰 생성을 위한 모듈이다.
자세한 내용은 모듈을 사용하면서 설명을 하겠다.

from django.http import JsonResponse, HttpResponseNotAllowed
from http import HTTPStatus
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.validators import ASCIIUsernameValidator
from user.utils.jwt import encode_jwt
from datetime import datetime, timedelta
from json import loads

JWT 토큰 생성 함수 구현하기

토큰 생성은 utils에 만들었던 encode_jwt함수를 통해서 진행한다.
토큰에 들어가는 데이터에 관해 설명하면 아래와 같다.

  • iat : 토큰 발행일
  • exp : 토큰 만료일 (토큰 발행일 + 7일)
  • aud : 토큰 발급자 (토큰을 발급한 사용자)
  • iss : 토근 발행자 (토큰을 발행한 주체)
def generate_access_token(username):
    iat = datetime.now()
    exp = iat + timedelta(days=7)

    data = {
        "iat": iat.timestamp(),
        "exp": exp.timestamp(),
        "aud": username,
        "iss": "Redux Todo Web Backend",
    }

    return encode_jwt(data)

이렇게 발행된 JWT토큰을 로그인, 회원가입에 성공하면 반환해준다.

로그인 View 구현하기

1. 함수 선언 및 틀 작성하기

csrf와 관련된 인증은 사용하지 않을 것이기 때문에
decorator를 이용해 csrf인증을 사용하지 않음을 명시해주었다.
기본적인 반환은 JsonResponse로 이루어지며 초기의 반환 데이터는
비어있는 딕셔너리 {}와 상태 코드 200 OK다.

@csrf_exempt
def login_view(request):
    data = {}
    status = HTTPStatus.OK

    return JsonResponse(data, status=status)

2. 예외 처리 구문 추가하기

try-except구문을 이용해 잘못된 요청이 왔을 경우 예외처리를 추가한다.

  • ValueError : 사용자의 입력값이 잘못되었을 경우
  • User.DoesNotExist : 존재하지 않는 사용자일 경우

ValueErrorUser.DoesNotExist예외가 발생할 경우 400 BAD REQUEST
dataerror라는 key로 에러메세지를 담아 반환한다.

@csrf_exempt
def login_view(request):
    data = {}
    status = HTTPStatus.OK

    try:        pass    except (ValueError, User.DoesNotExist):        data["error"] = "Invalid form. Please fill it out again."        status = HTTPStatus.BAD_REQUEST
    return JsonResponse(data, status=status)

3. 허용되는 HTTP Method만 처리하기

login기능은 POST메서드만을 사용해 처리가능하도록 구현하였다.
간단하게 requestmethod필드를 확인해 "POST"가 아닌 경우에는
405 Method Not AllowedHttpResponseNotAllowed를 반환하도록 해주었다.

@csrf_exempt
def login_view(request):
    data = {}
    status = HTTPStatus.OK

    try:
        if request.method == "POST":            pass        else:            return HttpResponseNotAllowed(["POST"])
    except (ValueError, User.DoesNotExist):
        data["error"] = "Invalid form. Please fill it out again."
        status = HTTPStatus.BAD_REQUEST

    return JsonResponse(data, status=status)

4. request 데이터 가져오기

requestbody값을 가져와 jsondict형태로 변환해준다.
그 후 "user""password"값을 가져와 하나라도 없으면 ValueError를 발생시킨다.

@csrf_exempt
def login_view(request):
    data = {}
    status = HTTPStatus.OK

    try:
        if request.method == "POST":
            json_body = loads(request.body)            username = json_body.get("user", None)            password = json_body.get("password", None)            if not username or not password:                raise ValueError()
        else:
            return HttpResponseNotAllowed(["POST"])

    except (ValueError, User.DoesNotExist):
        data["error"] = "Invalid form. Please fill it out again."
        status = HTTPStatus.BAD_REQUEST

    return JsonResponse(data, status=status)

5. 가져온 데이터로 사용자 확인하기

User모델을 사용해 username값을 갖는 사용자가 있는지 확인한다.
여기서 해당 사용자가 없을 경우 User.DoesNotExist예외가 발생한다.
해당 username을 갖는 사용자가 있을 경우 user객체의 check_password를 이용해
해당 사용자의 비밀번호가 유효한 비밀번호인지 판단하게 된다.
요청을 받은 비밀번호가 잘못된 비밀번호 일 경우 ValueError예외가 발생한다.

@csrf_exempt
def login_view(request):
    data = {}
    status = HTTPStatus.OK

    try:
        if request.method == "POST":
            json_body = loads(request.body)

            username = json_body.get("user", None)
            password = json_body.get("password", None)

            if not username or not password:
                raise ValueError()

            user = User.objects.get(username=username)            if not user.check_password(password):                raise ValueError()
        else:
            return HttpResponseNotAllowed(["POST"])

    except (ValueError, User.DoesNotExist):
        data["error"] = "Invalid form. Please fill it out again."
        status = HTTPStatus.BAD_REQUEST

    return JsonResponse(data, status=status)

6. JWT 토큰 발행 및 응답하기

5번의 과정까지 모두 정상적으로 통과했다면 유효한 사용자임을 확인한 것이다.
generate_access_token함수에 username을 전달해 토큰을 생성하였다.
그 후 JsonResponse의 데이터로 보낼 data 딕셔너리에 access_token이라는
key으로 JWT 토큰을 넣어주고 사용자 이름을 user라는 key로 넣어준다.

@csrf_exempt
def login_view(request):
    data = {}
    status = HTTPStatus.OK

    try:
        if request.method == "POST":
            json_body = loads(request.body)

            username = json_body.get("user", None)
            password = json_body.get("password", None)

            if not username or not password:
                raise ValueError()

            user = User.objects.get(username=username)

            if not user.check_password(password):
                raise ValueError()

            data["access_token"] = generate_access_token(username)            data["user"] = username
        else:
            return HttpResponseNotAllowed(["POST"])

    except (ValueError, User.DoesNotExist):
        data["error"] = "Invalid form. Please fill it out again."
        status = HTTPStatus.BAD_REQUEST

    return JsonResponse(data, status=status)

View 작동 테스트

로그인 기능 작동 확인하기

Postman을 이용해 작성한 View가 잘 동작하는지 확인해보겠다.
테스트를 위해 usernamelogin_testpassword1234로 계정을 생성했다.

1

이제 Postman을 이용해 요청을 보내보도록 하겠다.

2

위와 같이 로그인을 할 데이터를 body에 담아보냈다.

3

access_tokenuser데이터가 잘 반환되는 것을 확인할 수 있다.

예외 처리 확인하기

존재하지 않는 사용자를 넣어 예외 처리가 잘 되는지 확인해보자.

4

존재하지 않는 사용자로 요청시 400 BAD REQUEST가 반환되는 것을 볼 수 있다.

허용되지 않는 메서드를 사용해 요청을 보내보자.

5

PUT로 요청을 보내니 405 METHOD NOT ALLOWED가 반환되는 것을 볼 수 있다.

마무리

그렇게 길지 않은 코드로 JWT 토큰을 발행하는 로그인 기능을 구현하였다.
다음 포스트에서는 JWT토큰을 발행하는 회원가입을 구현해보도록 하겠다.
추가적으로는 발행한 토큰을 이용해 데이터를 반환하는 과정또한 다루어 보도록하겠다.
실제 작성된 코드는 여기에서 확인할 수 있다.

질문과 오타, 문제점 제보는 환영입니다.


Written by@Minsu Kim
Software Engineer at KakaoPay Corp.