diff --git a/common/utils.py b/common/utils.py index 92ffe28..11a61c9 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,3 +1,6 @@ +import random +import string + from django.utils import timezone from rest_framework.request import Request @@ -55,3 +58,7 @@ def get_now() -> timezone: def get_before_week() -> timezone: return (timezone.now() - timezone.timedelta(days=7)).strftime("%Y-%m-%d") + + +def get_random_string(length=6) -> string: + return "".join(random.choice(string.ascii_letters + string.digits) for i in range(length)) 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 d7bef42..3bfeee1 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,9 +1,8 @@ -import random -import string - -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 @@ -26,7 +25,7 @@ class UserConfirmCodeSerializer(UserSerializer): def create(self, validated_data): user = super().create(validated_data) - confirm_code = "".join(random.choice(string.ascii_letters + string.digits) for i in range(6)) + confirm_code = get_random_string() user_confirm_code = UserConfirmCode.objects.create(code=confirm_code, user=user) return user_confirm_code @@ -54,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 46c04ec..f84fc67 100644 --- a/users/views.py +++ b/users/views.py @@ -9,6 +9,7 @@ from users.serializers import ( UserConfirmCodeSerializer, UserConfirmSerializer, + UserLoginSerializer, UserSerializer, ) @@ -33,9 +34,9 @@ def post(self, request: Request) -> Response: username: 생성된 계정 이름 code: 생성된 인증 코드 """ - user_confirm_code_serializer = UserConfirmCodeSerializer(data=request.data) - user_confirm_code_serializer.is_valid(raise_exception=True) - user_confirm_code = user_confirm_code_serializer.save() + serializer = UserConfirmCodeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user_confirm_code = serializer.save() response_data = UserSerializer(user_confirm_code.user).data response_data["confirm_code"] = user_confirm_code.code @@ -56,18 +57,42 @@ def post(self, request: Request) -> Response: Args: username: 이름 password: 비밀번호 - code: 인증 코드 Returns: username: 이름 is_confirmed: 인증 여부 """ user = get_object_or_404(get_user_model(), username=request.data["username"]) - user_confirm_serializer = UserConfirmSerializer(user, data=request.data) - user_confirm_serializer.is_valid(raise_exception=True) - user_confirm_serializer.save() + serializer = UserConfirmSerializer(user, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() response_data = {} response_data["username"] = user.username 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)