diff --git a/Makefile b/Makefile index 7d0e8ccc..6c173211 100644 --- a/Makefile +++ b/Makefile @@ -103,7 +103,12 @@ run_tests: run_unit_tests # Run all tests .PHONY: run_unit_tests run_unit_tests: # Run unit tests @echo -e "$(COLOR_YELLOW)Start unit tests...$(COLOR_RESET)" - @cd src @poetry run pytest src/tests/unit - @cd .. @echo -e "$(COLOR_GREEN)Unit tests passed$(COLOR_RESET)" + + +.PHONY: create_superuser +create_superuser: # Run unit tests + @echo -e "$(COLOR_YELLOW)Creating superuser...$(COLOR_RESET)" + @poetry run python src/manage.py initadmin + @echo -e "$(COLOR_GREEN)Superuser created$(COLOR_RESET)" diff --git a/poetry.lock b/poetry.lock index 28e474cb..e49940a1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -354,6 +354,23 @@ django = ">=3.2" [package.extras] tests = ["coverage"] +[[package]] +name = "django-otp" +version = "1.2.2" +description = "A pluggable framework for adding two-factor authentication to Django using one-time passwords." +optional = false +python-versions = ">=3.7" +files = [ + {file = "django_otp-1.2.2-py3-none-any.whl", hash = "sha256:90765d5dac238a719f9550ac05681dd6307f513a81a10b6adb879b4abc6bc1a3"}, + {file = "django_otp-1.2.2.tar.gz", hash = "sha256:007a6354dabb3a1a54574bf73abf045ebbde0bb8734a38e2ed7845ba450f345e"}, +] + +[package.dependencies] +django = ">=3.2" + +[package.extras] +qrcode = ["qrcode"] + [[package]] name = "dnspython" version = "2.4.2" @@ -1296,4 +1313,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.11.1" -content-hash = "03a56e6a6d84f459588eb5f27d47be1b795f02df1a398a5c91c707645e14b84a" +content-hash = "3d36380363be3a867d55e3ba155bcdaecb575e54e8e153317f34b47e9e782aaa" diff --git a/pyproject.toml b/pyproject.toml index 607f51b5..fed4c03a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ pytest-django = "^4.5.2" pytest-asyncio = "^0.21.1" flake8-docstrings = "^1.7.0" django-ckeditor = "^6.7.0" +django-otp = "^1.2.2" [tool.poetry.group.dev.dependencies] diff --git a/requirements/develop.txt b/requirements/develop.txt index 745fafde..bf13696f 100644 --- a/requirements/develop.txt +++ b/requirements/develop.txt @@ -14,6 +14,7 @@ django-asgi-lifespan==0.1.0 ; python_full_version >= "3.11.1" and python_version django-ckeditor==6.7.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" django-environ==0.10.0 ; python_full_version >= "3.11.1" and python_version < "4" django-js-asset==2.1.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" +django-otp==1.2.2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" django==4.2.4 ; python_full_version >= "3.11.1" and python_version < "4.0" dnspython==2.4.2 ; python_full_version >= "3.11.1" and python_version < "4.0" email-validator==2.0.0.post2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" diff --git a/requirements/production.txt b/requirements/production.txt index 9be3fe6e..58c9865f 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -11,6 +11,7 @@ django-asgi-lifespan==0.1.0 ; python_full_version >= "3.11.1" and python_version django-ckeditor==6.7.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" django-environ==0.10.0 ; python_full_version >= "3.11.1" and python_version < "4" django-js-asset==2.1.0 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" +django-otp==1.2.2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" django==4.2.4 ; python_full_version >= "3.11.1" and python_version < "4.0" dnspython==2.4.2 ; python_full_version >= "3.11.1" and python_version < "4.0" email-validator==2.0.0.post2 ; python_full_version >= "3.11.1" and python_full_version < "4.0.0" diff --git a/src/config/settings.py b/src/config/settings.py index 8c4ade0c..c65ab9c0 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -25,6 +25,8 @@ "users.apps.UsersConfig", "core.apps.CoreConfig", "ckeditor", + "django_otp", + "django_otp.plugins.otp_email", ] MIDDLEWARE = [ @@ -33,6 +35,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django_otp.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -125,6 +128,9 @@ EMAIL_USE_SSL = True DEFAULT_RECEIVER = env.str("DEFAULT_EMAIL_ADDRESS") +OTP_EMAIL_SENDER = EMAIL_HOST_USER +OTP_EMAIL_BODY_HTML_TEMPLATE_PATH = "emailing/otp_email.html" + # Telegram bot settings TELEGRAM_TOKEN = env.str("TELEGRAM_TOKEN") USE_REDIS_PERSISTENCE = env.bool("REDIS", default=False) diff --git a/src/config/test_settings.py b/src/config/test_settings.py index 6fb3667b..f801b46a 100644 --- a/src/config/test_settings.py +++ b/src/config/test_settings.py @@ -24,6 +24,8 @@ "bot.apps.BotConfig", "users.apps.UsersConfig", "core.apps.CoreConfig", + "django_otp", + "django_otp.plugins.otp_email", ] MIDDLEWARE = [ @@ -32,6 +34,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django_otp.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -56,7 +59,6 @@ WSGI_APPLICATION = "config.wsgi.application" - DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", diff --git a/src/config/urls.py b/src/config/urls.py index 53156bea..85e367f3 100644 --- a/src/config/urls.py +++ b/src/config/urls.py @@ -3,6 +3,10 @@ from django.contrib import admin from django.urls import include, path +from users.sites import CustomOTPAdminSite + +admin.site.__class__ = CustomOTPAdminSite + urlpatterns = [ path("users/", include("users.urls"), name="users"), path("bot/", include("bot.urls"), name="bot"), @@ -10,5 +14,3 @@ ] urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) -admin.site.site_header = "Бот фонда 'Расправь крылья!'" -admin.site.site_title = "Бот фонда 'Расправь крылья!'" diff --git a/src/users/admin.py b/src/users/admin.py index dff58ce4..fe4a11e6 100644 --- a/src/users/admin.py +++ b/src/users/admin.py @@ -3,10 +3,9 @@ from django.contrib.auth.models import Group, Permission from django.utils.translation import gettext_lazy as _ -from utils.emailing.reset_password import send_password_reset_email - from .forms import UserChangeForm, UserCreationForm from .models import User +from .utils.emailing.reset_password import send_password_reset_email class UserAdmin(BaseUserAdmin): diff --git a/src/users/forms.py b/src/users/forms.py index 4edfd6f6..e15f740b 100644 --- a/src/users/forms.py +++ b/src/users/forms.py @@ -1,8 +1,13 @@ from django import forms +from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.hashers import make_password +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext_lazy +from django_otp.forms import OTPAuthenticationFormMixin +from django_otp.plugins.otp_email.models import EmailDevice from users.models import User -from utils.users.registration import generate_random_password +from users.utils.users.registration import generate_random_password class UserCreationForm(forms.ModelForm): @@ -33,3 +38,44 @@ class Meta: "is_active", "is_superuser", ) + + +class CustomOTPAuthenticationFormMixin(OTPAuthenticationFormMixin): + """Customized OTPAuthenticationFormMixin mixin.""" + + otp_error_messages = { + "token_required": _("Пожалуйста, введите одноразовый код."), + "challenge_exception": _("Ошибка генерации кода: {0}"), + "not_interactive": _("Код не может быть отправлен."), + "challenge_message": _("Код отправлен на указанную почту."), + "invalid_token": _("Неверный код. Проверьте правильность."), + "n_failed_attempts": ngettext_lazy( + "Допущено %(failure_count) ошибок. Доступ временно ограничен.", + "Допущено %(failure_count) ошибок. Доступ временно ограничен.", + "failure_count", + ), + "verification_not_allowed": _("Верификация недоступна"), + } + + def _chosen_device(self, user): + """Return EmailDevise as default.""" + return EmailDevice.objects.filter(user=user).first() + + +class CustomOTPAuthenticationForm( + CustomOTPAuthenticationFormMixin, AuthenticationForm +): + """Customized OTPAuthenticationForm form.""" + + otp_token = forms.CharField( + required=False, widget=forms.TextInput(attrs={"autocomplete": "off"}) + ) + + otp_challenge = forms.CharField(required=False) + + def clean(self): + """Clean form data.""" + self.cleaned_data = super().clean() + self.clean_otp(self.get_user()) + + return self.cleaned_data diff --git a/src/users/signals.py b/src/users/signals.py index e4140e92..33f79b6a 100644 --- a/src/users/signals.py +++ b/src/users/signals.py @@ -1,12 +1,18 @@ from django.db.models.signals import post_save from django.dispatch import receiver +from django_otp.plugins.otp_email.models import EmailDevice from users.models import User -from utils.emailing.reset_password import send_password_reset_email +from users.utils.emailing.reset_password import send_password_reset_email @receiver(post_save, sender=User) def password_reset_email(sender, instance, created, **kwargs): """Send email to new admin with link to set password.""" - if created and not instance.is_superuser: - send_password_reset_email(instance) + if created: + EmailDevice.objects.create( + user=instance, + email=instance.email, + ) + if not instance.is_superuser: + send_password_reset_email(instance) diff --git a/src/users/sites.py b/src/users/sites.py new file mode 100644 index 00000000..95c02d53 --- /dev/null +++ b/src/users/sites.py @@ -0,0 +1,14 @@ +from django_otp.admin import OTPAdminSite + +from users.forms import CustomOTPAuthenticationForm + + +class CustomOTPAdminSite(OTPAdminSite): + """Customized admin site.""" + + login_form = CustomOTPAuthenticationForm + + site_header = "Бот фонда 'Расправь крылья!'" + site_title = "Бот фонда 'Расправь крылья!'" + + login_template = "authentication/login.html" diff --git a/src/users/templates/authentication/login.html b/src/users/templates/authentication/login.html new file mode 100644 index 00000000..1b911aa7 --- /dev/null +++ b/src/users/templates/authentication/login.html @@ -0,0 +1,95 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} + {{ block.super }} + + {{ form.media }} + + +{% endblock %} + +{% block bodyclass %}{{ block.super }} login{% endblock %} + +{% block usertools %}{% endblock %} + +{% block nav-global %}{% endblock %} + +{% block content_title %}{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block nav-sidebar %}{% endblock %} + +{% block content %} +{% if form.errors and not form.non_field_errors %} +

+{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +

+{% endif %} + +{% if form.non_field_errors %} +{% for error in form.non_field_errors %} +

+ {{ error }} +

+{% endfor %} +{% endif %} + +
+ +{% if user.is_authenticated %} +

+{% blocktrans trimmed %} + You are authenticated as {{ username }}, but are not authorized to + access this page. Would you like to login to a different account? +{% endblocktrans %} +

+{% endif %} + +
{% csrf_token %} +
+ {{ form.username.errors }} + {{ form.username.label_tag }} {{ form.username }} +
+
+ {{ form.password.errors }} + {{ form.password.label_tag }} {{ form.password }} + +
+ {% if form.get_user %} +
+ {{ form.otp_token.errors }} + {{ form.otp_token }} +
+ {% endif %} + {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
+ + {% if form.get_user %} + + {% endif %} +
+
+ + +
+{% endblock %} diff --git a/src/users/templates/authentication/password_set_confirm.html b/src/users/templates/authentication/password_set_confirm.html new file mode 100644 index 00000000..a7404fb1 --- /dev/null +++ b/src/users/templates/authentication/password_set_confirm.html @@ -0,0 +1,97 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} + +{{ form.media }} + + +{% endblock %} + +{% block bodyclass %}{{ block.super }} login{% endblock %} + +{% block branding %} +

Новый пароль

+{% if user.is_anonymous %} + {% include "admin/color_theme_toggle.html" %} +{% endif %} +{% endblock %} + +{% block usertools %}{% endblock %} + +{% block nav-global %}{% endblock %} + +{% block content_title %}{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block nav-sidebar %}{% endblock %} + +{% block content %} + +{% if validlink %} +
+
+
+
+ {% if user.is_authenticated %} +

+ {% blocktrans trimmed %} + You are authenticated as {{ username }}, but are not + authorized to + access this page. Would you like to login to a + different account? + {% endblocktrans %} +

+ {% endif %} + + {% include "includes/form_errors.html"%} +
+ {% include "includes/form.html"%} +
+ + +
+ {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% else %} +
+
+
+
Ошибка
+
+

Ссылка установки пароля содержит ошибку или + устарела.

+
+
+
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/src/users/templates/base.html b/src/users/templates/base.html deleted file mode 100644 index cfd92eb5..00000000 --- a/src/users/templates/base.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - {% load static %} - - - - - - - - - {% block title %}{% endblock %} - - -Skip to main content - {% block content %}{% endblock %} - - diff --git a/src/templates/email.html b/src/users/templates/emailing/email.html similarity index 100% rename from src/templates/email.html rename to src/users/templates/emailing/email.html diff --git a/src/users/templates/emailing/otp_email.html b/src/users/templates/emailing/otp_email.html new file mode 100644 index 00000000..a3684cfd --- /dev/null +++ b/src/users/templates/emailing/otp_email.html @@ -0,0 +1,15 @@ +{% extends "emailing/email.html" %} +{% block subject %}Одноразовый код для доступа админ-панели "Расправь Крылья!"{% endblock %} +{% block content %} +
+ {% if message %} +

{{ message }}

+ {% else %} +

Для подтверждения ваших учетных данных используйте код:

+ {% endif %} +
+

{{ token }}

+
+

Никому его не сообщайте.

+
+{% endblock %} diff --git a/src/templates/password_reset_email.html b/src/users/templates/emailing/password_reset_email.html similarity index 92% rename from src/templates/password_reset_email.html rename to src/users/templates/emailing/password_reset_email.html index 64666965..68a97738 100644 --- a/src/templates/password_reset_email.html +++ b/src/users/templates/emailing/password_reset_email.html @@ -1,4 +1,4 @@ -{% extends "email.html" %} +{% extends "emailing/email.html" %} {% block subject %}Доступ к админ-панели бота фонда "Расправь Крылья!"{% endblock %} {% block content %}
diff --git a/src/users/templates/includes/form_errors.html b/src/users/templates/includes/form_errors.html index 4688138f..93e47443 100644 --- a/src/users/templates/includes/form_errors.html +++ b/src/users/templates/includes/form_errors.html @@ -1,13 +1,13 @@ {% if form.errors %} {% for field in form %} -{% for error in field.errors %} -
- {{ error|escape }} -
-{% endfor %} + {% for error in field.errors %} +
+ {{ error|escape }} +
+ {% endfor %} {% endfor %} {% for error in form.non_field_errors %} -
+
{{ error|escape }}
{% endfor %} diff --git a/src/users/templates/password_set_confirm.html b/src/users/templates/password_set_confirm.html deleted file mode 100644 index 098c1027..00000000 --- a/src/users/templates/password_set_confirm.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "base.html" %} -{% load user_filters %} -{% block title %}Новый пароль{% endblock %} -{% block content %} - -{% if validlink %} -
- -
-
-
-
- {% include "includes/form_errors.html"%} -
- {% include "includes/form.html"%} -
-
-
-
-
-
-
-{% if messages %} - -{% endif %} -{% else %} -
-
-
-
Ошибка
-
-

Ссылка установки пароля содержит ошибку или - устарела.

-
-
-
-
-{% endif %} - -{% endblock %} diff --git a/src/utils/emailing/__init__.py b/src/users/utils/__init__.py similarity index 100% rename from src/utils/emailing/__init__.py rename to src/users/utils/__init__.py diff --git a/src/utils/users/__init__.py b/src/users/utils/emailing/__init__.py similarity index 100% rename from src/utils/users/__init__.py rename to src/users/utils/emailing/__init__.py diff --git a/src/utils/emailing/render.py b/src/users/utils/emailing/render.py similarity index 100% rename from src/utils/emailing/render.py rename to src/users/utils/emailing/render.py diff --git a/src/utils/emailing/reset_password.py b/src/users/utils/emailing/reset_password.py similarity index 91% rename from src/utils/emailing/reset_password.py rename to src/users/utils/emailing/reset_password.py index dc79fe2a..d481cdd8 100644 --- a/src/utils/emailing/reset_password.py +++ b/src/users/utils/emailing/reset_password.py @@ -7,13 +7,13 @@ from config import settings from users.models import User -from utils.emailing.render import render_email_message +from users.utils.emailing.render import render_email_message def send_password_reset_email(instance: User, message=None, template=None): """Send email with password reset link.""" if template is None: - template = "password_reset_email.html" + template = "emailing/password_reset_email.html" reset_link = get_password_reset_link(instance) email = render_email_message( subject='Доступ к админ-панели бота "Расправь крылья!"', diff --git a/src/users/utils/users/__init__.py b/src/users/utils/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/utils/users/registration.py b/src/users/utils/users/registration.py similarity index 100% rename from src/utils/users/registration.py rename to src/users/utils/users/registration.py diff --git a/src/users/views.py b/src/users/views.py index 56e678ec..6bbf9183 100644 --- a/src/users/views.py +++ b/src/users/views.py @@ -7,12 +7,12 @@ class PasswordSetView(PasswordResetConfirmView): """User password reset view.""" success_url = reverse_lazy("admin:index") - template_name = "password_set_confirm.html" + template_name = "authentication/password_set_confirm.html" def form_valid(self, form): """Set user satus as active if password was changed.""" response = super().form_valid(form) - messages.success(self.request, "Ваш пароль был успешно изменен.") + messages.success(self.request, "Пароль был успешно изменен.") self.user.is_staff = True self.user.save() return response