How to Implement Secure Django OTP Authentication: Complete 2025 Guide with Email Verification
Master Django two-factor authentication with this comprehensive step-by-step tutorial covering OTP implementation, security best practices, and production deployment
Table of Contents
- Introduction to Django OTP Authentication
- Why Choose OTP Authentication for Django Applications?
- Prerequisites and Requirements
- Project Setup and Installation
- Database Models for OTP Management
- Email Configuration and SMTP Setup
- Authentication Views and Business Logic
- Frontend Implementation with TailwindCSS
- Advanced Security Features
- Testing Your Django OTP System
- Production Deployment Guide
- Performance Optimization
- Troubleshooting Common Issues
- Best Practices and Security Considerations
- Conclusion and Next Steps
Introduction to Django OTP Authentication
Django OTP (One-Time Password) authentication represents a critical security enhancement for modern web applications. This comprehensive guide demonstrates how to implement a professional-grade two-factor authentication system using Django, providing enhanced security through email-based OTP verification.
In today's cybersecurity landscape, traditional username-password authentication is insufficient. Data breaches and credential theft make two-factor authentication essential for protecting user accounts and sensitive data. This tutorial will walk you through creating a robust Django OTP system that rivals enterprise-grade solutions.
What You'll Learn
By following this guide, you'll master:
- Complete OTP Authentication System: Build a full-featured two-factor authentication system from scratch
- Professional UI Design: Create stunning login interfaces using TailwindCSS
- Security Best Practices: Implement rate limiting, session management, and security logging
- Production Deployment: Configure your system for real-world deployment with proper SMTP and security settings
- Performance Optimization: Ensure your authentication system scales efficiently
Key Features of Our Implementation
Our Django OTP system includes:
- ✅ Email-based OTP verification
- ✅ Professional responsive UI with TailwindCSS
- ✅ Rate limiting and brute-force protection
- ✅ Comprehensive security logging
- ✅ Session management and "Remember Me" functionality
- ✅ Production-ready configuration
- ✅ Comprehensive error handling
- ✅ Mobile-responsive design
- ✅ CSRF protection
- ✅ IP address tracking
Why Choose OTP Authentication for Django Applications?
Security Benefits
Enhanced Account Protection: OTP authentication significantly reduces the risk of account compromise. Even if passwords are stolen, attackers cannot access accounts without the second factor.
Compliance Requirements: Many industries require two-factor authentication for regulatory compliance (HIPAA, PCI DSS, GDPR).
User Trust: Implementing 2FA demonstrates commitment to security, increasing user confidence in your platform.
Business Advantages
Reduced Support Costs: Fewer account compromises mean fewer support tickets and password reset requests.
Competitive Edge: Security features differentiate your application in the marketplace.
Scalability: Email-based OTP scales easily compared to SMS-based solutions.
Technical Benefits
Framework Integration: Django's robust authentication system makes OTP integration seamless.
Customization: Full control over the authentication flow and user experience.
Cost-Effective: Email-based OTP eliminates SMS costs while maintaining security.
Prerequisites and Requirements
Technical Prerequisites
Before starting this tutorial, ensure you have:
- Python 3.8+: Latest Python version for optimal performance
- Django 4.0+: Modern Django framework with latest security features
- Basic Django Knowledge: Understanding of models, views, templates, and URL routing
- HTML/CSS Familiarity: Basic frontend development skills
- Database Setup: PostgreSQL, MySQL, or SQLite for development
Development Environment
Set up your development environment with these tools:
- Code Editor: VS Code, PyCharm, or your preferred IDE
- Git: Version control for project management
- Virtual Environment: Python venv or pipenv for dependency isolation
- Email Service: Gmail, SendGrid, or similar SMTP provider
Required Python Packages
Our implementation uses these carefully selected packages:
# Core Django packages
Django>=4.0.0
python-dotenv>=0.19.0
# Form handling and UI
django-crispy-forms>=1.14.0
crispy-tailwind>=0.5.0
# Email and validation
django-email-validator>=2.0.0
# Security and rate limiting
django-ratelimit>=3.0.0
# Production deployment
gunicorn>=20.0.0
whitenoise>=6.0.0
# Database drivers (choose one)
psycopg2-binary>=2.9.0 # PostgreSQL
mysqlclient>=2.1.0 # MySQL
Project Setup and Installation
Creating Your Django Project
Start by setting up a clean Django project structure:
# Create project directory
mkdir django-otp-auth
cd django-otp-auth
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install Django
pip install django
# Create Django project
django-admin startproject otpauth .
cd otpauth
# Create accounts app
python manage.py startapp accounts
# Install additional packages
pip install python-dotenv django-crispy-forms crispy-tailwind
Initial Django Configuration
Configure your settings.py
for optimal development:
# otpauth/settings.py
import os
from dotenv import load_dotenv
from pathlib import Path
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
# Security settings
SECRET_KEY = os.getenv('SECRET_KEY', 'your-secret-key-here')
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'crispy_forms',
'crispy_tailwind',
# Local apps
'accounts',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'otpauth.urls'
# Template configuration
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
# Database configuration
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Static files configuration
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Crispy Forms configuration
CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
CRISPY_TEMPLATE_PACK = "tailwind"
# Session configuration
SESSION_COOKIE_AGE = 3600 # 1 hour default
SESSION_SAVE_EVERY_REQUEST = True
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# Security settings
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
# Logging configuration
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.FileHandler',
'filename': 'django.log',
},
'security_file': {
'level': 'WARNING',
'class': 'logging.FileHandler',
'filename': 'security.log',
},
},
'loggers': {
'django': {
'handlers': ['file'],
'level': 'INFO',
'propagate': True,
},
'security': {
'handlers': ['security_file'],
'level': 'WARNING',
'propagate': True,
},
},
}
Environment Variables Setup
Create a .env
file in your project root:
# .env
SECRET_KEY=your-super-secret-django-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# Email configuration (Development)
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
# Email configuration (Production)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
DEFAULT_FROM_EMAIL=Your App <noreply@yourapp.com>
# Database (if using PostgreSQL in production)
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
Database Models for OTP Management
Core Model Architecture
Our OTP system requires three main models: UserProfile
, EmailOTP
, and LoginAttempt
. These models provide comprehensive user management, OTP handling, and security logging.
# accounts/models.py
from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
from django.core.exceptions import ValidationError
import secrets
import string
import hashlib
class UserProfile(models.Model):
"""Extended user profile with OTP preferences"""
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='profile'
)
otp_enabled = models.BooleanField(
default=False,
help_text="Enable two-factor authentication for this user"
)
phone_number = models.CharField(
max_length=15,
blank=True,
help_text="Phone number for SMS backup (future feature)"
)
backup_codes = models.JSONField(
default=list,
help_text="Emergency backup codes for account recovery"
)
last_otp_request = models.DateTimeField(
null=True,
blank=True,
help_text="Timestamp of last OTP request for rate limiting"
)
failed_otp_attempts = models.IntegerField(
default=0,
help_text="Counter for failed OTP attempts"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "User Profile"
verbose_name_plural = "User Profiles"
indexes = [
models.Index(fields=['user']),
models.Index(fields=['otp_enabled']),
]
def __str__(self):
return f"{self.user.username} Profile"
def can_request_otp(self):
"""Check if user can request a new OTP (rate limiting)"""
if not self.last_otp_request:
return True
time_diff = timezone.now() - self.last_otp_request
return time_diff.total_seconds() > 60 # 1 minute cooldown
def generate_backup_codes(self, count=8):
"""Generate emergency backup codes"""
codes = []
for _ in range(count):
code = ''.join(secrets.choice(string.ascii_uppercase + string.digits)
for _ in range(8))
codes.append(code)
# Hash codes before storing
self.backup_codes = [hashlib.sha256(code.encode()).hexdigest()
for code in codes]
self.save()
return codes # Return unhashed codes for user to save
def verify_backup_code(self, code):
"""Verify a backup code"""
code_hash = hashlib.sha256(code.upper().encode()).hexdigest()
if code_hash in self.backup_codes:
# Remove used backup code
self.backup_codes.remove(code_hash)
self.save()
return True
return False
class EmailOTP(models.Model):
"""Email-based OTP management"""
PURPOSE_CHOICES = [
('login', 'Login'),
('register', 'Registration'),
('password_reset', 'Password Reset'),
('email_change', 'Email Change'),
('enable_2fa', 'Enable 2FA'),
('disable_2fa', 'Disable 2FA'),
]
STATUS_CHOICES = [
('active', 'Active'),
('used', 'Used'),
('expired', 'Expired'),
('revoked', 'Revoked'),
]
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='otp_codes'
)
otp_code = models.CharField(
max_length=6,
help_text="6-digit OTP code"
)
purpose = models.CharField(
max_length=20,
choices=PURPOSE_CHOICES,
help_text="Purpose of this OTP"
)
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default='active'
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(
help_text="OTP expiration time"
)
used_at = models.DateTimeField(
null=True,
blank=True,
help_text="Timestamp when OTP was used"
)
ip_address = models.GenericIPAddressField(
null=True,
blank=True,
help_text="IP address from which OTP was requested"
)
user_agent = models.TextField(
blank=True,
help_text="Browser user agent string"
)
attempts = models.IntegerField(
default=0,
help_text="Number of verification attempts"
)
max_attempts = models.IntegerField(
default=3,
help_text="Maximum allowed attempts"
)
class Meta:
verbose_name = "Email OTP"
verbose_name_plural = "Email OTPs"
indexes = [
models.Index(fields=['user', 'purpose', 'status']),
models.Index(fields=['expires_at']),
models.Index(fields=['created_at']),
]
ordering = ['-created_at']
def __str__(self):
return f"OTP for {self.user.username} - {self.purpose}"
@classmethod
def generate_otp(cls, user, purpose, ip_address=None, user_agent='', expiry_minutes=10):
"""Generate a new 6-digit OTP"""
# Revoke existing active OTPs for same purpose
cls.objects.filter(
user=user,
purpose=purpose,
status='active'
).update(status='revoked')
# Generate secure OTP
otp_code = ''.join(secrets.choice(string.digits) for _ in range(6))
expires_at = timezone.now() + timezone.timedelta(minutes=expiry_minutes)
otp = cls.objects.create(
user=user,
otp_code=otp_code,
purpose=purpose,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent
)
# Update user profile
if hasattr(user, 'profile'):
user.profile.last_otp_request = timezone.now()
user.profile.save()
return otp
def is_valid(self):
"""Check if OTP is still valid"""
if self.status != 'active':
return False
if timezone.now() > self.expires_at:
self.status = 'expired'
self.save()
return False
if self.attempts >= self.max_attempts:
self.status = 'expired'
self.save()
return False
return True
def verify(self, code):
"""Verify OTP code"""
self.attempts += 1
self.save()
if not self.is_valid():
return False
if self.otp_code == code:
self.status = 'used'
self.used_at = timezone.now()
self.save()
# Reset failed attempts on user profile
if hasattr(self.user, 'profile'):
self.user.profile.failed_otp_attempts = 0
self.user.profile.save()
return True
# Increment failed attempts
if hasattr(self.user, 'profile'):
self.user.profile.failed_otp_attempts += 1
self.user.profile.save()
return False
def clean(self):
"""Model validation"""
if self.expires_at and self.expires_at <= timezone.now():
raise ValidationError("Expiration time must be in the future")
class LoginAttempt(models.Model):
"""Security logging for login attempts"""
RESULT_CHOICES = [
('success', 'Success'),
('invalid_credentials', 'Invalid Credentials'),
('invalid_otp', 'Invalid OTP'),
('expired_otp', 'Expired OTP'),
('rate_limited', 'Rate Limited'),
('account_locked', 'Account Locked'),
]
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='login_attempts'
)
username = models.CharField(
max_length=150,
help_text="Username attempted (even if user doesn't exist)"
)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(blank=True)
result = models.CharField(
max_length=20,
choices=RESULT_CHOICES
)
details = models.JSONField(
default=dict,
help_text="Additional details about the attempt"
)
timestamp = models.DateTimeField(auto_now_add=True)
session_key = models.CharField(max_length=40, blank=True)
class Meta:
verbose_name = "Login Attempt"
verbose_name_plural = "Login Attempts"
indexes = [
models.Index(fields=['ip_address', 'timestamp']),
models.Index(fields=['user', 'timestamp']),
models.Index(fields=['result']),
]
ordering = ['-timestamp']
def __str__(self):
return f"Login attempt by {self.username} - {self.result}"
@classmethod
def log_attempt(cls, username, ip_address, result, user=None, user_agent='',
session_key='', **details):
"""Log a login attempt"""
return cls.objects.create(
user=user,
username=username,
ip_address=ip_address,
user_agent=user_agent,
result=result,
details=details,
session_key=session_key
)
@classmethod
def get_recent_failures(cls, ip_address, minutes=15):
"""Get recent failed attempts from IP"""
since = timezone.now() - timezone.timedelta(minutes=minutes)
return cls.objects.filter(
ip_address=ip_address,
timestamp__gte=since,
result__in=['invalid_credentials', 'invalid_otp', 'expired_otp']
).count()
class SecurityEvent(models.Model):
"""Additional security event logging"""
EVENT_TYPES = [
('otp_enabled', 'OTP Enabled'),
('otp_disabled', 'OTP Disabled'),
('backup_codes_generated', 'Backup Codes Generated'),
('backup_code_used', 'Backup Code Used'),
('suspicious_activity', 'Suspicious Activity'),
('rate_limit_exceeded', 'Rate Limit Exceeded'),
]
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='security_events'
)
event_type = models.CharField(max_length=30, choices=EVENT_TYPES)
description = models.TextField()
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(blank=True)
metadata = models.JSONField(default=dict)
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Security Event"
verbose_name_plural = "Security Events"
ordering = ['-timestamp']
def __str__(self):
return f"{self.event_type} - {self.user.username}"
Database Migrations
Create and apply the migrations:
# Create initial migration
python manage.py makemigrations accounts
# Apply migrations
python manage.py migrate
# Create superuser
python manage.py createsuperuser
Model Admin Configuration
Configure Django admin for easy management:
# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from .models import UserProfile, EmailOTP, LoginAttempt, SecurityEvent
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
class UserAdmin(BaseUserAdmin):
inlines = (UserProfileInline,)
list_display = ('username', 'email', 'first_name', 'last_name',
'is_staff', 'get_otp_enabled')
list_filter = ('is_staff', 'is_superuser', 'is_active', 'date_joined')
def get_otp_enabled(self, obj):
try:
return obj.profile.otp_enabled
except UserProfile.DoesNotExist:
return False
get_otp_enabled.boolean = True
get_otp_enabled.short_description = 'OTP Enabled'
# Re-register UserAdmin
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
@admin.register(EmailOTP)
class EmailOTPAdmin(admin.ModelAdmin):
list_display = ('user', 'purpose', 'status', 'created_at', 'expires_at',
'attempts', 'ip_address')
list_filter = ('purpose', 'status', 'created_at')
search_fields = ('user__username', 'user__email', 'ip_address')
readonly_fields = ('otp_code', 'created_at', 'used_at')
fieldsets = (
(None, {
'fields': ('user', 'purpose', 'status')
}),
('OTP Details', {
'fields': ('otp_code', 'attempts', 'max_attempts')
}),
('Timestamps', {
'fields': ('created_at', 'expires_at', 'used_at')
}),
('Security', {
'fields': ('ip_address', 'user_agent')
}),
)
@admin.register(LoginAttempt)
class LoginAttemptAdmin(admin.ModelAdmin):
list_display = ('username', 'result', 'ip_address', 'timestamp')
list_filter = ('result', 'timestamp')
search_fields = ('username', 'ip_address')
readonly_fields = ('timestamp',)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.register(SecurityEvent)
class SecurityEventAdmin(admin.ModelAdmin):
list_display = ('user', 'event_type', 'ip_address', 'timestamp')
list_filter = ('event_type', 'timestamp')
search_fields = ('user__username', 'description', 'ip_address')
readonly_fields = ('timestamp',)
Email Configuration and SMTP Setup
Development Email Backend
For development, Django provides a console backend that prints emails to the terminal:
# settings.py (Development)
if DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
else:
# Production settings (see below)
pass
Production SMTP Configuration
Configure production email settings for various providers:
# settings.py (Production Email Settings)
# Gmail Configuration
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') # App password
EMAIL_TIMEOUT = 15
# SendGrid Configuration
# EMAIL_HOST = 'smtp.sendgrid.net'
# EMAIL_PORT = 587
# EMAIL_HOST_USER = 'apikey'
# EMAIL_HOST_PASSWORD = os.getenv('SENDGRID_API_KEY')
# Mailgun Configuration
# EMAIL_HOST = 'smtp.mailgun.org'
# EMAIL_PORT = 587
# EMAIL_HOST_USER = os.getenv('MAILGUN_SMTP_LOGIN')
# EMAIL_HOST_PASSWORD = os.getenv('MAILGUN_SMTP_PASSWORD')
# Common settings
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'Your App <noreply@yourapp.com>')
SERVER_EMAIL = DEFAULT_FROM_EMAIL
# Email security settings
EMAIL_USE_SSL = False # Use TLS instead
EMAIL_SSL_CERTFILE = None
EMAIL_SSL_KEYFILE = None
Email Utilities and Templates
Create comprehensive email utilities:
# accounts/utils.py
from django.core.mail import send_mail, EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
def get_client_ip(request):
"""Extract client IP address from request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR', '127.0.0.1')
return ip
def get_user_agent(request):
"""Extract user agent from request"""
return request.META.get('HTTP_USER_AGENT', '')
def get_device_info(user_agent):
"""Parse basic device information from user agent"""
if not user_agent:
return "Unknown Device"
user_agent = user_agent.lower()
# Mobile detection
if any(mobile in user_agent for mobile in ['mobile', 'android', 'iphone']):
if 'android' in user_agent:
return "Android Device"
elif 'iphone' in user_agent:
return "iPhone"
else:
return "Mobile Device"
# Desktop browsers
if 'chrome' in user_agent:
return "Chrome Browser"
elif 'firefox' in user_agent:
return "Firefox Browser"
elif 'safari' in user_agent:
return "Safari Browser"
elif 'edge' in user_agent:
return "Edge Browser"
return "Desktop Browser"
def send_otp_email(user, otp, request=None):
"""Send professional OTP email with HTML template"""
try:
# Get request details
ip_address = get_client_ip(request) if request else 'Unknown'
device_info = get_device_info(get_user_agent(request)) if request else 'Unknown Device'
# Email context
context = {
'user': user,
'otp_code': otp.otp_code,
'purpose': otp.get_purpose_display(),
'expires_at': otp.expires_at,
'ip_address': ip_address,
'device_info': device_info,
'timestamp': timezone.now(),
'app_name': getattr(settings, 'APP_NAME', 'Your App'),
'support_email': getattr(settings, 'SUPPORT_EMAIL', 'support@yourapp.com'),
}
# Render email templates
subject = f"Your {otp.get_purpose_display()} Verification Code"
html_message = render_to_string('emails/otp_email.html', context)
plain_message = render_to_string('emails/otp_email.txt', context)
# Create email message
email = EmailMultiAlternatives(
subject=subject,
body=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
)
email.attach_alternative(html_message, "text/html")
# Send email
email.send(fail_silently=False)
logger.info(f"OTP email sent successfully to {user.email} for {otp.purpose}")
return True
except Exception as e:
logger.error(f"Failed to send OTP email to {user.email}: {str(e)}")
return False
def send_security_alert_email(user, event_type, details, request=None):
"""Send security alert email"""
try:
ip_address = get_client_ip(request) if request else 'Unknown'
device_info = get_device_info(get_user_agent(request)) if request else 'Unknown Device'
context = {
'user': user,
'event_type': event_type,
'details': details,
'ip_address': ip_address,
'device_info': device_info,
'timestamp': timezone.now(),
'app_name': getattr(settings, 'APP_NAME', 'Your App'),
}
subject = f"Security Alert for Your Account"
html_message = render_to_string('emails/security_alert.html', context)
plain_message = render_to_string('emails/security_alert.txt', context)
email = EmailMultiAlternatives(
subject=subject,
body=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
)
email.attach_alternative(html_message, "text/html")
email.send(fail_silently=False)
logger.info(f"Security alert email sent to {user.email} for {event_type}")
return True
except Exception as e:
logger.error(f"Failed to send security alert email: {str(e)}")
return False
def validate_otp_rate_limit(user, ip_address):
"""Check if user/IP can request new OTP"""
from .models import EmailOTP
# Check user-specific rate limit
if hasattr(user, 'profile') and not user.profile.can_request_otp():
return False, "Please wait before requesting another code"
# Check IP-specific rate limit (5 requests per hour)
recent_requests = EmailOTP.objects.filter(
ip_address=ip_address,
created_at__gte=timezone.now() - timezone.timedelta(hours=1)
).count()
if recent_requests >= 5:
return False, "Too many requests from this IP address"
return True, ""
def cleanup_expired_otps():
"""Cleanup expired OTP codes (run as periodic task)"""
from .models import EmailOTP
expired_count = EmailOTP.objects.filter(
expires_at__lt=timezone.now(),
status='active'
).update(status='expired')
# Delete old OTPs (older than 24 hours)
old_otps = EmailOTP.objects.filter(
created_at__lt=timezone.now() - timezone.timedelta(days=1)
)
deleted_count = old_otps.count()
old_otps.delete()
logger.info(f"Cleanup completed: {expired_count} expired, {deleted_count} deleted")
return expired_count, deleted_count
Email Templates
Create professional email templates:
<!-- templates/emails/otp_email.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Verification Code</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.content {
padding: 30px;
}
.otp-code {
background: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 8px;
font-size: 32px;
font-weight: bold;
text-align: center;
padding: 20px;
margin: 20px 0;
letter-spacing: 8px;
color: #495057;
}
.info-box {
background: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 12px;
color: #6c757d;
}
.button {
display: inline-block;
background: #007bff;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{ app_name }}</h1>
<h2>{{ purpose }} Verification</h2>
</div>
<div class="content">
<p>Hello {{ user.first_name|default:user.username }},</p>
<p>Your verification code for {{ purpose|lower }} is:</p>
<div class="otp-code">{{ otp_code }}</div>
<div class="info-box">
<strong>Important:</strong>
<ul>
<li>This code expires at <strong>{{ expires_at|date:"M j, Y g:i A" }}</strong></li>
<li>Do not share this code with anyone</li>
<li>If you didn't request this, please ignore this email</li>
</ul>
</div>
<h3>Security Information</h3>
<p><strong>IP Address:</strong> {{ ip_address }}</p>
<p><strong>Device:</strong> {{ device_info }}</p>
<p><strong>Time:</strong> {{ timestamp|date:"M j, Y g:i A" }}</p>
<p>If you have any concerns about your account security, please contact our support team immediately.</p>
</div>
<div class="footer">
<p>This email was sent by {{ app_name }}. If you have questions, contact us at {{ support_email }}.</p>
<p>© {% now "Y" %} {{ app_name }}. All rights reserved.</p>
</div>
</div>
</body>
</html>
<!-- templates/emails/otp_email.txt -->
{{ app_name }} - {{ purpose }} Verification Code
Hello {{ user.first_name|default:user.username }},
Your verification code for {{ purpose|lower }} is: {{ otp_code }}
IMPORTANT INFORMATION:
- This code expires at {{ expires_at|date:"M j, Y g:i A" }}
- Do not share this code with anyone
- If you didn't request this, please ignore this email
SECURITY DETAILS:
- IP Address: {{ ip_address }}
- Device: {{ device_info }}
- Time: {{ timestamp|date:"M j, Y g:i A" }}
If you have any concerns about your account security, please contact our support team immediately at {{ support_email }}.
---
This email was sent by {{ app_name }}.
© {% now "Y" %} {{ app_name }}. All rights reserved.
Authentication Views and Business Logic
Core Authentication Views
Create comprehensive authentication views with proper error handling and security measures:
# accounts/views.py
import json
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.decorators.cache import never_cache
from django.contrib import messages
from django.utils import timezone
from django.core.exceptions import ValidationError
import logging
from .models import EmailOTP, UserProfile, LoginAttempt, SecurityEvent
from .utils import (
send_otp_email, get_client_ip, get_user_agent,
validate_otp_rate_limit, send_security_alert_email
)
logger = logging.getLogger(__name__)
@never_cache
def login_view(request):
"""Render login page"""
if request.user.is_authenticated:
return redirect('dashboard')
context = {
'title': 'Sign In to Your Account',
'page_description': 'Secure login with two-factor authentication',
}
return render(request, 'accounts/login.html', context)
@csrf_exempt
@require_http_methods(["POST"])
@never_cache
def send_login_otp(request):
"""Handle initial login and send OTP if required"""
try:
# Rate limiting check
ip_address = get_client_ip(request)
recent_failures = LoginAttempt.get_recent_failures(ip_address)
if recent_failures >= 5:
return JsonResponse({
'success': False,
'error': 'Too many failed attempts. Please try again later.',
'retry_after': 900 # 15 minutes
}, status=429)
# Parse request data
if request.content_type == 'application/json':
data = json.loads(request.body.decode('utf-8'))
else:
data = {
'username_or_email': request.POST.get('login', '').strip(),
'password': request.POST.get('password', ''),
'remember_me': request.POST.get('remember_me') == 'on'
}
username_or_email = data.get('username_or_email', '').strip()
password = data.get('password', '')
# Validation
if not username_or_email or not password:
LoginAttempt.log_attempt(
username=username_or_email,
ip_address=ip_address,
result='invalid_credentials',
user_agent=get_user_agent(request),
session_key=request.session.session_key or ''
)
return JsonResponse({
'success': False,
'error': 'Username/email and password are required'
}, status=400)
# Find user
user = User.objects.filter(
Q(username__iexact=username_or_email) |
Q(email__iexact=username_or_email)
).select_related('profile').first()
# Verify credentials
if not user or not user.check_password(password):
LoginAttempt.log_attempt(
username=username_or_email,
ip_address=ip_address,
result='invalid_credentials',
user=user,
user_agent=get_user_agent(request),
session_key=request.session.session_key or ''
)
return JsonResponse({
'success': False,
'error': 'Invalid username/email or password'
}, status=400)
# Check if account is active
if not user.is_active:
LoginAttempt.log_attempt(
username=username_or_email,
ip_address=ip_address,
result='account_locked',
user=user,
user_agent=get_user_agent(request)
)
return JsonResponse({
'success': False,
'error': 'Account is disabled. Please contact support.'
}, status=403)
# Get or create user profile
profile, created = UserProfile.objects.get_or_create(user=user)
# Check if OTP is required
if profile.otp_enabled:
# Validate rate limits
rate_limit_valid, rate_limit_message = validate_otp_rate_limit(user, ip_address)
if not rate_limit_valid:
return JsonResponse({
'success': False,
'error': rate_limit_message
}, status=429)
# Generate and send OTP
user_agent = get_user_agent(request)
try:
otp = EmailOTP.generate_otp(
user=user,
purpose='login',
ip_address=ip_address,
user_agent=user_agent
)
# Store login session data
request.session['otp_login_data'] = {
'user_id': user.id,
'username': username_or_email,
'remember_me': data.get('remember_me', False),
'timestamp': timezone.now().isoformat(),
'ip_address': ip_address
}
request.session.set_expiry(600) # 10 minutes for OTP session
# Send OTP email
if send_otp_email(user, otp, request):
logger.info(f"OTP sent for login: {user.username} from {ip_address}")
return JsonResponse({
'success': True,
'message': f'Verification code sent to {user.email[:3]}***{user.email[-10:]}',
'otp_required': True,
'expires_in': 600 # 10 minutes
})
else:
# Email sending failed
return JsonResponse({
'success': False,
'error': 'Failed to send verification code. Please try again.'
}, status=503)
except Exception as e:
logger.error(f"OTP generation failed for {user.username}: {str(e)}")
return JsonResponse({
'success': False,
'error': 'Unable to process request. Please try again.'
}, status=500)
else:
# Direct login (no OTP required)
login(request, user)
# Set session expiry
if data.get('remember_me'):
request.session.set_expiry(2592000) # 30 days
else:
request.session.set_expiry(0) # Browser close
# Log successful attempt
LoginAttempt.log_attempt(
username=username_or_email,
ip_address=ip_address,
result='success',
user=user,
user_agent=get_user_agent(request),
session_key=request.session.session_key
)
logger.info(f"Direct login successful: {user.username} from {ip_address}")
return JsonResponse({
'success': True,
'message': 'Login successful',
'redirect_url': '/dashboard/'
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Invalid request format'
}, status=400)
except Exception as e:
logger.error(f"Login error: {str(e)}")
return JsonResponse({
'success': False,
'error': 'An unexpected error occurred. Please try again.'
}, status=500)
@csrf_exempt
@require_http_methods(["POST"])
@never_cache
def verify_login_otp(request):
"""Verify OTP and complete login process"""
try:
# Get session data
otp_data = request.session.get('otp_login_data')
if not otp_data:
return JsonResponse({
'success': False,
'error': 'Session expired. Please start login process again.',
'redirect': '/login/'
}, status=400)
# Validate session timestamp
session_time = timezone.datetime.fromisoformat(otp_data['timestamp'])
if timezone.now() - session_time > timezone.timedelta(minutes=10):
del request.session['otp_login_data']
return JsonResponse({
'success': False,
'error': 'Session expired. Please start login process again.',
'redirect': '/login/'
}, status=400)
# Parse OTP code
if request.content_type == 'application/json':
data = json.loads(request.body.decode('utf-8'))
else:
data = request.POST
otp_code = data.get('otp_code', '').strip()
if not otp_code or len(otp_code) != 6 or not otp_code.isdigit():
return JsonResponse({
'success': False,
'error': 'Please enter a valid 6-digit verification code'
}, status=400)
# Get user and verify OTP
try:
user = User.objects.get(id=otp_data['user_id'])
except User.DoesNotExist:
del request.session['otp_login_data']
return JsonResponse({
'success': False,
'error': 'Invalid session. Please start login process again.',
'redirect': '/login/'
}, status=400)
# Find valid OTP
otp = EmailOTP.objects.filter(
user=user,
purpose='login',
status='active'
).order_by('-created_at').first()
if not otp:
LoginAttempt.log_attempt(
username=otp_data['username'],
ip_address=get_client_ip(request),
result='invalid_otp',
user=user,
user_agent=get_user_agent(request),
details={'reason': 'no_active_otp'}
)
return JsonResponse({
'success': False,
'error': 'No valid verification code found. Please request a new one.',
'redirect': '/login/'
}, status=400)
# Verify OTP
if otp.verify(otp_code):
# Complete login
login(request, user)
# Set session expiry based on remember me
if otp_data.get('remember_me'):
request.session.set_expiry(2592000) # 30 days
else:
request.session.set_expiry(0) # Browser close
# Log successful login
LoginAttempt.log_attempt(
username=otp_data['username'],
ip_address=get_client_ip(request),
result='success',
user=user,
user_agent=get_user_agent(request),
session_key=request.session.session_key,
details={'otp_verified': True}
)
# Clear OTP session data
del request.session['otp_login_data']
logger.info(f"OTP login successful: {user.username} from {get_client_ip(request)}")
return JsonResponse({
'success': True,
'message': 'Login successful',
'redirect_url': '/dashboard/'
})
else:
# OTP verification failed
ip_address = get_client_ip(request)
LoginAttempt.log_attempt(
username=otp_data['username'],
ip_address=ip_address,
result='invalid_otp',
user=user,
user_agent=get_user_agent(request),
details={'attempts': otp.attempts}
)
# Check if maximum attempts reached
if otp.attempts >= otp.max_attempts:
return JsonResponse({
'success': False,
'error': 'Maximum verification attempts exceeded. Please request a new code.',
'max_attempts_reached': True
}, status=400)
remaining_attempts = otp.max_attempts - otp.attempts
return JsonResponse({
'success': False,
'error': f'Invalid verification code. {remaining_attempts} attempts remaining.',
'remaining_attempts': remaining_attempts
}, status=400)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Invalid request format'
}, status=400)
except Exception as e:
logger.error(f"OTP verification error: {str(e)}")
return JsonResponse({
'success': False,
'error': 'Verification failed. Please try again.'
}, status=500)
@csrf_exempt
@require_http_methods(["POST"])
@never_cache
def resend_otp(request):
"""Resend OTP code"""
try:
# Check session
otp_data = request.session.get('otp_login_data')
if not otp_data:
return JsonResponse({
'success': False,
'error': 'No active OTP session found'
}, status=400)
# Get user
user = User.objects.get(id=otp_data['user_id'])
ip_address = get_client_ip(request)
# Rate limiting
rate_limit_valid, rate_limit_message = validate_otp_rate_limit(user, ip_address)
if not rate_limit_valid:
return JsonResponse({
'success': False,
'error': rate_limit_message
}, status=429)
# Generate new OTP
otp = EmailOTP.generate_otp(
user=user,
purpose='login',
ip_address=ip_address,
user_agent=get_user_agent(request)
)
# Send email
if send_otp_email(user, otp, request):
logger.info(f"OTP resent for: {user.username} from {ip_address}")
return JsonResponse({
'success': True,
'message': 'New verification code sent',
'expires_in': 600
})
else:
return JsonResponse({
'success': False,
'error': 'Failed to send verification code'
}, status=503)
except Exception as e:
logger.error(f"OTP resend error: {str(e)}")
return JsonResponse({
'success': False,
'error': 'Failed to resend code'
}, status=500)
@login_required
@never_cache
def dashboard_view(request):
"""User dashboard"""
context = {
'title': 'Dashboard',
'user': request.user,
'otp_enabled': getattr(request.user.profile, 'otp_enabled', False),
'recent_logins': LoginAttempt.objects.filter(
user=request.user,
result='success'
)[:5]
}
return render(request, 'accounts/dashboard.html', context)
@login_required
def logout_view(request):
"""Logout user"""
user = request.user
logout(request)
messages.success(request, 'You have been logged out successfully.')
logger.info(f"User logged out: {user.username}")
return redirect('login')
@login_required
@csrf_exempt
@require_http_methods(["POST"])
def toggle_otp(request):
"""Enable or disable OTP for user account"""
try:
profile, created = UserProfile.objects.get_or_create(user=request.user)
if request.content_type == 'application/json':
data = json.loads(request.body.decode('utf-8'))
else:
data = request.POST
enable_otp = data.get('enable_otp', False)
if enable_otp and not profile.otp_enabled:
# Enabling OTP - send verification email first
ip_address = get_client_ip(request)
otp = EmailOTP.generate_otp(
user=request.user,
purpose='enable_2fa',
ip_address=ip_address,
user_agent=get_user_agent(request)
)
if send_otp_email(request.user, otp, request):
# Store pending change in session
request.session['pending_otp_enable'] = True
return JsonResponse({
'success': True,
'message': 'Verification code sent to your email',
'verification_required': True
})
else:
return JsonResponse({
'success': False,
'error': 'Failed to send verification email'
}, status=503)
elif not enable_otp and profile.otp_enabled:
# Disabling OTP
profile.otp_enabled = False
profile.save()
# Log security event
SecurityEvent.objects.create(
user=request.user,
event_type='otp_disabled',
description='Two-factor authentication disabled',
ip_address=get_client_ip(request),
user_agent=get_user_agent(request)
)
# Send security alert
send_security_alert_email(
request.user,
'OTP Disabled',
'Two-factor authentication has been disabled for your account',
request
)
logger.info(f"OTP disabled for user: {request.user.username}")
return JsonResponse({
'success': True,
'message': 'Two-factor authentication disabled',
'otp_enabled': False
})
return JsonResponse({
'success': True,
'message': 'No changes made',
'otp_enabled': profile.otp_enabled
})
except Exception as e:
logger.error(f"Toggle OTP error: {str(e)}")
return JsonResponse({
'success': False,
'error': 'Failed to update settings'
}, status=500)
@login_required
@csrf_exempt
@require_http_methods(["POST"])
def confirm_enable_otp(request):
"""Confirm OTP enabling with verification code"""
try:
if not request.session.get('pending_otp_enable'):
return JsonResponse({
'success': False,
'error': 'No pending OTP enable request'
}, status=400)
if request.content_type == 'application/json':
data = json.loads(request.body.decode('utf-8'))
else:
data = request.POST
otp_code = data.get('otp_code', '').strip()
if not otp_code or len(otp_code) != 6:
return JsonResponse({
'success': False,
'error': 'Please enter a valid 6-digit code'
}, status=400)
# Find and verify OTP
otp = EmailOTP.objects.filter(
user=request.user,
purpose='enable_2fa',
status='active'
).order_by('-created_at').first()
if not otp or not otp.verify(otp_code):
return JsonResponse({
'success': False,
'error': 'Invalid or expired verification code'
}, status=400)
# Enable OTP
profile, created = UserProfile.objects.get_or_create(user=request.user)
profile.otp_enabled = True
profile.save()
# Generate backup codes
backup_codes = profile.generate_backup_codes()
# Clear pending session
del request.session['pending_otp_enable']
# Log security event
SecurityEvent.objects.create(
user=request.user,
event_type='otp_enabled',
description='Two-factor authentication enabled',
ip_address=get_client_ip(request),
user_agent=get_user_agent(request)
)
logger.info(f"OTP enabled for user: {request.user.username}")
return JsonResponse({
'success': True,
'message': 'Two-factor authentication enabled successfully',
'otp_enabled': True,
'backup_codes': backup_codes
})
except Exception as e:
logger.error(f"Confirm enable OTP error: {str(e)}")
return JsonResponse({
'success': False,
'error': 'Failed to enable two-factor authentication'
}, status=500)
URL Configuration
Configure URL patterns:
# accounts/urls.py
from django.urls import path
from . import views
app_name = 'accounts'
urlpatterns = [
# Authentication pages
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
path('dashboard/', views.dashboard_view, name='dashboard'),
# API endpoints
path('api/send-login-otp/', views.send_login_otp, name='send_login_otp'),
path('api/verify-login-otp/', views.verify_login_otp, name='verify_login_otp'),
path('api/resend-otp/', views.resend_otp, name='resend_otp'),
path('api/toggle-otp/', views.toggle_otp, name='toggle_otp'),
path('api/confirm-enable-otp/', views.confirm_enable_otp, name='confirm_enable_otp'),
]
# otpauth/urls.py
from django.contrib import admin
from django.urls import path, include
from django.shortcuts import redirect
urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls')),
path('', lambda request: redirect('accounts:login')),
]
Frontend Implementation with TailwindCSS
Base Template
Create a professional base template:
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}Secure authentication system with two-factor authentication{% endblock %}">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}{{ title|default:'Django OTP Auth' }}{% endblock %}</title>
<!-- TailwindCSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Custom styles -->
<style>
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.otp-input {
transition: all 0.3s ease;
}
.otp-input:focus {
transform: scale(1.05);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.4);
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.floating-animation {
animation: floating 6s ease-in-out infinite;
}
@keyframes floating {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
.typing-animation {
overflow: hidden;
border-right: 2px solid rgba(255,255,255,.75);
white-space: nowrap;
margin: 0 auto;
letter-spacing: .1em;
animation: typing 3.5s steps(40, end), blink-caret .75s step-end infinite;
}
@keyframes typing {
from { width: 0 }
to { width: 100% }
}
@keyframes blink-caret {
from, to { border-color: transparent }
50% { border-color: rgba(255,255,255,.75); }
}
</style>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔐</text></svg>">
{% block extra_head %}{% endblock %}
</head>
<body class="h-full {% block body_class %}bg-gray-900{% endblock %}">
<!-- Loading spinner -->
<div id="loading" class="fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 hidden">
<div class="animate-spin rounded-full h-32 w-32 border-b-2 border-white"></div>
</div>
<!-- Flash messages -->
{% if messages %}
<div id="flash-messages" class="fixed top-4 right-4 z-40 space-y-2">
{% for message in messages %}
<div class="bg-{{ message.tags == 'error' and 'red' or 'green' }}-500 text-white px-6 py-3 rounded-lg shadow-lg max-w-sm
transform transition-all duration-300 ease-in-out"
x-data="{ show: true }"
x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-x-full"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 transform translate-x-0"
x-transition:leave-end="opacity-0 transform translate-x-full"
x-init="setTimeout(() => show = false, 5000)">
<div class="flex items-center justify-between">
<span>{{ message }}</span>
<button @click="show = false" class="ml-4 text-white hover:text-gray-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
<!-- Alpine.js for interactive components -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Global JavaScript utilities -->
<script>
// CSRF Token utility
function getCSRFToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
}
// Show loading spinner
function showLoading() {
document.getElementById('loading').classList.remove('hidden');
}
// Hide loading spinner
function hideLoading() {
document.getElementById('loading').classList.add('hidden');
}
// Show toast notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 px-6 py-3 rounded-lg shadow-lg max-w-sm transform transition-all duration-300 ${
type === 'error' ? 'bg-red-500' : 'bg-green-500'
} text-white`;
toast.textContent = message;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// Remove after 5 seconds
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
// API request utility
async function apiRequest(url, data = {}, method = 'POST') {
try {
showLoading();
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: method !== 'GET' ? JSON.stringify(data) : null
});
const result = await response.json();
return { success: response.ok, data: result, status: response.status };
} catch (error) {
console.error('API request failed:', error);
return { success: false, data: { error: 'Network error occurred' }, status: 0 };
} finally {
hideLoading();
}
}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
Login Page Template
Create a stunning, responsive login interface:
<!-- templates/accounts/login.html -->
{% extends 'base.html' %}
{% block title %}Sign In - Secure Authentication{% endblock %}
{% block meta_description %}Sign in to your account with secure two-factor authentication. Professional Django OTP login system.{% endblock %}
{% block body_class %}gradient-bg min-h-screen{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8 relative overflow-hidden">
<!-- Animated background elements -->
<div class="absolute inset-0">
<div class="absolute top-10 left-10 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-pulse-slow floating-animation"></div>
<div class="absolute top-0 right-4 w-72 h-72 bg-yellow-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-pulse-slow floating-animation" style="animation-delay: 2s;"></div>
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-pink-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-pulse-slow floating-animation" style="animation-delay: 4s;"></div>
</div>
<div class="max-w-md w-full space-y-8 relative z-10">
<!-- Header -->
<div class="text-center">
<div class="mx-auto h-16 w-16 bg-white rounded-full flex items-center justify-center shadow-lg mb-6">
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h2 class="text-3xl font-extrabold text-white mb-2 typing-animation">
Sign in to your account
</h2>
<p class="text-purple-100">Enter your credentials to access your dashboard</p>
</div>
<!-- Login Form -->
<div id="loginCard" class="glass-effect rounded-2xl shadow-2xl p-8 transform transition-all duration-500 hover:scale-105">
<form id="loginForm" class="space-y-6">
{% csrf_token %}
<!-- Username/Email Field -->
<div class="relative">
<label for="login" class="sr-only">Username or Email</label>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"></path>
</svg>
</div>
<input id="login" name="login" type="text" required
class="appearance-none relative block w-full px-12 py-4 border-0 placeholder-gray-500 text-gray-900 rounded-xl
focus:outline-none focus:ring-4 focus:ring-purple-400 focus:border-transparent focus:z-10
bg-white bg-opacity-90 backdrop-blur-sm transition-all duration-300"
placeholder="Username or Email Address">
</div>
<!-- Password Field -->
<div class="relative">
<label for="password" class="sr-only">Password</label>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<input id="password" name="password" type="password" required
class="appearance-none relative block w-full px-12 py-4 border-0 placeholder-gray-500 text-gray-900 rounded-xl
focus:outline-none focus:ring-4 focus:ring-purple-400 focus:border-transparent focus:z-10
bg-white bg-opacity-90 backdrop-blur-sm transition-all duration-300"
placeholder="Password">
<button type="button" id="togglePassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
</div>
<!-- Remember Me -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input id="remember_me" name="remember_me" type="checkbox"
class="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded
bg-white bg-opacity-90">
<label for="remember_me" class="ml-2 block text-sm text-white">
Remember me for 30 days
</label>
</div>
<div class="text-sm">
<a href="#" class="font-medium text-purple-200 hover:text-white transition-colors duration-300">
Forgot your password?
</a>
</div>
</div>
<!-- Submit Button -->
<div>
<button type="submit" id="loginButton"
class="group relative w-full flex justify-center py-4 px-4 border border-transparent text-sm font-medium
rounded-xl text-white bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700
focus:outline-none focus:ring-4 focus:ring-purple-400 transition-all duration-300
transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg class="h-5 w-5 text-purple-300 group-hover:text-purple-200 transition-colors duration-300"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
</span>
<span id="loginButtonText">Sign In</span>
<div id="loginSpinner" class="hidden ml-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
</div>
</button>
</div>
<!-- Error Display -->
<div id="errorMessage" class="hidden text-red-300 text-sm text-center bg-red-500 bg-opacity-20 p-3 rounded-lg border border-red-300">
</div>
</form>
</div>
<!-- OTP Form (Hidden by default) -->
<div id="otpCard" class="hidden glass-effect rounded-2xl shadow-2xl p-8 transform transition-all duration-500">
<div class="text-center mb-6">
<div class="mx-auto h-16 w-16 bg-green-500 rounded-full flex items-center justify-center shadow-lg mb-4">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
</div>
<h3 class="text-2xl font-bold text-white mb-2">Check Your Email</h3>
<p class="text-purple-100" id="otpEmailMessage">We've sent a verification code to your email</p>
</div>
<!-- OTP Input Fields -->
<div class="flex justify-center space-x-3 mb-6">
<input type="text" maxlength="1"
class="otp-input w-14 h-14 text-center text-2xl font-bold border-2 border-white border-opacity-30
rounded-xl bg-white bg-opacity-20 text-white placeholder-purple-200
focus:outline-none focus:border-yellow-400 focus:bg-opacity-30 transition-all duration-300"
id="otp1" autocomplete="off">
<input type="text" maxlength="1"
class="otp-input w-14 h-14 text-center text-2xl font-bold border-2 border-white border-opacity-30
rounded-xl bg-white bg-opacity-20 text-white placeholder-purple-200
focus:outline-none focus:border-yellow-400 focus:bg-opacity-30 transition-all duration-300"
id="otp2" autocomplete="off">
<input type="text" maxlength="1"
class="otp-input w-14 h-14 text-center text-2xl font-bold border-2 border-white border-opacity-30
rounded-xl bg-white bg-opacity-20 text-white placeholder-purple-200
focus:outline-none focus:border-yellow-400 focus:bg-opacity-30 transition-all duration-300"
id="otp3" autocomplete="off">
<input type="text" maxlength="1"
class="otp-input w-14 h-14 text-center text-2xl font-bold border-2 border-white border-opacity-30
rounded-xl bg-white bg-opacity-20 text-white placeholder-purple-200
focus:outline-none focus:border-yellow-400 focus:bg-opacity-30 transition-all duration-300"
id="otp4" autocomplete="off">
<input type="text" maxlength="1"
class="otp-input w-14 h-14 text-center text-2xl font-bold border-2 border-white border-opacity-30
rounded-xl bg-white bg-opacity-20 text-white placeholder-purple-200
focus:outline-none focus:border-yellow-400 focus:bg-opacity-30 transition-all duration-300"
id="otp5" autocomplete="off">
<input type="text" maxlength="1"
class="otp-input w-14 h-14 text-center text-2xl font-bold border-2 border-white border-opacity-30
rounded-xl bg-white bg-opacity-20 text-white placeholder-purple-200
focus:outline-none focus:border-yellow-400 focus:bg-opacity-30 transition-all duration-300"
id="otp6" autocomplete="off">
</div>
<!-- Timer Display -->
<div class="text-center mb-4">
<p class="text-purple-200">Code expires in <span id="timer" class="font-bold text-yellow-300">10:00</span></p>
</div>
<!-- Action Buttons -->
<div class="space-y-3">
<button id="verifyOtpButton"
class="w-full flex justify-center py-4 px-4 border border-transparent text-sm font-medium
rounded-xl text-white bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700
focus:outline-none focus:ring-4 focus:ring-green-400 transition-all duration-300
transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl">
<span id="verifyButtonText">Verify & Sign In</span>
<div id="verifySpinner" class="hidden ml-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
</div>
</button>
<button id="resendOtpButton"
class="w-full flex justify-center py-3 px-4 border border-white border-opacity-30 text-sm font-medium
rounded-xl text-white bg-transparent hover:bg-white hover:bg-opacity-10
focus:outline-none focus:ring-4 focus:ring-purple-400 transition-all duration-300">
<span id="resendButtonText">Resend Code</span>
<div id="resendSpinner" class="hidden ml-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
</div>
</button>
<button id="backToLoginButton"
class="w-full text-center py-3 text-sm text-purple-200 hover:text-white transition-colors duration-300">
← Back to Login
</button>
</div>
<!-- OTP Error Display -->
<div id="otpErrorMessage" class="hidden mt-4 text-red-300 text-sm text-center bg-red-500 bg-opacity-20 p-3 rounded-lg border border-red-300">
</div>
</div>
<!-- Footer -->
<div class="text-center text-purple-200 text-sm">
<p>© 2025 Django OTP Auth. Secure authentication system.</p>
<p class="mt-1">Don't have an account? <a href="#" class="text-yellow-300 hover:text-yellow-200 transition-colors duration-300">Sign up here</a></p>
</div>
</div>
</div>
<!-- Custom JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Form elements
const loginForm = document.getElementById('loginForm');
const loginCard = document.getElementById('loginCard');
const otpCard = document.getElementById('otpCard');
const errorMessage = document.getElementById('errorMessage');
const otpErrorMessage = document.getElementById('otpErrorMessage');
const loginButton = document.getElementById('loginButton');
const loginButtonText = document.getElementById('loginButtonText');
const loginSpinner = document.getElementById('loginSpinner');
const verifyOtpButton = document.getElementById('verifyOtpButton');
const verifyButtonText = document.getElementById('verifyButtonText');
const verifySpinner = document.getElementById('verifySpinner');
const resendOtpButton = document.getElementById('resendOtpButton');
const backToLoginButton = document.getElementById('backToLoginButton');
const togglePassword = document.getElementById('togglePassword');
const passwordField = document.getElementById('password');
const timerElement = document.getElementById('timer');
let countdownTimer;
let resendCooldown = false;
// Password visibility toggle
togglePassword.addEventListener('click', function() {
const type = passwordField.getAttribute('type') === 'password' ? 'text' : 'password';
passwordField.setAttribute('type', type);
const eyeIcon = this.querySelector('svg');
if (type === 'text') {
eyeIcon.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"></path>
`;
} else {
eyeIcon.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
`;
}
});
// Handle login form submission
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
hideError();
const formData = new FormData(this);
const loginData = {
username_or_email: formData.get('login'),
password: formData.get('password'),
remember_me: formData.get('remember_me') === 'on'
};
setLoginButtonLoading(true);
try {
const response = await fetch('/accounts/api/send-login-otp/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify(loginData)
});
const result = await response.json();
if (result.success) {
if (result.otp_required) {
showOTPForm(result.message);
startTimer(600); // 10 minutes
} else {
window.location.href = result.redirect_url;
}
} else {
showError(result.error);
}
} catch (error) {
console.error('Login error:', error);
showError('Network error. Please try again.');
} finally {
setLoginButtonLoading(false);
}
});
// OTP input handling
const otpInputs = document.querySelectorAll('.otp-input');
otpInputs.forEach((input, index) => {
input.addEventListener('input', function(e) {
const value = e.target.value;
// Only allow digits
if (!/^\d*$/.test(value)) {
e.target.value = value.replace(/\D/g, '');
return;
}
// Move to next input
if (value.length === 1 && index < otpInputs.length - 1) {
otpInputs[index + 1].focus();
}
// Auto-submit when all fields filled
if (index === otpInputs.length - 1 && value.length === 1) {
const allFilled = Array.from(otpInputs).every(input => input.value.length === 1);
if (allFilled) {
setTimeout(() => verifyOTP(), 100);
}
}
});
input.addEventListener('keydown', function(e) {
// Handle backspace
if (e.key === 'Backspace' && this.value === '' && index > 0) {
otpInputs[index - 1].focus();
otpInputs[index - 1].value = '';
}
// Handle paste
if (e.key === 'v' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
navigator.clipboard.readText().then(text => {
const digits = text.replace(/\D/g, '').slice(0, 6);
digits.split('').forEach((digit, i) => {
if (otpInputs[i]) otpInputs[i].value = digit;
});
if (digits.length === 6) verifyOTP();
});
}
});
// Clear on focus if all fields filled incorrectly
input.addEventListener('focus', function() {
const allFilled = Array.from(otpInputs).every(input => input.value.length === 1);
if (allFilled && otpErrorMessage && !otpErrorMessage.classList.contains('hidden')) {
otpInputs.forEach(inp => inp.value = '');
hideOtpError();
}
});
});
// Verify OTP button
verifyOtpButton.addEventListener('click', verifyOTP);
// Resend OTP button
resendOtpButton.addEventListener('click', async function() {
if (resendCooldown) return;
setResendButtonLoading(true);
resendCooldown = true;
try {
const response = await fetch('/accounts/api/resend-otp/', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
});
const result = await response.json();
if (result.success) {
showToast('New verification code sent');
startTimer(600);
hideOtpError();
} else {
showOtpError(result.error);
}
} catch (error) {
console.error('Resend error:', error);
showOtpError('Failed to resend code');
} finally {
setResendButtonLoading(false);
setTimeout(() => { resendCooldown = false; }, 60000); // 1 minute cooldown
}
});
// Back to login button
backToLoginButton.addEventListener('click', function() {
showLoginForm();
clearOtpInputs();
hideOtpError();
if (countdownTimer) {
clearInterval(countdownTimer);
}
});
// Verify OTP function
async function verifyOTP() {
const otpCode = Array.from(otpInputs).map(input => input.value).join('');
if (otpCode.length !== 6) {
showOtpError('Please enter all 6 digits');
return;
}
hideOtpError();
setVerifyButtonLoading(true);
try {
const response = await fetch('/accounts/api/verify-login-otp/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ otp_code: otpCode })
});
const result = await response.json();
if (result.success) {
showToast('Login successful');
window.location.href = result.redirect_url;
} else {
showOtpError(result.error);
// Clear inputs if max attempts reached
if (result.max_attempts_reached) {
clearOtpInputs();
setTimeout(() => {
showLoginForm();
}, 3000);
} else {
// Focus first input for retry
otpInputs[0].focus();
}
}
} catch (error) {
console.error('OTP verification error:', error);
showOtpError('Verification failed. Please try again.');
} finally {
setVerifyButtonLoading(false);
}
}
// Timer function
function startTimer(seconds) {
let timeLeft = seconds;
if (countdownTimer) {
clearInterval(countdownTimer);
}
countdownTimer = setInterval(() => {
const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;
timerElement.textContent = `${minutes}:${secs.toString().padStart(2, '0')}`;
if (timeLeft <= 0) {
clearInterval(countdownTimer);
timerElement.textContent = 'Expired';
showOtpError('Verification code has expired. Please request a new one.');
}
timeLeft--;
}, 1000);
}
// UI Helper Functions
function showLoginForm() {
loginCard.classList.remove('hidden');
otpCard.classList.add('hidden');
// Animate transition
setTimeout(() => {
loginCard.style.transform = 'translateY(0) scale(1)';
loginCard.style.opacity = '1';
}, 100);
}
function showOTPForm(message) {
loginCard.classList.add('hidden');
otpCard.classList.remove('hidden');
if (message) {
document.getElementById('otpEmailMessage').textContent = message;
}
// Focus first OTP input
setTimeout(() => {
otpInputs[0].focus();
otpCard.style.transform = 'translateY(0) scale(1)';
otpCard.style.opacity = '1';
}, 100);
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.remove('hidden');
errorMessage.style.animation = 'shake 0.6s ease-in-out';
}
function hideError() {
errorMessage.classList.add('hidden');
errorMessage.style.animation = '';
}
function showOtpError(message) {
otpErrorMessage.textContent = message;
otpErrorMessage.classList.remove('hidden');
otpErrorMessage.style.animation = 'shake 0.6s ease-in-out';
}
function hideOtpError() {
otpErrorMessage.classList.add('hidden');
otpErrorMessage.style.animation = '';
}
function clearOtpInputs() {
otpInputs.forEach(input => input.value = '');
}
function setLoginButtonLoading(loading) {
if (loading) {
loginButtonText.textContent = 'Signing In...';
loginSpinner.classList.remove('hidden');
loginButton.disabled = true;
} else {
loginButtonText.textContent = 'Sign In';
loginSpinner.classList.add('hidden');
loginButton.disabled = false;
}
}
function setVerifyButtonLoading(loading) {
if (loading) {
verifyButtonText.textContent = 'Verifying...';
verifySpinner.classList.remove('hidden');
verifyOtpButton.disabled = true;
} else {
verifyButtonText.textContent = 'Verify & Sign In';
verifySpinner.classList.add('hidden');
verifyOtpButton.disabled = false;
}
}
function setResendButtonLoading(loading) {
const resendButtonText = document.getElementById('resendButtonText');
const resendSpinner = document.getElementById('resendSpinner');
if (loading) {
resendButtonText.textContent = 'Sending...';
resendSpinner.classList.remove('hidden');
resendOtpButton.disabled = true;
} else {
resendButtonText.textContent = 'Resend Code';
resendSpinner.classList.add('hidden');
resendOtpButton.disabled = false;
}
}
// Add shake animation CSS
const style = document.createElement('style');
style.textContent = `
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
20%, 40%, 60%, 80% { transform: translateX(10px); }
}
`;
document.head.appendChild(style);
});
</script>
{% endblock %}
Dashboard Template
Create a comprehensive user dashboard:
<!-- templates/accounts/dashboard.html -->
{% extends 'base.html' %}
{% block title %}Dashboard - Your Account{% endblock %}
{% block body_class %}bg-gray-50 min-h-screen{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50">
<!-- Navigation Header -->
<nav class="bg-white shadow-lg border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 bg-gradient-to-r from-purple-600 to-blue-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
</div>
<div class="ml-4">
<h1 class="text-xl font-semibold text-gray-900">Dashboard</h1>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-700">
Welcome, <span class="font-medium">{{ user.get_full_name|default:user.username }}</span>
</div>
<a href="{% url 'accounts:logout' %}"
class="bg-gray-100 hover:bg-gray-200 text-gray-800 px-4 py-2 rounded-lg transition-colors duration-200">
Sign Out
</a>
</div>
</div>
</div>
</nav>
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<!-- Security Status Card -->
<div class="bg-white overflow-hidden shadow-xl rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Account Security</h2>
<p class="text-sm text-gray-600">manage your account security settings</p>
</div>
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-10 w-10 {{ otp_enabled|yesno:'bg-green-100,bg-yellow-100' }} rounded-full flex items-center justify-center">
{% if otp_enabled %}
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
{% else %}
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.664-.833-2.464 0L5.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
{% endif %}
</div>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-900">Two-Factor Authentication</h3>
<p class="text-sm text-gray-600">
{% if otp_enabled %}
Your account is protected with 2FA
{% else %}
Enhance your account security
{% endif %}
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{{ otp_enabled|yesno:'bg-green-100 text-green-800,bg-yellow-100 text-yellow-800' }}">
{{ otp_enabled|yesno:'Enabled,Disabled' }}
</span>
<button id="toggleOtpButton"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
data-enabled="{{ otp_enabled|yesno:'true,false' }}">
{{ otp_enabled|yesno:'Disable,Enable' }} 2FA
</button>
</div>
</div>
</div>
</div>
<!-- Account Information -->
<div class="bg-white overflow-hidden shadow-xl rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Account Information</h2>
</div>
<div class="p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Full Name</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.get_full_name|default:'Not provided' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Username</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.username }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Email Address</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.email }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Account Created</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.date_joined|date:"F j, Y" }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Last Login</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.last_login|date:"F j, Y g:i A"|default:'Never' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Account Status</dt>
<dd class="mt-1">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{{ user.is_active|yesno:'bg-green-100 text-green-800,bg-red-100 text-red-800' }}">
{{ user.is_active|yesno:'Active,Inactive' }}
</span>
</dd>
</div>
</dl>
</div>
</div>
<!-- Recent Login Activity -->
<div class="bg-white overflow-hidden shadow-xl rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Recent Login Activity</h2>
<p class="text-sm text-gray-600">Your last 5 successful logins</p>
</div>
<div class="overflow-hidden">
{% if recent_logins %}
<ul class="divide-y divide-gray-200">
{% for login in recent_logins %}
<li class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">Successful Login</p>
<p class="text-sm text-gray-600">{{ login.timestamp|date:"F j, Y g:i A" }}</p>
</div>
</div>
<div class="text-sm text-gray-500">
IP: {{ login.ip_address }}
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-center py-6">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No login history</h3>
<p class="mt-1 text-sm text-gray-500">Your login activity will appear here.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- OTP Enable Modal -->
<div id="otpEnableModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900 mt-4">Verify Your Email</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">Enter the verification code sent to your email to enable 2FA.</p>
</div>
<div class="flex justify-center space-x-2 mt-4">
<input type="text" maxlength="1" class="modal-otp-input w-12 h-12 text-center border rounded focus:outline-none focus:border-green-500" id="modal_otp1">
<input type="text" maxlength="1" class="modal-otp-input w-12 h-12 text-center border rounded focus:outline-none focus:border-green-500" id="modal_otp2">
<input type="text" maxlength="1" class="modal-otp-input w-12 h-12 text-center border rounded focus:outline-none focus:border-green-500" id="modal_otp3">
<input type="text" maxlength="1" class="modal-otp-input w-12 h-12 text-center border rounded focus:outline-none focus:border-green-500" id="modal_otp4">
<input type="text" maxlength="1" class="modal-otp-input w-12 h-12 text-center border rounded focus:outline-none focus:border-green-500" id="modal_otp5">
<input type="text" maxlength="1" class="modal-otp-input w-12 h-12 text-center border rounded focus:outline-none focus:border-green-500" id="modal_otp6">
</div>
<div id="modalError" class="hidden text-red-600 text-sm mt-2"></div>
<div class="items-center px-4 py-3 mt-4">
<button id="confirmOtpButton" class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300">
Verify & Enable 2FA
</button>
<button id="cancelModalButton" class="mt-2 px-4 py-2 bg-gray-300 text-gray-800 text-base font-medium rounded-md w-full shadow-sm hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Backup Codes Modal -->
<div id="backupCodesModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100">
<svg class="h-6 w-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900 mt-4">Save Your Backup Codes</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">Save these backup codes in a secure location. You can use them to access your account if you lose access to your email.</p>
</div>
<div id="backupCodesList" class="mt-4 text-left bg-gray-100 p-4 rounded font-mono text-sm">
<!-- Backup codes will be inserted here -->
</div>
<div class="items-center px-4 py-3 mt-4">
<button id="downloadCodesButton" class="px-4 py-2 bg-blue-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300 mb-2">
Download as Text File
</button>
<button id="closeBackupModal" class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-300">
I've Saved These Codes
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const toggleOtpButton = document.getElementById('toggleOtpButton');
const otpEnableModal = document.getElementById('otpEnableModal');
const backupCodesModal = document.getElementById('backupCodesModal');
const modalOtpInputs = document.querySelectorAll('.modal-otp-input');
const confirmOtpButton = document.getElementById('confirmOtpButton');
const cancelModalButton = document.getElementById('cancelModalButton');
const modalError = document.getElementById('modalError');
const closeBackupModal = document.getElementById('closeBackupModal');
const downloadCodesButton = document.getElementById('downloadCodesButton');
let isOtpEnabled = toggleOtpButton.dataset.enabled === 'true';
// Toggle OTP button click
toggleOtpButton.addEventListener('click', async function() {
if (isOtpEnabled) {
// Disable OTP
if (confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.')) {
await toggleOTP(false);
}
} else {
// Enable OTP - show modal
await toggleOTP(true);
}
});
// Modal OTP inputs
modalOtpInputs.forEach((input, index) => {
input.addEventListener('input', function(e) {
const value = e.target.value.replace(/\D/g, '');
e.target.value = value;
if (value.length === 1 && index < modalOtpInputs.length - 1) {
modalOtpInputs[index + 1].focus();
}
if (index === modalOtpInputs.length - 1 && value.length === 1) {
const allFilled = Array.from(modalOtpInputs).every(inp => inp.value.length === 1);
if (allFilled) {
setTimeout(() => confirmEnableOTP(), 100);
}
}
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Backspace' && this.value === '' && index > 0) {
modalOtpInputs[index - 1].focus();
}
});
});
// Confirm OTP button
confirmOtpButton.addEventListener('click', confirmEnableOTP);
// Cancel modal
cancelModalButton.addEventListener('click', function() {
hideModal(otpEnableModal);
clearModalOtpInputs();
hideModalError();
});
// Close backup modal
closeBackupModal.addEventListener('click', function() {
hideModal(backupCodesModal);
location.reload(); // Refresh to show updated status
});
// Download backup codes
downloadCodesButton.addEventListener('click', function() {
const codes = document.getElementById('backupCodesList').textContent;
const blob = new Blob([codes], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'backup-codes.txt';
a.click();
window.URL.revokeObjectURL(url);
});
// Toggle OTP function
async function toggleOTP(enable) {
try {
const response = await fetch('/accounts/api/toggle-otp/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ enable_otp: enable })
});
const result = await response.json();
if (result.success) {
if (result.verification_required) {
showModal(otpEnableModal);
modalOtpInputs[0].focus();
showToast('Verification code sent to your email');
} else {
showToast(result.message);
updateOTPStatus(result.otp_enabled);
}
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Toggle OTP error:', error);
showToast('Failed to update settings', 'error');
}
}
// Confirm enable OTP
async function confirmEnableOTP() {
const otpCode = Array.from(modalOtpInputs).map(input => input.value).join('');
if (otpCode.length !== 6) {
showModalError('Please enter all 6 digits');
return;
}
hideModalError();
try {
const response = await fetch('/accounts/api/confirm-enable-otp/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ otp_code: otpCode })
});
const result = await response.json();
if (result.success) {
hideModal(otpEnableModal);
// Show backup codes
if (result.backup_codes) {
showBackupCodes(result.backup_codes);
}
showToast(result.message);
updateOTPStatus(result.otp_enabled);
} else {
showModalError(result.error);
}
} catch (error) {
console.error('Confirm OTP error:', error);
showModalError('Verification failed');
}
}
// Show backup codes
function showBackupCodes(codes) {
const codesList = document.getElementById('backupCodesList');
codesList.innerHTML = codes.map((code, index) =>
`${index + 1}. ${code}`
).join('\n');
showModal(backupCodesModal);
}
// Update OTP status in UI
function updateOTPStatus(enabled) {
isOtpEnabled = enabled;
toggleOtpButton.dataset.enabled = enabled.toString();
toggleOtpButton.textContent = enabled ? 'Disable 2FA' : 'Enable 2FA';
// Update status indicators
const statusElements = document.querySelectorAll('.otp-status');
statusElements.forEach(el => {
el.textContent = enabled ? 'Enabled' : 'Disabled';
el.className = enabled
? 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800'
: 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800';
});
}
// Modal helper functions
function showModal(modal) {
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function hideModal(modal) {
modal.classList.add('hidden');
document.body.style.overflow = 'auto';
}
function showModalError(message) {
modalError.textContent = message;
modalError.classList.remove('hidden');
}
function hideModalError() {
modalError.classList.add('hidden');
}
function clearModalOtpInputs() {
modalOtpInputs.forEach(input => input.value = '');
}
});
</script>
{% endblock %}
Advanced Security Features
Rate Limiting Implementation
Implement comprehensive rate limiting to prevent abuse:
# accounts/middleware.py
import time
from django.core.cache import cache
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
import logging
logger = logging.getLogger('security')
class RateLimitMiddleware(MiddlewareMixin):
"""Advanced rate limiting middleware"""
def __init__(self, get_response):
self.get_response = get_response
super().__init__(get_response)
def process_request(self, request):
"""Process incoming requests for rate limiting"""
# Skip rate limiting for certain paths
exempt_paths = ['/admin/', '/static/', '/media/']
if any(request.path.startswith(path) for path in exempt_paths):
return None
# Get client IP
ip_address = self.get_client_ip(request)
# Different rate limits for different endpoints
rate_limits = {
'/accounts/api/send-login-otp/': {'limit': 5, 'window': 300}, # 5 per 5 minutes
'/accounts/api/verify-login-otp/': {'limit': 10, 'window': 300}, # 10 per 5 minutes
'/accounts/api/resend-otp/': {'limit': 3, 'window': 600}, # 3 per 10 minutes
}
# Check if current path needs rate limiting
for path, limits in rate_limits.items():
if request.path == path:
if not self.check_rate_limit(ip_address, path, limits['limit'], limits['window']):
logger.warning(f"Rate limit exceeded for {ip_address} on {path}")
return JsonResponse({
'success': False,
'error': 'Rate limit exceeded. Please try again later.',
'retry_after': limits['window']
}, status=429)
return None
def get_client_ip(self, request):
"""Get client IP address"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '127.0.0.1')
def check_rate_limit(self, identifier, endpoint, limit, window):
"""Check if request is within rate limit"""
cache_key = f"rate_limit:{endpoint}:{identifier}"
# Get current request count
current_requests = cache.get(cache_key, [])
now = time.time()
# Filter requests within the window
recent_requests = [req_time for req_time in current_requests if now - req_time < window]
# Check if limit exceeded
if len(recent_requests) >= limit:
return False
# Add current request and update cache
recent_requests.append(now)
cache.set(cache_key, recent_requests, window)
return True
Security Event Logging
Implement comprehensive security event logging:
# accounts/security.py
import logging
import json
from django.utils import timezone
from django.contrib.gis.geoip2 import GeoIP2
from django.core.exceptions import ValidationError
from .models import SecurityEvent, LoginAttempt
from .utils import get_client_ip, get_user_agent
logger = logging.getLogger('security')
class SecurityMonitor:
"""Advanced security monitoring and logging"""
def __init__(self, request=None):
self.request = request
self.ip_address = get_client_ip(request) if request else None
self.user_agent = get_user_agent(request) if request else ''
def log_security_event(self, user, event_type, description, metadata=None):
"""Log security event with detailed information"""
try:
# Get location information (optional)
location_info = self.get_location_info(self.ip_address)
# Prepare metadata
event_metadata = {
'user_agent': self.user_agent,
'location': location_info,
'timestamp': timezone.now().isoformat(),
**(metadata or {})
}
# Create security event
SecurityEvent.objects.create(
user=user,
event_type=event_type,
description=description,
ip_address=self.ip_address or '127.0.0.1',
user_agent=self.user_agent,
metadata=event_metadata
)
# Log to file
logger.warning(f"Security Event: {event_type} | User: {user.username} | IP: {self.ip_address} | Details: {description}")
except Exception as e:
logger.error(f"Failed to log security event: {str(e)}")
def detect_suspicious_activity(self, user):
"""Detect suspicious login patterns"""
suspicious_indicators = []
# Check for multiple failed attempts
recent_failures = LoginAttempt.objects.filter(
user=user,
result__in=['invalid_credentials', 'invalid_otp'],
timestamp__gte=timezone.now() - timezone.timedelta(hours=1)
).count()
if recent_failures >= 3:
suspicious_indicators.append(f"Multiple failed attempts: {recent_failures}")
# Check for login from new location
recent_logins = LoginAttempt.objects.filter(
user=user,
result='success',
timestamp__gte=timezone.now() - timezone.timedelta(days=30)
).values_list('ip_address', flat=True).distinct()
if self.ip_address and self.ip_address not in recent_logins and len(recent_logins) > 0:
suspicious_indicators.append("Login from new IP address")
# Check for unusual timing
last_login = LoginAttempt.objects.filter(
user=user,
result='success'
).first()
if last_login:
time_diff = timezone.now() - last_login.timestamp
if time_diff.total_seconds() < 300: # Less than 5 minutes
suspicious_indicators.append("Very quick successive login")
return suspicious_indicators
def get_location_info(self, ip_address):
"""Get location information from IP address"""
try:
if not ip_address or ip_address in ['127.0.0.1', 'localhost']:
return {'city': 'Local', 'country': 'Local'}
g = GeoIP2()
location = g.city(ip_address)
return {
'city': location.get('city', 'Unknown'),
'country': location.get('country_name', 'Unknown'),
'country_code': location.get('country_code', 'Unknown')
}
except Exception:
return {'city': 'Unknown', 'country': 'Unknown'}
def check_account_lockout(self, user):
"""Check if account should be locked due to suspicious activity"""
recent_failures = LoginAttempt.objects.filter(
user=user,
result__in=['invalid_credentials', 'invalid_otp'],
timestamp__gte=timezone.now() - timezone.timedelta(hours=1)
).count()
if recent_failures >= 10:
return True, "Too many failed login attempts"
# Check for rapid-fire attempts
very_recent_attempts = LoginAttempt.objects.filter(
user=user,
timestamp__gte=timezone.now() - timezone.timedelta(minutes=5)
).count()
if very_recent_attempts >= 5:
return True, "Rapid successive login attempts"
return False, ""
def monitor_login_attempt(user, request, result, details=None):
"""Monitor and log login attempts with security analysis"""
monitor = SecurityMonitor(request)
# Log the attempt
LoginAttempt.log_attempt(
username=user.username if user else 'unknown',
ip_address=monitor.ip_address,
result=result,
user=user,
user_agent=monitor.user_agent,
session_key=request.session.session_key or '',
**details
)
if user and result != 'success':
# Check for suspicious activity
suspicious_indicators = monitor.detect_suspicious_activity(user)
if suspicious_indicators:
monitor.log_security_event(
user=user,
event_type='suspicious_activity',
description=f"Suspicious login activity detected: {', '.join(suspicious_indicators)}",
metadata={'indicators': suspicious_indicators}
)
# Check for account lockout
should_lock, reason = monitor.check_account_lockout(user)
if should_lock:
user.is_active = False
user.save()
monitor.log_security_event(
user=user,
event_type='account_locked',
description=f"Account automatically locked: {reason}",
metadata={'reason': reason}
)
CSRF and Security Headers
Enhanced security configuration:
# settings.py (Security enhancements)
# CSRF Protection
CSRF_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = 'Strict'
CSRF_TRUSTED_ORIGINS = ['https://yourdomain.com']
# Session Security
SESSION_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_AGE = 3600 # 1 hour
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_SAVE_EVERY_REQUEST = True
# Security Headers
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = not DEBUG
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Content Security Policy
CSP_DEFAULT_SRC = ["'self'"]
CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "https://cdn.tailwindcss.com", "https://unpkg.com"]
CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "https://cdn.tailwindcss.com"]
CSP_FONT_SRC = ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"]
CSP_IMG_SRC = ["'self'", "data:", "https:"]
# Additional Security Settings
X_FRAME_OPTIONS = 'DENY'
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'yourdomain.com']
# Password Validation
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 12,
}
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Logging Configuration for Security
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
'security': {
'format': '[{asctime}] {levelname} - SECURITY EVENT - {message}',
'style': '{',
},
},
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/django.log',
'maxBytes': 1024*1024*5, # 5 MB
'backupCount': 5,
'formatter': 'verbose',
},
'security_file': {
'level': 'WARNING',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/security.log',
'maxBytes': 1024*1024*5, # 5 MB
'backupCount': 10,
'formatter': 'security',
},
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'loggers': {
'django': {
'handlers': ['file', 'console'] if DEBUG else ['file'],
'level': 'INFO',
'propagate': True,
},
'security': {
'handlers': ['security_file', 'console'] if DEBUG else ['security_file'],
'level': 'WARNING',
'propagate': True,
},
'accounts': {
'handlers': ['file', 'console'] if DEBUG else ['file'],
'level': 'INFO',
'propagate': True,
},
},
}
Testing Your Django OTP System
Unit Tests
Create comprehensive unit tests:
# accounts/tests.py
from django.test import TestCase, Client
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
from unittest.mock import patch, MagicMock
import json
from .models import EmailOTP, UserProfile, LoginAttempt, SecurityEvent
from .utils import send_otp_email, validate_otp_rate_limit
class OTPAuthenticationTestCase(TestCase):
"""Test OTP authentication functionality"""
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpassword123'
)
self.profile = UserProfile.objects.create(
user=self.user,
otp_enabled=True
)
def test_login_without_otp(self):
"""Test login when OTP is disabled"""
self.profile.otp_enabled = False
self.profile.save()
response = self.client.post('/accounts/api/send-login-otp/', {
'username_or_email': 'testuser',
'password': 'testpassword123'
})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['success'])
self.assertFalse(data.get('otp_required', False))
@patch('accounts.utils.send_mail')
def test_login_with_otp(self, mock_send_mail):
"""Test login when OTP is enabled"""
mock_send_mail.return_value = True
response = self.client.post('/accounts/api/send-login-otp/', {
'username_or_email': 'testuser',
'password': 'testpassword123'
})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['success'])
self.assertTrue(data.get('otp_required', False))
# Check if OTP was created
otp = EmailOTP.objects.filter(user=self.user, purpose='login').first()
self.assertIsNotNone(otp)
self.assertTrue(otp.is_valid())
def test_invalid_credentials(self):
"""Test login with invalid credentials"""
response = self.client.post('/accounts/api/send-login-otp/', {
'username_or_email': 'testuser',
'password': 'wrongpassword'
})
self.assertEqual(response.status_code, 400)
data = json.loads(response.content)
self.assertFalse(data['success'])
# Check if login attempt was logged
attempt = LoginAttempt.objects.filter(username='testuser').first()
self.assertIsNotNone(attempt)
self.assertEqual(attempt.result, 'invalid_credentials')
def test_otp_verification_success(self):
"""Test successful OTP verification"""
# Create OTP
otp = EmailOTP.generate_otp(
user=self.user,
purpose='login',
ip_address='127.0.0.1'
)
# Set session data
session = self.client.session
session['otp_login_data'] = {
'user_id': self.user.id,
'username': 'testuser',
'remember_me': False,
'timestamp': timezone.now().isoformat(),
'ip_address': '127.0.0.1'
}
session.save()
response = self.client.post('/accounts/api/verify-login-otp/', {
'otp_code': otp.otp_code
})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['success'])
# Check if user is logged in
self.assertTrue('_auth_user_id' in self.client.session)
def test_otp_verification_invalid_code(self):
"""Test OTP verification with invalid code"""
# Create OTP
otp = EmailOTP.generate_otp(
user=self.user,
purpose='login',
ip_address='127.0.0.1'
)
# Set session data
session = self.client.session
session['otp_login_data'] = {
'user_id': self.user.id,
'username': 'testuser',
'remember_me': False,
'timestamp': timezone.now().isoformat(),
'ip_address': '127.0.0.1'
}
session.save()
response = self.client.post('/accounts/api/verify-login-otp/', {
'otp_code': '123456' # Wrong code
})
self.assertEqual(response.status_code, 400)
data = json.loads(response.content)
self.assertFalse(data['success'])
# Check if attempt was incremented
otp.refresh_from_db()
self.assertEqual(otp.attempts, 1)
def test_otp_expiration(self):
"""Test OTP expiration"""
# Create expired OTP
otp = EmailOTP.objects.create(
user=self.user,
otp_code='123456',
purpose='login',
expires_at=timezone.now() - timezone.timedelta(minutes=1),
ip_address='127.0.0.1'
)
self.assertFalse(otp.is_valid())
otp.refresh_from_db()
self.assertEqual(otp.status, 'expired')
def test_rate_limiting(self):
"""Test rate limiting functionality"""
# Test user rate limiting
self.profile.last_otp_request = timezone.now()
self.profile.save()
is_valid, message = validate_otp_rate_limit(self.user, '127.0.0.1')
self.assertFalse(is_valid)
def test_otp_enable_disable(self):
"""Test enabling and disabling OTP"""
self.profile.otp_enabled = False
self.profile.save()
# Login user
self.client.force_login(self.user)
# Enable OTP
with patch('accounts.utils.send_mail', return_value=True):
response = self.client.post('/accounts/api/toggle-otp/', {
'enable_otp': True
})
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['success'])
self.assertTrue(data.get('verification_required', False))
class SecurityTestCase(TestCase):
"""Test security features"""
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpassword123'
)
def test_login_attempt_logging(self):
"""Test that login attempts are properly logged"""
initial_count = LoginAttempt.objects.count()
self.client.post('/accounts/api/send-login-otp/', {
'username_or_email': 'testuser',
'password': 'wrongpassword'
})
self.assertEqual(LoginAttempt.objects.count(), initial_count + 1)
attempt = LoginAttempt.objects.latest('timestamp')
self.assertEqual(attempt.username, 'testuser')
self.assertEqual(attempt.result, 'invalid_credentials')
def test_security_event_logging(self):
"""Test security event logging"""
from .security import SecurityMonitor
request = self.client.request().wsgi_request
monitor = SecurityMonitor(request)
initial_count = SecurityEvent.objects.count()
monitor.log_security_event(
user=self.user,
event_type='test_event',
description='Test security event'
)
self.assertEqual(SecurityEvent.objects.count(), initial_count + 1)
class IntegrationTestCase(TestCase):
"""Integration tests for the complete authentication flow"""
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpassword123'
)
UserProfile.objects.create(user=self.user, otp_enabled=True)
@patch('accounts.utils.send_mail')
def test_complete_login_flow(self, mock_send_mail):
"""Test complete login flow with OTP"""
mock_send_mail.return_value = True
# Step 1: Initial login request
response = self.client.post('/accounts/api/send-login-otp/',
json.dumps({
'username_or_email': 'testuser',
'password': 'testpassword123',
'remember_me': False
}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['success'])
self.assertTrue(data['otp_required'])
# Step 2: Get the OTP from database
otp = EmailOTP.objects.filter(user=self.user, purpose='login').first()
self.assertIsNotNone(otp)
# Step 3: Verify OTP
response = self.client.post('/accounts/api/verify-login-otp/',
json.dumps({
'otp_code': otp.otp_code
}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['success'])
# Step 4: Check if user is logged in
self.assertTrue('_auth_user_id' in self.client.session)
# Step 5: Verify OTP is marked as used
otp.refresh_from_db()
self.assertEqual(otp.status, 'used')
def test_login_page_rendering(self):
"""Test that login page renders correctly"""
response = self.client.get('/accounts/login/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Sign in to your account')
self.assertContains(response, 'otp-input')
def test_dashboard_access_control(self):
"""Test dashboard access control"""
# Unauthenticated access should redirect
response = self.client.get('/accounts/dashboard/')
self.assertEqual(response.status_code, 302) # Redirect to login
# Authenticated access should work
self.client.force_login(self.user)
response = self.client.get('/accounts/dashboard/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Dashboard')
def run_tests():
"""Run all tests"""
from django.test.utils import get_runner
from django.conf import settings
TestRunner = get_runner(settings)
test_runner = TestRunner()
failures = test_runner.run_tests(['accounts'])
return failures == 0
Manual Testing Checklist
Create a comprehensive testing checklist:
# Django OTP Authentication Testing Checklist
## Functional Tests
### Login Process
- [ ] Login page loads correctly
- [ ] Form validation works for empty fields
- [ ] Invalid credentials show appropriate error
- [ ] Valid credentials without OTP log in directly
- [ ] Valid credentials with OTP enabled show OTP form
- [ ] OTP email is sent successfully
- [ ] Email contains correct OTP code
- [ ] OTP form accepts 6-digit codes only
- [ ] Invalid OTP shows error message
- [ ] Valid OTP completes login
- [ ] Remember Me functionality works
- [ ] Session expires correctly
### OTP Management
- [ ] OTP can be enabled from dashboard
- [ ] Verification email sent when enabling
- [ ] OTP enabling requires email verification
- [ ] Backup codes generated when enabling
- [ ] OTP can be disabled with confirmation
- [ ] Security alert sent when toggling OTP
### Security Features
- [ ] Rate limiting prevents brute force attacks
- [ ] Failed attempts are logged
- [ ] Suspicious activity is detected
- [ ] Account lockout works after multiple failures
- [ ] IP addresses are tracked correctly
- [ ] Security events are logged
## UI/UX Tests
### Responsiveness
- [ ] Login form works on mobile devices
- [ ] OTP input fields are touch-friendly
- [ ] Dashboard is mobile-responsive
- [ ] All interactive elements work on touch screens
### Accessibility
- [ ] Screen readers can navigate forms
- [ ] Keyboard navigation works throughout
- [ ] Color contrast meets accessibility standards
- [ ] Error messages are announced properly
### Performance
- [ ] Page loads in under 2 seconds
- [ ] OTP email sends within 30 seconds
- [ ] Form submissions respond quickly
- [ ] No JavaScript errors in console
## Security Tests
### Input Validation
- [ ] SQL injection attempts blocked
- [ ] XSS attempts sanitized
- [ ] CSRF protection working
- [ ] File upload restrictions (if applicable)
### Session Management
- [ ] Sessions expire appropriately
- [ ] Session fixation prevented
- [ ] Logout clears session completely
- [ ] Concurrent session handling
### Email Security
- [ ] OTP codes are cryptographically secure
- [ ] Email content doesn't expose sensitive data
- [ ] Rate limiting prevents email bombing
- [ ] Expired OTPs cannot be used
## Integration Tests
### Email Provider
- [ ] Gmail SMTP works correctly
- [ ] SendGrid integration functional
- [ ] Email delivery rates acceptable
- [ ] Bounce handling configured
### Database
- [ ] All migrations run successfully
- [ ] Data integrity maintained
- [ ] Performance acceptable under load
- [ ] Backup and recovery tested
### External Services
- [ ] GeoIP lookup works (if enabled)
- [ ] CDN serves static files correctly
- [ ] Monitoring systems functional
As I have Faced this Issue and its Solved! I love to share it... Thanks