From 9738e8de683da6154d839055701d2b890c5330b2 Mon Sep 17 00:00:00 2001 From: seongwon Date: Mon, 30 Oct 2023 18:15:33 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:Feat=20:=20Login=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20confirm=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20-=20login=20serialize?= =?UTF-8?q?r,=20view,=20url=20=EA=B5=AC=ED=98=84=20-=20token=20refresh=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20-=20JWT=20setting=20-=20user=20confirm?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 18 ++++++ users/serializers.py | 35 +++++++++- users/tests/views/test_confirm_user_view.py | 7 +- users/tests/views/test_login_view.py | 71 +++++++++++++++++++++ users/urls.py | 10 ++- users/views.py | 27 +++++++- 6 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 users/tests/views/test_login_view.py diff --git a/config/settings.py b/config/settings.py index 0970411..7f64160 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,6 +1,8 @@ from pathlib import Path import environ import os +from datetime import timedelta + BASE_DIR = Path(__file__).resolve().parent.parent @@ -185,3 +187,19 @@ } }, } + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": True, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": None, + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "JTI_CLAIM": "jti", +} diff --git a/users/serializers.py b/users/serializers.py index f664824..3bfeee1 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,5 +1,6 @@ -from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth import authenticate, get_user_model, password_validation from rest_framework import serializers +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from common.utils import get_random_string from users.models import UserConfirmCode @@ -52,3 +53,35 @@ def update(self, user, validated_data): user.is_confirmed = True user.save() return user + + +class UserLoginSerializer(serializers.Serializer): + username = serializers.CharField(required=True) + password = serializers.CharField(required=True, write_only=True) + token = serializers.SerializerMethodField(read_only=True) + + def get_token(self, user): + if user is not None: + refresh = TokenObtainPairSerializer.get_token(user) + refresh["username"] = user.username + data = { + "refresh": str(refresh), + "access": str(refresh.access_token), + } + return data + return None + + def validate(self, data): + user = authenticate(username=data["username"], password=data["password"]) + if user is None: + raise serializers.ValidationError("Username or Password is Incorrect") + if not user.is_confirmed: + raise serializers.ValidationError("User is not confirmed yet") + return data + + def create(self, validated_data): + user = authenticate(username=validated_data["username"], password=validated_data["password"]) + if user is not None: + user.is_active = True + user.save() + return user diff --git a/users/tests/views/test_confirm_user_view.py b/users/tests/views/test_confirm_user_view.py index 251ffa8..27ace61 100644 --- a/users/tests/views/test_confirm_user_view.py +++ b/users/tests/views/test_confirm_user_view.py @@ -51,7 +51,7 @@ def test_post_signup_fail_user_not_found(self): def test_post_signup_fail_invalid_password(self): response = self.client.post( - path=reverse("signup"), + path=reverse("confirm"), data=json.dumps( { "username": "testusername1", @@ -65,7 +65,7 @@ def test_post_signup_fail_invalid_password(self): def test_post_signup_fail_invalid_code(self): response = self.client.post( - path=reverse("signup"), + path=reverse("confirm"), data=json.dumps( { "username": "testusername1", @@ -79,8 +79,9 @@ def test_post_signup_fail_invalid_code(self): def test_post_signup_fail_already_confirmed(self): self.user.is_confirmed = True + self.user.save() response = self.client.post( - path=reverse("signup"), + path=reverse("confirm"), data=json.dumps( { "username": "testusername1", diff --git a/users/tests/views/test_login_view.py b/users/tests/views/test_login_view.py new file mode 100644 index 0000000..e046610 --- /dev/null +++ b/users/tests/views/test_login_view.py @@ -0,0 +1,71 @@ +import json + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from users.models import User + + +class SignupViewTest(APITestCase): + @classmethod + def setUpTestData(cls): + cls.confirmed_user = User.objects.create_user( + email="testuser1@example.com", username="testusername1", password="testpassword", is_confirmed=True + ) + cls.not_confirmed_user = User.objects.create_user( + email="testuser2@example.com", username="testusername2", password="testpassword", is_confirmed=False + ) + + def test_post_login_success(self): + response = self.client.post( + path=reverse("login"), + data=json.dumps( + { + "username": "testusername1", + "password": "testpassword", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.confirmed_user.is_active, True) + + def test_post_login_fail_user_not_found(self): + response = self.client.post( + path=reverse("login"), + data=json.dumps( + { + "username": "testusername3", + "password": "testpassword", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_post_login_fail_invalid_password(self): + response = self.client.post( + path=reverse("login"), + data=json.dumps( + { + "username": "testusername1", + "password": "testpassword2", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_post_login_fail_not_confirmed_user(self): + response = self.client.post( + path=reverse("login"), + data=json.dumps( + { + "username": "testusername2", + "password": "testpassword", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/users/urls.py b/users/urls.py index 4b70f0f..8e6b4ef 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,5 +1,11 @@ from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView -from users.views import ConfirmUserView, SignupView +from users.views import ConfirmUserView, LoginView, SignupView -urlpatterns = [path("signup/", SignupView.as_view(), name="signup"), path("confirm/", ConfirmUserView.as_view(), name="confirm")] +urlpatterns = [ + path("signup/", SignupView.as_view(), name="signup"), + path("confirm/", ConfirmUserView.as_view(), name="confirm"), + path("login/", LoginView.as_view(), name="login"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), +] diff --git a/users/views.py b/users/views.py index 70c9852..f84fc67 100644 --- a/users/views.py +++ b/users/views.py @@ -9,6 +9,7 @@ from users.serializers import ( UserConfirmCodeSerializer, UserConfirmSerializer, + UserLoginSerializer, UserSerializer, ) @@ -56,7 +57,6 @@ def post(self, request: Request) -> Response: Args: username: 이름 password: 비밀번호 - code: 인증 코드 Returns: username: 이름 is_confirmed: 인증 여부 @@ -71,3 +71,28 @@ def post(self, request: Request) -> Response: response_data["is_confirmed"] = user.is_confirmed return Response(response_data, status=status.HTTP_200_OK) + + +class LoginView(APIView): + @swagger_auto_schema( + operation_summary="유저 로그인", + request_body=UserLoginSerializer, + responses={ + status.HTTP_200_OK: UserLoginSerializer, + }, + ) + def post(self, request: Request) -> Response: + """ + username, paswword를 받아 유저 계정을 활성화하고 JWT 토큰을 발급합니다. + Args: + username: 이름 + password: 비밀번호 + Returns: + username: 이름 + token: access token과 refresh token + """ + serializer = UserLoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK)