diff --git a/Django/app/settings.py b/Django/app/settings.py index ae67d23..719fbfb 100644 --- a/Django/app/settings.py +++ b/Django/app/settings.py @@ -208,3 +208,11 @@ "USE_SESSION_AUTH": False, # 세션 인증 비활성화 (JWT 사용 시) "JSON_EDITOR": True, # JSON 형식 편집기 활성화 (선택 사항) } + +# # 이메일 설정 (예: Gmail 사용) +# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +# EMAIL_HOST = 'smtp.gmail.com' +# EMAIL_PORT = 587 +# EMAIL_USE_TLS = True +# EMAIL_HOST_USER = 'your-email@gmail.com' +# EMAIL_HOST_PASSWORD = 'your-email-password' \ No newline at end of file diff --git a/Django/app_user/migrations/0003_app_user_email_verification_token_and_more.py b/Django/app_user/migrations/0003_app_user_email_verification_token_and_more.py new file mode 100644 index 0000000..11d21b4 --- /dev/null +++ b/Django/app_user/migrations/0003_app_user_email_verification_token_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.7 on 2024-08-02 03:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app_user", "0002_alter_app_user_options_alter_app_user_managers_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="app_user", + name="email_verification_token", + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name="app_user", + name="is_email_verified", + field=models.BooleanField(default=False), + ), + ] diff --git a/Django/app_user/models.py b/Django/app_user/models.py index 2db5336..4d1817d 100644 --- a/Django/app_user/models.py +++ b/Django/app_user/models.py @@ -9,6 +9,8 @@ from django.contrib.auth.models import PermissionsMixin from django.db import models from django.utils.translation import gettext_lazy as _ +from django.utils.crypto import get_random_string # added common user login dependency +# from django.utils import timezone # added common user login dependency from rest_framework_simplejwt.tokens import OutstandingToken @@ -72,14 +74,23 @@ class App_User(AbstractBaseUser, PermissionsMixin): updated_at = models.DateTimeField(auto_now=True) # 업데이트 시간 last_login_at = models.DateTimeField(null=True, blank=True) # 마지막 로그인 시간 is_active = models.BooleanField(default=True) # 계정 활성화 여부 - role = models.CharField( - max_length=10, choices=UserRole.choices, default=UserRole.USER - ) + role = models.CharField(max_length=10, choices=UserRole.choices, default=UserRole.USER) + is_email_verified = models.BooleanField(default=False) # added common user email verified + email_verification_token = models.CharField(max_length=100, blank=True) # added common user email token + email = models.EmailField(unique=True) is_staff = models.BooleanField(default=False) # 스태프 권한 date_joined = models.DateTimeField(auto_now_add=True) # 가입일 is_superuser = models.BooleanField(default=False) # 최고 관리자 권한 refresh_token = models.TextField(blank=True, null=True) # 리프레시 토큰 저장 필드 + USERNAME_FIELD = "user_id" # user_id를 사용자 이름으로 사용 + REQUIRED_FIELDS = [ + "name", + "nick_name", + "password_hash", + "username", + 'email', # added common login email + ] # 필수 필드 설정 objects = AppUserManager() # 커스텀 사용자 관리자 사용 USERNAME_FIELD = "user_id" # 로그인에 사용할 필드 @@ -115,6 +126,10 @@ def decrypt_refresh_token(self): def __str__(self): return self.user_id + # added common user generate email token + def generate_email_token(self): + self.email_verification_token = get_random_string(length=32) + self.save() # --- SimpleJWT 관련 --- class OutstandingToken(models.Model): diff --git a/Django/app_user/serializers.py b/Django/app_user/serializers.py index d88e207..d6ca494 100644 --- a/Django/app_user/serializers.py +++ b/Django/app_user/serializers.py @@ -1,5 +1,58 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model, authenticate +from django.contrib.auth.password_validation import validate_password + +User = get_user_model() + +class UserRegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, required=True, validators=[validate_password]) + password2 = serializers.CharField(write_only=True, required=True) + """ + added common user sign-up registration + """ + class Meta: + model = User + fields = ('email', 'username', 'nickname', 'password', 'password2') + + def validate(self, attrs): + if attrs['password'] != attrs['password2']: + raise serializers.ValidationError({"password": "Password fields didn't match."}) + return attrs + + def create(self, validated_data): + user = User.objects.create( + username=validated_data['username'], + email=validated_data['email'], + nickname=validated_data['nickname'] + ) + user.set_password(validated_data['password']) + user.save() + return user + +class UserLoginSerializer(serializers.Serializer): + username = serializers.CharField(required=False) + email = serializers.EmailField(required=False) + password = serializers.CharField() + """ + added common user sign-in + """ + def validate(self, data): + if not data.get('username') and not data.get('email'): + raise serializers.ValidationError("Must include either 'username' or 'email'.") + return data + +class PasswordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField() + +class PasswordResetConfirmSerializer(serializers.Serializer): + token = serializers.CharField() + new_password = serializers.CharField(write_only=True, validators=[validate_password]) + from rest_framework_simplejwt.serializers import TokenRefreshSerializer +""" +added common user password reset management +""" class CustomTokenObtainPairSerializer(TokenRefreshSerializer): """ @@ -11,3 +64,4 @@ def get_token(cls, user): token = super().get_token(user) token["user_id"] = str(user.id) return token + diff --git a/Django/app_user/urls.py b/Django/app_user/urls.py index bcea9f9..009b83a 100644 --- a/Django/app_user/urls.py +++ b/Django/app_user/urls.py @@ -1,6 +1,8 @@ from django.urls import include from django.urls import path from rest_framework.routers import DefaultRouter +from .views import (UserRegistrationView, UserLoginView, EmailVerificationView, + PasswordResetRequestView, PasswordResetConfirmView, UserLogoutView) # import added common user from .views import BlacklistTokenUpdateView from .views import CustomTokenObtainPairView @@ -16,6 +18,14 @@ urlpatterns = [ # 뷰셋 URL 포함 path("", include(router.urls)), + path('register/', UserRegistrationView.as_view(), name='user-register'), + # added common login user-register path + path('login/', UserLoginView.as_view(), name='user-login'), + # added common login user-login path + path('verify-email//', EmailVerificationView.as_view(), name='verify-email'), + path('password-reset/', PasswordResetRequestView.as_view(), name='password-reset-request'), + path('password-reset/confirm/', PasswordResetConfirmView.as_view(), name='password-reset-confirm'), + path('logout/', UserLogoutView.as_view(), name='user-logout'), # Google OAuth 로그인 관련 URL path( "google/login/", GoogleLogin.as_view(), name="google_login" diff --git a/Django/app_user/views.py b/Django/app_user/views.py index 8281993..20b8247 100644 --- a/Django/app_user/views.py +++ b/Django/app_user/views.py @@ -1,36 +1,43 @@ import os - import requests from app_user.models import App_User from django.conf import settings -from django.contrib.auth import authenticate -from django.contrib.auth import get_user_model -from django.contrib.auth import login +from django.contrib.auth import authenticate, login, get_user_model from django.db import transaction -from django.http import HttpResponse -from django.http import JsonResponse -from django.shortcuts import redirect -from django.views import View +from django.http import HttpResponse, JsonResponse +from django.shortcuts import redirect, render +from django.core.mail import send_mail +from django.utils.crypto import get_random_string + from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import status -from rest_framework import viewsets +from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.test import APIRequestFactory +from rest_framework.routers import DefaultRouter from rest_framework.views import APIView from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.serializers import TokenRefreshSerializer -from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework_simplejwt.views import TokenBlacklistView -from rest_framework_simplejwt.views import TokenObtainPairView -from rest_framework_simplejwt.views import TokenRefreshView +from rest_framework_simplejwt.views import TokenBlacklistView, TokenObtainPairView, TokenRefreshView + +from .serializers import (UserRegistrationSerializer, UserLoginSerializer, + PasswordResetRequestSerializer, PasswordResetConfirmSerializer, + CustomTokenObtainPairSerializer) + +""" +duplicate import code clean legibility + +- django.contrib.auth에서 authenticate, login, get_user_model을 한 줄로 정리 +- django.http에서 HttpResponse, JsonResponse를 한 줄로 정리 +- django.shortcuts에서 redirect, render를 한 줄로 정리 +- rest_framework에서 status, viewsets를 한 줄로 정리 +- rest_framework_simplejwt.tokens에서 RefreshToken을 중복 제거 -from .serializers import CustomTokenObtainPairSerializer +""" User = get_user_model() state = os.environ.get("STATE") @@ -485,22 +492,317 @@ class UserInfoView(APIView): ) def get(self, request): """ - 액세스 토큰으로 사용자 정보를 조회합니다. + 사용자 회원 가입 + """ + return Response({"message": "사용자 회원 가입 API (미구현)"}) - **요청:** - - GET 요청 - **응답:** - - 200 OK: 사용자 정보 반환 - - 401 Unauthorized: 인증되지 않은 사용자 +class UserAccountRecoveryViewSet(viewsets.GenericViewSet): + """ + 사용자 계정 복구 관련 API + """ + + @swagger_auto_schema( + tags=["Account Recovery"], + operation_summary="비밀번호 찾기", + operation_description="이메일을 통해 비밀번호 재설정 링크를 전송합니다.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "email": openapi.Schema( + type=openapi.TYPE_STRING, description="사용자 이메일" + ) + }, + ), + responses={ + 200: openapi.Response(description="비밀번호 재설정 링크 전송 성공"), + 404: openapi.Response( + description="해당 이메일로 가입된 사용자를 찾을 수 없음" + ), + 400: openapi.Response(description="요청 데이터가 유효하지 않을 경우"), + }, + ) + @action( + detail=False, methods=["post"], name="find_password", url_path="find_password" + ) + def find_password(self, request): + """ + 비밀번호 찾기 + """ + return Response({"message": "비밀번호 찾기 API (미구현)"}) + + @swagger_auto_schema( + tags=["Account Recovery"], + operation_summary="아이디 찾기", + operation_description="이름과 전화번호를 통해 아이디를 찾습니다.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "name": openapi.Schema( + type=openapi.TYPE_STRING, description="사용자 이름" + ), + "phone_number": openapi.Schema( + type=openapi.TYPE_STRING, description="전화번호" + ), + }, + ), + responses={ + 200: openapi.Response(description="아이디 찾기 성공"), + 404: openapi.Response( + description="해당 정보로 가입된 사용자를 찾을 수 없음" + ), + 400: openapi.Response(description="요청 데이터가 유효하지 않을 경우"), + }, + ) + @action(detail=False, methods=["post"], name="find_id", url_path="find_id") + def find_id(self, request): + """ + 아이디 찾기 + """ + return Response({"message": "아이디 찾기 API (미구현)"}) + + @swagger_auto_schema( + tags=["Account Recovery"], + operation_summary="비밀번호 재설정", + operation_description="새로운 비밀번호로 재설정합니다. (비밀번호 찾기를 통해 전달받은 토큰 필요)", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "token": openapi.Schema( + type=openapi.TYPE_STRING, description="비밀번호 재설정 토큰" + ), + "new_password": openapi.Schema( + type=openapi.TYPE_STRING, description="새로운 비밀번호" + ), + }, + ), + responses={ + 200: openapi.Response(description="비밀번호 재설정 성공"), + 400: openapi.Response( + description="요청 데이터가 유효하지 않거나 토큰이 유효하지 않을 경우" + ), + }, + ) + @action( + detail=False, methods=["post"], name="reset_password", url_path="reset_password" + ) + def reset_password(self, request): """ - user = request.user # JWTAuthentication을 통해 인증된 사용자 정보 가져오기 - - return Response( - { - "username": user.username, - "user_id": user.user_id, - "email": user.email, - "is_active": user.is_active, + 비밀번호 재설정 + """ + return Response({"message": "비밀번호 재설정 API (미구현)"}) + + +# added Common User code fix update swagger + +User = get_user_model() + + +class UserRegistrationView(APIView): + @swagger_auto_schema( + request_body=UserRegistrationSerializer, + responses={ + 201: openapi.Response('User registered successfully'), + 400: openapi.Response('Invalid data'), + }, + tags=["User"], + operation_summary="사용자 등록", + operation_description="사용자를 등록하고 이메일 확인을 위한 토큰을 발송합니다." + ) + def post(self, request): + serializer = UserRegistrationSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + user.generate_email_token() + + # Send verification email + subject = 'Verify your email' + message = f'Please click the link to verify your email: {user.email_verification_token}' + send_mail(subject, message, settings.EMAIL_HOST_USER, [user.email]) + + return Response({ + 'message': 'User registered successfully. Please check your email to verify your account.', + 'user_id': user.id, + 'username': user.username, + 'email': user.email, + }, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class UserLoginView(APIView): + @swagger_auto_schema( + request_body=UserLoginSerializer, + responses={ + 200: openapi.Response('User logged in successfully'), + 403: openapi.Response('Email not verified'), + 400: openapi.Response('Invalid data'), + }, + tags=["User"], + operation_summary="사용자 로그인", + operation_description="이메일 확인 후 사용자가 로그인할 수 있도록 합니다." + ) + def post(self, request): + serializer = UserLoginSerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + user = serializer.validated_data['user'] + if not user.is_email_verified: + return Response({'error': 'Please verify your email before logging in.'}, + status=status.HTTP_403_FORBIDDEN) + + refresh = RefreshToken.for_user(user) + return Response({ + 'refresh': str(refresh), + 'access': str(refresh.access_token), + 'user_id': user.id, + 'username': user.username, + 'email': user.email, + }) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class EmailVerificationView(APIView): + @swagger_auto_schema( + responses={ + 200: openapi.Response('Email verified successfully'), + 400: openapi.Response('Invalid token'), + }, + tags=["User"], + operation_summary="이메일 인증", + operation_description="이메일 인증 토큰을 사용하여 사용자의 이메일을 인증합니다." + ) + def get(self, request, token): + try: + user = User.objects.get(email_verification_token=token) + user.is_email_verified = True + user.email_verification_token = '' + user.save() + return Response({'message': 'Email verified successfully'}, status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST) + + +class UserLogoutView(APIView): + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'refresh_token': openapi.Schema(type=openapi.TYPE_STRING) } - ) + ), + responses={ + 200: openapi.Response('Successfully logged out'), + 400: openapi.Response('Invalid token'), + }, + tags=["User"], + operation_summary="사용자 로그아웃", + operation_description="사용자가 로그아웃합니다." + ) + def post(self, request): + try: + refresh_token = request.data["refresh_token"] + token = RefreshToken(refresh_token) + token.blacklist() + return Response({'message': 'Successfully logged out.'}, status=status.HTTP_200_OK) + except Exception as e: + return Response({'error': 'Invalid token.'}, status=status.HTTP_400_BAD_REQUEST) + + +class PasswordResetRequestView(APIView): + @swagger_auto_schema( + request_body=PasswordResetRequestSerializer, + responses={ + 200: openapi.Response('Password reset email sent'), + 404: openapi.Response('User with this email does not exist'), + 400: openapi.Response('Invalid data'), + }, + tags=["User"], + operation_summary="비밀번호 재설정 요청", + operation_description="사용자의 이메일로 비밀번호 재설정 토큰을 발송합니다." + ) + def post(self, request): + serializer = PasswordResetRequestSerializer(data=request.data) + if serializer.is_valid(): + email = serializer.validated_data['email'] + try: + user = User.objects.get(email=email) + token = get_random_string(length=32) + user.email_verification_token = token + user.save() + + # Send password reset email + subject = 'Password Reset' + message = f'Use this token to reset your password: {token}' + send_mail(subject, message, settings.EMAIL_HOST_USER, [email]) + + return Response({'message': 'Password reset email sent'}, status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response({'error': 'User with this email does not exist'}, status=status.HTTP_404_NOT_FOUND) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PasswordResetConfirmView(APIView): + @swagger_auto_schema( + request_body=PasswordResetConfirmSerializer, + responses={ + 200: openapi.Response('Password reset successfully'), + 400: openapi.Response('Invalid token'), + }, + tags=["User"], + operation_summary="비밀번호 재설정 확인", + operation_description="재설정 토큰을 사용하여 비밀번호를 변경합니다." + ) + def post(self, request): + serializer = PasswordResetConfirmSerializer(data=request.data) + if serializer.is_valid(): + token = serializer.validated_data['token'] + new_password = serializer.validated_data['new_password'] + try: + user = User.objects.get(email_verification_token=token) + user.set_password(new_password) + user.email_verification_token = '' + user.save() + return Response({'message': 'Password reset successfully'}, status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class UserRefrigeratorViewSet(viewsets.ViewSet): # ListModelMixin 제거 + """ + 유저 냉장고 목록 관련 API + """ + + @swagger_auto_schema( + tags=["Refrigerator-List"], + operation_summary="유저 냉장고 목록 불러오기", + operation_description="로그인한 유저의 냉장고 목록을 불러옵니다.", + responses={ + 200: openapi.Response( + description="냉장고 목록 조회 성공", + schema=openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "id": openapi.Schema( + type=openapi.TYPE_INTEGER, description="냉장고 ID" + ), + "name": openapi.Schema( + type=openapi.TYPE_STRING, description="냉장고 이름" + ), + # ... 필요한 냉장고 정보 필드 추가 ... + }, + ), + ), + ), + 401: openapi.Response(description="로그인이 필요합니다."), + }, + ) + @action(detail=False, methods=["get"], name="list_refrigerators", url_path="list") + def list_refrigerators(self, request): + """ + 유저 냉장고 목록 불러오기 + """ + return Response({"message": "유저 냉장고 목록 불러오기 API (미구현)"}) \ No newline at end of file