version 0.0.21: added endpoints to reset password, added endpoint to finish signing up, move some tasks to celery, added templates

This commit is contained in:
Anatoly 2019-08-20 11:10:07 +03:00
parent 09ad324b43
commit 556e9ea564
19 changed files with 361 additions and 118 deletions

View File

@ -3,13 +3,15 @@ from typing import Union
from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
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 utils.models import ImageMixin, ProjectBaseMixin, PlatformMixin
@ -108,6 +110,24 @@ class User(ImageMixin, AbstractUser):
for token in refresh_tokens:
token.revoke()
@property
def get_signup_finish_token(self):
"""Make a token for finish signup."""
return gm_token_generator.make_token(self)
@property
def get_user_uid(self):
"""Get base64 value for user by primary key identifier"""
return urlsafe_base64_encode(force_bytes(self.pk))
def get_confirm_signup_template(self):
"""Get confirm signup email template"""
return render_to_string(
template_name=settings.CONFIRM_SIGNUP_TEMPLATE,
context={'token': self.get_signup_finish_token,
'uid': self.get_user_uid,
'domain_uri': settings.DOMAIN_URI})
def get_body_email_message(self, subject: str, message: str):
"""Prepare the body of the email message"""
return {
@ -119,7 +139,6 @@ class User(ImageMixin, AbstractUser):
def send_email(self, subject: str, message: str):
"""Send an email to reset user password"""
# todo: move to celery as celery task
send_mail(**self.get_body_email_message(subject=subject,
message=message))
@ -129,11 +148,11 @@ class ResetPasswordTokenQuerySet(models.QuerySet):
def expired(self):
"""Show only expired"""
return self.filter(expiry_datetime__gt=timezone.now())
return self.filter(expiry_datetime__lt=timezone.now())
def valid(self):
"""Show only valid"""
return self.filter(expiry_datetime__lt=timezone.now())
return self.filter(expiry_datetime__gt=timezone.now())
def by_user(self, user):
"""Show obj by user"""
@ -143,8 +162,6 @@ class ResetPasswordTokenQuerySet(models.QuerySet):
class ResetPasswordToken(PlatformMixin, ProjectBaseMixin):
"""Reset password model"""
RESETTING_TOKEN_TEMPLATE_NAME = 'account/password_reset_email.html'
user = models.ForeignKey(User,
related_name='password_reset_tokens',
on_delete=models.CASCADE,
@ -180,14 +197,20 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin):
"""Check if valid token or not"""
return timezone.now() > self.expiry_datetime
@property
def generate_token(self):
"""Generates a pseudo random code"""
return default_token_generator.make_token(self.user)
return gm_token_generator.make_token(self.user)
@staticmethod
def token_is_valid(user, token):
"""Check if token is valid"""
return default_token_generator.check_token(user, token)
return gm_token_generator.check_token(user, token)
def overdue(self):
"""Overdue instance"""
self.expiry_datetime = timezone.now()
self.save()
def save(self, *args, **kwargs):
"""Override save method"""
@ -197,25 +220,14 @@ class ResetPasswordToken(PlatformMixin, ProjectBaseMixin):
timezone.timedelta(hours=self.get_resetting_token_expiration)
)
if not self.key:
self.key = self.generate_token()
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=self.RESETTING_TOKEN_TEMPLATE_NAME,
template_name=settings.RESETTING_TOKEN_TEMPLATE_NAME,
context={'token': self.key,
'uid': self.user.get_user_uid,
'domain_uri': settings.DOMAIN_URI})
def send_reset_password_request(self):
"""Method to reset user password"""
subject = _('Password resetting')
# Send an email with url for resetting a password
self.user.send_email(subject=subject,
message=self.get_reset_password_template())
def confirm_reset_password_request(self):
"""Method to confirm reset user password request"""
# Remove access token and revoke refresh tokens
self.user.remove_access_tokens(source=[Application.MOBILE,
Application.WEB])

View File

@ -1,8 +1,10 @@
"""Serializers for account web"""
from django.contrib.auth import password_validation as password_validators
from django.conf import settings
from rest_framework import serializers
from django.contrib.auth import password_validation as password_validators
from account import models
from account import tasks
from utils import exceptions as utils_exceptions
@ -22,26 +24,46 @@ class PasswordResetSerializer(serializers.ModelSerializer):
obj = models.ResetPasswordToken.objects.create(
user=user,
ip_address=ip_address,
source=models.ResetPasswordToken.MOBILE
source=models.ResetPasswordToken.WEB
)
try:
# todo: make as celery task
obj.send_reset_password_request()
if settings.USE_CELERY:
tasks.send_reset_password_email.delay(obj.id)
else:
tasks.send_reset_password_email(obj.id)
return obj
except:
raise utils_exceptions.EmailSendingError(user.email)
# class PasswordResetConfirmSerializer(serializers.Serializer):
# """Serializer for reset password"""
#
# password = serializers.CharField(write_only=True)
#
# def validate_password(self, data):
# """Custom password validation"""
# try:
# password_validators.validate_password(password=data)
# except serializers.ValidationError as e:
# raise serializers.ValidationError(str(e))
# else:
# return data
class PasswordResetConfirmSerializer(serializers.ModelSerializer):
"""Serializer for model User"""
password = serializers.CharField(write_only=True)
class Meta:
"""Meta class"""
model = models.ResetPasswordToken
fields = ('password', )
def validate(self, attrs):
"""Override validate method"""
user = self.instance.user
password = attrs.get('password')
try:
# Compare new password with the old ones
if user.check_password(raw_password=password):
raise utils_exceptions.PasswordsAreEqual()
# Validate password
password_validators.validate_password(password=password)
except serializers.ValidationError as e:
raise serializers.ValidationError(str(e))
else:
return attrs
def update(self, instance, validated_data):
"""Override update method"""
# Update user password from instance
instance.user.set_password(validated_data.get('password'))
instance.user.save()
# Overdue instance
instance.overdue()
return instance

24
apps/account/tasks.py Normal file
View File

@ -0,0 +1,24 @@
"""Account app celery tasks."""
import logging
from celery import shared_task
from django.utils.translation import gettext_lazy as _
from . import models as account_models
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
@shared_task
def send_reset_password_email(request_id):
"""Send email to user for reset password."""
try:
obj = account_models.ResetPasswordToken.objects.get(id=request_id)
user = obj.user
user.send_email(subject=_('Password resetting'),
message=obj.get_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}')

View File

@ -9,8 +9,9 @@ app_name = 'account'
urlpatterns_api = [
path('reset-password/', views.PasswordResetView.as_view(),
name='password-reset'),
# path('reset-password/<str:token>/confirm/', views.PasswordResetConfirmView.as_view(),
# name='password-reset-confirm'),
path('reset-password/confirm/<str:uid>/<str:token>/',
views.PasswordResetConfirmView.as_view(),
name='password-reset-confirm'),
]
urlpatterns = urlpatterns_api + \

View File

@ -1,29 +1,60 @@
"""Web account views"""
from rest_framework import generics
from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_decode
from rest_framework import permissions
from rest_framework import status
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from account import models
from account.serializers import web as serializers
from utils import exceptions as utils_exceptions
from utils.models import gm_token_generator
from utils.views import (JWTCreateAPIView,
JWTGenericViewMixin)
# Password reset
class PasswordResetView(generics.CreateAPIView):
class PasswordResetView(JWTCreateAPIView):
"""View for resetting user password"""
serializer_class = serializers.PasswordResetSerializer
queryset = models.ResetPasswordToken
queryset = models.ResetPasswordToken.objects.valid()
# class PasswordResetConfirmView(generics.GenericAPIView):
# """View for confirmation new password"""
#
# serializer_class = serializers.PasswordResetConfirmSerializer
#
# def post(self, request, *args, **kwargs):
# """Post method to confirm user change password request"""
# user = request.user
# token = kwargs.get('token')
# serializer = self.get_serializer(data=request.data)
# serializer.is_valid(raise_exception=True)
# if models.ResetPasswordToken.token_is_valid(user=user,
# token=token):
# pass
class PasswordResetConfirmView(JWTGenericViewMixin):
"""View for confirmation new password"""
serializer_class = serializers.PasswordResetConfirmSerializer
permission_classes = (permissions.AllowAny,)
def get_queryset(self):
"""Override get_queryset method"""
return models.ResetPasswordToken.objects.valid()
def get_object(self):
"""Override get_object method
"""
queryset = self.filter_queryset(self.get_queryset())
uidb64 = self.kwargs.get('uid')
uid = force_text(urlsafe_base64_decode(uidb64))
token = self.kwargs.get('token')
filter_kwargs = {'key': token, 'user_id': uid}
obj = get_object_or_404(queryset, **filter_kwargs)
if not gm_token_generator.check_token(user=obj.user, token=token):
raise utils_exceptions.NotValidTokenError()
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
def put(self, request, *args, **kwargs):
"""Implement PUT method"""
instance = self.get_object()
serializer = self.get_serializer(instance=instance,
data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(status=status.HTTP_200_OK)

View File

@ -9,7 +9,7 @@ from django.conf import settings
from account import models as account_models
from authorization.models import Application, BlacklistedAccessToken
from utils import exceptions as utils_exceptions
from rest_framework_simplejwt.tokens import RefreshToken
from utils import methods as utils_methods
# JWT
from rest_framework_simplejwt import tokens
@ -60,7 +60,7 @@ class ClassicAuthSerializerMixin(BaseAuthSerializerMixin):
# Serializers
class SignupSerializer(JWTBaseSerializerMixin, serializers.ModelSerializer):
class SignupSerializer(serializers.ModelSerializer):
"""Signup serializer serializer mixin"""
# REQUEST
username = serializers.CharField(
@ -76,10 +76,19 @@ class SignupSerializer(JWTBaseSerializerMixin, serializers.ModelSerializer):
class Meta:
model = account_models.User
fields = (
'username', 'password', 'email', 'newsletter',
'access_token', 'refresh_token',
'username',
'password',
'email',
'newsletter'
)
def validate_username(self, data):
"""Custom username validation"""
valid = utils_methods.username_validator(username=data)
if not valid:
raise utils_exceptions.NotValidUsernameError()
return data
def validate_password(self, data):
"""Custom password validation"""
try:
@ -137,7 +146,7 @@ class RefreshTokenSerializer(serializers.Serializer):
def validate(self, attrs):
"""Override validate method"""
token = RefreshToken(attrs['refresh_token'])
token = tokens.RefreshToken(attrs['refresh_token'])
data = {'access_token': str(token.access_token)}

View File

@ -0,0 +1,21 @@
"""Authorization app celery tasks."""
import logging
from django.utils.translation import gettext_lazy as _
from celery import shared_task
from account import models as account_models
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
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

@ -1,11 +1,11 @@
"""Common url routing for application authorization"""
from django.conf import settings
from django.urls import path
from django.conf.urls import url
from django.urls import path
from oauth2_provider.views import AuthorizationView
from social_django import views as social_django_views
from rest_framework_social_oauth2 import views as drf_social_oauth2_views
from social_core.utils import setting_name
from social_django import views as social_django_views
from authorization.views import common as views
@ -31,6 +31,8 @@ urlpatterns_oauth2 = [
urlpatterns_jwt = [
path('signup/', views.SignUpView.as_view(),
name='signup'),
path('signup/finish/<str:uid>/<str:token>/', views.SignupFinishView.as_view(),
name='signup-finish'),
# sign in
path('login/', views.LoginByUsernameOrEmailView.as_view(),
name='login'),

View File

@ -3,6 +3,9 @@ import json
from braces.views import CsrfExemptMixin
from django.conf import settings
from django.urls import reverse
from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from oauth2_provider.oauth2_backends import OAuthLibCore
from oauth2_provider.settings import oauth2_settings
@ -16,9 +19,11 @@ from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore
from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer
from account.models import User
from authorization import tasks
from authorization.models import Application
from authorization.serializers import common as serializers
from utils import exceptions as utils_exceptions
from utils.models import gm_token_generator
from utils.views import (JWTGenericViewMixin,
JWTCreateAPIView)
@ -187,26 +192,59 @@ class SignUpView(JWTCreateAPIView):
_locale = self._get_locale(request)
try:
locale = self._check_locale(locale=_locale)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
response = Response(serializer.data, status=status.HTTP_201_CREATED)
access_token = serializer.data.get('access_token')
refresh_token = serializer.data.get('refresh_token')
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:
raise utils_exceptions.LocaleNotExisted(locale=_locale)
else:
return self._put_cookies_in_response(
cookies=self._put_data_in_cookies(
locale=locale,
access_token=access_token,
refresh_token=refresh_token),
cookies=self._put_data_in_cookies(locale=locale),
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
class LoginByUsernameOrEmailView(JWTAuthViewMixin):
"""Login by email and password"""

View File

@ -61,3 +61,24 @@ class LocaleNotExisted(exceptions.APIException):
'detail': self.default_detail % locale
}
super().__init__()
class NotValidUsernameError(exceptions.APIException):
"""The exception should be thrown when passed username has @ symbol
"""
status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Wrong username')
class NotValidTokenError(exceptions.APIException):
"""The exception should be thrown when token in url is not valid
"""
status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Not valid token')
class PasswordsAreEqual(exceptions.APIException):
"""The exception should be raised when passed password is the same as old ones
"""
status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Password is already in use')

View File

@ -2,6 +2,9 @@
import random
from django.http.request import HttpRequest
from rest_framework.request import Request
import re
from django.utils.timezone import datetime
from django.conf import settings
def generate_code(digits=6, string_output=True):
@ -19,3 +22,21 @@ def get_token_from_request(request):
return request.headers.get('Authorization').split(' ')[::-1][0]
elif isinstance(request, Request):
return request._request.headers.get('Authorization').split(' ')[::-1][0]
def username_validator(username: str) -> bool:
"""Validate given username"""
pattern = r'[@,]+'
if re.search(pattern=pattern, string=username):
return False
else:
return True
def image_path(instance, filename):
"""Determine avatar path method."""
filename = '%s.jpeg' % generate_code()
return 'image/%s/%s/%s' % (
instance._meta.model_name,
datetime.now().strftime(settings.REST_DATE_FORMAT),
filename)

View File

@ -1,16 +1,15 @@
"""Utils app models."""
import random
from datetime import datetime
from django.conf import settings
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.contrib.gis.db import models
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.fields.jsonb import KeyTextTransform
from django.utils import timezone
from django.utils.html import mark_safe
from django.utils.translation import ugettext_lazy as _
from easy_thumbnails.fields import ThumbnailerImageField
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.fields.jsonb import KeyTextTransform
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from utils.methods import image_path
class ProjectBaseMixin(models.Model):
@ -53,19 +52,6 @@ class BaseAttributes(ProjectBaseMixin):
abstract = True
def generate_code():
"""Generate code method."""
return '%06d' % random.randint(0, 999999)
def image_path(instance, filename):
"""Determine avatar path method."""
filename = '%s.jpeg' % generate_code()
return 'image/%s/%s/%s' % (
instance._meta.model_name,
datetime.now().strftime(settings.REST_DATE_FORMAT),
filename)
class ImageMixin(models.Model):
"""Avatar model."""
@ -142,12 +128,14 @@ class LocaleManagerMixin(models.Manager):
return queryset
class SignupConfirmationTokenGenerator(PasswordResetTokenGenerator):
class GMTokenGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user, timestamp):
return (
str(user.pk) + str(timestamp) + str(user.is_active)
str(user.pk) +
str(timestamp) +
str(user.is_active)
)
confirm_signup_token = SignupConfirmationTokenGenerator()
gm_token_generator = GMTokenGenerator()

View File

@ -1,6 +1,6 @@
from rest_framework_social_oauth2.backends import DjangoOAuth2
from oauth2_provider.models import AccessToken
from django.contrib.auth.models import User
from oauth2_provider.models import AccessToken
from rest_framework_social_oauth2.backends import DjangoOAuth2
class GMOAuth2(DjangoOAuth2):

View File

@ -1,9 +1,12 @@
from rest_framework import generics
from collections import namedtuple
from rest_framework import generics
from rest_framework import status
from rest_framework.response import Response
from translation import models as translation_models
from utils import exceptions
from rest_framework.response import Response
from rest_framework import status
from rest_framework_simplejwt import tokens
# JWT
@ -18,6 +21,15 @@ class JWTGenericViewMixin(generics.GenericAPIView):
REFRESH_TOKEN_SECURE = False
COOKIE = namedtuple('COOKIE', ['key', 'value', 'http_only', 'secure'])
def _create_jwt_token(self, user) -> dict:
"""Return dictionary with pairs access and refresh tokens"""
token = tokens.RefreshToken.for_user(user)
token['user'] = user.get_user_info()
return {
'access_token': str(token.access_token),
'refresh_token': str(token),
}
def _get_locale(self, request):
"""Get locale from request"""
return request.COOKIES.get('locale')
@ -62,6 +74,16 @@ class JWTGenericViewMixin(generics.GenericAPIView):
def _put_cookies_in_response(self, cookies: list, response: Response):
"""Update COOKIES in response from namedtuple"""
for cookie in cookies:
# todo: remove config for develop
import os
configuration = os.environ.get('SETTINGS_CONFIGURATION', None)
if configuration == 'development':
response.set_cookie(key=cookie.key,
value=cookie.value,
secure=cookie.secure,
httponly=cookie.http_only,
domain='.id-east.ru')
else:
response.set_cookie(key=cookie.key,
value=cookie.value,
secure=cookie.secure,

View File

@ -72,7 +72,7 @@ EXTERNAL_APPS = [
'social_django',
'rest_framework_social_oauth2',
'django_extensions',
'rest_framework_simplejwt.token_blacklist'
'rest_framework_simplejwt.token_blacklist',
]
@ -349,3 +349,12 @@ SIMPLE_JWT = {
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}
# AUTHORIZATION
PASSWORD_RESET_TIMEOUT_DAYS = 1
# TEMPLATES
RESETTING_TOKEN_TEMPLATE_NAME = 'account/password_reset_email.html'
CONFIRM_SIGNUP_TEMPLATE = 'account/confirm_signup.html'

View File

@ -5,8 +5,19 @@ ALLOWED_HOSTS = ['*', ]
SEND_SMS = False
SMS_CODE_SHOW = True
USE_CELERY = False
DOMAIN_URI = '0.0.0.0:8000'
# OTHER SETTINGS
API_HOST = '0.0.0.0:8000'
API_HOST_URL = 'http://%s' % API_HOST
# CELERY
BROKER_URL = 'amqp://rabbitmq:5672'
CELERY_RESULT_BACKEND = BROKER_URL
CELERY_BROKER_URL = BROKER_URL
# Increase access token lifetime for local deploy
SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'] = timedelta(days=365)

View File

@ -0,0 +1,12 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}You're receiving this email because you trying to register new account at {{ site_name }}.{% endblocktrans %}
{% trans "Please confirm your email address to complete the registration:" %}
{% block signup_confirm %}
http://{{ domain_uri }}{% url 'auth:signup-finish' uid=uid token=token %}
{% endblock %}
{% trans "Thanks for using our site!" %}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endautoescape %}

View File

@ -3,9 +3,8 @@
{% trans "Please go to the following page and choose a new password:" %}
{% block reset_link %}
http://{{ domain_uri }}{% url 'web:account:password-reset-confirm' token=token %}
http://{{ domain_uri }}{% url 'web:account:password-reset-confirm' uid=uid token=token %}
{% endblock %}
{% trans 'Your username, in case youve forgotten:' %} {{ user.get_username }}
{% trans "Thanks for using our site!" %}