From 657f4fe11cd00eb3d2dd75182b9a24ad1bcb9b6d Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Sun, 25 Feb 2024 20:55:30 +0300 Subject: [PATCH] Setup password recovery through email --- .vscode/settings.json | 1 + TODO.txt | 7 +- docker-compose-prod.yml | 23 ++-- rsconcept/backend/.env.dev | 9 ++ rsconcept/backend/.env.prod | 9 ++ rsconcept/backend/.env.prod.local | 9 ++ rsconcept/backend/apps/users/apps.py | 3 + rsconcept/backend/apps/users/serializers.py | 5 - rsconcept/backend/apps/users/signals.py | 37 ++++++ rsconcept/backend/apps/users/tests/t_views.py | 25 +++- rsconcept/backend/apps/users/urls.py | 9 +- rsconcept/backend/project/settings.py | 13 ++ rsconcept/backend/requirements.txt | 1 + rsconcept/backend/requirements_dev.txt | 1 + .../templates/password_reset_email.html | 20 +++ rsconcept/frontend/src/app/Router.tsx | 5 + .../frontend/src/context/AuthContext.tsx | 84 ++++++++++++- rsconcept/frontend/src/models/library.ts | 18 +++ .../frontend/src/pages/PasswordChangePage.tsx | 117 ++++++++++++++++++ .../src/pages/RestorePasswordPage.tsx | 76 +++++++++++- rsconcept/frontend/src/utils/backendAPI.ts | 29 ++++- 21 files changed, 472 insertions(+), 29 deletions(-) create mode 100644 rsconcept/backend/apps/users/signals.py create mode 100644 rsconcept/backend/templates/password_reset_email.html create mode 100644 rsconcept/frontend/src/pages/PasswordChangePage.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index d08d75cd..12e2cfcf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -85,6 +85,7 @@ "NPRO", "NUMR", "Opencorpora", + "passwordreset", "perfectivity", "PNCT", "ponomarev", diff --git a/TODO.txt b/TODO.txt index 6b125c76..fc8450a8 100644 --- a/TODO.txt +++ b/TODO.txt @@ -9,11 +9,14 @@ For more specific TODOs see comments in code - блок организации библиотеки моделей - проектный модуль? - обратная связь - система баг репортов -- система обработки ошибок backend [Tech] -- multilevel modals / rework modal system +- rewrite custom password-reset [deployment] - logs collection - status dashboard for servers + +[security] +- password-reset leaks info of email being used +- do not use schemaID for access (prevent enumerating IDs access) \ No newline at end of file diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 6298f1a4..96a6037a 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -21,6 +21,12 @@ secrets: file: ./secrets/django_key.txt db_password: file: ./secrets/db_password.txt + email_host: + file: ./secrets/email_host.txt + email_user: + file: ./secrets/email_user.txt + email_password: + file: ./secrets/email_password.txt services: frontend: @@ -28,7 +34,7 @@ services: restart: always depends_on: - backend - build: + build: context: ./rsconcept/frontend args: BUILD_TYPE: production @@ -36,7 +42,6 @@ services: - 3000 command: serve -s /home/node -l 3000 - backend: container_name: portal-backend restart: always @@ -45,20 +50,24 @@ services: secrets: - db_password - django_key + - email_host + - email_user + - email_password build: context: ./rsconcept/backend env_file: ./rsconcept/backend/.env.prod environment: SECRET_KEY: /run/secrets/django_key DB_PASSWORD: /run/secrets/db_password + EMAIL_HOST: /run/secrets/email_host + EMAIL_HOST_USER: /run/secrets/email_user + EMAIL_HOST_PASSWORD: /run/secrets/email_password expose: - 8000 volumes: - django_static_volume:/home/app/web/static - django_media_volume:/home/app/web/media - command: - gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000 - + command: gunicorn -w 3 project.wsgi --bind 0.0.0.0:8000 postgresql-db: container_name: portal-db @@ -72,7 +81,6 @@ services: volumes: - postgres_volume:/var/lib/postgresql/data - certbot: container_name: portal-certbot restart: no @@ -81,7 +89,6 @@ services: - cerbot_www_volume:/var/www/certbot/:rw - cerbot_conf_volume:/etc/letsencrypt/:rw - nginx: container_name: portal-router restart: always @@ -94,7 +101,7 @@ services: - 443:443 depends_on: - backend - command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"''' volumes: - django_static_volume:/var/www/static - django_media_volume:/var/www/media diff --git a/rsconcept/backend/.env.dev b/rsconcept/backend/.env.dev index 501f8a22..9269dc32 100644 --- a/rsconcept/backend/.env.dev +++ b/rsconcept/backend/.env.dev @@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static MEDIA_ROOT=/home/app/web/media +# Email +EMAIL_HOST=localhost +EMAIL_PORT=1025 +EMAIL_HOST_USER=False +EMAIL_HOST_PASSWORD=False +EMAIL_SSL=False +EMAIL_TLS=False + + # Database settings DB_ENGINE=django.db.backends.postgresql_psycopg2 DB_NAME=portal-db diff --git a/rsconcept/backend/.env.prod b/rsconcept/backend/.env.prod index 90cedabd..62432a1c 100644 --- a/rsconcept/backend/.env.prod +++ b/rsconcept/backend/.env.prod @@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static MEDIA_ROOT=/home/app/web/media +# Email +# EMAIL_HOST= +# EMAIL_HOST_USER= +# EMAIL_HOST_PASSWORD= +EMAIL_PORT=443 +EMAIL_SSL=True +EMAIL_TLS=False + + # Database settings DB_ENGINE=django.db.backends.postgresql_psycopg2 DB_NAME=portal-db diff --git a/rsconcept/backend/.env.prod.local b/rsconcept/backend/.env.prod.local index da3f2e1e..84268d38 100644 --- a/rsconcept/backend/.env.prod.local +++ b/rsconcept/backend/.env.prod.local @@ -12,6 +12,15 @@ STATIC_ROOT=/home/app/web/static MEDIA_ROOT=/home/app/web/media +# Email +EMAIL_HOST=localhost +EMAIL_PORT=1025 +EMAIL_HOST_USER=False +EMAIL_HOST_PASSWORD=False +EMAIL_SSL=False +EMAIL_TLS=False + + # Database settings DB_ENGINE=django.db.backends.postgresql_psycopg2 DB_NAME=portal-db diff --git a/rsconcept/backend/apps/users/apps.py b/rsconcept/backend/apps/users/apps.py index 0376c6a9..61a6e7b5 100644 --- a/rsconcept/backend/apps/users/apps.py +++ b/rsconcept/backend/apps/users/apps.py @@ -6,3 +6,6 @@ class UsersConfig(AppConfig): ''' Application config. ''' default_auto_field = 'django.db.models.BigAutoField' name = 'apps.users' + + def ready(self): + import apps.users.signals # pylint: disable=unused-import,import-outside-toplevel diff --git a/rsconcept/backend/apps/users/serializers.py b/rsconcept/backend/apps/users/serializers.py index 7a48796c..a790e5ee 100644 --- a/rsconcept/backend/apps/users/serializers.py +++ b/rsconcept/backend/apps/users/serializers.py @@ -105,11 +105,6 @@ class ChangePasswordSerializer(serializers.Serializer): new_password = serializers.CharField(required=True) -class ResetPasswordSerializer(serializers.Serializer): - ''' Serializer: Change password. ''' - email = serializers.EmailField(required=True) - - class SignupSerializer(serializers.ModelSerializer): ''' Serializer: Create user profile. ''' id = serializers.IntegerField(read_only=True) diff --git a/rsconcept/backend/apps/users/signals.py b/rsconcept/backend/apps/users/signals.py new file mode 100644 index 00000000..9154ecd5 --- /dev/null +++ b/rsconcept/backend/apps/users/signals.py @@ -0,0 +1,37 @@ +''' Signals: user events. ''' +from django.core.mail import send_mail +from django.dispatch import receiver +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +from django_rest_passwordreset.signals import reset_password_token_created # type: ignore + + +_EMAIL_NOREPLY = 'noreply.portal@acconcept.ru' +_EMAIL_SUBJECT = 'Восстановление пароля КонцептПортал' +_EMAIL_TEMPLATE = 'password_reset_email.html' +_RECOVERY_URL = 'https://portal.acconcept.ru/password-change' + + +@receiver(reset_password_token_created) +def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs): + ''' + Handles password reset tokens + When a token is created, an e-mail needs to be sent to the user + ''' + context = { + 'current_user': reset_password_token.user, + 'username': reset_password_token.user.username, + 'first_name': reset_password_token.user.first_name, + 'email': reset_password_token.user.email, + 'reset_password_url': f'{_RECOVERY_URL}?token={reset_password_token.key}' + } + email_html_message = render_to_string(_EMAIL_TEMPLATE, context) + email_plaintext_message = strip_tags(email_html_message) + send_mail( + subject=_EMAIL_SUBJECT, + message=email_plaintext_message, + from_email=_EMAIL_NOREPLY, + recipient_list=[context['email']], + html_message=email_html_message + ) diff --git a/rsconcept/backend/apps/users/tests/t_views.py b/rsconcept/backend/apps/users/tests/t_views.py index cd7b554a..05270a2f 100644 --- a/rsconcept/backend/apps/users/tests/t_views.py +++ b/rsconcept/backend/apps/users/tests/t_views.py @@ -97,8 +97,8 @@ class TestUserUserProfileAPIView(APITestCase): self.assertEqual(response.data['last_name'], 'lastName') def test_edit_profile(self): - newmail = 'newmail@gmail.com' - data = {'email': newmail} + new_mail = 'newmail@gmail.com' + data = {'email': new_mail} response = self.client.patch( '/users/api/profile', data=data, format='json' @@ -112,7 +112,7 @@ class TestUserUserProfileAPIView(APITestCase): ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['username'], self.username) - self.assertEqual(response.data['email'], newmail) + self.assertEqual(response.data['email'], new_mail) def test_change_password(self): newpassword = 'pw2' @@ -150,6 +150,25 @@ class TestUserUserProfileAPIView(APITestCase): self.assertTrue(self.client.login(username=self.user.username, password=newpassword)) self.assertFalse(self.client.login(username=self.user.username, password=self.password)) + def test_password_reset_request(self): + data = { + 'email': 'invalid@gmail.com' + } + response = self.client.post( + '/users/api/password-reset', + data=data, format='json' + ) + self.assertEqual(response.status_code, 400) + + data = { + 'email': self.email + } + response = self.client.post( + '/users/api/password-reset', + data=data, format='json' + ) + self.assertEqual(response.status_code, 200) + class TestSignupAPIView(APITestCase): def setUp(self): diff --git a/rsconcept/backend/apps/users/urls.py b/rsconcept/backend/apps/users/urls.py index 876e62ac..c37b3a06 100644 --- a/rsconcept/backend/apps/users/urls.py +++ b/rsconcept/backend/apps/users/urls.py @@ -1,6 +1,9 @@ ''' Routing: User profile and Authorization. ''' -from django.urls import path +from django.urls import path, include from . import views +from django_rest_passwordreset.views import reset_password_confirm, \ + reset_password_request_token, \ + reset_password_validate_token urlpatterns = [ @@ -11,4 +14,8 @@ urlpatterns = [ path('api/login', views.LoginAPIView.as_view()), path('api/logout', views.LogoutAPIView.as_view()), path('api/change-password', views.UpdatePassword.as_view()), + # django_rest_passwordreset APIs see https://pypi.org/project/django-rest-passwordreset/ + path('api/password-reset', reset_password_request_token, name='reset-password-request'), + path('api/password-reset/validate', reset_password_validate_token, name='reset-password-validate'), + path('api/password-reset/confirm', reset_password_confirm, name='reset-password-confirm') ] diff --git a/rsconcept/backend/project/settings.py b/rsconcept/backend/project/settings.py index bb3ab66b..ee59965e 100644 --- a/rsconcept/backend/project/settings.py +++ b/rsconcept/backend/project/settings.py @@ -29,6 +29,18 @@ DEBUG = os.environ.get('DEBUG', True) in [True, 'True', '1'] ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(';') INTERNAL_IPS = ['127.0.0.1'] if DEBUG else [] +# MAIL SETUP +EMAIL_HOST = os.environ.get('EMAIL_HOST', 'localhost') +EMAIL_PORT = os.environ.get('EMAIL_PORT', '1025') +EMAIL_USE_SSL = os.environ.get('EMAIL_SSL', False) +EMAIL_USE_TLS = os.environ.get('EMAIL_TLS', False) +EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') +EMAIL_BACKEND = \ + 'django.core.mail.backends.smtp.EmailBackend' \ + if EMAIL_HOST != '' else \ + 'django.core.mail.backends.console.EmailBackend' + INSTALLED_APPS = [ 'django.contrib.admin', @@ -40,6 +52,7 @@ INSTALLED_APPS = [ 'django_filters', 'rest_framework', + 'django_rest_passwordreset', 'corsheaders', 'apps.users', diff --git a/rsconcept/backend/requirements.txt b/rsconcept/backend/requirements.txt index 66898b6e..73999e10 100644 --- a/rsconcept/backend/requirements.txt +++ b/rsconcept/backend/requirements.txt @@ -9,6 +9,7 @@ pymorphy2==0.9.1 pymorphy2-dicts-ru==2.4.417127.4579844 pymorphy2-dicts-uk==2.4.1.1.1460299261 razdel==0.5.0 +django-rest-passwordreset==1.4.0 psycopg2-binary==2.9.9 gunicorn==21.2.0 \ No newline at end of file diff --git a/rsconcept/backend/requirements_dev.txt b/rsconcept/backend/requirements_dev.txt index 9c5e9a1c..d61f23d6 100644 --- a/rsconcept/backend/requirements_dev.txt +++ b/rsconcept/backend/requirements_dev.txt @@ -14,6 +14,7 @@ mypy pylint coverage djangorestframework-stubs[compatible-mypy] +django-rest-passwordreset psycopg2-binary gunicorn \ No newline at end of file diff --git a/rsconcept/backend/templates/password_reset_email.html b/rsconcept/backend/templates/password_reset_email.html new file mode 100644 index 00000000..8c610c84 --- /dev/null +++ b/rsconcept/backend/templates/password_reset_email.html @@ -0,0 +1,20 @@ + + +
+Здравствуйте, {{first_name}}
++ Мы получили запрос на восстановление пароля для пользователя {{username}}, + привязанного к данному email. Если Вы отправили указанный запрос, то + перейдите по ссылке для задания нового пароля: +
+ ++ Если Вы не отправляли запрос, то можете игнорировать данное сообщение. +
+С уважением,
+Команда КонцептПортал
+ + diff --git a/rsconcept/frontend/src/app/Router.tsx b/rsconcept/frontend/src/app/Router.tsx index 9bbb41c4..503aeefe 100644 --- a/rsconcept/frontend/src/app/Router.tsx +++ b/rsconcept/frontend/src/app/Router.tsx @@ -6,6 +6,7 @@ import LibraryPage from '@/pages/LibraryPage'; import LoginPage from '@/pages/LoginPage'; import ManualsPage from '@/pages/ManualsPage'; import NotFoundPage from '@/pages/NotFoundPage'; +import PasswordChangePage from '@/pages/PasswordChangePage'; import RegisterPage from '@/pages/RegisterPage'; import RestorePasswordPage from '@/pages/RestorePasswordPage'; import RSFormPage from '@/pages/RSFormPage'; @@ -35,6 +36,10 @@ export const Router = createBrowserRouter([ path: 'restore-password', element:Автоматическое восстановление пароля не доступно.
-
- Возможно восстановление пароля через обращение на
На указанную почту отправлены инструкции по восстановлению пароля.
+