From 851017847cc66f39f8c7ebdae19d68177f22393b Mon Sep 17 00:00:00 2001 From: Data-Name-ID Date: Mon, 1 Apr 2024 17:31:33 +0300 Subject: [PATCH] [fix] auth --- backend/project/api/urls.py | 7 + backend/project/api/users/authentication.py | 51 ---- .../api/users/migrations/0001_initial.py | 239 ++++-------------- backend/project/api/users/models.py | 14 +- backend/project/api/users/serializers.py | 91 +++---- backend/project/api/users/urls.py | 17 +- backend/project/api/users/views.py | 130 +--------- backend/project/config/settings.py | 26 +- backend/project/config/urls.py | 7 - 9 files changed, 135 insertions(+), 447 deletions(-) delete mode 100644 backend/project/api/users/authentication.py diff --git a/backend/project/api/urls.py b/backend/project/api/urls.py index 9205054..11750c4 100644 --- a/backend/project/api/urls.py +++ b/backend/project/api/urls.py @@ -12,6 +12,13 @@ schema_view = get_schema_view( urlpatterns = [ path("ping", include("api.ping.urls")), path("auth", include("api.users.urls")), + path( + "", + include( + "rest_framework.urls", + namespace="rest_framework", + ), + ), # API documentation path( "swagger/", diff --git a/backend/project/api/users/authentication.py b/backend/project/api/users/authentication.py deleted file mode 100644 index 2caec7d..0000000 --- a/backend/project/api/users/authentication.py +++ /dev/null @@ -1,51 +0,0 @@ -import bcrypt -import jwt -from django.conf import settings -from rest_framework.authentication import ( - BaseAuthentication, -) -from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated -from rest_framework.permissions import IsAuthenticated - -from api.users.models import User - - -class JWTAuthentication(BaseAuthentication): - def authenticate_header(self, request): # noqa: ARG002 - return "Provide a valid token in the 'Authorization' header" - - def authenticate(self, request): - if IsAuthenticated not in getattr( - request.resolver_match.func.cls, "permission_classes", [] - ): - return None - - token = request.headers.get("Authorization", "").split("Bearer ")[-1] - - if not token: - raise NotAuthenticated - - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=["HS256"] - ) - - user = User.objects.get(id=payload["id"]) - - if not bcrypt.checkpw( - payload["password"].encode("utf-8"), - user.password.encode("utf-8"), - ): - error = "Token has expired" - raise AuthenticationFailed(error) - except User.DoesNotExist: - error = "Invalid token" - raise AuthenticationFailed(error) from None - except jwt.ExpiredSignatureError: - error = "Token has expired" - raise AuthenticationFailed(error) from None - except jwt.InvalidTokenError: - error = "Invalid token" - raise AuthenticationFailed(error) from None - else: - return (user, None) diff --git a/backend/project/api/users/migrations/0001_initial.py b/backend/project/api/users/migrations/0001_initial.py index c9ee641..f20c833 100644 --- a/backend/project/api/users/migrations/0001_initial.py +++ b/backend/project/api/users/migrations/0001_initial.py @@ -1,12 +1,12 @@ -# Generated by Django 4.2.11 on 2024-04-01 01:58 +# Generated by Django 5.0.3 on 2024-04-01 13:44 import api.users.models import django.contrib.auth.models import django.contrib.auth.validators import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): @@ -14,225 +14,72 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), + ('auth', '0012_alter_user_first_name_max_length'), ] operations = [ migrations.CreateModel( - name="Achievements", + name='Achievements', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "file", - models.FileField( - upload_to=api.users.models.Achievements.get_file_path - ), - ), - ("info", models.TextField(max_length=255)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=api.users.models.get_file_path)), + ('info', models.TextField(max_length=255)), ], ), migrations.CreateModel( - name="Skill", + name='Skill', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=255, unique=True)), - ( - "level", - models.IntegerField( - validators=[ - django.core.validators.MinValueValidator(1), - django.core.validators.MaxValueValidator(10), - ] - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('level', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])), ], options={ - "abstract": False, + 'abstract': False, }, ), migrations.CreateModel( - name="Specialization", + name='Specialization', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=255, unique=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), ], options={ - "abstract": False, + 'abstract': False, }, ), migrations.CreateModel( - name="User", + 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", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "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, unique=True)), - ("birthday", models.DateField(blank=True, null=True)), - ( - "avatar", - models.ImageField( - max_length=200, - null=True, - upload_to=api.users.models.User.get_file_path, - ), - ), - ("country", models.TextField(blank=True)), - ("city", models.TextField(blank=True)), - ( - "experience", - models.IntegerField( - null=True, - validators=[ - django.core.validators.MinValueValidator(0), - django.core.validators.MinValueValidator(100), - ], - ), - ), - ( - "bio", - models.TextField( - blank=True, - validators=[django.core.validators.MaxLengthValidator(512)], - ), - ), - ( - "achievements", - models.ManyToManyField(blank=True, to="users.achievements"), - ), - ( - "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", - ), - ), - ("skills", models.ManyToManyField(blank=True, to="users.skill")), - ( - "specialization", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="users.specialization", - ), - ), - ( - "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", - ), - ), + ('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')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('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, unique=True)), + ('birthday', models.DateField(blank=True, null=True)), + ('avatar', models.ImageField(max_length=200, null=True, upload_to=api.users.models.User.get_file_path)), + ('country', models.TextField(blank=True)), + ('city', models.TextField(blank=True)), + ('experience', models.IntegerField(null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MinValueValidator(100)])), + ('bio', models.TextField(blank=True, validators=[django.core.validators.MaxLengthValidator(512)])), + ('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')), + ('achievements', models.ManyToManyField(blank=True, to='users.achievements')), + ('skills', models.ManyToManyField(blank=True, to='users.skill')), + ('specialization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.specialization')), ], options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, }, managers=[ - ("objects", django.contrib.auth.models.UserManager()), + ('objects', django.contrib.auth.models.UserManager()), ], ), ] diff --git a/backend/project/api/users/models.py b/backend/project/api/users/models.py index 94ebb11..bcd496b 100644 --- a/backend/project/api/users/models.py +++ b/backend/project/api/users/models.py @@ -7,6 +7,10 @@ from django.db import models from api.core.models import AbstractTag +def get_file_path(filename): + return f"achievements/{uuid.uuid4()}/{filename}" + + class Skill(AbstractTag): level = models.IntegerField( validators=[ @@ -17,18 +21,12 @@ class Skill(AbstractTag): class Achievements(models.Model): - def get_file_path(self, filename): - folder_name = str(uuid.uuid4()) - return f"achievements/{folder_name}/{filename}" - - file = models.FileField( # noqa: DJ012 - upload_to=get_file_path, - ) + file = models.FileField(upload_to=get_file_path) info = models.TextField( max_length=255, ) - def __str__(self): # noqa: DJ012 + def __str__(self): return self.info diff --git a/backend/project/api/users/serializers.py b/backend/project/api/users/serializers.py index 64ff146..02c48f6 100644 --- a/backend/project/api/users/serializers.py +++ b/backend/project/api/users/serializers.py @@ -1,79 +1,60 @@ -from django.contrib.auth.password_validation import validate_password +from django.contrib.auth.hashers import check_password, make_password from rest_framework import serializers from api.users.models import User -class UserRegistrationSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = [ - "first_name", - "last_name", - "username", - "email", - "password", - "country", - "city", - ] - - def validate_password(self, value): - validate_password(value) - - return value - - -class UserLoginSerializer(serializers.Serializer): - remember_me = serializers.BooleanField(default=False, required=False) - username = serializers.CharField() - password = serializers.CharField() - - -class UserProfileSerializer(serializers.ModelSerializer): +class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ( - "first_name", - "last_name", - "username", "email", "birthday", + "avatar", "country", "city", "bio", - "avatar", "experience", + "password", + "first_name", + "last_name", "specialization", "achievements", + "username", "skills", ) + extra_kwargs = {"password": {"write_only": True}} + + def validate(self, attrs): + if User.objects.filter(username=attrs["username"]).exists(): + raise serializers.ValidationError( + {"username": "Username already exists"} + ) + return super().validate(attrs) + + def create(self, validated_data): + validated_data["password"] = make_password( + validated_data["password"], + ) + return super().create(validated_data) + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(write_only=True) + new_password = serializers.CharField(source="password", write_only=True) -class UpdateProfileSerializer(serializers.ModelSerializer): class Meta: model = User - fields = [ - "countryCode", - "isPublic", - "phone", - "image", - ] - - def to_representation(self, instance): - data = super().to_representation(instance) - if data["image"] is None: - del data["image"] - if data["phone"] is None: - del data["phone"] - return data - - -class PasswordChangeSerializer(serializers.Serializer): - # ruff: noqa: N815 - old_password = serializers.CharField(required=True) - new_password = serializers.CharField(required=True) - - def validate_password(self, value): - validate_password(value) + fields = ("old_password", "new_password") + def validate_old_password(self, value): + if not check_password(value, self.instance.password): + msg = "Wrong password" + raise serializers.ValidationError(msg) return value + + def update(self, instance, validated_data): + instance.set_password(validated_data["password"]) + instance.save() + return instance diff --git a/backend/project/api/users/urls.py b/backend/project/api/users/urls.py index f6bc0e8..75fbe1c 100644 --- a/backend/project/api/users/urls.py +++ b/backend/project/api/users/urls.py @@ -1,4 +1,9 @@ from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) import api.users.views @@ -10,9 +15,19 @@ urlpatterns = [ ), path( "/sign-in/", - api.users.views.SigninUserApiView.as_view(), + TokenObtainPairView.as_view(), name="sign-in", ), + path( + "api/token/refresh/", + TokenRefreshView.as_view(), + name="token_refresh", + ), + path( + "api/token/verify/", + TokenVerifyView.as_view(), + name="token_verify", + ), path( "/me/profile/", api.users.views.ProfileMeApiView.as_view(), diff --git a/backend/project/api/users/views.py b/backend/project/api/users/views.py index 7de57df..58ccc58 100644 --- a/backend/project/api/users/views.py +++ b/backend/project/api/users/views.py @@ -1,131 +1,27 @@ -from datetime import timedelta - -import bcrypt -import jwt -from django.conf import settings -from django.utils import timezone -from rest_framework import status -from rest_framework.exceptions import ( - NotAuthenticated, - PermissionDenied, - ValidationError, -) +from rest_framework.generics import CreateAPIView, UpdateAPIView from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView -from api.users.models import User from api.users.serializers import ( - PasswordChangeSerializer, - UpdateProfileSerializer, - UserLoginSerializer, - UserProfileSerializer, - UserRegistrationSerializer, + ChangePasswordSerializer, + UserSerializer, ) -class SignupUserApiView(APIView): - def post(self, request): - serializer = UserRegistrationSerializer(data=request.data) - - if serializer.is_valid(): - password = serializer.validated_data["password"] - password_hash = bcrypt.hashpw( - password.encode("utf-8"), bcrypt.gensalt() - ).decode("utf-8") - serializer.validated_data["password"] = password_hash - - serializer.save() - - return Response("ok", status=status.HTTP_201_CREATED) - - raise ValidationError(serializer.errors) +class SignupUserApiView(CreateAPIView): + http_method_names = ("post",) + serializer_class = UserSerializer -class SigninUserApiView(APIView): - def post(self, request): - serializer = UserLoginSerializer(data=request.data) - - if not serializer.is_valid(): - raise ValidationError(serializer.errors) - - username = serializer.validated_data.get("username") - password = serializer.validated_data.get("password") - - user = User.objects.filter(username=username).first() - - if user is not None: - if not bcrypt.checkpw( - password.encode("utf-8"), user.password.encode("utf-8") - ): - raise NotAuthenticated( - {"error": "Invalid credentials"}, - ) - else: - raise NotAuthenticated( - {"error": "Invalid credentials"}, - ) - - token = jwt.encode( - { - "id": user.id, - "password": password, - "exp": timezone.now() + timedelta(hours=24), - }, - settings.SECRET_KEY, - algorithm="HS256", - ) - - return Response({"token": token}) - - -class ProfileMeApiView(APIView): +class ProfileMeApiView(UpdateAPIView): permission_classes = [IsAuthenticated] - def get(self, request): - serializer = UserProfileSerializer(request.user) - return Response(serializer.data) - - def patch(self, request): - user = request.user - serializer = UpdateProfileSerializer( - user, data=request.data, partial=True - ) - if serializer.is_valid(): - errors = User.check_unique(user.id, serializer.validated_data) - if errors: - return Response( - {"reason:": str(errors)}, status=status.HTTP_409_CONFLICT - ) - serializer.save() - - return Response(self._get_profile_data(user)) - - raise ValidationError(serializer.errors) + def get_object(self): + return self.request.user -class PasswordChangeApiView(APIView): +class PasswordChangeApiView(UpdateAPIView): permission_classes = [IsAuthenticated] + serializer_class = ChangePasswordSerializer - def post(self, request): - serializer = PasswordChangeSerializer(data=request.data) - - if serializer.is_valid(): - old_password = serializer.validated_data.get("oldPassword") - new_password = serializer.validated_data.get("newPassword") - - if bcrypt.checkpw( - old_password.encode("utf-8"), - request.user.password.encode("utf-8"), - ): - password_hash = bcrypt.hashpw( - new_password.encode("utf-8"), bcrypt.gensalt() - ).decode("utf-8") - request.user.password = password_hash - request.user.save() - - return Response({"status": "ok"}, status=status.HTTP_200_OK) - - raise PermissionDenied({"error": "Invalid old password"}) - - raise ValidationError(serializer.errors) + def get_object(self): + return self.request.user diff --git a/backend/project/config/settings.py b/backend/project/config/settings.py index 35facb5..c4a0767 100755 --- a/backend/project/config/settings.py +++ b/backend/project/config/settings.py @@ -1,5 +1,6 @@ import pathlib import sys +from datetime import timedelta import environs @@ -54,7 +55,6 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", @@ -130,15 +130,12 @@ AUTH_PASSWORD_VALIDATORS = [ ] LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_TZ = True - USE_I18N = True -STATIC_URL = "static/" +USE_TZ = True +TIME_ZONE = "UTC" +STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "static" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" @@ -149,15 +146,20 @@ REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": [ "django_filters.rest_framework.DjangoFilterBackend" ], - "DEFAULT_AUTHENTICATION_CLASSES": ( - "api.users.authentication.JWTAuthentication", - ), + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + ], } -APPEND_SLASH = False - CORS_ORIGIN_ALLOW_ALL = True + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=7), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), +} + + if DEBUG and not (TESTING or MIGRATING): INSTALLED_APPS.append("debug_toolbar") MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") diff --git a/backend/project/config/urls.py b/backend/project/config/urls.py index 1e03ce5..0f70bf4 100755 --- a/backend/project/config/urls.py +++ b/backend/project/config/urls.py @@ -5,13 +5,6 @@ from django.urls import include, path urlpatterns = [ # Built-in urls path("admin/", admin.site.urls), - path( - "api-auth/", - include( - "rest_framework.urls", - namespace="rest_framework", - ), - ), # API path("api/", include("api.urls")), ]