From 4e803c53e01adb658c1370bd570168151201a303 Mon Sep 17 00:00:00 2001 From: lokses Date: Tue, 28 Jul 2026 10:41:11 +0300 Subject: [PATCH] =?UTF-8?q?2FA:=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=B2=D1=83=D1=85?= =?UTF-8?q?=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=BD=D0=B0=D1=8F=20=D0=B0?= =?UTF-8?q?=D1=83=D1=82=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20(TOTP)=20=D0=B4=D0=BB=D1=8F=20staff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Модель Employee: totp_secret (шифруется), totp_enabled, totp_backup_codes - Сервис twofa: генерация секрета/QR, проверка TOTP, резервные коды (sha256) - TwoFactorMiddleware: enforcement для is_staff на портале и /admin/ - Страницы setup/verify/backup-codes; сброс 2fa_verified при каждом входе - Админка: колонка статуса 2FA, фильтр, действие «Сбросить 2FA» - Тесты: enforcement, setup, verify, резервные коды, disable Co-Authored-By: Claude Opus 4.8 --- acore/settings/base.py | 1 + acore/urls.py | 3 + apps/employees/admin.py | 19 +++- apps/employees/apps.py | 3 + apps/employees/middleware.py | 35 ++++++++ ...up_codes_employee_totp_enabled_and_more.py | 29 ++++++ apps/employees/models/base.py | 12 +++ apps/employees/services/__init__.py | 0 apps/employees/services/twofa.py | 79 +++++++++++++++++ apps/employees/signals_2fa.py | 10 +++ apps/employees/tests.py | 88 +++++++++++++++++++ core/views.py | 67 ++++++++++++++ templates/2fa-backup-codes.html | 28 ++++++ templates/2fa-setup.html | 42 +++++++++ templates/2fa-verify.html | 36 ++++++++ 15 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 apps/employees/middleware.py create mode 100644 apps/employees/migrations/0011_employee_totp_backup_codes_employee_totp_enabled_and_more.py create mode 100644 apps/employees/services/__init__.py create mode 100644 apps/employees/services/twofa.py create mode 100644 apps/employees/signals_2fa.py create mode 100644 templates/2fa-backup-codes.html create mode 100644 templates/2fa-setup.html create mode 100644 templates/2fa-verify.html diff --git a/acore/settings/base.py b/acore/settings/base.py index 8a1b9da..5dd43f2 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -91,6 +91,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'apps.employees.middleware.TwoFactorMiddleware', # обязательная 2FA для staff (после Auth) 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'core.middleware.UpdateLastSeenMiddleware', diff --git a/acore/urls.py b/acore/urls.py index 7e6d7c8..87a5066 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -37,6 +37,9 @@ urlpatterns = [ path('accounts/login/', views.login_view, name='login'), path('login/', views.login_view, name='login'), path('logout/', views.logout_view, name='logout'), + path('2fa/setup/', views.twofa_setup_view, name='twofa_setup'), + path('2fa/backup-codes/', views.twofa_backup_codes_view, name='twofa_backup_codes'), + path('2fa/verify/', views.twofa_verify_view, name='twofa_verify'), path('dashboard/', views.dashboard_view, name='dashboard'), path('employees/', views.employees_view, name='employees'), path('employees//', views.employee_detail_view, name='employee_detail'), diff --git a/apps/employees/admin.py b/apps/employees/admin.py index 7c3e02f..4cd1e6c 100644 --- a/apps/employees/admin.py +++ b/apps/employees/admin.py @@ -107,12 +107,27 @@ class EmployeeAdmin(ModelAdmin): list_display = [ 'photo_thumbnail', 'username', 'last_name', 'first_name', 'groups_display', 'user_location_type', 'user_pharmacy', 'department_display', 'post', - 'is_active', 'hire_date' + 'is_active', 'twofa_status', 'hire_date' ] - list_filter = ['groups', 'is_active', 'is_staff', 'user_location_type', 'department'] + list_filter = ['groups', 'is_active', 'is_staff', 'user_location_type', 'department', 'totp_enabled'] search_fields = ['username', 'email', 'last_name', 'first_name', 'middle_name'] list_select_related = ['user_pharmacy', 'department', 'post'] readonly_fields = ('photo_preview',) + actions = ['reset_2fa'] + + @admin.display(description='2FA', boolean=True) + def twofa_status(self, obj): + return obj.totp_enabled + + @admin.action(description='Сбросить 2FA (отключить у выбранных)') + def reset_2fa(self, request, queryset): + from apps.employees.services import twofa + n = 0 + for emp in queryset: + if emp.totp_enabled or emp.totp_secret: + twofa.disable(emp) + n += 1 + self.message_user(request, f'2FA сброшена у {n} сотрудник(ов).') fieldsets = ( (_('Аутентификация'), { diff --git a/apps/employees/apps.py b/apps/employees/apps.py index 15eb005..35c4bd0 100644 --- a/apps/employees/apps.py +++ b/apps/employees/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class EmployeesConfig(AppConfig): name = 'apps.employees' verbose_name = 'Сотрудники' + + def ready(self): + import apps.employees.signals_2fa # noqa: F401 diff --git a/apps/employees/middleware.py b/apps/employees/middleware.py new file mode 100644 index 0000000..19f2f19 --- /dev/null +++ b/apps/employees/middleware.py @@ -0,0 +1,35 @@ +"""Принуждение 2FA для staff-пользователей на всём портале и в /admin/. + +- staff без включённой 2FA → редирект на настройку (обязательное включение); +- staff с 2FA, но не прошедший проверку в текущей сессии → редирект на ввод кода. + +Покрывает и Django-админку: вход в /admin/ создаёт обычную сессию, но доступ +к страницам /admin/ блокируется, пока сессия не помечена как 2fa_verified. +""" +from django.shortcuts import redirect +from django.urls import reverse + + +class TwoFactorMiddleware: + # Пути, доступные без пройденной 2FA (сам процесс входа/настройки, статика, API). + ALLOWED_PREFIXES = ( + '/2fa/', '/logout/', '/accounts/login/', '/login/', + '/admin/login/', '/admin/logout/', '/i18n/', + '/static/', '/media/', '/api/', + ) + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = getattr(request, 'user', None) + path = request.path + + if (user and user.is_authenticated and user.is_staff + and not path.startswith(self.ALLOWED_PREFIXES)): + if not user.totp_enabled: + return redirect('twofa_setup') + if not request.session.get('2fa_verified'): + return redirect(f"{reverse('twofa_verify')}?next={path}") + + return self.get_response(request) diff --git a/apps/employees/migrations/0011_employee_totp_backup_codes_employee_totp_enabled_and_more.py b/apps/employees/migrations/0011_employee_totp_backup_codes_employee_totp_enabled_and_more.py new file mode 100644 index 0000000..4c82b51 --- /dev/null +++ b/apps/employees/migrations/0011_employee_totp_backup_codes_employee_totp_enabled_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.3 on 2026-07-28 07:33 + +import core.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0010_employee_last_seen'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='totp_backup_codes', + field=models.JSONField(blank=True, default=list, help_text='Хеши одноразовых резервных кодов; использованный код удаляется.', verbose_name='Резервные коды 2FA (хеши)'), + ), + migrations.AddField( + model_name='employee', + name='totp_enabled', + field=models.BooleanField(default=False, verbose_name='2FA включена'), + ), + migrations.AddField( + model_name='employee', + name='totp_secret', + field=core.models.fields.EncryptedCharField(blank=True, help_text='Секрет TOTP (шифруется). Заполняется при настройке 2FA.', max_length=255, verbose_name='TOTP-секрет'), + ), + ] diff --git a/apps/employees/models/base.py b/apps/employees/models/base.py index 7b0956b..5ffc9ec 100644 --- a/apps/employees/models/base.py +++ b/apps/employees/models/base.py @@ -3,6 +3,7 @@ from django.utils import timezone from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from core.models import BaseModel +from core.models.fields import EncryptedCharField from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from core.utils.validators import validate_ukrainian_phone, validate_birth_date, validate_ukrainian_tax_number from core.utils.pagination import PAGE_SIZE_CHOICES, DEFAULT_PAGE_SIZE @@ -177,6 +178,17 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel): help_text=_('Обновляется автоматически при активности в портале (не в AD/Mattermost)'), ) + # --- Двухфакторная аутентификация (TOTP) --- + totp_secret = EncryptedCharField( + _('TOTP-секрет'), max_length=255, blank=True, + help_text=_('Секрет TOTP (шифруется). Заполняется при настройке 2FA.'), + ) + totp_enabled = models.BooleanField(_('2FA включена'), default=False) + totp_backup_codes = models.JSONField( + _('Резервные коды 2FA (хеши)'), default=list, blank=True, + help_text=_('Хеши одноразовых резервных кодов; использованный код удаляется.'), + ) + objects = EmployeeManager() USERNAME_FIELD = 'username' diff --git a/apps/employees/services/__init__.py b/apps/employees/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/employees/services/twofa.py b/apps/employees/services/twofa.py new file mode 100644 index 0000000..821333b --- /dev/null +++ b/apps/employees/services/twofa.py @@ -0,0 +1,79 @@ +"""Двухфакторная аутентификация (TOTP) для сотрудников. + +Секрет хранится в Employee.totp_secret (шифруется EncryptedCharField). +Резервные коды хранятся хешами (sha256) в Employee.totp_backup_codes — +одноразовые, использованный код удаляется. +""" +import base64 +import hashlib +import io +import secrets + +import pyotp +import qrcode + +ISSUER = 'АКС Портал' +BACKUP_CODES_COUNT = 10 + + +def generate_secret() -> str: + return pyotp.random_base32() + + +def provisioning_uri(employee, secret: str) -> str: + """otpauth://-URI для добавления в приложение-аутентификатор.""" + label = employee.email or employee.username + return pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=ISSUER) + + +def qr_data_uri(uri: str) -> str: + """PNG QR-кода как data:URI (для ).""" + img = qrcode.make(uri) + buf = io.BytesIO() + img.save(buf, format='PNG') + b64 = base64.b64encode(buf.getvalue()).decode() + return f'data:image/png;base64,{b64}' + + +def verify_totp(secret: str, code: str) -> bool: + if not secret or not code: + return False + # valid_window=1 — допускаем соседний 30-сек интервал (рассинхрон часов). + return pyotp.TOTP(secret).verify(code.strip().replace(' ', ''), valid_window=1) + + +def _hash_code(code: str) -> str: + return hashlib.sha256(code.encode()).hexdigest() + + +def generate_backup_codes(n: int = BACKUP_CODES_COUNT): + """Возвращает (plain_codes, hashes). plain показываем один раз, храним hashes.""" + codes, hashes = [], [] + for _ in range(n): + raw = secrets.token_hex(4) # 8 hex-символов + code = f'{raw[:4]}-{raw[4:]}' # вид xxxx-xxxx + codes.append(code) + hashes.append(_hash_code(code)) + return codes, hashes + + +def verify_and_consume_backup(employee, code: str) -> bool: + """Проверяет резервный код; при совпадении удаляет его (одноразовый).""" + if not code: + return False + h = _hash_code(code.strip().lower().replace(' ', '')) + stored = list(employee.totp_backup_codes or []) + if h in stored: + stored.remove(h) + employee.totp_backup_codes = stored + employee.save(update_fields=['totp_backup_codes']) + return True + return False + + +def disable(employee): + """Полностью выключить 2FA у сотрудника (сброс).""" + employee.totp_enabled = False + employee.totp_secret = '' + employee.totp_backup_codes = [] + employee.save(update_fields=['totp_enabled', 'totp_secret', 'totp_backup_codes']) diff --git a/apps/employees/signals_2fa.py b/apps/employees/signals_2fa.py new file mode 100644 index 0000000..9fee3dc --- /dev/null +++ b/apps/employees/signals_2fa.py @@ -0,0 +1,10 @@ +"""Сброс отметки 2fa_verified при каждом входе — чтобы после логина +(и на портале, и в /admin/) требовалась свежая проверка второго фактора.""" +from django.contrib.auth.signals import user_logged_in +from django.dispatch import receiver + + +@receiver(user_logged_in) +def clear_2fa_verified(sender, request, user, **kwargs): + if request is not None and hasattr(request, 'session'): + request.session.pop('2fa_verified', None) diff --git a/apps/employees/tests.py b/apps/employees/tests.py index 67c02e7..7655c90 100644 --- a/apps/employees/tests.py +++ b/apps/employees/tests.py @@ -138,3 +138,91 @@ class EmployeeAPITests(TestCase): self.client.force_login(self.staff) resp = self.client.delete(f'/api/employees/{self.target.pk}/') self.assertEqual(resp.status_code, 405) + + +class TwoFactorTests(TestCase): + """Обязательная 2FA для staff: enforcement middleware, setup, verify, резервные коды.""" + + def setUp(self): + self.staff = make_employee('tfa_staff', is_staff=True) + self.regular = make_employee('tfa_user') + + def test_staff_without_2fa_redirected_to_setup(self): + self.client.force_login(self.staff) + r = self.client.get('/dashboard/') + self.assertEqual(r.status_code, 302) + self.assertIn('/2fa/setup/', r['Location']) + + def test_regular_user_not_forced(self): + self.client.force_login(self.regular) + r = self.client.get('/dashboard/') + self.assertNotIn('/2fa/', r.get('Location', '') or '') + + def test_setup_enables_2fa_and_issues_backup_codes(self): + import pyotp + self.client.force_login(self.staff) + self.client.get('/2fa/setup/') # генерируем секрет в сессии + secret = self.client.session['2fa_setup_secret'] + code = pyotp.TOTP(secret).now() + r = self.client.post('/2fa/setup/', {'code': code}) + self.assertEqual(r.status_code, 302) + self.assertIn('/2fa/backup-codes/', r['Location']) + self.staff.refresh_from_db() + self.assertTrue(self.staff.totp_enabled) + self.assertTrue(self.staff.totp_secret) + self.assertEqual(len(self.staff.totp_backup_codes), 10) + + def test_setup_rejects_wrong_code(self): + self.client.force_login(self.staff) + self.client.get('/2fa/setup/') + r = self.client.post('/2fa/setup/', {'code': '000000'}) + self.assertEqual(r.status_code, 200) + self.staff.refresh_from_db() + self.assertFalse(self.staff.totp_enabled) + + def test_verify_flow_with_totp(self): + import pyotp + secret = pyotp.random_base32() + self.staff.totp_secret = secret + self.staff.totp_enabled = True + self.staff.save() + self.client.force_login(self.staff) + r = self.client.get('/dashboard/') + self.assertIn('/2fa/verify/', r['Location']) + bad = self.client.post('/2fa/verify/', {'code': '000000', 'next': '/dashboard/'}) + self.assertEqual(bad.status_code, 200) + code = pyotp.TOTP(secret).now() + ok = self.client.post('/2fa/verify/', {'code': code, 'next': '/dashboard/'}) + self.assertEqual(ok.status_code, 302) + self.assertIn('/dashboard/', ok['Location']) + self.assertEqual(self.client.get('/dashboard/').status_code, 200) + + def test_backup_code_consumed_on_use(self): + import pyotp + from apps.employees.services import twofa + codes, hashes = twofa.generate_backup_codes() + self.staff.totp_secret = pyotp.random_base32() + self.staff.totp_enabled = True + self.staff.totp_backup_codes = hashes + self.staff.save() + self.client.force_login(self.staff) + r = self.client.post('/2fa/verify/', {'code': codes[0], 'next': '/dashboard/'}) + self.assertEqual(r.status_code, 302) + self.staff.refresh_from_db() + self.assertEqual(len(self.staff.totp_backup_codes), 9) + # повторное использование того же кода не проходит + again = twofa.verify_and_consume_backup(self.staff, codes[0]) + self.assertFalse(again) + + def test_disable_resets_all_fields(self): + import pyotp + from apps.employees.services import twofa + self.staff.totp_secret = pyotp.random_base32() + self.staff.totp_enabled = True + self.staff.totp_backup_codes = ['x'] + self.staff.save() + twofa.disable(self.staff) + self.staff.refresh_from_db() + self.assertFalse(self.staff.totp_enabled) + self.assertEqual(self.staff.totp_secret, '') + self.assertEqual(self.staff.totp_backup_codes, []) diff --git a/core/views.py b/core/views.py index 9949dcc..781ec86 100644 --- a/core/views.py +++ b/core/views.py @@ -899,5 +899,72 @@ def logout_view(request): return redirect('login') +# ── Двухфакторная аутентификация (TOTP) ────────────────────────────────── + +@login_required +def twofa_setup_view(request): + """Настройка 2FA: QR + ручной ключ, подтверждение кодом → включение.""" + from apps.employees.services import twofa + user = request.user + + secret = request.session.get('2fa_setup_secret') + if not secret: + secret = twofa.generate_secret() + request.session['2fa_setup_secret'] = secret + + error = '' + if request.method == 'POST': + if twofa.verify_totp(secret, request.POST.get('code', '')): + codes, hashes = twofa.generate_backup_codes() + user.totp_secret = secret + user.totp_enabled = True + user.totp_backup_codes = hashes + user.save(update_fields=['totp_secret', 'totp_enabled', 'totp_backup_codes']) + request.session.pop('2fa_setup_secret', None) + request.session['2fa_verified'] = True + request.session['2fa_backup_codes'] = codes # показываем один раз + return redirect('twofa_backup_codes') + error = 'Неверный код. Проверьте время на устройстве и попробуйте снова.' + + uri = twofa.provisioning_uri(user, secret) + return render(request, '2fa-setup.html', { + 'secret': secret, 'qr': twofa.qr_data_uri(uri), 'error': error, + }) + + +@login_required +def twofa_backup_codes_view(request): + """Показ резервных кодов один раз (после включения 2FA).""" + codes = request.session.pop('2fa_backup_codes', None) + if not codes: + return redirect('/') + return render(request, '2fa-backup-codes.html', {'codes': codes}) + + +def twofa_verify_view(request): + """Ввод TOTP/резервного кода после входа (сессия ещё не 2fa_verified).""" + from apps.employees.services import twofa + user = request.user + if not user.is_authenticated: + return redirect('login') + if not user.totp_enabled: + return redirect('twofa_setup') + + next_url = request.GET.get('next') or request.POST.get('next') or '/' + if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=settings.ALLOWED_HOSTS): + next_url = '/' + if request.session.get('2fa_verified'): + return redirect(next_url) + + error = '' + if request.method == 'POST': + code = request.POST.get('code', '') + if twofa.verify_totp(user.totp_secret, code) or twofa.verify_and_consume_backup(user, code): + request.session['2fa_verified'] = True + return redirect(next_url) + error = 'Неверный код.' + return render(request, '2fa-verify.html', {'error': error, 'next': next_url}) + + def modal_add_employee_view(request): return render(request, 'partials/modal-add-employee.html') diff --git a/templates/2fa-backup-codes.html b/templates/2fa-backup-codes.html new file mode 100644 index 0000000..5d3915e --- /dev/null +++ b/templates/2fa-backup-codes.html @@ -0,0 +1,28 @@ +{% load static %} + + + + + + Резервные коды — АКС Портал + + + + +
+
+

2FA включена ✓

+

Сохраните резервные коды в надёжном месте. Каждый работает один раз — используйте их, если потеряете доступ к приложению. Больше они не будут показаны.

+
+ +
+
+ {% for code in codes %} + {{ code }} + {% endfor %} +
+ Я сохранил коды — продолжить +
+
+ + diff --git a/templates/2fa-setup.html b/templates/2fa-setup.html new file mode 100644 index 0000000..0d2c8f9 --- /dev/null +++ b/templates/2fa-setup.html @@ -0,0 +1,42 @@ +{% load static %} + + + + + + Настройка 2FA — АКС Портал + + + + +
+
+

Двухфакторная аутентификация

+

Для аккаунтов администраторов 2FA обязательна. Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Authy).

+
+ +
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+ QR +
+ +
+
Или введите ключ вручную:
+ {{ secret }} +
+ +
+ {% csrf_token %} + + + +
+
+
+ + diff --git a/templates/2fa-verify.html b/templates/2fa-verify.html new file mode 100644 index 0000000..4b5edf6 --- /dev/null +++ b/templates/2fa-verify.html @@ -0,0 +1,36 @@ +{% load static %} + + + + + + Подтверждение входа — АКС Портал + + + + +
+
+

Подтверждение входа

+

Введите код из приложения-аутентификатора или один из резервных кодов.

+
+ +
+ {% if error %} +
{{ error }}
+ {% endif %} +
+ {% csrf_token %} + + + + +
+ +
+
+ +