2FA: обязательная двухфакторная аутентификация (TOTP) для staff

- Модель 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 10:41:11 +03:00
parent 5c078c6cb5
commit 4e803c53e0
15 changed files with 450 additions and 2 deletions
+1
View File
@@ -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',
+3
View File
@@ -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/<int:pk>/', views.employee_detail_view, name='employee_detail'),
+17 -2
View File
@@ -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 = (
(_('Аутентификация'), {
+3
View File
@@ -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
+35
View File
@@ -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)
@@ -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-секрет'),
),
]
+12
View File
@@ -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'
View File
+79
View File
@@ -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 src>)."""
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'])
+10
View File
@@ -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)
+88
View File
@@ -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, [])
+67
View File
@@ -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')
+28
View File
@@ -0,0 +1,28 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Резервные коды — АКС Портал</title>
<link rel="stylesheet" href="{% static 'assets/css/main.css' %}">
</head>
<body style="background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-sidebar-accent) 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; font-family: var(--font-sans);">
<div style="width: 100%; max-width: 28rem; background: var(--color-card); border-radius: 0.75rem; border: 1px solid var(--color-border); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1); overflow: hidden;">
<div style="padding: 1.75rem 2rem 1.25rem; text-align: center; border-bottom: 1px solid var(--color-border);">
<h1 style="font-size: 1.25rem; font-weight: 600; color: var(--color-foreground); margin-bottom: 0.375rem;">2FA включена ✓</h1>
<p style="font-size: 0.875rem; color: var(--color-muted-foreground);">Сохраните резервные коды в надёжном месте. Каждый работает один раз — используйте их, если потеряете доступ к приложению. Больше они не будут показаны.</p>
</div>
<div style="padding: 1.75rem 2rem;">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.5rem; margin-bottom:1.25rem;">
{% for code in codes %}
<code style="background:var(--color-muted); border:1px solid var(--color-border); border-radius:0.375rem; padding:0.5rem; text-align:center; font-size:0.95rem; font-weight:600; letter-spacing:0.05em; color:var(--color-foreground);">{{ code }}</code>
{% endfor %}
</div>
<a href="/" class="btn btn-primary" style="width:100%; justify-content:center; text-decoration:none;">Я сохранил коды — продолжить</a>
</div>
</div>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Настройка 2FA — АКС Портал</title>
<link rel="stylesheet" href="{% static 'assets/css/main.css' %}">
</head>
<body style="background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-sidebar-accent) 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; font-family: var(--font-sans);">
<div style="width: 100%; max-width: 30rem; background: var(--color-card); border-radius: 0.75rem; border: 1px solid var(--color-border); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1); overflow: hidden;">
<div style="padding: 1.75rem 2rem 1.25rem; text-align: center; border-bottom: 1px solid var(--color-border);">
<h1 style="font-size: 1.25rem; font-weight: 600; color: var(--color-foreground); margin-bottom: 0.375rem;">Двухфакторная аутентификация</h1>
<p style="font-size: 0.875rem; color: var(--color-muted-foreground);">Для аккаунтов администраторов 2FA обязательна. Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Authy).</p>
</div>
<div style="padding: 1.75rem 2rem;">
{% if error %}
<div style="margin-bottom: 1rem; color: #d63333; background: rgba(214,51,51,0.1); border: 1px solid rgba(214,51,51,0.25); padding: 0.75rem; border-radius: 0.5rem; font-size: 0.875rem;">{{ error }}</div>
{% endif %}
<div style="display:flex; justify-content:center; margin-bottom:1rem;">
<img src="{{ qr }}" alt="QR" style="width:200px; height:200px; border:1px solid var(--color-border); border-radius:0.5rem;">
</div>
<div style="text-align:center; margin-bottom:1.25rem;">
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">Или введите ключ вручную:</div>
<code style="font-size:0.9rem; font-weight:600; letter-spacing:0.05em; color:var(--color-foreground); word-break:break-all;">{{ secret }}</code>
</div>
<form method="post">
{% csrf_token %}
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">Код из приложения (6 цифр)</label>
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required autofocus
class="input" style="width:100%; text-align:center; letter-spacing:0.3em; font-size:1.1rem; margin-bottom:1rem;">
<button type="submit" class="btn btn-primary" style="width:100%; justify-content:center;">Включить 2FA</button>
</form>
</div>
</div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Подтверждение входа — АКС Портал</title>
<link rel="stylesheet" href="{% static 'assets/css/main.css' %}">
</head>
<body style="background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-sidebar-accent) 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; font-family: var(--font-sans);">
<div style="width: 100%; max-width: 26rem; background: var(--color-card); border-radius: 0.75rem; border: 1px solid var(--color-border); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1); overflow: hidden;">
<div style="padding: 1.75rem 2rem 1.25rem; text-align: center; border-bottom: 1px solid var(--color-border);">
<h1 style="font-size: 1.25rem; font-weight: 600; color: var(--color-foreground); margin-bottom: 0.375rem;">Подтверждение входа</h1>
<p style="font-size: 0.875rem; color: var(--color-muted-foreground);">Введите код из приложения-аутентификатора или один из резервных кодов.</p>
</div>
<div style="padding: 1.75rem 2rem;">
{% if error %}
<div style="margin-bottom: 1rem; color: #d63333; background: rgba(214,51,51,0.1); border: 1px solid rgba(214,51,51,0.25); padding: 0.75rem; border-radius: 0.5rem; font-size: 0.875rem;">{{ error }}</div>
{% endif %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">Код подтверждения</label>
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" required autofocus
class="input" style="width:100%; text-align:center; letter-spacing:0.2em; font-size:1.1rem; margin-bottom:1rem;">
<button type="submit" class="btn btn-primary" style="width:100%; justify-content:center;">Подтвердить</button>
</form>
<div style="text-align:center; margin-top:1rem;">
<a href="{% url 'logout' %}" style="font-size:0.8125rem; color:var(--color-muted-foreground); text-decoration:none;">Выйти</a>
</div>
</div>
</div>
</body>
</html>