@@ -19,6 +19,15 @@ class BusinessAccountSelector: return cls(account.first()) + @classmethod + def filter_by_email(cls, email: str, company: BusinessUserHost | None = None): + account = BusinessAccount.objects.filter(user__email=email) + if company is not None: + account = account.filter(parent_company=company) + if not account.exists(): + return None + return account.first() + def account_type(self) -> str: return self.account.account_privileges @@ -2,6 +2,7 @@ from decimal import Decimal from typing import Any, OrderedDict, Tuple from django.utils.translation import gettext_lazy as _ +from rest_framework.request import Request from authentication.models import ( BusinessAccount, @@ -9,6 +10,7 @@ from authentication.models import ( CustomUserModel, ) from authentication.models.choices import InvitationStatus +from authentication.serializers import ChangePasswordSerializer class BusinessAccountService: @@ -59,6 +61,12 @@ class BusinessAccountService: return cls(account.first()) + @classmethod + def get_company_name(cls, user: CustomUserModel): + if user.account_type == 'business_host': + return user.host_account + return user.business_account.parent_company + def update(self, data: OrderedDict) -> BusinessAccount: if data['status'] == InvitationStatus.ACCEPTED: self.accept() @@ -84,6 +92,22 @@ class BusinessAccountService: self.account.account_privileges = new_privileges self.account.save() + def update_user_password(self, request: Request): + serializer = ChangePasswordSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if serializer.validated_data['password_1'] != serializer.validated_data['password_2']: + raise Exception(_("Passwords don't match")) + + if self.account.user.account_type == 'business_security' and request.user.account_type == 'business_security': + raise Exception(_("You do not have sufficient rights to perform this action")) + + if self.account.user.account_type not in ('business_account', 'business_security'): + raise Exception(_("You do not have sufficient rights to perform this action")) + + self.account.user.set_password(serializer.validated_data['password_1']) + self.account.user.save() + def update_status(self, status: Tuple[str, Any]): if status not in self.STATUS_STATE_MACHINE[self.account.acceptance_status]: raise Exception('Update to this state is impossible') @@ -248,3 +248,14 @@ class BusinessHostService: self.user.host_account.save() return BusinessHostSelector(self.user).get_allowed_models() + + def reinvite_business_account(self, business_account: BusinessAccount) -> None: + if ( + self.user.account_type == business_account.user.account_type + and self.user.account_type == 'business_admin' + ): + raise Exception(_("You do not have sufficient rights to perform this action")) + password = generate_token(15) + business_account.user.set_password(password) + business_account.user.save() + EmailService(self.user).send_reinvited_email(business_account, password) @@ -139,6 +139,23 @@ class EmailService: ) mail.send(fail_silently=True) + def send_reinvited_email(self, account: BusinessAccount, password: str | None = None) -> None: + if self.user.account_type not in ('business_host', 'business_admin'): + raise Exception(_('Regular users cannot send introductory letters')) + message = f""" + Новые данные для входа на платформу AIR: + + email: {account.user.email} + password: {password} + """ + mail = EmailMessage( + subject='Ваш аккаунт на платформе AIR', + body=message, + from_email=settings.EMAIL_HOST_USER, + to=(account.user.email,), + ) + mail.send(fail_silently=True) + def send_corporate_invitation_email(self, account: BusinessAccount): if self.user.host_account is None: raise Exception(_('Regular users cannot send invitation letters')) @@ -1,14 +1,16 @@ -from logging import getLogger +import logging from typing import Any +from django.conf import settings from django.contrib.auth import logout from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse from authentication.models.business_host import BusinessUserHost from authentication.selectors.user_selector import UserSelector from authentication.utils import get_client_ip -logger = getLogger('test') +logger = logging.getLogger(__name__) class CompanyIPMiddleware: @@ -16,7 +18,6 @@ class CompanyIPMiddleware: self.get_response = get_response def __call__(self, request) -> Any: - response = self.get_response(request) if request.user.is_authenticated: acc_type = UserSelector(request.user).check_account_type() if acc_type == 'regular' or request.user.is_staff or request.user.is_superuser: @@ -33,4 +34,24 @@ class CompanyIPMiddleware: ): logout(request) raise PermissionDenied + response = self.get_response(request) + return response + + +class AdminSecurityMiddleware: + def __init__(self, get_response) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest): + client_ip = get_client_ip(request) + if ( + 'djangoadmin' in request.get_full_path().split('/') + and (proxy_addr := settings.ADMIN_SETTINGS.get('security', {}).get('address')) + and proxy_addr not in client_ip + ): + return HttpResponse( + f"403 Forbidden; You aren't login correctly; Current IP: {client_ip}", + status=403, + ) + response = self.get_response(request) return response @@ -64,3 +64,10 @@ class HasBusinessAdminPermissions(BasePermission): account_type = request.user.business_account.account_privileges return account_type == AccountPrivileges.ADMIN + + +class ChangeEmployeePassPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + return request.user.account_type in ('business_host', 'business_admin', 'business_security') @@ -74,6 +74,11 @@ urlpatterns = [ views.BusinessHostAPIView.as_view(), name='business-host', ), + path( + 'business-host/re-invite/', + views.ReinviteBusinessAccountAPIView.as_view(), + name='business-host-re-invite', + ), path( 'business-host/create', views.StartHostRegistrationAPIView.as_view(), @@ -85,6 +90,11 @@ urlpatterns = [ name='allowed-models', ), path('business-host/logs/', views.LogsAPIView.as_view()), + path( + 'business-host/account/change-pass/', + views.ChangeHostPassAPIView.as_view(), + name='change_host_pass', + ), path( 'business-host/accounts', views.HostWorkersAPIView.as_view(), @@ -1,6 +1,8 @@ from decimal import Decimal from secrets import token_urlsafe +from django.http import HttpRequest + def generate_token(size: int) -> str: return token_urlsafe(size) @@ -10,10 +12,6 @@ def get_default_token_amount() -> Decimal: return Decimal('1000') -def get_client_ip(request): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - ip = x_forwarded_for.split(',')[0] - else: - ip = request.META.get('REMOTE_ADDR') - return ip +def get_client_ip(request: HttpRequest) -> str: + x_forwarded_for: str = request.META.get('HTTP_X_FORWARDED_FOR', '') + return x_forwarded_for.split(',')[0] if x_forwarded_for else request.META.get('REMOTE_ADDR', '') @@ -3,6 +3,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal from itertools import chain from logging import getLogger +from typing import Any, Tuple, Dict from uuid import UUID from django.conf import settings @@ -36,7 +37,9 @@ from authentication.permissions import ( IsBusinessSecurity, IsTelegramAirBot, IsVKMiniApp, + ChangeEmployeePassPermission, ) +from authentication.selectors.business_account_selector import BusinessAccountSelector from authentication.selectors.business_host_selector import ( BusinessHostSelector, ) @@ -71,6 +74,7 @@ from authentication.serializers import ( UpdateProfilePictureSerializer, UpdateUserDataSerializer, UserDataSerializer, + ChangePasswordSerializer, ) from authentication.services.business_account_service import ( BusinessAccountService, @@ -306,6 +310,28 @@ class BusinessHostAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) +class ReinviteBusinessAccountAPIView(APIView): + permission_classes = (HasBusinessAdminPermissions,) + + @extend_schema( + parameters=[ + OpenApiParameter('email', str, 'path', required=True), + ], + ) + def post(self, request: Request, email: str, *args: Tuple[Any], **kwargs: Dict[Any, Any]) -> Response: + """Re-invite business account including generation of a new password""" + try: + business_account = BusinessAccountSelector.filter_by_email( + email, + BusinessAccountService.get_company_name(request.user) + ) + BusinessHostService(request.user).reinvite_business_account(business_account=business_account) + return Response({'detail': _('Business account has been re-invited')}, status=status.HTTP_200_OK) + except Exception as err: + return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) + + + class HostWorkersAPIView(APIView): permission_classes = (HasBusinessAdminPermissions,) @@ -337,6 +363,27 @@ class HostWorkersAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) +class ChangeHostPassAPIView(APIView): + permission_classes = (ChangeEmployeePassPermission,) + @extend_schema( + parameters=[ + OpenApiParameter('email', str, 'path', required=True), + ], + request=ChangePasswordSerializer, + ) + def patch(self, request, *args, **kwargs): + """Change business account password""" + try: + business_account = BusinessAccountSelector.filter_by_email( + request.parser_context['kwargs'].get('email'), + BusinessAccountService.get_company_name(self.request.user) + ) + BusinessAccountService(business_account).update_user_password(request) + return Response({'detail': 'Host password has been updated'}, status=status.HTTP_200_OK) + except Exception as err: + return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) + + class HostModelStatisticsAPIView(APIView): permission_classes = (HasBusinessAdminPermissions,) @@ -30,8 +30,10 @@ MIDDLEWARE = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'authentication.middleware.CompanyIPMiddleware', + 'authentication.middleware.AdminSecurityMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'social_django.middleware.SocialAuthExceptionMiddleware', ] CORE_APP = 'core.apps.CoreConfig' @@ -383,6 +385,7 @@ ADMIN_SETTINGS = { 'auth', ], }, + 'security': {'address': env.str('SECURITY_PROXY', '')}, } if ( @@ -1113,3 +1113,9 @@ msgid "Model is blocked by outdating or temporary block, please retry later" msgstr "" "Модель заблокирована, т.к перестала обновляться или временно, попробуйте " "позже" + +msgid "Business account has been re-invited" +msgstr "Повторное приглашение сотруднику успешно отправлено" + +msgid "You do not have sufficient rights to perform this action" +msgstr "У вас недостаточно прав для выполнения этого действия" @@ -77,4 +77,7 @@ DJANGO_SUPERUSER_PASSWORD=root # DJANGO CHANNELS CHANNELS_HOST_MDB=channels-mdb -CHANNELS_PORT_MDB=6379 \ No newline at end of file +CHANNELS_PORT_MDB=6379 + +# ADMIN PROXY USAGE +SECURITY_PROXY=172.18.0.1 # default random docker network address \ No newline at end of file @@ -50,13 +50,13 @@ services: - traefik.http.routers.backend.tls.certresolver=defaultresolver - traefik.http.routers.backend.service=backend - - traefik.http.routers.backend-admin-http.rule=Host(`$DOMAIN`) && PathPrefix(`djangoadmin`) + - traefik.http.routers.backend-admin-http.rule=Host(`$DOMAIN`) && PathPrefix(`/djangoadmin`) - traefik.http.routers.backend-admin-http.entrypoints=web - traefik.http.routers.backend-admin-http.tls=false - traefik.http.routers.backend-admin-http.service=backend - traefik.http.routers.backend-admin-http.middlewares=sts-header@swarm,https-redirect@swarm - - traefik.http.routers.backend-admin-https.rule=Host(`$DOMAIN`) && PathPrefix(`djangoadmin`) + - traefik.http.routers.backend-admin-https.rule=Host(`$DOMAIN`) && PathPrefix(`/djangoadmin`) - traefik.http.routers.backend-admin-https.entrypoints=websecure - traefik.http.routers.backend-admin-https.service=backend - traefik.http.routers.backend-admin-https.tls=true