Skip to content

Commit

Permalink
#620 User workflow: create new account (#142)
Browse files Browse the repository at this point in the history
* saving

* addes todos

* read function for sending email from settings

* use system sender id

* read sms sender for system notifications

* throttlinh for system notifications

* check code

* delete cookie after login, show errors

* pr fix
  • Loading branch information
KlemenSpruk authored Oct 13, 2023
1 parent ec32db9 commit 69efef6
Show file tree
Hide file tree
Showing 14 changed files with 332 additions and 37 deletions.
63 changes: 51 additions & 12 deletions django_project_base/account/rest/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@
from typing import Optional

import swapper
from django.contrib.auth import get_user_model, update_session_auth_hash
from django.contrib.auth import get_user_model, login, update_session_auth_hash
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse, OpenApiTypes
from dynamicforms import fields as df_fields, serializers as df_serializers, viewsets as df_viewsets
from dynamicforms.action import Actions
from rest_framework import fields, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_registration.api.views import change_password, logout, register, verify_registration
from rest_registration.api.views import change_password, logout, register
from rest_registration.settings import registration_settings
from social_django.models import UserSocialAuth

from django_project_base.account.rest.reset_password import ResetPasswordSerializer
Expand Down Expand Up @@ -179,15 +184,7 @@ class SuperUserChangePasswordSerializer(ChangePasswordSerializer):
return response


class VerifyRegistrationSerializer(serializers.Serializer):
user_id = fields.CharField(required=True)
timestamp = fields.IntegerField(required=True)
signature = fields.CharField(required=True)


class VerifyRegistrationViewSet(viewsets.ViewSet):
serializer_class = VerifyRegistrationSerializer()

@extend_schema(
description="Verify registration with signature. The endpoint will generate an e-mail with a confirmation URL "
"which the user should GET to confirm the account.",
Expand All @@ -199,11 +196,53 @@ class VerifyRegistrationViewSet(viewsets.ViewSet):
@action(
detail=False,
methods=["post"],
url_path="verify_registration",
url_path="verify-registration",
url_name="verify-registration",
)
def verify_registration(self, request: Request) -> Response:
return verify_registration(request._request)
if (
(flow_id := request.COOKIES.get("register-flow"))
and (code := cache.get(flow_id))
and (req_code := request.data.get("code"))
and code == req_code
and len(code)
and len(req_code)
and (user := cache.get(code))
):
user.is_active = True
user.save(update_fields=["is_active"])
login(request, user, backend="django_project_base.base.auth_backends.UsersCachingBackend")
response = Response()
response.delete_cookie("register-flow")
return response
raise ValidationError(dict(code=[_("Code invalid")]))

@action(
detail=False,
methods=["post"],
url_path="verify-registration-email-change",
url_name="verify-registration-email-change",
)
def verify_registration_change_email(self, request: Request) -> Response:
if (
(flow_id := request.COOKIES.get("register-flow"))
and (code := cache.get(flow_id))
and len(code)
and (user := cache.get(code))
):
email = request.data.get("email")
if not email:
raise ValidationError(dict(email=[_("Email invalid")]))
try:
validate_email(email)
except DjangoValidationError:
raise ValidationError(dict(email=[_("Email invalid")]))
user.email = email
user.save(update_fields=["email"])
if registration_settings.REGISTER_VERIFICATION_ENABLED:
registration_settings.REGISTER_VERIFICATION_EMAIL_SENDER(request=request, user=user)
return Response()
raise PermissionDenied


class AbstractRegisterSerializer(df_serializers.Serializer):
Expand Down
1 change: 1 addition & 0 deletions django_project_base/account/rest/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,5 @@ def create(self, request: Request, *args, **kwargs) -> Response:
and (user := get_user_model().objects.filter(pk=user_id).first())
):
UserLoginEvent(user=user).trigger(payload=request)
response.delete_cookie("register-flow")
return response
24 changes: 19 additions & 5 deletions django_project_base/account/rest/profile.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import uuid
from random import randrange

import django
Expand All @@ -11,6 +12,7 @@
from django.db.models import ForeignKey, Model, QuerySet
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiResponse
from dynamicforms import fields
Expand All @@ -36,7 +38,10 @@
from django_project_base.account.rest.project_profiles_utils import get_project_members
from django_project_base.base.event import UserRegisteredEvent
from django_project_base.constants import NOTIFY_NEW_USER_SETTING_NAME
from django_project_base.notifications.email_notification import EMailNotification, EMailNotificationWithListOfEmails
from django_project_base.notifications.email_notification import (
EMailNotificationWithListOfEmails,
SystemEMailNotification,
)
from django_project_base.notifications.models import DjangoProjectBaseMessage
from django_project_base.permissions import BasePermissions
from django_project_base.rest.project import ProjectSerializer, ProjectViewSet
Expand Down Expand Up @@ -327,7 +332,17 @@ def list(self, request, *args, **kwargs):
permission_classes=[],
)
def register_account(self, request: Request, **kwargs):
return Response(ProfileRegisterSerializer(None, context=self.get_serializer_context()).data)
register_flow_identifier = str(uuid.uuid4())
response = Response(ProfileRegisterSerializer(None, context=self.get_serializer_context()).data)
response.set_cookie(
"register-flow",
register_flow_identifier,
max_age=settings.CONFIRMATION_CODE_TIMEOUT,
httponly=True,
samesite="Strict",
)
cache.set(register_flow_identifier, get_random_string(length=6), timeout=settings.CONFIRMATION_CODE_TIMEOUT)
return response

@extend_schema(
description="Registering new account",
Expand All @@ -342,7 +357,7 @@ def register_account(self, request: Request, **kwargs):
def create_new_account(self, request: Request, **kwargs):
# set default values
request.data["date_joined"] = datetime.datetime.now()
request.data["is_active"] = True
request.data["is_active"] = False

# call serializer to do the data processing drf way - hijack
serializer = ProfileRegisterSerializer(
Expand Down Expand Up @@ -592,7 +607,7 @@ def create(self, request, *args, **kwargs):
.first()
) and sett.python_value:
recipients = [response.data[get_pk_name(get_user_model())]]
EMailNotification(
SystemEMailNotification(
message=DjangoProjectBaseMessage(
subject=_("Your account was created for you"),
body=render_to_string(
Expand All @@ -604,7 +619,6 @@ def create(self, request, *args, **kwargs):
footer="",
content_type=DjangoProjectBaseMessage.HTML,
),
raw_recipents=recipients,
project=project.slug,
recipients=recipients,
user=self.request.user.pk,
Expand Down
30 changes: 30 additions & 0 deletions django_project_base/account/service/register_user_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext as __
from natural.date import compress
from rest_framework.request import Request

from django_project_base.notifications.email_notification import SystemEMailNotification
from django_project_base.notifications.models import DjangoProjectBaseMessage


def send_register_verification_email_notification(
request: Request,
user,
) -> None:
if (flow_id := request.COOKIES.get("register-flow")) and (code := cache.get(flow_id)):
SystemEMailNotification(
message=DjangoProjectBaseMessage(
subject=f"{__('Account confirmation for')} {request.META['HTTP_HOST']}",
body=f"{__('You or someone acting as you registered an account at')} "
f"{request.META['HTTP_HOST']}. "
f"\n\n{__('Your verification code is')}: "
f"{code} \n\n {__('Code is valid for')} {compress(settings.CONFIRMATION_CODE_TIMEOUT)}.\n\n"
f"{__('If this was not you or it was unintentional, you may safely ignore this message.')}",
footer="",
content_type=DjangoProjectBaseMessage.PLAIN_TEXT,
),
recipients=[user.pk],
user=user.pk,
).send()
cache.set(code, user, timeout=settings.CONFIRMATION_CODE_TIMEOUT)
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
from rest_registration.utils.users import get_user_verification_id

from django_project_base.account.constants import RESET_USER_PASSWORD_VERIFICATION_CODE
from django_project_base.notifications.base.enums import NotificationLevel, NotificationType as NotificationTypeDPB
from django_project_base.notifications.email_notification import EMailNotification
from django_project_base.notifications.email_notification import SystemEMailNotification
from django_project_base.notifications.models import DjangoProjectBaseMessage


Expand All @@ -31,7 +30,7 @@ def send_reset_password_verification_email(request: Request, user, send=False) -
code = get_random_string(length=6)
cache.set(code_ck, code, timeout=settings.CONFIRMATION_CODE_TIMEOUT)

EMailNotification(
SystemEMailNotification(
message=DjangoProjectBaseMessage(
subject=f"{__('Password recovery for')} {request.META['HTTP_HOST']}",
body=f"{__('You or someone acting as you requested a password reset for your account at')} "
Expand All @@ -42,11 +41,6 @@ def send_reset_password_verification_email(request: Request, user, send=False) -
footer="",
content_type=DjangoProjectBaseMessage.PLAIN_TEXT,
),
raw_recipents=[user.pk],
project=None,
persist=True,
level=NotificationLevel.INFO,
type=NotificationTypeDPB.STANDARD,
recipients=[user.pk],
user=request.user.pk,
).send()
Expand Down
3 changes: 3 additions & 0 deletions django_project_base/base/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import swapper
from django.conf import settings
from django.shortcuts import get_object_or_404
from rest_registration.settings import registration_settings

from django_project_base.constants import EMAIL_SENDER_ID_SETTING_NAME

Expand Down Expand Up @@ -98,6 +99,8 @@ def trigger(self, payload=None, **kwargs):
UserInviteFoundEvent(self.user).trigger(payload=invite, request=payload)
return
payload.session.pop("invite-pk", None)
if registration_settings.REGISTER_VERIFICATION_ENABLED:
registration_settings.REGISTER_VERIFICATION_EMAIL_SENDER(request=payload, user=self.user)


class UserLoginEvent(BaseEvent):
Expand Down
8 changes: 5 additions & 3 deletions django_project_base/licensing/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ def log(
if not allowed_users:
allowed_users = getattr(kwargs.get("settings", object()), "NOTIFICATIONS_ALLOWED_USERS", [])

if str(user_profile_pk) not in list(map(str, allowed_users)):
is_system_notification = kwargs.get("is_system_notification")

if not is_system_notification and str(user_profile_pk) not in list(map(str, allowed_users)):
raise PermissionDenied

if used >= MONTHLY_ACCESS_LIMIT_IN_CURRENCY_UNITS: # janez medja
if not is_system_notification and used >= MONTHLY_ACCESS_LIMIT_IN_CURRENCY_UNITS: # janez medja
raise PermissionDenied(gettext("Your license is consumed. Please contact support."))

if on_sucess:
Expand All @@ -112,7 +114,7 @@ def log(
content_type_object_id=str(record.pk).replace("-", ""),
content_type=content_type,
amount=amount,
comment=dict(comment=comment, count=accesses_used, item_price=item_price),
comment=dict(comment=comment, count=accesses_used, item_price=item_price, sender=kwargs.get("sender", "")),
)

return accesses_used
6 changes: 4 additions & 2 deletions django_project_base/notifications/base/channels/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ def _set_provider(self, val: ProviderIntegration):

def sender(self, notification: DjangoProjectBaseNotification) -> str:
_sender = getattr(notification, "sender", {}).get(self.name)
assert _sender, "Notification sender is required"
if not getattr(settings, "TESTING", False):
assert _sender, "Notification sender is required"
return _sender

def _find_provider(
Expand Down Expand Up @@ -135,7 +136,8 @@ def send(self, notification: DjangoProjectBaseNotification, extra_data, **kwargs

def make_send(notification_obj, rec_obj, message_str, dlr_pk) -> Optional[DeliveryReport]:
try:
self.provider.client_send(self.sender(notification_obj), rec_obj, message_str, dlr_pk)
if not getattr(settings, "TESTING", False):
self.provider.client_send(self.sender(notification_obj), rec_obj, message_str, dlr_pk)
sent = True
except Exception as te:
logger.exception(te)
Expand Down
11 changes: 11 additions & 0 deletions django_project_base/notifications/base/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ def _ensure_channels(
for channel_name in channels:
# ensure dlr user and check providers
channel = ChannelIdentifier.channel(channel_name, extra_data=self._extra_data, project_slug=self._project)

if not channel and self._extra_data.get("is_system_notification"):
continue

assert channel

if self.send_notification_sms and channel.name == MailChannel.name:
Expand All @@ -223,4 +227,11 @@ def _ensure_channels(
notification.user = self._user

notification.sender = Notification._get_sender_config(self._project)

if self._extra_data.get("is_system_notification"):
notification.sender[MailChannel.name] = getattr(settings, "SYSTEM_EMAIL_SENDER_ID", "")
from django_project_base.notifications.base.channels.sms_channel import SmsChannel

notification.sender[SmsChannel.name] = getattr(settings, "SYSTEM_SMS_SENDER_ID", "")

return notification
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ def make_send(self, notification: DjangoProjectBaseNotification, extra_data) ->
on_sucess=lambda: channel.send(notification, extra_data),
db=db_connection,
settings=extra_data.get("SETTINGS", object()),
is_system_notification=extra_data.get("is_system_notification"),
sender=channel.sender(notification),
)
sent_channels.append(channel) if any_sent > 0 else failed_channels.append(channel)
except Exception as e:
Expand Down
55 changes: 55 additions & 0 deletions django_project_base/notifications/email_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import uuid
from typing import List, Optional, Type

from django.core.cache import cache
from rest_framework.exceptions import PermissionDenied

from django_project_base.notifications.base.channels.channel import Channel
from django_project_base.notifications.base.channels.mail_channel import MailChannel
from django_project_base.notifications.base.enums import NotificationLevel, NotificationType
Expand Down Expand Up @@ -89,3 +92,55 @@ def send(self) -> DjangoProjectBaseNotification:
]
self.enqueue_notification(notification, self._extra_data)
return notification


class SystemEMailNotification(EMailNotification):
system_mail_throttling_ck = "system-mail-throttling-ck"
allowed_number_of_system_requests_per_minute = 5

def __init__(
self,
message: DjangoProjectBaseMessage,
recipients,
**kwargs,
) -> None:
super().__init__(
message,
raw_recipents=recipients,
project=None,
persist=True,
level=None,
locale=None,
delay=int(datetime.datetime.now().timestamp()),
type=None,
recipients=recipients,
is_system_notification=True,
**kwargs,
)

def _get_system_email_cache_key(self) -> str:
return f"{self.system_mail_throttling_ck}-{datetime.datetime.now().minute}"

def _get_system_email_cache_value(self) -> List[float]:
if ck_val := cache.get(self._get_system_email_cache_key()):
return ck_val
return []

def _check_request_limit(self):
if len(self._get_system_email_cache_value()) > self.allowed_number_of_system_requests_per_minute:
raise PermissionDenied

def _register_system_email(self):
if ck_val := self._get_system_email_cache_value():
if len(ck_val) > self.allowed_number_of_system_requests_per_minute + 1:
return
ck_val.append(datetime.datetime.now().timestamp())
cache.set(self._get_system_email_cache_key(), ck_val, timeout=70)
return
cache.set(self._get_system_email_cache_key(), [datetime.datetime.now().timestamp()], timeout=70)

def send(self) -> DjangoProjectBaseNotification:
# throttling for system messages
self._check_request_limit()
self._register_system_email()
return super().send()
Loading

0 comments on commit 69efef6

Please sign in to comment.