Django-Admin.ru

Защита Django Admin в продакшне: URL, IP, django-axes и 2FA

Как защитить Django Admin в продакшне: сменить URL, ограничить доступ по IP на nginx, блокировать перебор паролей django-axes, включить 2FA и HTTPS.

Редакция Django-Admin.ru
· обновлено · 11 мин чтения

Содержание · 10 разделов
  1. Смените URL админки
  2. Ограничьте доступ по IP или VPN на nginx
  3. Блокируйте перебор паролей с django-axes
  4. Включите двухфакторную аутентификацию (django-otp)
  5. Как не запереть самого себя
  6. Включите HTTPS и защищённые cookie
  7. Сессии и пароли сотрудников
  8. Журнал действий: LogEntry
  9. Чек-лист безопасности Django Admin
  10. Частые вопросы
  11. Достаточно ли просто сменить /admin/?
  12. Я заблокировал себя в django-axes. Что делать?
  13. После включения django-otp не могу войти в админку
  14. Можно ли отключить админку на публичном сервере?
  15. Как узнать о каждом входе в админку?
  16. Итог

Админку Django в продакшне защищают в несколько слоёв: неочевидный URL отсекает сканеры, ограничение по IP или VPN на nginx закрывает её от интернета, django-axes блокирует перебор паролей, django-otp добавляет второй фактор, а настройки SECURE_* гарантируют HTTPS и защищённые cookie. Ни одна мера не работает в одиночку: смена /admin/ без остальных слоёв почти ничего не даёт.

Ниже — настройка каждого слоя с конфигурациями, проверенными на Django 5.2 LTS и 6.x, и чек-лист в конце. Если вы только знакомитесь с админкой, начните с полного руководства по Django Admin.

Смените URL админки

Путь admin/ проверяют все автоматические сканеры. Вынесите его в переменную окружения, чтобы он не попадал в репозиторий:

# config/urls.py
import os

from django.contrib import admin
from django.urls import path

ADMIN_URL = os.environ.get("DJANGO_ADMIN_URL", "admin/")

urlpatterns = [
    path(ADMIN_URL, admin.site.urls),
]
# .env на сервере — путь обязательно со слешем в конце
DJANGO_ADMIN_URL=control-7f3a9c/

Что важно учесть:

  • Не упоминайте путь в robots.txt и sitemap — Disallow: /control-7f3a9c/ сразу выдаёт адрес.
  • Ссылки на админку в шаблонах стройте через {% url 'admin:index' %}, а не хардкодом: тогда смена пути ничего не сломает.
  • Смена пути только снижает шум. Целенаправленный атакующий может найти путь в утёкших логах, JS-бандле или истории git. Поэтому дальше — настоящие барьеры.

Ограничьте доступ по IP или VPN на nginx

Самая надёжная мера — не пускать к админке никого, кроме офиса и VPN. Делайте это на уровне веб-сервера: запрос отсекается ещё до Django. Как настроить связку целиком, описано в статье про деплой Django с Gunicorn и Nginx.

# /etc/nginx/conf.d/example.conf
limit_req_zone $binary_remote_addr zone=admin_login:10m rate=10r/m;

server {
    listen 443 ssl;
    server_name example.org;

    # Вся админка — только из офиса и VPN
    location /control-7f3a9c/ {
        allow 203.0.113.10;   # офис
        allow 10.8.0.0/24;    # подсеть VPN
        deny all;

        include /etc/nginx/proxy_params;
        proxy_pass http://127.0.0.1:8000;
    }

    # Страница входа: те же правила + ограничение частоты запросов
    location = /control-7f3a9c/login/ {
        allow 203.0.113.10;
        allow 10.8.0.0/24;
        deny all;

        limit_req zone=admin_login burst=5 nodelay;
        limit_req_status 429;

        include /etc/nginx/proxy_params;
        proxy_pass http://127.0.0.1:8000;
    }

    location / {
        include /etc/nginx/proxy_params;
        proxy_pass http://127.0.0.1:8000;
    }
}

Важно: директивы allow/deny внутри location не наследуются соседними блоками. Если для страницы входа заведён отдельный location = ..., правила доступа нужно повторить в нём — иначе /login/ окажется открыт всему интернету.

Файл /etc/nginx/proxy_params поставляется с пакетом nginx в Debian и Ubuntu: он передаёт приложению заголовки Host, X-Real-IP, X-Forwarded-For и X-Forwarded-Proto. В пакетах с nginx.org, на RHEL-совместимых системах и в Docker-образе nginx этого файла нет — вместо include пропишите заголовки в каждом location:

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

Проверьте конфигурацию и перечитайте её: sudo nginx -t && sudo systemctl reload nginx. Посторонние адреса получат 403 Forbidden.

Если перед nginx стоит CDN или балансировщик, $remote_addr будет адресом прокси, а не клиента. Тогда настройте модуль ngx_http_realip_module (set_real_ip_from + real_ip_header) — иначе правила allow сработают не так, как вы ожидаете.

Альтернатива для распределённой команды — открыть админку только во внутренней сети (WireGuard, Tailscale, корпоративный VPN) или вынести её на отдельный поддомен с собственным server-блоком и ограничениями.

Блокируйте перебор паролей с django-axes

django-axes считает неудачные попытки входа и временно блокирует источник атаки. Установка:

python -m pip install django-axes
# settings.py
from datetime import timedelta

INSTALLED_APPS += ["axes"]

AUTHENTICATION_BACKENDS = [
    "axes.backends.AxesStandaloneBackend",  # обязательно первым
    "django.contrib.auth.backends.ModelBackend",
]

MIDDLEWARE += [
    "axes.middleware.AxesMiddleware",  # рекомендуется последним
]

AXES_FAILURE_LIMIT = 5                       # попыток до блокировки
AXES_COOLOFF_TIME = timedelta(minutes=30)    # без этого — бессрочная блокировка
AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]
AXES_RESET_ON_SUCCESS = True                 # успешный вход обнуляет счётчик
python manage.py migrate

Ключевые моменты конфигурации:

  • AXES_COOLOFF_TIME принимает timedelta (нужен from datetime import timedelta), число часов или None. Со значением по умолчанию None заблокированный остаётся заблокированным, пока вы не сбросите его вручную.
  • AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]] — блокировка по связке «логин + IP». Если блокировать только по username, злоумышленник может намеренно запереть ваш аккаунт с любого адреса. Если только по IP — все пользователи за одним NAT пострадают вместе.
  • После лимита попыток axes отвечает кодом 429 Too Many Requests (настраивается через AXES_HTTP_RESPONSE_CODE).

Если Django за прокси. По умолчанию axes берёт IP из REMOTE_ADDR, а за nginx это 127.0.0.1 — то есть все клиенты для axes выглядят одинаково. Установите python -m pip install "django-axes[ipware]" и укажите, из какого заголовка брать адрес. Когда перед Django стоит один nginx с proxy_params, надёжнее всего X-Real-IP: nginx перезаписывает его адресом клиента, и подделать его из браузера нельзя.

AXES_IPWARE_META_PRECEDENCE_ORDER = ["HTTP_X_REAL_IP", "REMOTE_ADDR"]

Связка AXES_IPWARE_PROXY_COUNT = 1 и HTTP_X_FORWARDED_FOR в такой схеме не подходит: nginx кладёт в X-Forwarded-For только адрес клиента, ipware не находит в цепочке ожидаемого прокси и возвращает None вместо IP. AXES_IPWARE_PROXY_COUNT нужен, когда перед nginx есть ещё балансировщик или CDN и в X-Forwarded-For несколько адресов.

Разблокировать пользователя можно командами:

python manage.py axes_reset_username admin   # конкретный логин
python manage.py axes_reset_ip 203.0.113.10  # конкретный IP
python manage.py axes_reset                  # все блокировки

Журнал попыток (Access attempts, Access logs) появляется прямо в админке.

Включите двухфакторную аутентификацию (django-otp)

Даже сложный пароль может утечь. django-otp добавляет на страницу входа одноразовый код из приложения-аутентификатора (Google Authenticator, Aegis, 1Password).

python -m pip install "django-otp[qrcode]"

Экстра qrcode нужна, чтобы админка показывала QR-код для настройки приложения.

# settings.py
INSTALLED_APPS += [
    "django_otp",
    "django_otp.plugins.otp_totp",    # коды из приложения (TOTP)
    "django_otp.plugins.otp_static",  # резервные одноразовые коды
]

MIDDLEWARE = [
    # ...
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django_otp.middleware.OTPMiddleware",  # сразу после AuthenticationMiddleware
    # ...
]
# config/urls.py
from django.contrib import admin
from django_otp.admin import OTPAdminSite

admin.site.__class__ = OTPAdminSite

После python manage.py migrate админка пускает только пользователей, подтвердивших вход кодом с зарегистрированного устройства. Пользователь без устройства для неё всё равно что не is_staff.

Как не запереть самого себя

Возникает проблема курицы и яйца: чтобы добавить TOTP-устройство в админке, нужно в неё войти, а войти без устройства нельзя. Выпустите себе резервный код из консоли:

python manage.py addstatictoken admin
# → wqb4qi55

Дальше:

  1. На странице входа введите логин и пароль — форма попросит код и покажет список устройств.
  2. Выберите устройство Backup Code и введите код из консоли (он одноразовый).
  3. В разделе TOTP devices добавьте устройство для своего пользователя и откройте ссылку qrcode в списке устройств.
  4. Отсканируйте QR-код приложением. Следующий вход — уже с кодом из него.

Повторите эти действия для каждого сотрудника с доступом в админку. Создание первого администратора описано в статье про createsuperuser.

Совет: django-otp и django-axes совместимы — axes считает неудачные попытки на той же форме входа.

Пароль и сессионная cookie не должны передаваться по HTTP. Минимальный набор настроек для продакшна за nginx:

# settings.py (продакшн)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://example.org"]

SECURE_HSTS_SECONDS = 60 * 60 * 24 * 365
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
НастройкаЧто делает
SECURE_PROXY_SSL_HEADERДоверять заголовку прокси о том, что исходный запрос был по HTTPS
SECURE_SSL_REDIRECTПеренаправлять все HTTP-запросы на HTTPS
SESSION_COOKIE_SECUREОтправлять сессионную cookie только по HTTPS
CSRF_COOKIE_SECUREТо же для CSRF-cookie
CSRF_TRUSTED_ORIGINSДоверенные origin со схемой — без этого вход за прокси может упасть
SECURE_HSTS_SECONDSБраузер запомнит, что сайт работает только по HTTPS, на заданный срок

Важно: SECURE_PROXY_SSL_HEADER включайте, только если прокси всегда выставляет X-Forwarded-Proto и затирает пришедший от клиента заголовок (proxy_set_header X-Forwarded-Proto $scheme;). HSTS начинайте с малого значения (например, 3600) и увеличивайте, когда убедитесь, что HTTPS работает на всех поддоменах — откатить его быстро нельзя.

Проверьте конфигурацию встроенной командой — она перечислит пропущенные настройки безопасности:

python manage.py check --deploy

Заголовок X-Frame-Options: DENY против кликджекинга Django ставит по умолчанию через XFrameOptionsMiddleware. Политику CSP (Content Security Policy — список разрешённых источников скриптов, стилей и картинок) в Django 5.2 добавляют пакетом django-csp. С Django 6.0 поддержка встроена: подключите ContentSecurityPolicyMiddleware и опишите политику в SECURE_CSP:

# settings.py, Django 6.0+
from django.utils.csp import CSP

MIDDLEWARE += ["django.middleware.csp.ContentSecurityPolicyMiddleware"]

SECURE_CSP = {
    "default-src": [CSP.SELF],
    "img-src": [CSP.SELF, "data:"],
}

Стандартная админка работает с такой политикой, но сторонние темы и виджеты с внешними CDN могут потребовать дополнительных источников. Начните с SECURE_CSP_REPORT_ONLY: браузер будет сообщать о нарушениях, ничего не блокируя.

Сессии и пароли сотрудников

Длинные сессии превращают украденный ноутбук в открытую админку. Сократите срок жизни сессии:

SESSION_COOKIE_AGE = 60 * 60 * 8          # 8 часов вместо 2 недель по умолчанию
SESSION_EXPIRE_AT_BROWSER_CLOSE = True    # сессия закончится с закрытием браузера

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
        "OPTIONS": {"min_length": 12},
    },
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
]

SESSION_COOKIE_HTTPONLY по умолчанию уже True — JavaScript не прочитает сессионную cookie. После смены пароля Django завершает остальные сессии пользователя. Просроченные сессии из базы периодически удаляйте командой python manage.py clearsessions (например, по cron).

Принцип минимальных прав тоже часть защиты: выдавайте сотрудникам is_staff и нужные права через группы, а is_superuser — только тем, кому он действительно нужен. Подробнее о группах и правах — в разделе аутентификация в Django.

Журнал действий: LogEntry

Каждое добавление, изменение и удаление через админку Django записывает в модель LogEntry: кто, когда, какой объект и какие поля изменились. По умолчанию журнал виден только в истории объекта и в блоке «Последние действия». Зарегистрируйте его в админке только для чтения:

# core/admin.py
from django.contrib import admin
from django.contrib.admin.models import LogEntry


@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin):
    list_display = ["action_time", "user", "content_type", "object_repr", "action_flag"]
    list_filter = ["action_flag", "content_type"]
    search_fields = ["object_repr", "change_message", "user__username"]
    date_hierarchy = "action_time"
    list_select_related = ["user", "content_type"]

    def has_add_permission(self, request):
        return False

    def has_change_permission(self, request, obj=None):
        return False

    def has_delete_permission(self, request, obj=None):
        return False

Запрос к журналу из кода или shell:

from django.contrib.admin.models import LogEntry

for entry in LogEntry.objects.filter(user__username="admin")[:10]:
    print(entry.action_time, entry.get_action_flag_display(), entry.object_repr,
          entry.get_change_message())
# → 2026-10-09 10:12:03+00:00 Change Заказ A-17 Changed Status.

Учтите ограничения: LogEntry фиксирует только действия через страницы админки — формы и встроенное удаление. Массовые операции через queryset.update() в него не попадают — логируйте их сами (см. оптимизацию admin actions). Входы и неудачные попытки журналирует django-axes.

Чек-лист безопасности Django Admin

  • путь админки вынесен в переменную окружения и не упоминается в robots.txt
  • доступ к админке ограничен по IP, VPN или отдельному поддомену на nginx
  • django-axes настроен с AXES_COOLOFF_TIME и блокировкой по «логин + IP»
  • за прокси axes видит реальный IP клиента (django-axes[ipware] и заголовок X-Real-IP)
  • у каждого сотрудника настроена 2FA через django-otp
  • SECURE_SSL_REDIRECT, secure-cookie и HSTS включены, check --deploy без предупреждений
  • срок сессии сокращён, пароли проверяются валидаторами
  • is_superuser — только у тех, кому он нужен; остальным права через группы
  • журнал LogEntry доступен для аудита

Частые вопросы

Достаточно ли просто сменить /admin/?

Нет. Смена пути убирает шум от ботов, но от целенаправленной атаки не защищает. Минимум для продакшна — HTTPS, ограничение попыток входа и 2FA.

Я заблокировал себя в django-axes. Что делать?

Выполните на сервере python manage.py axes_reset_username <логин> или axes_reset_ip <адрес>. Если AXES_COOLOFF_TIME задан, блокировка снимется сама по истечении срока.

После включения django-otp не могу войти в админку

Админка OTPAdminSite пускает только пользователей с подтверждённым устройством. Выпустите резервный код командой addstatictoken и войдите с ним, выбрав устройство Backup Code, как описано в разделе о django-otp.

Можно ли отключить админку на публичном сервере?

Да. Подключайте маршрут по флагу из окружения, а админку запускайте отдельным экземпляром приложения во внутренней сети:

# config/urls.py
import os

from django.contrib import admin
from django.urls import path

urlpatterns = [
    # публичные маршруты
]

if os.environ.get("DJANGO_ADMIN_ENABLED") == "1":
    urlpatterns.append(path(os.environ.get("DJANGO_ADMIN_URL", "admin/"), admin.site.urls))

На публичном сервере без флага путь админки вернёт 404.

Как узнать о каждом входе в админку?

Подпишитесь на сигнал user_logged_in и пишите входы сотрудников в отдельный лог — оттуда их легко отправить в систему мониторинга:

# core/signals.py — импортируйте модуль в AppConfig.ready()
import logging

from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver

logger = logging.getLogger("security")


@receiver(user_logged_in)
def log_staff_login(sender, request, user, **kwargs):
    if user.is_staff:
        logger.warning("Вход в админку: %s, IP %s", user.get_username(), request.META.get("REMOTE_ADDR"))

За прокси в REMOTE_ADDR окажется адрес nginx. Если django-axes уже настроен на реальный IP, используйте его функцию axes.helpers.get_client_ip_address(request).

Итог

Надёжная защита админки складывается из слоёв: скрытый путь, сетевые ограничения, лимит попыток, второй фактор, HTTPS и аудит. Начните с IP-фильтра и 2FA — они закрывают большинство реальных атак. Остальные настройки админки собраны в руководстве по Django Admin, а все статьи кластера — в разделе Django Admin.

  • #Django Admin
  • #Безопасность
  • #django-axes
  • #2FA
  • #nginx
  • #Продакшн
↑ ↓ выборEnter открытьEsc закрыть