diff --git a/accounts/managers.py b/accounts/managers.py new file mode 100644 index 0000000..89f185c --- /dev/null +++ b/accounts/managers.py @@ -0,0 +1,56 @@ +from django.contrib.auth.base_user import BaseUserManager +from django.utils.translation import gettext_lazy as _ +from datetime import date +import string +import random + +STRING_SEQUENCE = string.ascii_uppercase + string.digits # 새로운 인증 코드 생성 + +class CustomUserManager(BaseUserManager): + """ + Custom user model manager where email is the unique identifiers + for authentication instead of usernames. + """ + #일반 유저 생성 + def create_user(self,username, email, password, **extra_fields): + """ + Create and save a User with the given email and password. + """ + if not email: + raise ValueError(_('The Email must be set')) + if not username: + raise ValueError("username은 필수 영역입니다.") + #email 형태를 동일하게 만들기 위한 함수 + user = self.model( + username=username, + email=self.normalize_email(email), + **extra_fields) + + user.auth_code = self.create_auth_code() + user.set_password(password) + user.save(using=self._db) + return user + + @classmethod + def create_auth_code(self): + auth_code = "" + for _ in range(6): + auth_code += random.choice(STRING_SEQUENCE) + return auth_code + + + #관리자 유저 생성 + def create_superuser(self,username, email, password, **extra_fields): + """ + Create and save a SuperUser with the given email and password. + """ + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('is_active', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError(_('Superuser must have is_staff=True.')) + if extra_fields.get('is_superuser') is not True: + raise ValueError(_('Superuser must have is_superuser=True.')) + + return self.create_user(username, email, password, **extra_fields) \ No newline at end of file diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..4c6d6df --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.6 on 2023-10-27 17:52 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, verbose_name='email address')), + ('username', models.CharField(max_length=30, unique=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('auth_code', models.CharField(blank=True, max_length=6, null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index ad7c5b8..957ff13 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,44 +1,26 @@ -import string -import random - from django.db import models -from django.contrib.auth.models import AbstractUser, BaseUserManager - - -STRING_SEQUENCE = string.ascii_uppercase + string.digits - - -class UserManager(BaseUserManager): - def create_user(self, username, password=None, **extra_fields): - if not username: - raise ValueError("username은 필수 영역입니다.") - user = self.model(username=username, **extra_fields) - user.set_password(password) - user.auth_code = UserManager.create_auth_code() - user.save(using=self._db) - return user - - def create_superuser(self, username, password=None, **extra_fields): - extra_fields.setdefault("is_staff", True) - extra_fields.setdefault("is_superuser", True) - return self.create_user(username, password, **extra_fields) - - @classmethod - def create_auth_code(cls): - auth_code = "" - for _ in range(6): - auth_code += random.choice(STRING_SEQUENCE) - - return auth_code +from django.contrib.auth.models import AbstractUser +from .managers import CustomUserManager class User(AbstractUser): - objects = UserManager() - auth_code = models.CharField(null=True, blank=True) + objects = CustomUserManager() + + email = models.EmailField(verbose_name='email address') + username = models.CharField(max_length=30, unique=True) + updated_at = models.DateTimeField(auto_now=True) + auth_code = models.CharField(max_length=6, null=True, blank=True) + + ##user model에서 각 row를 식별해줄 key를 설정 USERNAME_FIELD = 'username' REQUIRED_FIELDS = [] + #파이썬에서 어떤값(또는 객체)을 문자열로 변환하는데 사용하는 str() + #내장 함수가 아닌 파이썬 내장 클래스 + def __str__(self): + return self.email + def get_hashtag(self): return f"#{self.username}" diff --git a/accounts/serializers.py b/accounts/serializers.py index e69de29..d541c92 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -0,0 +1,20 @@ +from django.contrib.auth import get_user_model +from dj_rest_auth.registration.serializers import RegisterSerializer +from rest_framework import serializers +from dj_rest_auth.serializers import LoginSerializer +##연습용 Serializer +from rest_framework.serializers import ModelSerializer +from datetime import datetime + +class CustomRegisterSerializer(RegisterSerializer): + class Meta: + model = get_user_model() + fields = [ + "username", + "email", + "password", + ] + +class CustomLoginSerializer(LoginSerializer): + # email 필드를 제거 + email = None diff --git a/accounts/urls.py b/accounts/urls.py index 8f08775..83ccddc 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,9 +1,12 @@ -from django.urls import path -from accounts import views +from django.urls import include, path +from . import views +from .views import CustomLoginView -app_name = "accounts" +app_name = "auth" # base_url: v1/accounts/ urlpatterns = [ - # + path('login/', CustomLoginView.as_view(), name='custom-login'), + path('', include('dj_rest_auth.urls'), name='dj_rest_auth'), + path('registration/', include('dj_rest_auth.registration.urls'), name='registration'), ] \ No newline at end of file diff --git a/accounts/validators.py b/accounts/validators.py new file mode 100644 index 0000000..55fa43a --- /dev/null +++ b/accounts/validators.py @@ -0,0 +1,32 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + + +# 숫자, 문자, 특수문자 중 2가지 이상을 포함하는지 확인 +class CharacterClassesValidator: + def validate(self, password, user=None): + character_classes = 0 + if any(char.isdigit() for char in password): + character_classes += 1 + if any(char.isalpha() for char in password): + character_classes += 1 + if not character_classes >= 2: + raise ValidationError( + _("비밀번호에 숫자, 문자, 특수문자 중 2가지 이상을 포함해야합니다"), + code="password_classes_not_met", + ) + +# 3회 이상 연속되는 문자 사용을 방지 +class NoConsecutiveCharactersValidator: + def validate(self, password, user=None): + consecutive_count = 0 + for i in range(1, len(password)): + if ord(password[i]) == ord(password[i - 1]): + consecutive_count += 1 + if consecutive_count >= 3: + raise ValidationError( + _("비밀번호에 2회 이상 연속 되는 문자는 사용이 불가 합니다."), + code="consecutive_characters", + ) + else: + consecutive_count = 0 \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index 91ea44a..e6c07da 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,11 @@ -from django.shortcuts import render +from dj_rest_auth.views import LoginView +from rest_framework.response import Response +from rest_framework import status +from .serializers import CustomLoginSerializer # 커스텀 시리얼라이저를 가져옴 -# Create your views here. +class CustomLoginView(LoginView): + serializer_class = CustomLoginSerializer # 커스텀 시리얼라이저를 사용 + + def post(self, request, *args, **kwargs): + # 로그인 로직을 그대로 유지 + return super(CustomLoginView, self).post(request, *args, **kwargs) \ No newline at end of file diff --git a/config/root_urls.py b/config/root_urls.py index be77853..3f727d9 100644 --- a/config/root_urls.py +++ b/config/root_urls.py @@ -24,6 +24,6 @@ # [end-point] path('admin/', admin.site.urls), - path('v1/accounts/', include('accounts.urls')), + path('v1/auth/', include('accounts.urls')), path('v1/posts/', include('posts.urls')), ] \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 4918bca..b5cc2da 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,7 +1,7 @@ from pathlib import Path import os import environ - +from datetime import timedelta BASE_DIR = Path(__file__).resolve().parent.parent @@ -30,6 +30,18 @@ THIRD_PARTY_APPS = [ # [Django-Rest-Framework] "rest_framework", + 'rest_framework.authtoken', + 'rest_framework_simplejwt.token_blacklist', + + # django-rest-auth + 'dj_rest_auth', + 'dj_rest_auth.registration', + + #django-allauth + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + "corsheaders", # CORS "drf_yasg", # swagger ] @@ -41,6 +53,65 @@ INSTALLED_APPS = SYSTEM_APPS + THIRD_PARTY_APPS + CUSTOM_APPS +AUTH_USER_MODEL = 'accounts.User' + +#dj-rest-auth 관련 환경 설정 +REST_AUTH = { + #jwt-token 관련 + # jwt 인증 방식을 사용할지 여부 + 'USE_JWT': True, + # JWT_AUTH_HTTPONLY : 쿠키를 http only로 할 것인지 여부 (default == True) + # 위 설정을 refresh token을 보안상의 이유로 http only 쿠키를 설정할 필요가 있다, refresh_token을 cookie로 전달 + + # refresh token을 담은 쿠키 이름 + 'JWT_AUTH_REFRESH_COOKIE': "refresh_token", + #jwt쿠키 csrf 검사 + 'JWT_AUTH_COOKIE_USE_CSRF' : True, + #세션 로그인 기능 (default == True), 세션 로그인을 False로 하지 않으면 sessionid가 쿠키로 남기 때문에 지워주었다. + 'SESSION_LOGIN' : False, + 'JWT_AUTH_HTTPONLY':False, + #custom한 serializer로 변경 + 'REGISTER_SERIALIZER': 'accounts.serializers.CustomRegisterSerializer', + + +} +#simple JWT 환경 설정 +SIMPLE_JWT = { + 'JWT_SECRET_KEY': SECRET_KEY, # JWT 에 서명하는데 사용되는 시크릿키. 장고의 시크릿키가 디폴트. + 'JWT_ALGORITHM': 'HS256', # PyJWT 에서 암호화 서명에 지원되는 알고리즘으로 마찬가지로 이것 또한 기본값. + 'JWT_VERIFY_EXPIRATION' : True, # 토큰 만료 시간 확인. 기본값 True. + + + 'JWT_ALLOW_REFRESH': True, # 토큰 새로고침 기능 활성화. 기본값 False. + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, +} + +# rest_framework에서의 permission과 authentication +REST_FRAMEWORK = { + + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + # "rest_framework.authentication.SessionAuthentication", 지우면 API 엔드포인트에서 로그인이 안되니 주의하자!! + #'rest_framework.authentication.SessionAuthentication', + 'DEFAULT_AUTHENTICATION_CLASSES': ( + [ 'rest_framework.authentication.SessionAuthentication', + 'dj_rest_auth.jwt_auth.JWTCookieAuthentication' ] + ), + + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' +} + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +SITE_ID = 1 + +ACCOUNT_USERNAME_REQUIRED = True +ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_EMAIL_VERIFICATION = 'none' + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -49,6 +120,10 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + + 'allauth.account.middleware.AccountMiddleware', + ] ROOT_URLCONF = 'config.root_urls' @@ -78,17 +153,18 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', - # 'NAME': env("DB_NAME"), - # 'USER': env("DB_USER"), - # 'PASSWORD': env("DB_PASSWORD"), - # 'HOST': env("DB_HOST"), - # 'PORT': env("DB_PORT"), - 'NAME': 'mydb', - 'USER': 'root', - 'PASSWORD': 'rootpassword', - 'HOST': 'mysql', # Docker Compose 서비스 이름 - 'PORT': 3306, + 'NAME': env("DB_NAME"), + 'USER': env("DB_USER"), + 'PASSWORD': env("DB_PASSWORD"), + 'HOST': env("DB_HOST"), + 'PORT': env("DB_PORT"), + # 'NAME': 'mydb', + # 'USER': 'root', + # 'PASSWORD': 'rootpassword', + # 'HOST': 'mysql', # Docker Compose 서비스 이름 + # 'PORT': 3306, }, + #개인 mysql과 연결 # 'test': { # 'ENGINE': 'django.db.backends.mysql', # 'NAME': env("TEST_DB_NAME"), @@ -112,6 +188,12 @@ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 10, # 원하는 최소 길이로 변경 + }, + }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, @@ -121,6 +203,12 @@ { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, + { + 'NAME': 'validators.CharacterClassesValidator', + }, + { + 'NAME': 'validators.NoConsecutiveCharactersValidator', + }, ] diff --git a/posts/migrations/0001_initial.py b/posts/migrations/0001_initial.py new file mode 100644 index 0000000..39f4d3c --- /dev/null +++ b/posts/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.6 on 2023-10-27 17:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import posts.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Posts', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_id', models.CharField(max_length=100)), + ('type', models.CharField(choices=[(posts.models.SNSType['FACEBOOK'], 'facebook'), (posts.models.SNSType['INSTAGRAM'], 'instagram'), (posts.models.SNSType['THREAD'], 'thread'), (posts.models.SNSType['TWITTER'], 'twitter')], max_length=50)), + ('title', models.CharField(max_length=100)), + ('content', models.TextField(max_length=1000)), + ('view_count', models.PositiveIntegerField(default=0)), + ('like_count', models.PositiveIntegerField(default=0)), + ('share_count', models.PositiveIntegerField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='HashTags', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='posts.posts')), + ], + ), + ] diff --git a/posts/models.py b/posts/models.py index cce0e16..7110f67 100644 --- a/posts/models.py +++ b/posts/models.py @@ -22,11 +22,11 @@ class Posts(models.Model): share_count = models.PositiveIntegerField(default=0) updated_at = models.DateTimeField(auto_now=True) created_at = models.DateTimeField(auto_now_add=True) - account = models.ForeignKey(User) + account = models.ForeignKey(User,on_delete=models.CASCADE) class HashTags(models.Model): name = models.CharField(max_length=50) updated_at = models.DateTimeField(auto_now=True) created_at = models.DateTimeField(auto_now_add=True) - post = models.ForeignKey(Posts) + post = models.ForeignKey(Posts, on_delete=models.CASCADE)