diff --git a/apps/account/admin.py b/apps/account/admin.py index 9cbe52eb..7c914c2b 100644 --- a/apps/account/admin.py +++ b/apps/account/admin.py @@ -27,7 +27,7 @@ class UserAdmin(BaseUserAdmin): (_('Important dates'), {'fields': ('last_login', 'date_joined')}), (_('Permissions'), { 'fields': ( - 'is_active', 'is_staff', 'is_superuser', 'email_confirmed' + 'is_active', 'is_staff', 'is_superuser', 'email_confirmed', 'groups', 'user_permissions'), 'classes': ('collapse', ) }), diff --git a/apps/account/models.py b/apps/account/models.py index 1b654e7c..40c06ca4 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -15,7 +15,7 @@ from rest_framework.authtoken.models import Token from authorization.models import Application from utils.models import ImageMixin, ProjectBaseMixin, PlatformMixin -from utils.models import gm_token_generator +from utils.models import GMTokenGenerator class UserManager(BaseUserManager): @@ -126,24 +126,40 @@ class User(ImageMixin, AbstractUser): @property def confirm_email_token(self): """Make a token for finish signup.""" - return gm_token_generator.make_token(self) + return GMTokenGenerator(purpose=GMTokenGenerator.CONFIRM_EMAIL).make_token(self) + + @property + def change_email_token(self): + """Make a token for change email.""" + return GMTokenGenerator(purpose=GMTokenGenerator.CHANGE_EMAIL).make_token(self) @property def reset_password_token(self): """Make a token for finish signup.""" - return password_token_generator.make_token(self) + return GMTokenGenerator(purpose=GMTokenGenerator.RESET_PASSWORD).make_token(self) @property def get_user_uidb64(self): """Get base64 value for user by primary key identifier""" return urlsafe_base64_encode(force_bytes(self.pk)) - def get_confirm_email_template(self): + @property + def confirm_email_template(self): """Get confirm email template""" return render_to_string( template_name=settings.CONFIRM_EMAIL_TEMPLATE, context={'token': self.confirm_email_token, - 'uid': self.get_user_uidb64, + 'uidb64': self.get_user_uidb64, + 'domain_uri': settings.DOMAIN_URI, + 'site_name': settings.SITE_NAME}) + + @property + def change_email_template(self): + """Get change email template""" + return render_to_string( + template_name=settings.CHANGE_EMAIL_TEMPLATE, + context={'token': self.change_email_token, + 'uidb64': self.get_user_uidb64, 'domain_uri': settings.DOMAIN_URI, 'site_name': settings.SITE_NAME}) @@ -221,10 +237,20 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin): """Generates a pseudo random code""" return password_token_generator.make_token(self.user) + @property + def reset_password_template(self): + """Get reset password template""" + return render_to_string( + template_name=settings.RESETTING_TOKEN_TEMPLATE, + context={'token': self.key, + 'uidb64': self.user.get_user_uidb64, + 'domain_uri': settings.DOMAIN_URI, + 'site_name': settings.SITE_NAME}) + @staticmethod def token_is_valid(user, token): """Check if token is valid""" - return gm_token_generator.check_token(user, token) + return password_token_generator.check_token(user, token) def overdue(self): """Overdue instance""" @@ -241,13 +267,3 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin): if not self.key: self.key = self.generate_token return super(ResetPasswordToken, self).save(*args, **kwargs) - - def get_reset_password_template(self): - """Get reset password template""" - return render_to_string( - template_name=settings.RESETTING_TOKEN_TEMPLATE, - context={'token': self.key, - 'uidb64': self.user.get_user_uidb64, - 'domain_uri': settings.DOMAIN_URI, - 'site_name': settings.SITE_NAME}) - diff --git a/apps/account/serializers/common.py b/apps/account/serializers/common.py index 1bc4d201..4fbdb0f1 100644 --- a/apps/account/serializers/common.py +++ b/apps/account/serializers/common.py @@ -6,6 +6,7 @@ from account import models from utils import exceptions as utils_exceptions from rest_framework_simplejwt import tokens from django.conf import settings +from account import tasks # User serializers @@ -98,3 +99,47 @@ class RefreshTokenSerializer(serializers.Serializer): data['refresh_token'] = str(token) return data + + +class ChangeEmailSerializer(serializers.ModelSerializer): + """Change user email serializer""" + + class Meta: + """Meta class""" + model = models.User + fields = ( + 'id', + 'email', + ) + read_only_fields = ( + 'id', + ) + + def validate_email(self, value): + """Validate email value""" + if value == self.instance.email: + # todo: added custom exception + raise serializers.ValidationError() + return value + + def validate(self, attrs): + """Override validate method""" + email_confirmed = self.instance.email_confirmed + if not email_confirmed: + # todo: added custom exception + raise serializers.ValidationError() + return attrs + + def update(self, instance, validated_data): + """ + Override update method + """ + instance.email = validated_data.get('email') + instance.email_confirmed = False + instance.save() + # Send verification link on user email for change email address + if settings.USE_CELERY: + tasks.confirm_new_email_address.delay(instance.id) + else: + tasks.confirm_new_email_address(instance.id) + return instance diff --git a/apps/account/tasks.py b/apps/account/tasks.py index b2ce5657..0367a59e 100644 --- a/apps/account/tasks.py +++ b/apps/account/tasks.py @@ -17,8 +17,20 @@ def send_reset_password_email(request_id): obj = models.ResetPasswordToken.objects.get(id=request_id) user = obj.user user.send_email(subject=_('Password resetting'), - message=obj.get_reset_password_template()) + message=obj.reset_password_template) except: logger.error(f'METHOD_NAME: {send_reset_password_email.__name__}\n' f'DETAIL: Exception occurred for ResetPasswordToken instance: ' f'{request_id}') + + +@shared_task +def confirm_new_email_address(user_id): + """Send email to user new email.""" + try: + user = models.User.objects.get(id=user_id) + user.send_email(subject=_('Validate new email address'), + message=user.change_email_template) + except: + logger.error(f'METHOD_NAME: {confirm_new_email_address.__name__}\n' + f'DETAIL: Exception occurred for user: {user_id}') diff --git a/apps/account/urls/common.py b/apps/account/urls/common.py index f903ccb0..2a426fcb 100644 --- a/apps/account/urls/common.py +++ b/apps/account/urls/common.py @@ -7,5 +7,7 @@ app_name = 'account' urlpatterns = [ path('user/', views.UserView.as_view(), name='user-get-update'), - path('refresh-token/', views.RefreshTokenView.as_view(), name="refresh-token"), + path('refresh-token/', views.RefreshTokenView.as_view(), name='refresh-token'), + path('change-email/', views.ChangeEmailView.as_view(), name='change-email'), + path('change-email/confirm///', views.ChangeEmailConfirmView.as_view(), name='change-email-confirm'), ] diff --git a/apps/account/views/common.py b/apps/account/views/common.py index 218267f6..15abde77 100644 --- a/apps/account/views/common.py +++ b/apps/account/views/common.py @@ -3,6 +3,10 @@ from fcm_django.models import FCMDevice from rest_framework import generics, status from rest_framework import permissions from rest_framework.response import Response +from django.utils.encoding import force_text +from django.utils.http import urlsafe_base64_decode +from utils.models import GMTokenGenerator +from utils import exceptions as utils_exceptions from account import models from account.serializers import common as serializers @@ -72,3 +76,43 @@ class RefreshTokenView(JWTGenericViewMixin): cookies=self._put_data_in_cookies(access_token=access_token, refresh_token=refresh_token), response=response) + + +# Change user email +class ChangeEmailView(JWTGenericViewMixin): + """Change user email view""" + serializer_class = serializers.ChangeEmailSerializer + queryset = models.User.objects.all() + + def patch(self, request, *args, **kwargs): + """Implement POST-method""" + # Get user instance + instance = self.request.user + + serializer = self.get_serializer(data=request.data, instance=instance) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(status=status.HTTP_200_OK) + + +class ChangeEmailConfirmView(JWTGenericViewMixin): + """View for confirm changing email""" + + permission_classes = (permissions.AllowAny, ) + + def get(self, request, *args, **kwargs): + """Implement GET-method""" + uidb64 = kwargs.get('uidb64') + token = kwargs.get('token') + uid = force_text(urlsafe_base64_decode(uidb64)) + user_qs = models.User.objects.filter(pk=uid) + if user_qs.exists(): + user = user_qs.first() + if not GMTokenGenerator(GMTokenGenerator.CHANGE_EMAIL).check_token( + user, token): + raise utils_exceptions.NotValidTokenError() + # Approve email status + user.confirm_email() + return Response(status=status.HTTP_200_OK) + else: + raise utils_exceptions.UserNotFoundError() diff --git a/apps/account/views/web.py b/apps/account/views/web.py index ec13b057..acd8978b 100644 --- a/apps/account/views/web.py +++ b/apps/account/views/web.py @@ -21,7 +21,7 @@ from account import models from account.forms import SetPasswordForm from account.serializers import web as serializers from utils import exceptions as utils_exceptions -from utils.models import gm_token_generator +from utils.models import GMTokenGenerator from utils.views import (JWTCreateAPIView, JWTGenericViewMixin) @@ -55,7 +55,8 @@ class PasswordResetConfirmView(JWTGenericViewMixin): filter_kwargs = {'key': token, 'user_id': user_id} obj = get_object_or_404(queryset, **filter_kwargs) - if not gm_token_generator.check_token(user=obj.user, token=token): + if not GMTokenGenerator(GMTokenGenerator.RESET_PASSWORD).check_token( + user=obj.user, token=token): raise utils_exceptions.NotValidAccessTokenError() # May raise a permission denied @@ -89,6 +90,8 @@ class PasswordContextMixin: class FormPasswordResetSuccessView(views.APIView): """View for successful reset password""" + permission_classes = (permissions.AllowAny, ) + def get(self, request, *args, **kwargs): """Implement GET-method""" return Response(status=status.HTTP_200_OK) diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 1371e26b..f7750468 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -123,7 +123,8 @@ class LoginByUsernameOrEmailSerializer(JWTBaseSerializerMixin, serializers.Model username_or_email = attrs.pop('username_or_email') password = attrs.pop('password') user_qs = account_models.User.objects.filter(Q(username=username_or_email) | - Q(email=username_or_email)) + (Q(email=username_or_email) & + Q(email_confirmed=True))) if not user_qs.exists(): raise utils_exceptions.UserNotFoundError() else: diff --git a/apps/authorization/tasks.py b/apps/authorization/tasks.py index 76830ae0..a2ae4bb3 100644 --- a/apps/authorization/tasks.py +++ b/apps/authorization/tasks.py @@ -15,7 +15,7 @@ def send_confirm_email(user_id): try: obj = account_models.User.objects.get(id=user_id) obj.send_email(subject=_('Email confirmation'), - message=obj.get_confirm_email_template()) + message=obj.confirm_email_template) except: logger.error(f'METHOD_NAME: {send_confirm_email.__name__}\n' f'DETAIL: Exception occurred for user: {user_id}') diff --git a/apps/authorization/views/common.py b/apps/authorization/views/common.py index 8d9e0d36..598cb04e 100644 --- a/apps/authorization/views/common.py +++ b/apps/authorization/views/common.py @@ -16,7 +16,7 @@ from rest_framework.response import Response from rest_framework_simplejwt import tokens as jwt_tokens from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer -from utils.models import gm_token_generator +from utils.models import GMTokenGenerator from account.models import User from authorization.models import Application @@ -182,7 +182,8 @@ class VerifyEmailConfirmView(JWTGenericViewMixin): user_qs = User.objects.filter(pk=uid) if user_qs.exists(): user = user_qs.first() - if not gm_token_generator.check_token(user, token): + if not GMTokenGenerator(GMTokenGenerator.CONFIRM_EMAIL).check_token( + user, token): raise utils_exceptions.NotValidTokenError() # Approve email status user.confirm_email() diff --git a/apps/utils/models.py b/apps/utils/models.py index 0e7fdd7f..b2d922dd 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -189,13 +189,39 @@ class LocaleManagerMixin(models.Manager): class GMTokenGenerator(PasswordResetTokenGenerator): + CHANGE_EMAIL = 0 + RESET_PASSWORD = 1 + CHANGE_PASSWORD = 2 + CONFIRM_EMAIL = 3 + + TOKEN_CHOICES = ( + CHANGE_EMAIL, + RESET_PASSWORD, + CHANGE_PASSWORD, + CONFIRM_EMAIL + ) + + def __init__(self, purpose: int): + if purpose in self.TOKEN_CHOICES: + self.purpose = purpose + + def get_fields(self, user, timestamp): + """ + Get user fields for hash value. + """ + fields = [str(timestamp), str(user.is_active), str(user.pk)] + if self.purpose == self.CHANGE_EMAIL or \ + self.purpose == self.CONFIRM_EMAIL: + fields.extend([str(user.email_confirmed), str(user.email)]) + elif self.purpose == self.RESET_PASSWORD or \ + self.purpose == self.CHANGE_PASSWORD: + fields.append(str(user.password)) + return fields + def _make_hash_value(self, user, timestamp): - return ( - str(user.pk) + - str(user.email_confirmed) + - str(timestamp) + - str(user.is_active) - ) - - -gm_token_generator = GMTokenGenerator() + """ + Hash the user's primary key and some user state that's sure to change + after a password reset to produce a token that invalidated when it's + used. + """ + return self.get_fields(user, timestamp) diff --git a/project/settings/base.py b/project/settings/base.py index 29e34caa..509f14b7 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -369,6 +369,7 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1 # TEMPLATES CONFIRMATION_PASSWORD_RESET_TEMPLATE = 'account/password_reset_confirm.html' RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html' +CHANGE_EMAIL_TEMPLATE = 'account/change_email.html' CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html' diff --git a/project/templates/account/change_email.html b/project/templates/account/change_email.html new file mode 100644 index 00000000..ceec753f --- /dev/null +++ b/project/templates/account/change_email.html @@ -0,0 +1,12 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}You're receiving this email because you want to change email address at {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page for confirmation new email address:" %} +{% block reset_link %} +http://{{ domain_uri }}{% url 'web:account:change-email-confirm' uidb64=uidb64 token=token %} +{% endblock %} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endautoescape %} \ No newline at end of file