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:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
(_('Аутентификация'), {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+29
@@ -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-секрет'),
|
||||
),
|
||||
]
|
||||
@@ -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'
|
||||
|
||||
@@ -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'])
|
||||
@@ -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)
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user