added endpoint to account app; added "email_confirmed" field to User model; refactored auth app;

This commit is contained in:
Anatoly 2019-08-20 15:43:59 +03:00
parent 3c00dd3d1d
commit 047803bf43
16 changed files with 334 additions and 84 deletions

60
apps/account/forms.py Normal file
View File

@ -0,0 +1,60 @@
from django import forms
from django.contrib.auth import (
password_validation,
)
from django.utils.translation import gettext_lazy as _
from utils import exceptions
class SetPasswordForm(forms.Form):
"""
A form that lets a user change set their password without entering the old
password
"""
error_messages = {
'password_mismatch': _("The two password fields didn't match."),
'password_equal': _("Password already in use."),
}
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput,
strip=False,
help_text=password_validation.password_validators_help_text_html(),
)
new_password2 = forms.CharField(
label=_("New password confirmation"),
strip=False,
widget=forms.PasswordInput,
)
def __init__(self, user, *args, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def clean_new_password1(self):
password1 = self.cleaned_data.get('new_password1')
if self.user.check_password(password1):
raise forms.ValidationError(
self.error_messages['password_equal'],
code='password_equal',
)
return password1
def clean_new_password2(self):
password1 = self.cleaned_data.get('new_password1')
password2 = self.cleaned_data.get('new_password2')
if password1 and password2:
if password1 != password2:
raise forms.ValidationError(
self.error_messages['password_mismatch'],
code='password_mismatch',
)
password_validation.validate_password(password2, self.user)
return password2
def save(self, commit=True):
password = self.cleaned_data["new_password1"]
self.user.set_password(password)
if commit:
self.user.save()
return self.user

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2019-08-20 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0003_resetpasswordtoken'),
]
operations = [
migrations.AddField(
model_name='user',
name='email_confirmed',
field=models.BooleanField(default=False, verbose_name='email status'),
),
]

View File

@ -3,18 +3,19 @@ from typing import Union
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager
from django.contrib.auth.tokens import default_token_generator as password_token_generator
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import models from django.db import models
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from django.utils.encoding import force_bytes, force_text
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from utils.models import gm_token_generator
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
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@ -57,6 +58,7 @@ class User(ImageMixin, AbstractUser):
"""Base user model.""" """Base user model."""
email = models.EmailField(_('email address'), blank=True, email = models.EmailField(_('email address'), blank=True,
null=True, default=None) null=True, default=None)
email_confirmed = models.BooleanField(_('email status'), default=False)
newsletter = models.NullBooleanField(default=True) newsletter = models.NullBooleanField(default=True)
EMAIL_FIELD = 'email' EMAIL_FIELD = 'email'
@ -93,6 +95,11 @@ class User(ImageMixin, AbstractUser):
def remove_token(self): def remove_token(self):
Token.objects.filter(user=self).delete() Token.objects.filter(user=self).delete()
def confirm_email(self):
"""Method to confirm user email address"""
self.email_confirmed = True
self.save()
def remove_access_tokens(self, source: Union[int, Union[tuple, list]]): def remove_access_tokens(self, source: Union[int, Union[tuple, list]]):
"""Method to remove user access tokens""" """Method to remove user access tokens"""
source = source if isinstance(source, list) else [source, ] source = source if isinstance(source, list) else [source, ]
@ -111,21 +118,26 @@ class User(ImageMixin, AbstractUser):
token.revoke() token.revoke()
@property @property
def get_signup_finish_token(self): def get_confirm_email_token(self):
"""Make a token for finish signup.""" """Make a token for finish signup."""
return gm_token_generator.make_token(self) return gm_token_generator.make_token(self)
@property @property
def get_user_uid(self): def get_reset_password_token(self):
"""Make a token for finish signup."""
return password_token_generator.make_token(self)
@property
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_signup_template(self): def get_confirm_email_template(self):
"""Get confirm signup email template""" """Get confirm email template"""
return render_to_string( return render_to_string(
template_name=settings.CONFIRM_SIGNUP_TEMPLATE, template_name=settings.CONFIRM_EMAIL_TEMPLATE,
context={'token': self.get_signup_finish_token, context={'token': self.get_confirm_email_token,
'uid': self.get_user_uid, 'uid': self.get_user_uidb64,
'domain_uri': settings.DOMAIN_URI}) 'domain_uri': settings.DOMAIN_URI})
def get_body_email_message(self, subject: str, message: str): def get_body_email_message(self, subject: str, message: str):
@ -200,7 +212,7 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin):
@property @property
def generate_token(self): def generate_token(self):
"""Generates a pseudo random code""" """Generates a pseudo random code"""
return gm_token_generator.make_token(self.user) return password_token_generator.make_token(self.user)
@staticmethod @staticmethod
def token_is_valid(user, token): def token_is_valid(user, token):
@ -226,8 +238,8 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin):
def get_reset_password_template(self): def get_reset_password_template(self):
"""Get reset password template""" """Get reset password template"""
return render_to_string( return render_to_string(
template_name=settings.RESETTING_TOKEN_TEMPLATE_NAME, template_name=settings.RESETTING_TOKEN_TEMPLATE,
context={'token': self.key, context={'token': self.key,
'uid': self.user.get_user_uid, 'uidb64': self.user.get_user_uidb64,
'domain_uri': settings.DOMAIN_URI}) 'domain_uri': settings.DOMAIN_URI})

View File

@ -4,7 +4,7 @@ import logging
from celery import shared_task from celery import shared_task
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from . import models as account_models from . import models
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
def send_reset_password_email(request_id): def send_reset_password_email(request_id):
"""Send email to user for reset password.""" """Send email to user for reset password."""
try: try:
obj = account_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.get_reset_password_template())
@ -22,3 +22,15 @@ def send_reset_password_email(request_id):
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 send_confirm_email(user_id):
"""Send verification email to user."""
try:
obj = models.User.objects.get(id=user_id)
obj.send_email(subject=_('Email confirmation'),
message=obj.get_confirm_email_template())
except:
logger.error(f'METHOD_NAME: {send_confirm_email.__name__}\n'
f'DETAIL: Exception occurred for user: {user_id}')

View File

@ -7,11 +7,17 @@ from account.views import web as views
app_name = 'account' app_name = 'account'
urlpatterns_api = [ urlpatterns_api = [
path('verify/email/', views.VerifyEmailView.as_view(),
name='verify-email'),
path('verify/email/confirm/<uidb64>/<token>/', views.VerifyEmailConfirmView.as_view(),
name='verify-email-confirm'),
path('reset-password/', views.PasswordResetView.as_view(), path('reset-password/', views.PasswordResetView.as_view(),
name='password-reset'), name='password-reset'),
path('reset-password/confirm/<str:uid>/<str:token>/', path('form/reset-password/<uidb64>/<token>/', views.FormPasswordResetConfirmView.as_view(),
views.PasswordResetConfirmView.as_view(), name='form-password-reset-confirm'),
name='password-reset-confirm'), # Redirect endpoint after success
path('form/reset-password/success/', views.FormPasswordResetSuccessView.as_view(),
name='form-password-reset-success'),
] ]
urlpatterns = urlpatterns_api + \ urlpatterns = urlpatterns_api + \

View File

@ -1,17 +1,70 @@
"""Web account views""" """Web account views"""
from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_decode from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic.edit import FormView
from rest_framework import permissions from rest_framework import permissions
from rest_framework import status from rest_framework import status
from django.shortcuts import get_object_or_404 from rest_framework import views
from rest_framework.response import Response from rest_framework.response import Response
from account import tasks
from account import models from account import models
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 gm_token_generator
from utils.views import (JWTCreateAPIView, from utils.views import (JWTCreateAPIView,
JWTGenericViewMixin) JWTGenericViewMixin,
JWTUpdateAPIView)
# Email confirmation
class VerifyEmailView(JWTGenericViewMixin):
"""View for confirmation email"""
def post(self, request, *args, **kwargs):
"""Implement POST method"""
user = request.user
if user.email_confirmed:
raise utils_exceptions.EmailConfirmedError()
# Send verification link on user email
if settings.USE_CELERY:
tasks.send_confirm_email.delay(user.id)
else:
tasks.send_confirm_email(user.id)
return Response(status=status.HTTP_200_OK)
class VerifyEmailConfirmView(JWTGenericViewMixin):
"""View for confirmation 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 gm_token_generator.check_token(user, token):
raise utils_exceptions.NotValidTokenError()
# Change email status
user.confirm_email()
return Response(status=status.HTTP_200_OK)
else:
raise utils_exceptions.UserNotFoundError()
# Password reset # Password reset
@ -34,12 +87,12 @@ class PasswordResetConfirmView(JWTGenericViewMixin):
"""Override get_object method """Override get_object method
""" """
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
uidb64 = self.kwargs.get('uid') uidb64 = self.kwargs.get('uidb64')
uid = force_text(urlsafe_base64_decode(uidb64)) user_id = force_text(urlsafe_base64_decode(uidb64))
token = self.kwargs.get('token') token = self.kwargs.get('token')
filter_kwargs = {'key': token, 'user_id': uid} 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 gm_token_generator.check_token(user=obj.user, token=token):
@ -58,3 +111,100 @@ class PasswordResetConfirmView(JWTGenericViewMixin):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
return Response(status=status.HTTP_200_OK) return Response(status=status.HTTP_200_OK)
# Form view
class PasswordContextMixin:
extra_context = None
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'title': self.title,
**(self.extra_context or {})
})
return context
class FormPasswordResetSuccessView(views.APIView):
"""View for successful reset password"""
def get(self, request, *args, **kwargs):
"""Implement GET-method"""
return Response(status=status.HTTP_200_OK)
class FormPasswordResetConfirmView(PasswordContextMixin, FormView):
INTERNAL_RESET_URL_TOKEN = 'set-password'
INTERNAL_RESET_SESSION_TOKEN = '_password_reset_token'
form_class = SetPasswordForm
post_reset_login = False
post_reset_login_backend = None
success_url = reverse_lazy('web:account:form-password-reset-success')
template_name = settings.CONFIRMATION_PASSWORD_RESET_TEMPLATE
title = _('Enter new password')
token_generator = default_token_generator
@method_decorator(sensitive_post_parameters())
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
assert 'uidb64' in kwargs and 'token' in kwargs
self.validlink = False
self.user = self.get_user(kwargs['uidb64'])
if self.user is not None:
token = kwargs['token']
if token == self.INTERNAL_RESET_URL_TOKEN:
session_token = self.request.session.get(self.INTERNAL_RESET_SESSION_TOKEN)
if self.token_generator.check_token(self.user, session_token):
# If the token is valid, display the password reset form.
self.validlink = True
return super().dispatch(*args, **kwargs)
else:
if self.token_generator.check_token(self.user, token):
# Store the token in the session and redirect to the
# password reset form at a URL without the token. That
# avoids the possibility of leaking the token in the
# HTTP Referer header.
self.request.session[self.INTERNAL_RESET_SESSION_TOKEN] = token
redirect_url = self.request.path.replace(token, self.INTERNAL_RESET_URL_TOKEN)
return HttpResponseRedirect(redirect_url)
# Display the "Password reset unsuccessful" page.
return self.render_to_response(self.get_context_data())
def get_user(self, uidb64):
try:
# urlsafe_base64_decode() decodes to bytestring
uid = urlsafe_base64_decode(uidb64).decode()
user = models.User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, models.User.DoesNotExist, ValidationError):
user = None
return user
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.user
return kwargs
def form_valid(self, form):
# Saving form
form.save()
# Pop token
del self.request.session[self.INTERNAL_RESET_SESSION_TOKEN]
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.validlink:
context['validlink'] = True
else:
context.update({
'form': None,
'title': _('Password reset unsuccessful'),
'validlink': False,
})
return context

View File

@ -134,7 +134,7 @@ class LoginByUsernameOrEmailSerializer(JWTBaseSerializerMixin, serializers.Model
authentication = authenticate(username=user.get_username(), authentication = authenticate(username=user.get_username(),
password=password) password=password)
if not authentication: if not authentication:
raise utils_exceptions.UserNotFoundError() raise utils_exceptions.WrongAuthCredentials()
self.instance = user self.instance = user
return attrs return attrs

View File

@ -7,15 +7,3 @@ from account import models as account_models
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@shared_task
def send_confirm_signup_email(user_id):
"""Send verification email to user."""
try:
obj = account_models.User.objects.get(id=user_id)
obj.send_email(subject=_('Confirm signup'),
message=obj.get_confirm_signup_template())
except:
logger.error(f'METHOD_NAME: {send_confirm_signup_email.__name__}\n'
f'DETAIL: Exception occurred for user: {user_id}')

View File

@ -31,8 +31,6 @@ urlpatterns_oauth2 = [
urlpatterns_jwt = [ urlpatterns_jwt = [
path('signup/', views.SignUpView.as_view(), path('signup/', views.SignUpView.as_view(),
name='signup'), name='signup'),
path('signup/finish/<str:uid>/<str:token>/', views.SignupFinishView.as_view(),
name='signup-finish'),
# sign in # sign in
path('login/', views.LoginByUsernameOrEmailView.as_view(), path('login/', views.LoginByUsernameOrEmailView.as_view(),
name='login'), name='login'),

View File

@ -195,11 +195,7 @@ class SignUpView(JWTCreateAPIView):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
response = Response(serializer.data, status=status.HTTP_201_CREATED) response = Response(status=status.HTTP_201_CREATED)
if settings.USE_CELERY:
tasks.send_confirm_signup_email.delay(serializer.instance.id)
else:
tasks.send_confirm_signup_email(serializer.instance.id)
except utils_exceptions.LocaleNotExisted: except utils_exceptions.LocaleNotExisted:
raise utils_exceptions.LocaleNotExisted(locale=_locale) raise utils_exceptions.LocaleNotExisted(locale=_locale)
else: else:
@ -208,43 +204,6 @@ class SignUpView(JWTCreateAPIView):
response=response) response=response)
class SignupFinishView(JWTGenericViewMixin):
"""View for confirmation signup"""
permission_classes = (permissions.AllowAny, )
def get(self, request, *args, **kwargs):
"""Implement GET-method"""
_locale = self._get_locale(request)
try:
locale = self._check_locale(locale=_locale)
uidb64 = kwargs.get('uid')
token = kwargs.get('token')
uid = force_text(urlsafe_base64_decode(uidb64))
user = User.objects.filter(pk=uid)
if user.exists():
if not gm_token_generator.check_token(user.first(), token):
raise utils_exceptions.NotValidTokenError()
response = Response(status=status.HTTP_200_OK)
else:
raise utils_exceptions.UserNotFoundError()
except utils_exceptions.LocaleNotExisted:
raise utils_exceptions.LocaleNotExisted(locale=_locale)
else:
return self._put_cookies_in_response(
cookies=self._put_data_in_cookies(locale=locale),
response=response)
def get_success_url(self):
"""Return url to success page considering Mobile component."""
return reverse('mobile:transaction-mobile:success')
def get_fail_url(self, **kwargs):
"""Return url to fail page considering Mobile component."""
return reverse('mobile:transaction-mobile:fail')
# Login by username|email + password # Login by username|email + password
class LoginByUsernameOrEmailView(JWTAuthViewMixin): class LoginByUsernameOrEmailView(JWTAuthViewMixin):
"""Login by email and password""" """Login by email and password"""

View File

@ -82,3 +82,17 @@ class PasswordsAreEqual(exceptions.APIException):
""" """
status_code = status.HTTP_400_BAD_REQUEST status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Password is already in use') default_detail = _('Password is already in use')
class EmailConfirmedError(exceptions.APIException):
"""The exception should be raised when user email status is already confirmed
"""
status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Email address is already confirmed')
class WrongAuthCredentials(exceptions.APIException):
"""The exception should be raised when credentials is not valid for this user
"""
status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Wrong authorization credentials')

View File

@ -133,6 +133,7 @@ class GMTokenGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user, timestamp): def _make_hash_value(self, user, timestamp):
return ( return (
str(user.pk) + str(user.pk) +
str(user.email_confirmed) +
str(timestamp) + str(timestamp) +
str(user.is_active) str(user.is_active)
) )

View File

@ -356,5 +356,6 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1
# TEMPLATES # TEMPLATES
RESETTING_TOKEN_TEMPLATE_NAME = 'account/password_reset_email.html' CONFIRMATION_PASSWORD_RESET_TEMPLATE = 'account/password_reset_confirm.html'
CONFIRM_SIGNUP_TEMPLATE = 'account/confirm_signup.html' RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html'
CONFIRM_EMAIL_TEMPLATE = 'account/confirm_email.html'

View File

@ -3,7 +3,7 @@
{% trans "Please confirm your email address to complete the registration:" %} {% trans "Please confirm your email address to complete the registration:" %}
{% block signup_confirm %} {% block signup_confirm %}
http://{{ domain_uri }}{% url 'auth:signup-finish' uid=uid token=token %} http://{{ domain_uri }}{% url 'web:account:verify-email-confirm' uidb64=uid token=token %}
{% endblock %} {% endblock %}
{% trans "Thanks for using our site!" %} {% trans "Thanks for using our site!" %}

View File

@ -0,0 +1,31 @@
{% load i18n static %}
{% block content %}
{% if validlink %}
<p>{% trans "Please enter your new password twice so we can verify you typed it in correctly." %}</p>
<form method="post">{% csrf_token %}
<fieldset class="module aligned">
<div class="form-row field-password1">
{{ form.new_password1.errors }}
<label for="id_new_password1">{% trans 'New password:' %}</label>
{{ form.new_password1 }}
</div>
<div class="form-row field-password2">
{{ form.new_password2.errors }}
<label for="id_new_password2">{% trans 'Confirm password:' %}</label>
{{ form.new_password2 }}
</div>
<input type="submit" value="{% trans 'Change my password' %}">
</fieldset>
</form>
{% else %}
<p>{% trans "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}</p>
{% endif %}
{% endblock %}

View File

@ -3,7 +3,7 @@
{% trans "Please go to the following page and choose a new password:" %} {% trans "Please go to the following page and choose a new password:" %}
{% block reset_link %} {% block reset_link %}
http://{{ domain_uri }}{% url 'web:account:password-reset-confirm' uid=uid token=token %} http://{{ domain_uri }}{% url 'web:account:form-password-reset-confirm' uidb64=uidb64 token=token %}
{% endblock %} {% endblock %}
{% trans "Thanks for using our site!" %} {% trans "Thanks for using our site!" %}