Securing the Django admin interface is crucial for protecting your application and data. This chapter covers comprehensive security measures, from basic configurations to advanced protection strategies.
# settings.py
import os
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 12, # Require longer passwords
}
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Session security
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True # No JavaScript access
SESSION_COOKIE_AGE = 3600 # 1 hour timeout
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# CSRF protection
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
CSRF_USE_SESSIONS = True
# Admin-specific settings
ADMIN_SESSION_TIMEOUT = 1800 # 30 minutes
# Install: pip install django-otp qrcode
# settings.py
INSTALLED_APPS = [
'django_otp',
'django_otp.plugins.otp_totp',
'django_otp.plugins.otp_static',
# ... other apps
]
MIDDLEWARE = [
'django_otp.middleware.OTPMiddleware',
# ... other middleware
]
# Custom admin site with 2FA
from django_otp.admin import OTPAdminSite
from django_otp.decorators import otp_required
class SecureAdminSite(OTPAdminSite):
"""Admin site with mandatory 2FA"""
def has_permission(self, request):
"""Require 2FA for admin access"""
return (
request.user.is_active and
request.user.is_staff and
request.user.is_verified()
)
# Replace default admin site
admin_site = SecureAdminSite(name='secure_admin')
# Register models with secure admin
from django.contrib.auth.models import User, Group
from django_otp.admin import OTPTokenAdmin
from django_otp.models import Device
admin_site.register(User)
admin_site.register(Group)
# models.py
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.db import models
class CustomUser(AbstractUser):
"""Extended user model with admin roles"""
ADMIN_ROLES = [
('content_admin', 'Content Administrator'),
('user_admin', 'User Administrator'),
('system_admin', 'System Administrator'),
('read_only', 'Read Only Access'),
]
admin_role = models.CharField(
max_length=20,
choices=ADMIN_ROLES,
blank=True,
null=True
)
last_admin_login = models.DateTimeField(null=True, blank=True)
failed_login_attempts = models.IntegerField(default=0)
account_locked_until = models.DateTimeField(null=True, blank=True)
# admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils import timezone
class SecureUserAdmin(UserAdmin):
"""Secure user admin with role management"""
list_display = [
'username', 'email', 'admin_role', 'is_staff',
'last_admin_login', 'failed_login_attempts'
]
list_filter = ['admin_role', 'is_staff', 'is_superuser', 'last_admin_login']
fieldsets = UserAdmin.fieldsets + (
('Admin Security', {
'fields': (
'admin_role',
'last_admin_login',
'failed_login_attempts',
'account_locked_until'
)
}),
)
readonly_fields = ['last_admin_login', 'failed_login_attempts']
def get_queryset(self, request):
"""Filter users based on admin role"""
queryset = super().get_queryset(request)
if not request.user.is_superuser:
# Non-superusers can only see users with lower privileges
if request.user.admin_role == 'user_admin':
queryset = queryset.filter(
admin_role__in=['content_admin', 'read_only']
)
elif request.user.admin_role == 'content_admin':
queryset = queryset.filter(admin_role='read_only')
return queryset
admin.site.register(CustomUser, SecureUserAdmin)
# urls.py
import os
from django.contrib import admin
from django.urls import path, include
# Use environment variable for admin URL
ADMIN_URL = os.environ.get('ADMIN_URL', 'admin/')
# Ensure admin URL doesn't end with predictable patterns
if ADMIN_URL in ['admin/', 'administrator/', 'manage/', 'control/']:
raise ValueError("Admin URL should not use common patterns")
urlpatterns = [
path(ADMIN_URL, admin.site.urls),
# ... other patterns
]
# Additional security: Random admin URL generation
import secrets
import string
def generate_admin_url():
"""Generate random admin URL"""
chars = string.ascii_letters + string.digits
return ''.join(secrets.choice(chars) for _ in range(16)) + '/'
# Use in production deployment scripts
# middleware.py
from django.http import HttpResponseForbidden
from django.conf import settings
import ipaddress
import logging
logger = logging.getLogger(__name__)
class AdminIPRestrictionMiddleware:
"""Restrict admin access by IP address with logging"""
def __init__(self, get_response):
self.get_response = get_response
self.allowed_ips = getattr(settings, 'ADMIN_ALLOWED_IPS', [])
self.admin_url = getattr(settings, 'ADMIN_URL', 'admin/')
def __call__(self, request):
if request.path.startswith(f'/{self.admin_url}'):
client_ip = self.get_client_ip(request)
if not self.is_ip_allowed(client_ip):
logger.warning(
f'Admin access denied for IP {client_ip} - '
f'User: {getattr(request.user, "username", "Anonymous")} - '
f'Path: {request.path}'
)
return HttpResponseForbidden('Access denied from this IP address')
return self.get_response(request)
def get_client_ip(self, request):
"""Get real client IP address"""
# Check for IP in various headers (for load balancers/proxies)
ip_headers = [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'HTTP_CF_CONNECTING_IP', # Cloudflare
'REMOTE_ADDR'
]
for header in ip_headers:
ip = request.META.get(header)
if ip:
# Handle comma-separated IPs (X-Forwarded-For)
ip = ip.split(',')[0].strip()
try:
ipaddress.ip_address(ip)
return ip
except ValueError:
continue
return request.META.get('REMOTE_ADDR', '0.0.0.0')
def is_ip_allowed(self, ip):
"""Check if IP is in allowed networks"""
if not self.allowed_ips:
return True # No restrictions if list is empty
try:
client_ip = ipaddress.ip_address(ip)
for allowed_network in self.allowed_ips:
if client_ip in ipaddress.ip_network(allowed_network, strict=False):
return True
except ValueError as e:
logger.error(f'Invalid IP address format: {ip} - {e}')
return False
# settings.py
MIDDLEWARE = [
'myapp.middleware.AdminIPRestrictionMiddleware',
# ... other middleware
]
# Production IP restrictions
ADMIN_ALLOWED_IPS = [
'192.168.1.0/24', # Office network
'10.0.0.0/8', # VPN network
'203.0.113.100/32', # Specific admin IP
]
# middleware.py
from django.core.cache import cache
from django.http import HttpResponseTooManyRequests
from django.contrib.auth import authenticate
import time
class AdminRateLimitMiddleware:
"""Rate limiting for admin login attempts"""
def __init__(self, get_response):
self.get_response = get_response
self.max_attempts = 5
self.lockout_duration = 900 # 15 minutes
self.admin_url = getattr(settings, 'ADMIN_URL', 'admin/')
def __call__(self, request):
if self.is_admin_login(request):
client_ip = self.get_client_ip(request)
if self.is_rate_limited(client_ip):
return HttpResponseTooManyRequests(
'Too many login attempts. Please try again later.'
)
# Track failed attempts
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
if username and password:
user = authenticate(username=username, password=password)
if not user:
self.record_failed_attempt(client_ip, username)
return self.get_response(request)
def is_admin_login(self, request):
"""Check if this is an admin login request"""
return (
request.path.startswith(f'/{self.admin_url}login/') or
request.path == f'/{self.admin_url}login'
)
def is_rate_limited(self, ip):
"""Check if IP is rate limited"""
cache_key = f'admin_login_attempts:{ip}'
attempts = cache.get(cache_key, 0)
return attempts >= self.max_attempts
def record_failed_attempt(self, ip, username):
"""Record failed login attempt"""
cache_key = f'admin_login_attempts:{ip}'
attempts = cache.get(cache_key, 0) + 1
cache.set(cache_key, attempts, self.lockout_duration)
# Log security event
logger.warning(
f'Failed admin login attempt #{attempts} - '
f'IP: {ip}, Username: {username}'
)
# Lock user account after multiple failures
if attempts >= 3:
try:
from django.contrib.auth.models import User
user = User.objects.get(username=username)
user.failed_login_attempts = attempts
if attempts >= self.max_attempts:
user.account_locked_until = timezone.now() + timedelta(
seconds=self.lockout_duration
)
user.save()
except User.DoesNotExist:
pass
# admin.py
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.utils.html import strip_tags
import bleach
class SecureModelAdmin(admin.ModelAdmin):
"""Base admin class with input sanitization"""
def clean_html_fields(self, obj):
"""Sanitize HTML content in specified fields"""
html_fields = getattr(self, 'html_fields', [])
for field_name in html_fields:
if hasattr(obj, field_name):
content = getattr(obj, field_name)
if content:
# Allow only safe HTML tags
allowed_tags = [
'p', 'br', 'strong', 'em', 'u', 'ol', 'ul', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'
]
cleaned_content = bleach.clean(
content,
tags=allowed_tags,
strip=True
)
setattr(obj, field_name, cleaned_content)
def save_model(self, request, obj, form, change):
"""Override save to add security checks"""
# Sanitize HTML fields
self.clean_html_fields(obj)
# Log admin actions
action = 'changed' if change else 'added'
logger.info(
f'Admin action: {request.user.username} {action} '
f'{obj._meta.model_name} "{obj}"'
)
super().save_model(request, obj, form, change)
@admin.register(Article)
class ArticleAdmin(SecureModelAdmin):
html_fields = ['content', 'excerpt'] # Fields to sanitize
# validators.py
import magic
import os
from django.core.exceptions import ValidationError
from django.conf import settings
class SecureFileValidator:
"""Comprehensive file upload validation"""
def __init__(self, allowed_types=None, max_size=None, scan_content=True):
self.allowed_types = allowed_types or []
self.max_size = max_size or 10 * 1024 * 1024 # 10MB default
self.scan_content = scan_content
def __call__(self, file):
self.validate_size(file)
self.validate_type(file)
self.validate_filename(file)
if self.scan_content:
self.scan_for_malware(file)
def validate_size(self, file):
"""Validate file size"""
if file.size > self.max_size:
raise ValidationError(
f'File size {file.size} exceeds maximum allowed size {self.max_size}'
)
def validate_type(self, file):
"""Validate file type using magic numbers"""
file.seek(0)
file_header = file.read(1024)
file.seek(0)
detected_type = magic.from_buffer(file_header, mime=True)
if self.allowed_types and detected_type not in self.allowed_types:
raise ValidationError(
f'File type {detected_type} is not allowed'
)
def validate_filename(self, file):
"""Validate and sanitize filename"""
filename = file.name
# Check for dangerous extensions
dangerous_extensions = [
'.exe', '.bat', '.cmd', '.scr', '.pif', '.com',
'.php', '.asp', '.jsp', '.js', '.vbs', '.sh'
]
file_ext = os.path.splitext(filename)[1].lower()
if file_ext in dangerous_extensions:
raise ValidationError(f'File extension {file_ext} is not allowed')
# Check for path traversal attempts
if '..' in filename or '/' in filename or '\\' in filename:
raise ValidationError('Invalid filename')
def scan_for_malware(self, file):
"""Basic malware signature detection"""
file.seek(0)
content = file.read(8192) # Read first 8KB
file.seek(0)
# Check for suspicious patterns
malicious_patterns = [
b'<script',
b'javascript:',
b'<?php',
b'<%',
b'eval(',
b'exec(',
b'system(',
b'shell_exec(',
]
content_lower = content.lower()
for pattern in malicious_patterns:
if pattern in content_lower:
raise ValidationError('Potentially malicious content detected')
# Usage in admin
secure_image_validator = SecureFileValidator(
allowed_types=['image/jpeg', 'image/png', 'image/gif'],
max_size=5 * 1024 * 1024 # 5MB
)
class MediaFileAdmin(admin.ModelAdmin):
def formfield_for_dbfield(self, db_field, **kwargs):
"""Add validators to file fields"""
if db_field.name in ['image', 'file']:
kwargs['validators'] = [secure_image_validator]
return super().formfield_for_dbfield(db_field, **kwargs)
# logging_middleware.py
import logging
import json
from django.utils import timezone
logger = logging.getLogger('admin_security')
class AdminSecurityMiddleware:
"""Log all admin activities for security monitoring"""
def __init__(self, get_response):
self.get_response = get_response
self.admin_url = getattr(settings, 'ADMIN_URL', 'admin/')
def __call__(self, request):
if request.path.startswith(f'/{self.admin_url}'):
self.log_admin_access(request)
response = self.get_response(request)
if request.path.startswith(f'/{self.admin_url}'):
self.log_admin_response(request, response)
return response
def log_admin_access(self, request):
"""Log admin access attempts"""
log_data = {
'timestamp': timezone.now().isoformat(),
'ip_address': self.get_client_ip(request),
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
'method': request.method,
'path': request.path,
'user': getattr(request.user, 'username', 'Anonymous'),
'is_authenticated': request.user.is_authenticated,
'session_key': request.session.session_key,
}
# Log POST data (excluding sensitive fields)
if request.method == 'POST':
post_data = dict(request.POST)
# Remove sensitive fields
sensitive_fields = ['password', 'csrfmiddlewaretoken']
for field in sensitive_fields:
post_data.pop(field, None)
log_data['post_data'] = post_data
logger.info(f'Admin access: {json.dumps(log_data)}')
def log_admin_response(self, request, response):
"""Log admin response status"""
if response.status_code >= 400:
logger.warning(
f'Admin error response: {response.status_code} - '
f'User: {getattr(request.user, "username", "Anonymous")} - '
f'Path: {request.path} - '
f'IP: {self.get_client_ip(request)}'
)
# settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
'security': {
'format': '{asctime} SECURITY {levelname} {message}',
'style': '{',
},
},
'handlers': {
'admin_security_file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/var/log/django/admin_security.log',
'maxBytes': 10 * 1024 * 1024, # 10MB
'backupCount': 5,
'formatter': 'security',
},
'security_email': {
'level': 'WARNING',
'class': 'django.utils.log.AdminEmailHandler',
'formatter': 'verbose',
},
},
'loggers': {
'admin_security': {
'handlers': ['admin_security_file', 'security_email'],
'level': 'INFO',
'propagate': False,
},
},
}
# monitoring.py
from django.core.management.base import BaseCommand
from django.core.mail import send_mail
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
import re
class Command(BaseCommand):
"""Monitor admin security events"""
def handle(self, *args, **options):
self.check_suspicious_activity()
self.check_failed_logins()
self.check_privilege_escalations()
self.check_unusual_access_patterns()
def check_suspicious_activity(self):
"""Check for suspicious admin activity"""
# Check for multiple failed login attempts
recent_time = timezone.now() - timedelta(hours=1)
with open('/var/log/django/admin_security.log', 'r') as f:
log_content = f.read()
# Parse failed login attempts
failed_attempts = re.findall(
r'Failed admin login attempt.*IP: ([\d.]+).*Username: (\w+)',
log_content
)
# Group by IP and count attempts
ip_attempts = {}
for ip, username in failed_attempts:
if ip not in ip_attempts:
ip_attempts[ip] = []
ip_attempts[ip].append(username)
# Alert on suspicious patterns
for ip, usernames in ip_attempts.items():
if len(usernames) > 10: # More than 10 attempts
self.send_security_alert(
f'Brute force attack detected from IP {ip}',
f'Multiple failed login attempts: {len(usernames)} attempts '
f'for usernames: {", ".join(set(usernames))}'
)
def check_privilege_escalations(self):
"""Check for unauthorized privilege changes"""
recent_time = timezone.now() - timedelta(hours=24)
# Check for users granted superuser status
new_superusers = User.objects.filter(
is_superuser=True,
date_joined__gte=recent_time
)
for user in new_superusers:
self.send_security_alert(
f'New superuser created: {user.username}',
f'User {user.username} was granted superuser privileges '
f'on {user.date_joined}'
)
def send_security_alert(self, subject, message):
"""Send security alert email"""
send_mail(
f'SECURITY ALERT: {subject}',
message,
'security@example.com',
['admin@example.com', 'security-team@example.com'],
fail_silently=False,
)
# settings/production.py
import os
# Security settings
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']
# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Cookie security
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# Admin security
ADMIN_URL = os.environ.get('ADMIN_URL')
if not ADMIN_URL or len(ADMIN_URL) < 10:
raise ValueError("ADMIN_URL must be set and at least 10 characters long")
# Database security
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': os.environ.get('DB_HOST'),
'PORT': os.environ.get('DB_PORT', '5432'),
'OPTIONS': {
'sslmode': 'require',
},
}
}
# File upload security
FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
FILE_UPLOAD_PERMISSIONS = 0o644
# security_middleware.py
class SecurityHeadersMiddleware:
"""Add security headers to all responses"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Add security headers
response['X-Content-Type-Options'] = 'nosniff'
response['X-Frame-Options'] = 'DENY'
response['X-XSS-Protection'] = '1; mode=block'
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Content Security Policy for admin
if request.path.startswith('/admin/'):
response['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'"
)
return response
# security_tests.py
from django.test import TestCase, Client
from django.contrib.auth.models import User
from django.urls import reverse
import time
class AdminSecurityTests(TestCase):
"""Test admin security measures"""
def setUp(self):
self.client = Client()
self.admin_user = User.objects.create_superuser(
username='admin',
email='admin@example.com',
password='secure_password_123'
)
def test_admin_url_not_predictable(self):
"""Test that admin URL is not easily guessable"""
common_urls = [
'/admin/',
'/administrator/',
'/manage/',
'/control/',
'/backend/'
]
for url in common_urls:
response = self.client.get(url)
# Should not return admin login page
self.assertNotContains(response, 'Django administration', status_code=404)
def test_rate_limiting(self):
"""Test rate limiting on login attempts"""
login_url = reverse('admin:login')
# Make multiple failed login attempts
for i in range(6):
response = self.client.post(login_url, {
'username': 'admin',
'password': 'wrong_password'
})
# Should be rate limited after 5 attempts
response = self.client.post(login_url, {
'username': 'admin',
'password': 'wrong_password'
})
self.assertEqual(response.status_code, 429) # Too Many Requests
def test_session_timeout(self):
"""Test admin session timeout"""
self.client.login(username='admin', password='secure_password_123')
# Access admin page
response = self.client.get(reverse('admin:index'))
self.assertEqual(response.status_code, 200)
# Simulate session timeout by manipulating session
session = self.client.session
session['_auth_user_id'] = str(self.admin_user.id)
session['_session_expiry'] = time.time() - 3600 # Expired 1 hour ago
session.save()
# Should redirect to login
response = self.client.get(reverse('admin:index'))
self.assertRedirects(response, '/admin/login/?next=/admin/')
def test_csrf_protection(self):
"""Test CSRF protection on admin forms"""
self.client.login(username='admin', password='secure_password_123')
# Try to submit form without CSRF token
response = self.client.post(reverse('admin:auth_user_add'), {
'username': 'testuser',
'password1': 'testpass123',
'password2': 'testpass123'
})
# Should be rejected due to missing CSRF token
self.assertEqual(response.status_code, 403)
By implementing these security measures, you can significantly reduce the risk of unauthorized access and protect your Django admin interface from common attack vectors. Remember that security is an ongoing process that requires regular updates and monitoring.
Admin Actions
Admin actions provide a powerful way to perform bulk operations on selected objects in the Django admin interface. This chapter covers creating custom actions, handling complex operations, and building efficient bulk processing tools.
Middleware
Django middleware is a powerful framework for processing requests and responses globally across your application. It provides hooks into Django's request/response processing, allowing you to modify requests before they reach views and responses before they're sent to clients.