added endpoint to change an email

This commit is contained in:
Anatoly 2019-08-30 17:56:35 +03:00
parent f0743fe688
commit f8b87c4788
13 changed files with 197 additions and 34 deletions

View File

@ -27,7 +27,7 @@ class UserAdmin(BaseUserAdmin):
(_('Important dates'), {'fields': ('last_login', 'date_joined')}), (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
(_('Permissions'), { (_('Permissions'), {
'fields': ( 'fields': (
'is_active', 'is_staff', 'is_superuser', 'email_confirmed' 'is_active', 'is_staff', 'is_superuser', 'email_confirmed',
'groups', 'user_permissions'), 'groups', 'user_permissions'),
'classes': ('collapse', ) 'classes': ('collapse', )
}), }),

View File

@ -15,7 +15,7 @@ from rest_framework.authtoken.models import Token
from authorization.models import Application from authorization.models import Application
from utils.models import ImageMixin, ProjectBaseMixin, PlatformMixin from utils.models import ImageMixin, ProjectBaseMixin, PlatformMixin
from utils.models import gm_token_generator from utils.models import GMTokenGenerator
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@ -126,24 +126,40 @@ class User(ImageMixin, AbstractUser):
@property @property
def confirm_email_token(self): def confirm_email_token(self):
"""Make a token for finish signup.""" """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 @property
def reset_password_token(self): def reset_password_token(self):
"""Make a token for finish signup.""" """Make a token for finish signup."""
return password_token_generator.make_token(self) return GMTokenGenerator(purpose=GMTokenGenerator.RESET_PASSWORD).make_token(self)
@property @property
def get_user_uidb64(self): def get_user_uidb64(self):
"""Get base64 value for user by primary key identifier""" """Get base64 value for user by primary key identifier"""
return urlsafe_base64_encode(force_bytes(self.pk)) return urlsafe_base64_encode(force_bytes(self.pk))
def get_confirm_email_template(self): @property
def confirm_email_template(self):
"""Get confirm email template""" """Get confirm email template"""
return render_to_string( return render_to_string(
template_name=settings.CONFIRM_EMAIL_TEMPLATE, template_name=settings.CONFIRM_EMAIL_TEMPLATE,
context={'token': self.confirm_email_token, 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, 'domain_uri': settings.DOMAIN_URI,
'site_name': settings.SITE_NAME}) 'site_name': settings.SITE_NAME})
@ -221,10 +237,20 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin):
"""Generates a pseudo random code""" """Generates a pseudo random code"""
return password_token_generator.make_token(self.user) 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 @staticmethod
def token_is_valid(user, token): def token_is_valid(user, token):
"""Check if token is valid""" """Check if token is valid"""
return gm_token_generator.check_token(user, token) return password_token_generator.check_token(user, token)
def overdue(self): def overdue(self):
"""Overdue instance""" """Overdue instance"""
@ -241,13 +267,3 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin):
if not self.key: if not self.key:
self.key = self.generate_token self.key = self.generate_token
return super(ResetPasswordToken, self).save(*args, **kwargs) 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})

View File

@ -6,6 +6,7 @@ from account import models
from utils import exceptions as utils_exceptions from utils import exceptions as utils_exceptions
from rest_framework_simplejwt import tokens from rest_framework_simplejwt import tokens
from django.conf import settings from django.conf import settings
from account import tasks
# User serializers # User serializers
@ -98,3 +99,47 @@ class RefreshTokenSerializer(serializers.Serializer):
data['refresh_token'] = str(token) data['refresh_token'] = str(token)
return data 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

View File

@ -17,8 +17,20 @@ def send_reset_password_email(request_id):
obj = models.ResetPasswordToken.objects.get(id=request_id) obj = models.ResetPasswordToken.objects.get(id=request_id)
user = obj.user user = obj.user
user.send_email(subject=_('Password resetting'), user.send_email(subject=_('Password resetting'),
message=obj.get_reset_password_template()) message=obj.reset_password_template)
except: except:
logger.error(f'METHOD_NAME: {send_reset_password_email.__name__}\n' logger.error(f'METHOD_NAME: {send_reset_password_email.__name__}\n'
f'DETAIL: Exception occurred for ResetPasswordToken instance: ' f'DETAIL: Exception occurred for ResetPasswordToken instance: '
f'{request_id}') 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}')

View File

@ -7,5 +7,7 @@ app_name = 'account'
urlpatterns = [ urlpatterns = [
path('user/', views.UserView.as_view(), name='user-get-update'), 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/<uidb64>/<token>/', views.ChangeEmailConfirmView.as_view(), name='change-email-confirm'),
] ]

View File

@ -3,6 +3,10 @@ from fcm_django.models import FCMDevice
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework import permissions from rest_framework import permissions
from rest_framework.response import Response 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 import models
from account.serializers import common as serializers from account.serializers import common as serializers
@ -72,3 +76,43 @@ class RefreshTokenView(JWTGenericViewMixin):
cookies=self._put_data_in_cookies(access_token=access_token, cookies=self._put_data_in_cookies(access_token=access_token,
refresh_token=refresh_token), refresh_token=refresh_token),
response=response) 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()

View File

@ -21,7 +21,7 @@ from account import models
from account.forms import SetPasswordForm from account.forms import SetPasswordForm
from account.serializers import web as serializers from account.serializers import web as serializers
from utils import exceptions as utils_exceptions from utils import exceptions as utils_exceptions
from utils.models import gm_token_generator from utils.models import GMTokenGenerator
from utils.views import (JWTCreateAPIView, from utils.views import (JWTCreateAPIView,
JWTGenericViewMixin) JWTGenericViewMixin)
@ -55,7 +55,8 @@ class PasswordResetConfirmView(JWTGenericViewMixin):
filter_kwargs = {'key': token, 'user_id': user_id} filter_kwargs = {'key': token, 'user_id': user_id}
obj = get_object_or_404(queryset, **filter_kwargs) 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() raise utils_exceptions.NotValidAccessTokenError()
# May raise a permission denied # May raise a permission denied
@ -89,6 +90,8 @@ class PasswordContextMixin:
class FormPasswordResetSuccessView(views.APIView): class FormPasswordResetSuccessView(views.APIView):
"""View for successful reset password""" """View for successful reset password"""
permission_classes = (permissions.AllowAny, )
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Implement GET-method""" """Implement GET-method"""
return Response(status=status.HTTP_200_OK) return Response(status=status.HTTP_200_OK)

View File

@ -123,7 +123,8 @@ class LoginByUsernameOrEmailSerializer(JWTBaseSerializerMixin, serializers.Model
username_or_email = attrs.pop('username_or_email') username_or_email = attrs.pop('username_or_email')
password = attrs.pop('password') password = attrs.pop('password')
user_qs = account_models.User.objects.filter(Q(username=username_or_email) | 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(): if not user_qs.exists():
raise utils_exceptions.UserNotFoundError() raise utils_exceptions.UserNotFoundError()
else: else:

View File

@ -15,7 +15,7 @@ def send_confirm_email(user_id):
try: try:
obj = account_models.User.objects.get(id=user_id) obj = account_models.User.objects.get(id=user_id)
obj.send_email(subject=_('Email confirmation'), obj.send_email(subject=_('Email confirmation'),
message=obj.get_confirm_email_template()) message=obj.confirm_email_template)
except: except:
logger.error(f'METHOD_NAME: {send_confirm_email.__name__}\n' logger.error(f'METHOD_NAME: {send_confirm_email.__name__}\n'
f'DETAIL: Exception occurred for user: {user_id}') f'DETAIL: Exception occurred for user: {user_id}')

View File

@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework_simplejwt import tokens as jwt_tokens from rest_framework_simplejwt import tokens as jwt_tokens
from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore
from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer 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 account.models import User
from authorization.models import Application from authorization.models import Application
@ -182,7 +182,8 @@ class VerifyEmailConfirmView(JWTGenericViewMixin):
user_qs = User.objects.filter(pk=uid) user_qs = User.objects.filter(pk=uid)
if user_qs.exists(): if user_qs.exists():
user = user_qs.first() 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() raise utils_exceptions.NotValidTokenError()
# Approve email status # Approve email status
user.confirm_email() user.confirm_email()

View File

@ -189,13 +189,39 @@ class LocaleManagerMixin(models.Manager):
class GMTokenGenerator(PasswordResetTokenGenerator): 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): def _make_hash_value(self, user, timestamp):
return ( """
str(user.pk) + Hash the user's primary key and some user state that's sure to change
str(user.email_confirmed) + after a password reset to produce a token that invalidated when it's
str(timestamp) + used.
str(user.is_active) """
) return self.get_fields(user, timestamp)
gm_token_generator = GMTokenGenerator()

View File

@ -369,6 +369,7 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1
# TEMPLATES # TEMPLATES
CONFIRMATION_PASSWORD_RESET_TEMPLATE = 'account/password_reset_confirm.html' CONFIRMATION_PASSWORD_RESET_TEMPLATE = 'account/password_reset_confirm.html'
RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html' RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html'
CHANGE_EMAIL_TEMPLATE = 'account/change_email.html'
CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html' CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html'

View File

@ -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 %}