diff --git a/config/settings.py b/config/settings.py index 1c49906..a30645b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -31,6 +31,10 @@ def get_secret(setting, keys=keys): # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = get_secret("SECRET_KEY") +EMAIL_ID = get_secret("EMAIL_ID") +EMAIL_SECRET = get_secret("EMAIL_SECRET") +EMAIL_FROM = get_secret("EMAIL_FROM") + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = get_secret("DEBUG") @@ -68,7 +72,10 @@ ROOT_URLCONF = get_secret("ROOT_URLCONF") TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, "templates")], + 'DIRS': [ + os.path.join(BASE_DIR, "templates"), + os.path.join(BASE_DIR, "tools/emails/templates") + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/cons.py b/tools/cons.py new file mode 100644 index 0000000..2643d4d --- /dev/null +++ b/tools/cons.py @@ -0,0 +1,5 @@ +from django.utils.translation import gettext_lazy as _ + +PASSWORD_HAS_BEEN_CHANGED = _("Password has been changed.") +EMAIL_HAS_BEEN_SENT = _("Email was sent.") +EMAIL_COULD_NOT_BE_SENT = _("The email could not be sent.") \ No newline at end of file diff --git a/tools/emails/data.py b/tools/emails/data.py new file mode 100644 index 0000000..45b81ed --- /dev/null +++ b/tools/emails/data.py @@ -0,0 +1,50 @@ +from mailjet_rest import Client + +from django.template.loader import render_to_string, get_template +from config import settings +from tools.tools import EmailException + + +class EmailSender: + def __init__(self, data): + self.client_id = settings.EMAIL_ID + self.client_secret = settings.EMAIL_SECRET + self.email_from = settings.EMAIL_FROM + self.data = data + + def authenticate_user(self): + return Client( + auth=(self.client_id, self.client_secret), + version='v3.1' + ) + + def prepare_email_template(self, template_name): + template = get_template(template_name) + return template.render(self.data) + + def prepare_email(self): + return { + 'Messages': [ + { + "From": { + "Email": "hubertjan98@gmail.com", + "Name": "Hubert" + }, + "To": [ + { + "Email": f"{self.data['email_to']}", + "Name": f"{self.data['user_first_name']}" + } + ], + "Subject": f"{self.data['subject']}", + "HTMLPart": self.prepare_email_template('reset_password.html'), + } + ] + } + + def send_email(self): + response = self.authenticate_user().send.create(data=self.prepare_email()) + if response.status_code != 200: + raise EmailException() + else: + return response diff --git a/tools/emails/templates/reset_password.html b/tools/emails/templates/reset_password.html new file mode 100644 index 0000000..75d2b7c --- /dev/null +++ b/tools/emails/templates/reset_password.html @@ -0,0 +1,126 @@ + + + + + + + + + template_reset_password + + + + + + +
+ + + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +

ZAPOMNIAŁEŚ

 HASŁA?

Cześć, {{ user_first_name }} {{ user_last_name }}

Dostaliśmy prośbę o zmianę Twojego hasła!

Jeśli nie zgłaszałeś takiej prośby, proszę zignoruj tę wiadomość. Jeśli jednak chcesz zmienić swoje naciśnij poniższy przycisk i przepisz odpowiednio dane:

UID: {{ uid }} 

Token: {{ token }} 

Kod: {{ reset_code }} 

RESET PASSWORD
+
+ + diff --git a/tools/emails/templates/reset_password.txt b/tools/emails/templates/reset_password.txt new file mode 100644 index 0000000..ced40d6 --- /dev/null +++ b/tools/emails/templates/reset_password.txt @@ -0,0 +1 @@ +{{ variable }} diff --git a/tools/tools.py b/tools/tools.py new file mode 100644 index 0000000..4341193 --- /dev/null +++ b/tools/tools.py @@ -0,0 +1,41 @@ +from rest_framework import status +from rest_framework.response import Response +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_text, force_bytes +from rest_framework.exceptions import APIException +from . import cons + + +def encode_uid(pk): + return force_text(urlsafe_base64_encode(force_bytes(pk))) + + +def decode_uid(pk): + return force_text(urlsafe_base64_decode(pk)) + +class EmailException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = cons.EMAIL_COULD_NOT_BE_SENT + + def __init__(self): + self.detail = {"detail": force_text(self.default_detail)} + + +class PasswordResetShortcut: + + def post(self, request): + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + return Response({"detail": cons.EMAIL_HAS_BEEN_SENT}, status=status.HTTP_200_OK) + else: + return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PasswordResetConfirmShortcut: + + def post(self, request): + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + return Response({"detail": cons.PASSWORD_HAS_BEEN_CHANGED}, status=status.HTTP_200_OK) + else: + return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/users/cons.py b/users/cons.py new file mode 100644 index 0000000..0d1ca79 --- /dev/null +++ b/users/cons.py @@ -0,0 +1,8 @@ +from django.utils.translation import gettext_lazy as _ + +USER_WITH_FOLLOWING_EMAIL_DOES_NOT_EXIST = "Uzytkownik o podanym adresie email nie istnieje." +INVALID_UID = "Niepoprawne UID" +PASSWORDS_ARE_NOT_THE_SAME = "Hasła nie są takie same" +INVALID_TOKEN = "Niepoprawny token" +INVALID_CODE = "Niepoprawny kod" +PASSWORD_RESET = "Zmiana hasła" \ No newline at end of file diff --git a/users/managers.py b/users/managers.py index d4dece3..8e34a97 100644 --- a/users/managers.py +++ b/users/managers.py @@ -1,4 +1,13 @@ +import random + +from django.apps import apps from django.contrib.auth.base_user import BaseUserManager +from django.contrib.auth.tokens import default_token_generator + +from config import settings +from users import cons +from tools.emails.data import EmailSender +from tools.tools import encode_uid, decode_uid from .querysets import UserQuerySet @@ -9,13 +18,50 @@ class UserManager(BaseUserManager): return UserQuerySet(self.model, using=self._db) def create(self, email, password=None, **kwargs): - - if password is None: - message = "User must have valid password" - raise ValueError(message) - - user = self.model(email=email, **kwargs) + user = self.model(email=email, password=password, **kwargs) user.set_password(password) user.save() - return user + + def reset_password(self, user_email): + User = apps.get_model("users", "User") + user = User.objects.get(email=user_email["email"]) + credits = self.get_uid_and_activation_token(user) + self.prepare_code(user, credits["token"], credits["uid"]) + + def get_uid_and_activation_token(self, user): + token_generator = default_token_generator + token = token_generator.make_token(user) + uid = encode_uid(user.id) + return {"uid": uid, "token": token} + + def prepare_code(self, user, token, uid): + reset_code = int("".join(random.choice("0123456789") for _ in range(6))) + user.reset_code = reset_code + user.save() + self.send_email(user, uid, token, user.reset_code) + + # def create_link(self, uid, token, code): + # return f"{settings.CLIENT_DOMAIN}/password/reset/?uid={uid}&token={token}&code={code}" + + def send_email(self, user, uid, token, reset_code): + EmailSender(self.prepare_data_to_send_email(user, uid, token, reset_code)).send_email() + + def prepare_data_to_send_email(self, user, uid, token, reset_code): + return { + "subject": f"{cons.PASSWORD_RESET}", + "email_to": user.email, + "user_first_name": user.first_name, + "user_last_name": user.last_name, + "uid": uid, + "token": token, + "reset_code": reset_code, + "link": "http://localhost:8000/users/password/reset/confirm" + } + + def confirm_reset_password(self, **validated_data): + User = apps.get_model("users", "User") + user = User.objects.get(id=decode_uid(validated_data["uid"])) + user.set_password(validated_data["new_password"]) + user.reset_code = None + user.save(update_fields=["password", "reset_code"]) diff --git a/users/migrations/0002_alter_user_reset_code.py b/users/migrations/0002_alter_user_reset_code.py new file mode 100644 index 0000000..26b124d --- /dev/null +++ b/users/migrations/0002_alter_user_reset_code.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2021-12-11 20:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='reset_code', + field=models.IntegerField(), + ), + ] diff --git a/users/migrations/0003_alter_user_reset_code.py b/users/migrations/0003_alter_user_reset_code.py new file mode 100644 index 0000000..c82a26a --- /dev/null +++ b/users/migrations/0003_alter_user_reset_code.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2021-12-11 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_alter_user_reset_code'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='reset_code', + field=models.IntegerField(null=True), + ), + ] diff --git a/users/models.py b/users/models.py index 886360f..8e351fd 100644 --- a/users/models.py +++ b/users/models.py @@ -12,7 +12,7 @@ class User(AbstractBaseUser): is_active = models.BooleanField(default=False) confirmation_number = models.CharField(max_length=100) - reset_code = models.CharField(max_length=100) + reset_code = models.IntegerField(null=True) avatar = models.ImageField(upload_to="avatars/", null=True) USERNAME_FIELD = "email" diff --git a/users/serializers.py b/users/serializers.py index 2209870..cc2725c 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,6 +1,8 @@ from rest_framework import serializers from users.models import User +from users import cons +from tools.tools import decode_uid, encode_uid class UserSerializer(serializers.ModelSerializer): @@ -24,3 +26,56 @@ class UserSerializer(serializers.ModelSerializer): "is_active", "password" ) + + +class UserPasswordResetSerializer(serializers.Serializer): + email = serializers.CharField() + + def validate(self, data): + email = data.get("email") + user = User.objects.filter(email__iexact=email) + if not user: + msg = {"detail": cons.USER_WITH_FOLLOWING_EMAIL_DOES_NOT_EXIST} + raise serializers.ValidationError(msg) + self.do_actions(data) + return data + + def do_actions(self, validated_data): + return User.objects.reset_password(validated_data) + + +class UserPasswordResetConfirmSerializer(serializers.Serializer): + uid = serializers.CharField() + token = serializers.CharField() + code = serializers.IntegerField() + new_password = serializers.CharField(style={"input_type": "password"}) + repeat_new_password = serializers.CharField(style={"input_type": "password"}) + + def validate_uid(self, value): + try: + uid = decode_uid(value) + self.user = User.objects.get(pk=uid) + except (User.DoesNotExist, ValueError, TypeError, OverflowError): + raise serializers.ValidationError(cons.INVALID_UID) + return value + + def validate_new_passwords(self, attrs): + new_password = attrs["new_password"] + repeat_new_password = attrs["repeat_new_password"] + if new_password != repeat_new_password: + raise serializers.ValidationError(cons.PASSWORDS_ARE_NOT_THE_SAME) + return attrs + + def validate(self, attrs): + token = attrs["token"] + code = attrs["code"] + if not self.context["view"].token_generator.check_token(self.user, token): + raise serializers.ValidationError(cons.INVALID_TOKEN) + if self.user.reset_code != code: + raise serializers.ValidationError(cons.INVALID_CODE) + self.validate_new_passwords(attrs) + self.do_actions(attrs) + return attrs + + def do_actions(self, validated_data): + return User.objects.confirm_reset_password(**validated_data) \ No newline at end of file diff --git a/users/urls.py b/users/urls.py index cf7ca52..d377d91 100644 --- a/users/urls.py +++ b/users/urls.py @@ -4,6 +4,7 @@ from django.urls import path from users.views import UserModelViewSet from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.views import TokenRefreshView +from .views import PasswordReset, UserPasswordResetConfirmView router = DefaultRouter(trailing_slash=False) @@ -13,5 +14,7 @@ urlpatterns = [ path("", include(router.urls)), path('api/token', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/token/refresh', TokenRefreshView.as_view(), name='token_refresh'), + path("password/reset", PasswordReset.as_view()), + path("password/reset/confirm", UserPasswordResetConfirmView.as_view()), ] diff --git a/users/views.py b/users/views.py index a441865..45088d3 100644 --- a/users/views.py +++ b/users/views.py @@ -2,8 +2,41 @@ from rest_framework import viewsets from users.models import User from users.serializers import UserSerializer +from rest_framework import views, exceptions, status, viewsets, permissions, generics +from tools.tools import PasswordResetShortcut, PasswordResetConfirmShortcut +from django.contrib.auth.tokens import default_token_generator +from .serializers import UserPasswordResetSerializer, UserPasswordResetConfirmSerializer class UserModelViewSet(viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer + + +class PasswordReset(PasswordResetShortcut, generics.GenericAPIView): + """ + ```json + { + "email": "testowymail@gmail.com" + } + ``` + """ + serializer_class = UserPasswordResetSerializer + permission_classes = (permissions.AllowAny, ) + + +class UserPasswordResetConfirmView(PasswordResetConfirmShortcut, generics.GenericAPIView): + """ + ```json + { + "uid": "NYz", + "token": "asdasdasd", + "code": 123456, + "newPassword": "testowe", + "repeatNewPassword": "testowe" + } + ``` + """ + serializer_class = UserPasswordResetConfirmSerializer + permission_classes = (permissions.AllowAny, ) + token_generator = default_token_generator \ No newline at end of file