From 81d844bb105c6f54cbe0fe19627cb064407e1679 Mon Sep 17 00:00:00 2001 From: Chris Li <c58li@uwaterloo.ca> Date: Thu, 19 Jan 2023 00:28:14 -0500 Subject: [PATCH] Add basic auth models and views. --- core/api/auth.py | 35 ++++++++++++++++++++ core/api/password.py | 42 ++++++++++++++++++++++++ core/apps.py | 3 ++ core/models/profile.py | 24 ++++++++++++++ core/models/utils.py | 9 ++++++ core/serializers/__init__.py | 0 core/serializers/login.py | 13 ++++++++ core/serializers/password.py | 12 +++++++ core/serializers/profile.py | 9 ++++++ core/serializers/register.py | 17 ++++++++++ core/serializers/user.py | 8 +++++ core/signals.py | 51 ++++++++++++++++++++++++++++++ core/urls.py | 12 +++++-- docs/api.md | 38 ++++++++++++++++++++++ ece651_backend/settings.py | 27 ++++++++++++++-- ece651_backend/urls.py | 28 +++++++--------- requirements.txt | 11 +++++-- templates/user_reset_password.html | 12 +++++++ templates/user_reset_password.txt | 1 + 19 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 core/api/auth.py create mode 100644 core/api/password.py create mode 100644 core/models/profile.py create mode 100644 core/models/utils.py create mode 100644 core/serializers/__init__.py create mode 100644 core/serializers/login.py create mode 100644 core/serializers/password.py create mode 100644 core/serializers/profile.py create mode 100644 core/serializers/register.py create mode 100644 core/serializers/user.py create mode 100644 core/signals.py create mode 100644 docs/api.md create mode 100644 templates/user_reset_password.html create mode 100644 templates/user_reset_password.txt diff --git a/core/api/auth.py b/core/api/auth.py new file mode 100644 index 0000000..4526686 --- /dev/null +++ b/core/api/auth.py @@ -0,0 +1,35 @@ +from rest_framework import generics +from rest_framework.response import Response +from knox.models import AuthToken + +from core.serializers.login import LoginSerializer +from core.serializers.register import RegisterSerializer +from core.serializers.user import UserSerializer + + +class RegisterAPI(generics.GenericAPIView): + serializer_class = RegisterSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + token = AuthToken.objects.create(user) + return Response({ + "user": UserSerializer(user, context=self.get_serializer_context()).data, + "token": token[1] + }) + + +class LoginAPI(generics.GenericAPIView): + serializer_class = LoginSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data + token = AuthToken.objects.create(user) + return Response({ + "user": UserSerializer(user, context=self.get_serializer_context()).data, + "token": token[1], + }) diff --git a/core/api/password.py b/core/api/password.py new file mode 100644 index 0000000..47b8ee6 --- /dev/null +++ b/core/api/password.py @@ -0,0 +1,42 @@ +from rest_framework import status +from rest_framework import generics +from rest_framework.response import Response +from django.contrib.auth.models import User +from core.serializers.password import ChangePasswordSerializer +from rest_framework.permissions import IsAuthenticated + +class ChangePasswordView(generics.UpdateAPIView): + """ + An endpoint for changing password. + """ + serializer_class = ChangePasswordSerializer + model = User + permission_classes = (IsAuthenticated,) + + def get_object(self, queryset=None): + obj = self.request.user + return obj + + def update(self, request, *args, **kwargs): + self.object = self.get_object() + serializer = self.get_serializer(data=request.data) + + if serializer.is_valid(): + # Check old password + if not self.object.check_password(serializer.data.get("old_password")): + return Response({ + "old_password": ["Wrong password."] + }, status=status.HTTP_400_BAD_REQUEST) + # set_password also hashes the password that the user will get + self.object.set_password(serializer.data.get("new_password")) + self.object.save() + response = { + 'status': 'success', + 'code': status.HTTP_200_OK, + 'message': 'Password updated successfully', + 'data': [] + } + + return Response(response) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/core/apps.py b/core/apps.py index 8115ae6..df7e09f 100644 --- a/core/apps.py +++ b/core/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class CoreConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core' + + def ready(self) -> None: + import core.signals diff --git a/core/models/profile.py b/core/models/profile.py new file mode 100644 index 0000000..2d56ab1 --- /dev/null +++ b/core/models/profile.py @@ -0,0 +1,24 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from django.contrib.auth.models import User +from django.db import models +from django_countries.fields import CountryField +from django_extensions.db.models import TimeStampedModel + +from .utils import UUIDModel + + +class Profile(TimeStampedModel, UUIDModel): + user = models.OneToOneField(User, on_delete=models.CASCADE) + bio = models.TextField(blank=True) + birthday = models.DateField(verbose_name="Birthday", null=True) + country = CountryField(null=True, verbose_name="Country") + city = models.CharField(max_length=100, verbose_name="City in English") + affiliation = models.CharField( + max_length=100, verbose_name="Name of your organization in English", + ) + photo = models.ImageField(upload_to='media/', max_length=100000, null=True, blank=True, default="https://static.productionready.io/images/smiley-cyrus.jpg") + + def __str__(self): + return self.user.username diff --git a/core/models/utils.py b/core/models/utils.py new file mode 100644 index 0000000..c4d01da --- /dev/null +++ b/core/models/utils.py @@ -0,0 +1,9 @@ +import uuid +from django.db import models + + +class UUIDModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + + class Meta: + abstract = True diff --git a/core/serializers/__init__.py b/core/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/serializers/login.py b/core/serializers/login.py new file mode 100644 index 0000000..0c753e3 --- /dev/null +++ b/core/serializers/login.py @@ -0,0 +1,13 @@ +from django.contrib.auth import authenticate +from rest_framework import serializers + + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField() + + def validate(self, data): + user = authenticate(**data) + if user and user.is_active: + return user + raise serializers.ValidationError('Incorrect Credentials Passed.') diff --git a/core/serializers/password.py b/core/serializers/password.py new file mode 100644 index 0000000..4b8748a --- /dev/null +++ b/core/serializers/password.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from django.contrib.auth.models import User + + +class ChangePasswordSerializer(serializers.Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True) diff --git a/core/serializers/profile.py b/core/serializers/profile.py new file mode 100644 index 0000000..e35bd29 --- /dev/null +++ b/core/serializers/profile.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from core.models.profile import Profile + + +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = ('id', 'user', 'bio', 'birthday', 'country', 'city', 'affiliation', 'photo') diff --git a/core/serializers/register.py b/core/serializers/register.py new file mode 100644 index 0000000..6f338de --- /dev/null +++ b/core/serializers/register.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import User +from rest_framework import serializers + +# Register Serializer +class RegisterSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('id', 'username', 'email', 'password') + extra_kwargs = {'password': {'write_only': True}} + + def create(self, validated_data): + user = User.objects.create_user( + validated_data['username'], + validated_data['email'], + validated_data['password'] + ) + return user diff --git a/core/serializers/user.py b/core/serializers/user.py new file mode 100644 index 0000000..0d9f8ec --- /dev/null +++ b/core/serializers/user.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from django.contrib.auth.models import User + +# User Serializer +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('id', 'username', 'email') diff --git a/core/signals.py b/core/signals.py new file mode 100644 index 0000000..5d4aaf4 --- /dev/null +++ b/core/signals.py @@ -0,0 +1,51 @@ +from django.contrib.auth.models import User +from django.core.mail import EmailMultiAlternatives +from django.db.models.signals import post_save +from django.dispatch import receiver +from django_rest_passwordreset.signals import reset_password_token_created +from django.template.loader import render_to_string +from django.urls import reverse + +from core.models.profile import Profile + + +# @receiver(post_save, sender=User) +# def create_related_profile(sender, instance, created, *args, **kwargs): +# # Notice that we're checking for `created` here. We only want to do this +# # the first time the `User` instance is created. If the save that caused +# # this signal to be run was an update action, we know the user already +# # has a profile. +# if instance and created: +# print(instance,created) +# instance.profile = Profile.objects.create(user=instance) + + +@receiver(reset_password_token_created) +def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs): + # send an e-mail to the user + context = { + 'current_user': reset_password_token.user, + 'username': reset_password_token.user.username, + 'email': reset_password_token.user.email, + 'reset_password_url': "{}?token={}".format( + instance.request.build_absolute_uri(reverse('password_reset:reset-password-confirm')), + reset_password_token.key) + } + + # render email text + email_html_message = render_to_string('email/user_reset_password.html', context) + email_plaintext_message = render_to_string('email/user_reset_password.txt', context) + # email_plaintext_message = "{}?token={}".format(reverse('password_reset:reset-password-request'), reset_password_token.key) + + msg = EmailMultiAlternatives( + # title: + "Password Reset for {title}".format(title="Some website title"), + # message: + email_plaintext_message, + # from: + "noreply@somehost.local", + # to: + [reset_password_token.user.email] + ) + msg.attach_alternative(email_html_message, "text/html") + msg.send() diff --git a/core/urls.py b/core/urls.py index 6b77c06..17ed457 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,6 +1,14 @@ -from django.urls import path +from django.urls import path, include +from knox import views as knox_views +from core.api.auth import RegisterAPI, LoginAPI +from core.api.password import ChangePasswordView -urlpatterns = [ +urlpatterns = [ + path('api/auth/register', RegisterAPI.as_view(), name='register'), + path('api/auth/login', LoginAPI.as_view(), name='login'), + path('api/auth/logout', knox_views.LogoutView.as_view(), name='logout'), + path('api/change-password', ChangePasswordView.as_view(), name='change-password'), + path('api/password_reset', include('django_rest_passwordreset.urls', namespace='password_reset')), ] diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..fc791ac --- /dev/null +++ b/docs/api.md @@ -0,0 +1,38 @@ +# API + +## Authentication + +- url: api/auth/register + + ```json + // request + { + "username": "admin", + "email": "admin@bot.com", + "password": "Password@123" + } + // response + { + "user": { + "id": 2, + "username": "admin1", + "email": "admin1@bot.com" + }, + "token": "790e890d571753148bbc9c4447f106e74ecf4d1404f080245f3e259703d58b09" + } + ``` + +- url: api/auth/login + +```json +// request +{ + "username": "admin", + "password": "Password@123" +} +//response +{ + "expiry": "2020-06-29T02:56:44.924698Z", + "token": "99a27b2ebe718a2f0db6224e55b622a59ccdae9cf66861c60979a25ffb4f133e" +} +``` diff --git a/ece651_backend/settings.py b/ece651_backend/settings.py index 64daeb9..5f2876a 100644 --- a/ece651_backend/settings.py +++ b/ece651_backend/settings.py @@ -9,7 +9,7 @@ https://docs.djangoproject.com/en/4.1/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ """ - +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -31,15 +31,27 @@ ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ - 'core.apps.CoreConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django_extensions', + 'rest_framework', + 'django_rest_passwordreset', + 'knox', + 'core', ] +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'knox.auth.TokenAuthentication', + ], +} + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -55,7 +67,9 @@ ROOT_URLCONF = 'ece651_backend.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [ + os.path.join(BASE_DIR, 'templates'), + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -122,3 +136,10 @@ STATIC_URL = 'static/' # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Email settings +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Media Settings +MEDIA_URL = 'media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') diff --git a/ece651_backend/urls.py b/ece651_backend/urls.py index 44b0332..85e0eb6 100644 --- a/ece651_backend/urls.py +++ b/ece651_backend/urls.py @@ -1,22 +1,16 @@ -"""ece651_backend URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include +from rest_framework import routers + +router = routers.DefaultRouter() -urlpatterns = [ - path('api/core/', include('core.urls')), +urlpatterns = router.urls + +urlpatterns += [ + path('', include('core.urls')), path('admin/', admin.site.urls), ] + +urlpatterns += static('media/', document_root=settings.MEDIA_ROOT) diff --git a/requirements.txt b/requirements.txt index ba4c576..6611e82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,24 @@ # This file may be used to create an environment using: # $ conda create --name <env> --file <this file> asgiref=3.5.2=py39hecd8cb5_0 -ca-certificates=2022.10.11=hecd8cb5_0 -certifi=2022.12.7=py39hecd8cb5_0 +ca-certificates=2022.12.7=h033912b_0 +certifi=2022.12.7=pyhd8ed1ab_0 cffi=1.15.1=py39h6c40b1e_3 cryptography=38.0.4=py39hf6deb26_0 django=4.1=py39hecd8cb5_0 +django-extensions=3.2.1=pyhd8ed1ab_0 +django-rest-knox=4.2.0=pypi_0 +django-rest-passwordreset=1.3.0=pypi_0 +djangorestframework=3.14.0=pyhd8ed1ab_0 libcxx=14.0.6=h9765a3e_0 libffi=3.4.2=hecd8cb5_6 ncurses=6.3=hca72f7f_3 -openssl=1.1.1s=hca72f7f_0 +openssl=1.1.1s=hfd90126_1 pip=22.3.1=py39hecd8cb5_0 pycparser=2.21=pyhd3eb1b0_0 pyjwt=2.6.0=pyhd8ed1ab_0 python=3.9.15=h218abb5_2 +pytz=2022.7.1=pyhd8ed1ab_0 readline=8.2=hca72f7f_0 setuptools=65.6.3=py39hecd8cb5_0 sqlite=3.40.1=h880c91c_0 diff --git a/templates/user_reset_password.html b/templates/user_reset_password.html new file mode 100644 index 0000000..b44376b --- /dev/null +++ b/templates/user_reset_password.html @@ -0,0 +1,12 @@ +{% autoescape off %} +To initiate the password reset process for you {{ username }} for your BuyforFree Account, +click the link below: + +http://josla.com/confirm{{reset_password_url}} + +If clicking the link above doesn't work, please copy and paste the URL in a new browser +window instead. + +Sincerely, +Josla +{% endautoescape %} \ No newline at end of file diff --git a/templates/user_reset_password.txt b/templates/user_reset_password.txt new file mode 100644 index 0000000..608af9a --- /dev/null +++ b/templates/user_reset_password.txt @@ -0,0 +1 @@ +Josla password reset -- GitLab