update
This commit is contained in:
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
47
accounts/admin.py
Normal file
47
accounts/admin.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Admin configuration for accounts app.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from .models import User, UserProfile, ActivityLog, FailedLoginAttempt
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""Custom user admin."""
|
||||
list_display = ('username', 'email', 'role', 'is_verified', 'is_active', 'created_at')
|
||||
list_filter = ('role', 'is_verified', 'is_active', 'created_at')
|
||||
fieldsets = BaseUserAdmin.fieldsets + (
|
||||
('Additional Info', {'fields': ('role', 'is_verified', 'mfa_enabled', 'last_login_ip')}),
|
||||
)
|
||||
add_fieldsets = BaseUserAdmin.add_fieldsets + (
|
||||
('Additional Info', {'fields': ('role', 'is_verified')}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
"""User profile admin."""
|
||||
list_display = ('user', 'first_name', 'last_name', 'consent_given', 'preferred_language')
|
||||
list_filter = ('consent_given', 'preferred_language')
|
||||
search_fields = ('user__username', 'user__email', 'first_name', 'last_name')
|
||||
|
||||
|
||||
@admin.register(ActivityLog)
|
||||
class ActivityLogAdmin(admin.ModelAdmin):
|
||||
"""Activity log admin."""
|
||||
list_display = ('user', 'action', 'ip_address', 'timestamp')
|
||||
list_filter = ('action', 'timestamp')
|
||||
search_fields = ('user__username', 'ip_address')
|
||||
readonly_fields = ('user', 'action', 'ip_address', 'user_agent', 'details', 'timestamp')
|
||||
date_hierarchy = 'timestamp'
|
||||
|
||||
|
||||
@admin.register(FailedLoginAttempt)
|
||||
class FailedLoginAttemptAdmin(admin.ModelAdmin):
|
||||
"""Failed login attempt admin."""
|
||||
list_display = ('email_or_username', 'ip_address', 'timestamp', 'is_blocked')
|
||||
list_filter = ('is_blocked', 'timestamp')
|
||||
search_fields = ('email_or_username', 'ip_address')
|
||||
readonly_fields = ('email_or_username', 'ip_address', 'user_agent', 'timestamp')
|
||||
date_hierarchy = 'timestamp'
|
||||
6
accounts/apps.py
Normal file
6
accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
141
accounts/form_mixins.py
Normal file
141
accounts/form_mixins.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Form mixins for bot protection and validation.
|
||||
"""
|
||||
from django import forms
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import time
|
||||
|
||||
|
||||
class HoneypotMixin:
|
||||
"""
|
||||
Honeypot field mixin - adds a hidden field that bots will fill but humans won't.
|
||||
"""
|
||||
# This field should be left empty by real users
|
||||
website = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput(attrs={'tabindex': '-1', 'autocomplete': 'off'}),
|
||||
label='', # Empty label so screen readers skip it
|
||||
)
|
||||
|
||||
def clean_website(self):
|
||||
"""If this field is filled, it's likely a bot."""
|
||||
website = self.cleaned_data.get('website')
|
||||
if website:
|
||||
raise forms.ValidationError('Bot detected. Please try again.')
|
||||
return website
|
||||
|
||||
|
||||
class TimeBasedValidationMixin:
|
||||
"""
|
||||
Time-based validation - prevents forms from being submitted too quickly (bot behavior).
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add a hidden timestamp field
|
||||
self.fields['form_timestamp'] = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput(),
|
||||
initial=str(time.time())
|
||||
)
|
||||
|
||||
def clean_form_timestamp(self):
|
||||
"""Validate that form wasn't submitted too quickly."""
|
||||
timestamp = self.cleaned_data.get('form_timestamp')
|
||||
if not timestamp:
|
||||
# If timestamp is missing, it might be a bot
|
||||
raise forms.ValidationError('Invalid form submission.')
|
||||
|
||||
try:
|
||||
submit_time = float(timestamp)
|
||||
current_time = time.time()
|
||||
elapsed = current_time - submit_time
|
||||
|
||||
# Forms submitted in less than 2 seconds are likely bots
|
||||
if elapsed < 2:
|
||||
raise forms.ValidationError('Form submitted too quickly. Please take your time.')
|
||||
|
||||
# Forms submitted after 1 hour are likely stale
|
||||
if elapsed > 3600:
|
||||
raise forms.ValidationError('Form session expired. Please refresh and try again.')
|
||||
except (ValueError, TypeError):
|
||||
raise forms.ValidationError('Invalid form submission.')
|
||||
|
||||
return timestamp
|
||||
|
||||
|
||||
class RateLimitMixin:
|
||||
"""
|
||||
Rate limiting mixin - prevents too many form submissions from the same IP/user.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop('request', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if self.request:
|
||||
# Get client IP
|
||||
ip = self.get_client_ip(self.request)
|
||||
|
||||
# Create a unique key for this form type
|
||||
form_name = self.__class__.__name__
|
||||
cache_key = f'form_submission_{form_name}_{ip}'
|
||||
|
||||
# Check submission count
|
||||
submissions = cache.get(cache_key, 0)
|
||||
|
||||
# Limit: 10 submissions per hour per IP
|
||||
if submissions >= 10:
|
||||
raise forms.ValidationError(
|
||||
'Too many submissions. Please wait before submitting again.'
|
||||
)
|
||||
|
||||
# Increment counter
|
||||
cache.set(cache_key, submissions + 1, 3600) # 1 hour
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def get_client_ip(self, request):
|
||||
"""Get client IP address."""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class BrowserFingerprintMixin:
|
||||
"""
|
||||
Browser fingerprint validation - ensures form is submitted from a real browser.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['user_agent_hash'] = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
|
||||
def clean_user_agent_hash(self):
|
||||
"""Validate user agent is present and reasonable."""
|
||||
ua_hash = self.cleaned_data.get('user_agent_hash')
|
||||
|
||||
# If no user agent hash, it might be a bot
|
||||
if not ua_hash:
|
||||
raise forms.ValidationError('Invalid browser signature.')
|
||||
|
||||
return ua_hash
|
||||
|
||||
|
||||
class BotProtectionMixin(HoneypotMixin, TimeBasedValidationMixin, RateLimitMixin):
|
||||
"""
|
||||
Combined bot protection mixin that includes:
|
||||
- Honeypot field
|
||||
- Time-based validation
|
||||
- Rate limiting
|
||||
"""
|
||||
pass
|
||||
|
||||
138
accounts/forms.py
Normal file
138
accounts/forms.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Forms for accounts app.
|
||||
"""
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from django.utils import timezone
|
||||
from .models import User, UserProfile
|
||||
from .security import InputSanitizer, PasswordSecurity
|
||||
from .form_mixins import BotProtectionMixin
|
||||
|
||||
|
||||
class UserRegistrationForm(BotProtectionMixin, UserCreationForm):
|
||||
"""User registration form with security validation and bot protection."""
|
||||
email = forms.EmailField(required=True)
|
||||
consent_given = forms.BooleanField(
|
||||
required=True,
|
||||
label='I agree to the Privacy Policy and Terms of Service'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('username', 'email', 'password1', 'password2', 'consent_given')
|
||||
|
||||
def clean_username(self):
|
||||
username = self.cleaned_data.get('username')
|
||||
if username:
|
||||
# Sanitize username
|
||||
username = InputSanitizer.sanitize_html(username)
|
||||
# Check for SQL injection patterns
|
||||
sanitized = InputSanitizer.sanitize_sql(username)
|
||||
if sanitized is None:
|
||||
raise forms.ValidationError('Invalid username format.')
|
||||
return username
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data.get('email')
|
||||
if email:
|
||||
# Validate email format
|
||||
if not InputSanitizer.validate_email(email):
|
||||
raise forms.ValidationError('Invalid email format.')
|
||||
# Sanitize email
|
||||
email = InputSanitizer.sanitize_html(email)
|
||||
return email
|
||||
|
||||
def clean_password1(self):
|
||||
password = self.cleaned_data.get('password1')
|
||||
if password:
|
||||
is_strong, message = PasswordSecurity.check_password_strength(password)
|
||||
if not is_strong:
|
||||
raise forms.ValidationError(message)
|
||||
return password
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
user.email = self.cleaned_data['email']
|
||||
if commit:
|
||||
user.save()
|
||||
# Create profile with consent
|
||||
profile = UserProfile.objects.create(
|
||||
user=user,
|
||||
consent_given=self.cleaned_data['consent_given']
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
"""User profile edit form."""
|
||||
first_name = forms.CharField(max_length=100, required=False)
|
||||
last_name = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ('first_name', 'last_name', 'phone', 'preferred_language')
|
||||
widgets = {
|
||||
'phone': forms.TextInput(attrs={'placeholder': '+359...'}),
|
||||
}
|
||||
|
||||
|
||||
class MFAVerifyForm(forms.Form):
|
||||
"""MFA verification form."""
|
||||
token = forms.CharField(
|
||||
max_length=6,
|
||||
min_length=6,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '000000',
|
||||
'autofocus': True,
|
||||
'pattern': '[0-9]{6}',
|
||||
'maxlength': '6'
|
||||
}),
|
||||
label='Verification Code',
|
||||
help_text='Enter the 6-digit code from your authenticator app'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def verify_token(self):
|
||||
"""Verify the TOTP token."""
|
||||
if not self.user:
|
||||
return False
|
||||
|
||||
token = self.cleaned_data.get('token')
|
||||
if not token:
|
||||
return False
|
||||
|
||||
# Get the TOTP device (for login, use confirmed device; for setup, use unconfirmed)
|
||||
try:
|
||||
# Try confirmed device first (for login)
|
||||
device = TOTPDevice.objects.get(user=self.user, name='default', confirmed=True)
|
||||
except TOTPDevice.DoesNotExist:
|
||||
# Try unconfirmed device (for setup)
|
||||
try:
|
||||
device = TOTPDevice.objects.get(user=self.user, name='default', confirmed=False)
|
||||
except TOTPDevice.DoesNotExist:
|
||||
return False
|
||||
|
||||
return device.verify_token(token)
|
||||
|
||||
|
||||
class MFASetupForm(forms.Form):
|
||||
"""MFA setup form (for confirmation)."""
|
||||
token = forms.CharField(
|
||||
max_length=6,
|
||||
min_length=6,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '000000',
|
||||
'autofocus': True,
|
||||
'pattern': '[0-9]{6}',
|
||||
'maxlength': '6'
|
||||
}),
|
||||
label='Verification Code',
|
||||
help_text='Enter the 6-digit code from your authenticator app to confirm setup'
|
||||
)
|
||||
|
||||
0
accounts/management/__init__.py
Normal file
0
accounts/management/__init__.py
Normal file
0
accounts/management/commands/__init__.py
Normal file
0
accounts/management/commands/__init__.py
Normal file
110
accounts/management/commands/check_security.py
Normal file
110
accounts/management/commands/check_security.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Management command to check security settings and vulnerabilities.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from accounts.models import FailedLoginAttempt, ActivityLog
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check security settings and report potential vulnerabilities'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(self.style.SUCCESS('Security Audit Report'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
|
||||
issues = []
|
||||
warnings = []
|
||||
|
||||
# Check DEBUG mode
|
||||
if settings.DEBUG:
|
||||
warnings.append('DEBUG mode is enabled - disable in production!')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('✓ DEBUG mode is disabled'))
|
||||
|
||||
# Check SECRET_KEY
|
||||
if settings.SECRET_KEY == 'django-insecure-change-this-in-production':
|
||||
issues.append('CRITICAL: Default SECRET_KEY is being used!')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('✓ SECRET_KEY is set'))
|
||||
|
||||
# Check ALLOWED_HOSTS
|
||||
if not settings.ALLOWED_HOSTS:
|
||||
issues.append('ALLOWED_HOSTS is empty - set in production!')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ ALLOWED_HOSTS: {settings.ALLOWED_HOSTS}'))
|
||||
|
||||
# Check HTTPS settings
|
||||
if not settings.DEBUG:
|
||||
if not getattr(settings, 'SECURE_SSL_REDIRECT', False):
|
||||
issues.append('SECURE_SSL_REDIRECT should be True in production')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('✓ SSL redirect enabled'))
|
||||
|
||||
# Check password hashers
|
||||
if 'argon2' in settings.PASSWORD_HASHERS[0].lower():
|
||||
self.stdout.write(self.style.SUCCESS('✓ Using Argon2 password hasher'))
|
||||
else:
|
||||
warnings.append('Consider using Argon2 password hasher')
|
||||
|
||||
# Check session security
|
||||
if settings.SESSION_COOKIE_HTTPONLY:
|
||||
self.stdout.write(self.style.SUCCESS('✓ Session cookies are HTTP-only'))
|
||||
else:
|
||||
issues.append('SESSION_COOKIE_HTTPONLY should be True')
|
||||
|
||||
if settings.SESSION_COOKIE_SECURE or settings.DEBUG:
|
||||
self.stdout.write(self.style.SUCCESS('✓ Session cookies are secure'))
|
||||
else:
|
||||
issues.append('SESSION_COOKIE_SECURE should be True in production')
|
||||
|
||||
# Check CSRF protection
|
||||
if settings.CSRF_COOKIE_HTTPONLY:
|
||||
self.stdout.write(self.style.SUCCESS('✓ CSRF cookies are HTTP-only'))
|
||||
else:
|
||||
issues.append('CSRF_COOKIE_HTTPONLY should be True')
|
||||
|
||||
# Check failed login attempts
|
||||
recent_failures = FailedLoginAttempt.objects.filter(
|
||||
timestamp__gte=timezone.now() - timedelta(hours=24)
|
||||
).count()
|
||||
|
||||
if recent_failures > 0:
|
||||
self.stdout.write(self.style.WARNING(f'⚠ {recent_failures} failed login attempts in last 24 hours'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('✓ No recent failed login attempts'))
|
||||
|
||||
# Check for users with weak passwords (if possible)
|
||||
users_without_mfa = User.objects.filter(mfa_enabled=False).count()
|
||||
total_users = User.objects.count()
|
||||
if total_users > 0:
|
||||
mfa_percentage = (users_without_mfa / total_users) * 100
|
||||
if mfa_percentage > 50:
|
||||
warnings.append(f'Only {100-mfa_percentage:.1f}% of users have MFA enabled')
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ {100-mfa_percentage:.1f}% of users have MFA enabled'))
|
||||
|
||||
# Report issues
|
||||
if issues:
|
||||
self.stdout.write(self.style.ERROR('\n' + '=' * 60))
|
||||
self.stdout.write(self.style.ERROR('CRITICAL ISSUES:'))
|
||||
for issue in issues:
|
||||
self.stdout.write(self.style.ERROR(f'✗ {issue}'))
|
||||
|
||||
if warnings:
|
||||
self.stdout.write(self.style.WARNING('\n' + '=' * 60))
|
||||
self.stdout.write(self.style.WARNING('WARNINGS:'))
|
||||
for warning in warnings:
|
||||
self.stdout.write(self.style.WARNING(f'⚠ {warning}'))
|
||||
|
||||
if not issues and not warnings:
|
||||
self.stdout.write(self.style.SUCCESS('\n✓ No security issues found!'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\n' + '=' * 60))
|
||||
|
||||
45
accounts/management/commands/create_initial_tags.py
Normal file
45
accounts/management/commands/create_initial_tags.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Management command to create initial scam tags.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from reports.models import ScamTag
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create initial scam tags'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tags = [
|
||||
{'name': 'Phishing', 'description': 'Phishing scams', 'color': '#dc3545'},
|
||||
{'name': 'Fake Website', 'description': 'Fake or fraudulent websites', 'color': '#fd7e14'},
|
||||
{'name': 'Romance Scam', 'description': 'Romance and dating scams', 'color': '#e83e8c'},
|
||||
{'name': 'Investment Scam', 'description': 'Investment and financial scams', 'color': '#ffc107'},
|
||||
{'name': 'Tech Support', 'description': 'Tech support scams', 'color': '#20c997'},
|
||||
{'name': 'Identity Theft', 'description': 'Identity theft attempts', 'color': '#6f42c1'},
|
||||
{'name': 'Fake Product', 'description': 'Fake product sales', 'color': '#17a2b8'},
|
||||
{'name': 'Advance Fee', 'description': 'Advance fee fraud', 'color': '#343a40'},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
for tag_data in tags:
|
||||
tag, created = ScamTag.objects.get_or_create(
|
||||
name=tag_data['name'],
|
||||
defaults={
|
||||
'description': tag_data['description'],
|
||||
'color': tag_data['color']
|
||||
}
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Created tag: {tag.name}')
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Tag already exists: {tag.name}')
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nCreated {created_count} new tags.')
|
||||
)
|
||||
|
||||
377
accounts/management/commands/create_sample_data.py
Normal file
377
accounts/management/commands/create_sample_data.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Management command to create sample data for testing.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from accounts.models import UserProfile, ActivityLog
|
||||
from reports.models import ScamReport, ScamTag, ScamVerification
|
||||
from osint.models import OSINTTask, OSINTResult
|
||||
from moderation.models import ModerationQueue, ModerationAction
|
||||
from analytics.models import ReportStatistic, UserStatistic
|
||||
from legal.models import ConsentRecord
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import random
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create sample data for testing'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--clear',
|
||||
action='store_true',
|
||||
help='Clear existing data before creating sample data',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options['clear']:
|
||||
self.stdout.write(self.style.WARNING('Clearing existing data...'))
|
||||
ScamReport.objects.all().delete()
|
||||
User.objects.filter(is_superuser=False).delete()
|
||||
ScamTag.objects.all().delete()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Creating sample data...'))
|
||||
|
||||
# Create users
|
||||
users = self.create_users()
|
||||
|
||||
# Create tags
|
||||
tags = self.create_tags()
|
||||
|
||||
# Create reports
|
||||
reports = self.create_reports(users, tags)
|
||||
|
||||
# Create OSINT data
|
||||
self.create_osint_data(reports, users)
|
||||
|
||||
# Create moderation data
|
||||
self.create_moderation_data(reports, users)
|
||||
|
||||
# Create analytics data
|
||||
self.create_analytics_data()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\nSample data created successfully!'))
|
||||
self.stdout.write(self.style.SUCCESS(f'Created {len(users)} users'))
|
||||
self.stdout.write(self.style.SUCCESS(f'Created {len(tags)} tags'))
|
||||
self.stdout.write(self.style.SUCCESS(f'Created {len(reports)} reports'))
|
||||
|
||||
def create_users(self):
|
||||
"""Create sample users."""
|
||||
users = []
|
||||
|
||||
# Create admin user
|
||||
admin, created = User.objects.get_or_create(
|
||||
username='admin',
|
||||
defaults={
|
||||
'email': 'admin@fraudplatform.bg',
|
||||
'role': 'admin',
|
||||
'is_verified': True,
|
||||
'is_staff': True,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
admin.set_password('admin123')
|
||||
admin.save()
|
||||
UserProfile.objects.create(
|
||||
user=admin,
|
||||
first_name='Admin',
|
||||
last_name='User',
|
||||
consent_given=True,
|
||||
consent_date=timezone.now()
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'Created admin user: {admin.username}'))
|
||||
users.append(admin)
|
||||
|
||||
# Create moderator users
|
||||
for i in range(2):
|
||||
mod, created = User.objects.get_or_create(
|
||||
username=f'moderator{i+1}',
|
||||
defaults={
|
||||
'email': f'moderator{i+1}@fraudplatform.bg',
|
||||
'role': 'moderator',
|
||||
'is_verified': True,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
mod.set_password('mod123')
|
||||
mod.save()
|
||||
UserProfile.objects.create(
|
||||
user=mod,
|
||||
first_name=f'Moderator{i+1}',
|
||||
last_name='User',
|
||||
consent_given=True,
|
||||
consent_date=timezone.now()
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'Created moderator: {mod.username}'))
|
||||
users.append(mod)
|
||||
|
||||
# Create normal users
|
||||
user_data = [
|
||||
('john_doe', 'john@example.com', 'John', 'Doe'),
|
||||
('jane_smith', 'jane@example.com', 'Jane', 'Smith'),
|
||||
('ivan_petrov', 'ivan@example.com', 'Ivan', 'Petrov'),
|
||||
('maria_georgieva', 'maria@example.com', 'Maria', 'Georgieva'),
|
||||
('test_user', 'test@example.com', 'Test', 'User'),
|
||||
]
|
||||
|
||||
for username, email, first_name, last_name in user_data:
|
||||
user, created = User.objects.get_or_create(
|
||||
username=username,
|
||||
defaults={
|
||||
'email': email,
|
||||
'role': 'normal',
|
||||
'is_verified': True,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
user.set_password('user123')
|
||||
user.save()
|
||||
UserProfile.objects.create(
|
||||
user=user,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
consent_given=True,
|
||||
consent_date=timezone.now()
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'Created user: {user.username}'))
|
||||
users.append(user)
|
||||
|
||||
return users
|
||||
|
||||
def create_tags(self):
|
||||
"""Create sample tags."""
|
||||
tag_data = [
|
||||
('Phishing', 'Phishing scams', '#dc3545'),
|
||||
('Fake Website', 'Fake or fraudulent websites', '#fd7e14'),
|
||||
('Romance Scam', 'Romance and dating scams', '#e83e8c'),
|
||||
('Investment Scam', 'Investment and financial scams', '#ffc107'),
|
||||
('Tech Support', 'Tech support scams', '#20c997'),
|
||||
('Identity Theft', 'Identity theft attempts', '#6f42c1'),
|
||||
('Fake Product', 'Fake product sales', '#17a2b8'),
|
||||
('Advance Fee', 'Advance fee fraud', '#343a40'),
|
||||
]
|
||||
|
||||
tags = []
|
||||
for name, description, color in tag_data:
|
||||
tag, created = ScamTag.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={
|
||||
'description': description,
|
||||
'color': color
|
||||
}
|
||||
)
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f'Created tag: {tag.name}'))
|
||||
tags.append(tag)
|
||||
|
||||
return tags
|
||||
|
||||
def create_reports(self, users, tags):
|
||||
"""Create sample scam reports."""
|
||||
normal_users = [u for u in users if u.role == 'normal']
|
||||
if not normal_users:
|
||||
return []
|
||||
|
||||
report_data = [
|
||||
{
|
||||
'title': 'Fake Bulgarian Bank Website',
|
||||
'description': 'I received an email claiming to be from my bank asking me to verify my account. The website looked identical to the real bank website but the URL was slightly different. When I entered my credentials, I realized it was a phishing attempt.',
|
||||
'scam_type': 'phishing',
|
||||
'reported_url': 'https://fake-bank-bg.com',
|
||||
'reported_email': 'support@fake-bank-bg.com',
|
||||
'status': 'verified',
|
||||
'verification_score': 95,
|
||||
},
|
||||
{
|
||||
'title': 'Romance Scam on Dating Site',
|
||||
'description': 'Someone contacted me on a dating site and after weeks of chatting, asked me to send money for an emergency. I later found out this was a common romance scam pattern.',
|
||||
'scam_type': 'romance_scam',
|
||||
'reported_email': 'scammer@example.com',
|
||||
'reported_phone': '+359888123456',
|
||||
'status': 'verified',
|
||||
'verification_score': 88,
|
||||
},
|
||||
{
|
||||
'title': 'Fake Investment Opportunity',
|
||||
'description': 'Received a call about a "guaranteed" investment opportunity with high returns. They asked for an upfront fee and promised unrealistic returns. This is clearly a scam.',
|
||||
'scam_type': 'investment_scam',
|
||||
'reported_phone': '+359888654321',
|
||||
'reported_company': 'Fake Investment Group',
|
||||
'status': 'verified',
|
||||
'verification_score': 92,
|
||||
},
|
||||
{
|
||||
'title': 'Tech Support Scam Call',
|
||||
'description': 'Received a call from someone claiming to be from Microsoft tech support. They said my computer was infected and asked me to install remote access software. This is a known tech support scam.',
|
||||
'scam_type': 'tech_support_scam',
|
||||
'reported_phone': '+359888999888',
|
||||
'status': 'verified',
|
||||
'verification_score': 90,
|
||||
},
|
||||
{
|
||||
'title': 'Fake Online Store',
|
||||
'description': 'Ordered a product from an online store that looked legitimate. After payment, I never received the product and the website disappeared. The products were fake listings.',
|
||||
'scam_type': 'fake_product',
|
||||
'reported_url': 'https://fake-store-bg.com',
|
||||
'reported_email': 'orders@fake-store-bg.com',
|
||||
'status': 'verified',
|
||||
'verification_score': 85,
|
||||
},
|
||||
{
|
||||
'title': 'Phishing Email - Tax Refund',
|
||||
'description': 'Received an email claiming I was eligible for a tax refund. The email asked me to click a link and provide personal information. This is a phishing attempt.',
|
||||
'scam_type': 'phishing',
|
||||
'reported_email': 'tax-refund@scam.com',
|
||||
'status': 'pending',
|
||||
'verification_score': 0,
|
||||
},
|
||||
{
|
||||
'title': 'Advance Fee Fraud - Lottery Win',
|
||||
'description': 'Received an email claiming I won a lottery I never entered. They asked for payment of "processing fees" to claim the prize. This is advance fee fraud.',
|
||||
'scam_type': 'advance_fee',
|
||||
'reported_email': 'lottery@scam.com',
|
||||
'status': 'under_review',
|
||||
'verification_score': 75,
|
||||
},
|
||||
{
|
||||
'title': 'Fake Job Offer',
|
||||
'description': 'Received a job offer via email that seemed too good to be true. They asked for personal documents and bank account information before any interview. This is a scam.',
|
||||
'scam_type': 'other',
|
||||
'reported_email': 'hr@fake-company.com',
|
||||
'reported_url': 'https://fake-jobs-bg.com',
|
||||
'status': 'verified',
|
||||
'verification_score': 87,
|
||||
},
|
||||
]
|
||||
|
||||
reports = []
|
||||
for i, data in enumerate(report_data):
|
||||
reporter = random.choice(normal_users)
|
||||
created_at = timezone.now() - timedelta(days=random.randint(1, 30))
|
||||
|
||||
report = ScamReport.objects.create(
|
||||
reporter=reporter,
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
scam_type=data['scam_type'],
|
||||
reported_url=data.get('reported_url', ''),
|
||||
reported_email=data.get('reported_email', ''),
|
||||
reported_phone=data.get('reported_phone', ''),
|
||||
reported_company=data.get('reported_company', ''),
|
||||
status=data['status'],
|
||||
verification_score=data['verification_score'],
|
||||
is_public=True if data['status'] == 'verified' else False,
|
||||
is_anonymous=random.choice([True, False]),
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
# Add random tags
|
||||
report.tags.set(random.sample(tags, random.randint(1, 3)))
|
||||
|
||||
if data['status'] == 'verified':
|
||||
report.verified_at = created_at + timedelta(hours=random.randint(1, 48))
|
||||
report.save()
|
||||
|
||||
reports.append(report)
|
||||
self.stdout.write(self.style.SUCCESS(f'Created report: {report.title}'))
|
||||
|
||||
return reports
|
||||
|
||||
def create_osint_data(self, reports, users):
|
||||
"""Create sample OSINT data."""
|
||||
moderators = [u for u in users if u.role in ['moderator', 'admin']]
|
||||
if not moderators or not reports:
|
||||
return
|
||||
|
||||
for report in reports[:5]: # Add OSINT data to first 5 reports
|
||||
# Create OSINT tasks
|
||||
task_types = ['whois_lookup', 'dns_lookup', 'ssl_check', 'email_analysis']
|
||||
for task_type in random.sample(task_types, 2):
|
||||
OSINTTask.objects.create(
|
||||
report=report,
|
||||
task_type=task_type,
|
||||
status='completed',
|
||||
parameters={'target': report.reported_url or report.reported_email or report.reported_phone},
|
||||
result={'status': 'success', 'data': 'Sample OSINT data'},
|
||||
started_at=report.created_at + timedelta(minutes=5),
|
||||
completed_at=report.created_at + timedelta(minutes=10),
|
||||
)
|
||||
|
||||
# Create OSINT results
|
||||
OSINTResult.objects.create(
|
||||
report=report,
|
||||
source='WHOIS Lookup',
|
||||
data_type='whois',
|
||||
raw_data={'domain': report.reported_url, 'registrar': 'Fake Registrar'},
|
||||
processed_data={'risk_level': 'high', 'domain_age': '30 days'},
|
||||
confidence_level=85,
|
||||
is_verified=True,
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Created OSINT data for: {report.title}'))
|
||||
|
||||
def create_moderation_data(self, reports, users):
|
||||
"""Create sample moderation data."""
|
||||
moderators = [u for u in users if u.role in ['moderator', 'admin']]
|
||||
if not moderators or not reports:
|
||||
return
|
||||
|
||||
# Add pending reports to moderation queue
|
||||
pending_reports = [r for r in reports if r.status == 'pending']
|
||||
for report in pending_reports:
|
||||
ModerationQueue.objects.create(
|
||||
report=report,
|
||||
priority=random.choice(['low', 'normal', 'high']),
|
||||
assigned_to=random.choice(moderators) if random.choice([True, False]) else None,
|
||||
)
|
||||
|
||||
# Create moderation actions for verified reports
|
||||
verified_reports = [r for r in reports if r.status == 'verified']
|
||||
for report in verified_reports:
|
||||
moderator = random.choice(moderators)
|
||||
ModerationAction.objects.create(
|
||||
report=report,
|
||||
moderator=moderator,
|
||||
action_type='approve',
|
||||
previous_status='pending',
|
||||
new_status='verified',
|
||||
reason='Verified through OSINT and manual review',
|
||||
created_at=report.verified_at or report.created_at + timedelta(hours=1),
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Created moderation data for {len(reports)} reports'))
|
||||
|
||||
def create_analytics_data(self):
|
||||
"""Create sample analytics data."""
|
||||
today = timezone.now().date()
|
||||
|
||||
# Create report statistics for last 7 days
|
||||
for i in range(7):
|
||||
date = today - timedelta(days=i)
|
||||
ReportStatistic.objects.get_or_create(
|
||||
date=date,
|
||||
defaults={
|
||||
'total_reports': ScamReport.objects.filter(created_at__date=date).count(),
|
||||
'pending_reports': ScamReport.objects.filter(status='pending', created_at__date=date).count(),
|
||||
'verified_reports': ScamReport.objects.filter(status='verified', created_at__date=date).count(),
|
||||
'rejected_reports': ScamReport.objects.filter(status='rejected', created_at__date=date).count(),
|
||||
}
|
||||
)
|
||||
|
||||
# Create user statistics
|
||||
UserStatistic.objects.get_or_create(
|
||||
date=today,
|
||||
defaults={
|
||||
'total_users': User.objects.count(),
|
||||
'new_users': User.objects.filter(created_at__date=today).count(),
|
||||
'active_users': User.objects.filter(last_login__date=today).count(),
|
||||
'moderators': User.objects.filter(role__in=['moderator', 'admin']).count(),
|
||||
'admins': User.objects.filter(role='admin').count(),
|
||||
}
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Created analytics data'))
|
||||
|
||||
120
accounts/management/commands/create_test_users.py
Normal file
120
accounts/management/commands/create_test_users.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Management command to create test users for dashboard testing.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from accounts.models import UserProfile
|
||||
from django.utils import timezone
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create test users (normal, moderator, admin) for dashboard testing'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS('Creating test users...'))
|
||||
|
||||
# Create/Update Normal User
|
||||
normal_user, created = User.objects.get_or_create(
|
||||
username='normal_user',
|
||||
defaults={
|
||||
'email': 'normal@test.bg',
|
||||
'role': 'normal',
|
||||
'is_verified': True,
|
||||
}
|
||||
)
|
||||
normal_user.set_password('normal123')
|
||||
normal_user.role = 'normal'
|
||||
normal_user.is_verified = True
|
||||
normal_user.save()
|
||||
|
||||
if not hasattr(normal_user, 'profile'):
|
||||
UserProfile.objects.create(
|
||||
user=normal_user,
|
||||
first_name='Normal',
|
||||
last_name='User',
|
||||
consent_given=True,
|
||||
consent_date=timezone.now()
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{"Created" if created else "Updated"} normal user: {normal_user.username} (password: normal123)'
|
||||
))
|
||||
|
||||
# Create/Update Moderator User
|
||||
moderator_user, created = User.objects.get_or_create(
|
||||
username='moderator',
|
||||
defaults={
|
||||
'email': 'moderator@test.bg',
|
||||
'role': 'moderator',
|
||||
'is_verified': True,
|
||||
}
|
||||
)
|
||||
moderator_user.set_password('moderator123')
|
||||
moderator_user.role = 'moderator'
|
||||
moderator_user.is_verified = True
|
||||
moderator_user.save()
|
||||
|
||||
if not hasattr(moderator_user, 'profile'):
|
||||
UserProfile.objects.create(
|
||||
user=moderator_user,
|
||||
first_name='Moderator',
|
||||
last_name='User',
|
||||
consent_given=True,
|
||||
consent_date=timezone.now()
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{"Created" if created else "Updated"} moderator user: {moderator_user.username} (password: moderator123)'
|
||||
))
|
||||
|
||||
# Create/Update Admin User
|
||||
admin_user, created = User.objects.get_or_create(
|
||||
username='admin',
|
||||
defaults={
|
||||
'email': 'admin@test.bg',
|
||||
'role': 'admin',
|
||||
'is_verified': True,
|
||||
'is_staff': True,
|
||||
'is_superuser': True,
|
||||
}
|
||||
)
|
||||
admin_user.set_password('admin123')
|
||||
admin_user.role = 'admin'
|
||||
admin_user.is_verified = True
|
||||
admin_user.is_staff = True
|
||||
admin_user.is_superuser = True
|
||||
admin_user.save()
|
||||
|
||||
if not hasattr(admin_user, 'profile'):
|
||||
UserProfile.objects.create(
|
||||
user=admin_user,
|
||||
first_name='Admin',
|
||||
last_name='User',
|
||||
consent_given=True,
|
||||
consent_date=timezone.now()
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{"Created" if created else "Updated"} admin user: {admin_user.username} (password: admin123)'
|
||||
))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\n' + '='*60))
|
||||
self.stdout.write(self.style.SUCCESS('Test Users Created Successfully!'))
|
||||
self.stdout.write(self.style.SUCCESS('='*60))
|
||||
self.stdout.write(self.style.SUCCESS('\nLogin Credentials:'))
|
||||
self.stdout.write(self.style.SUCCESS('\n1. Normal User:'))
|
||||
self.stdout.write(self.style.SUCCESS(' Username: normal_user'))
|
||||
self.stdout.write(self.style.SUCCESS(' Password: normal123'))
|
||||
self.stdout.write(self.style.SUCCESS(' Dashboard: /reports/my-reports/'))
|
||||
self.stdout.write(self.style.SUCCESS('\n2. Moderator:'))
|
||||
self.stdout.write(self.style.SUCCESS(' Username: moderator'))
|
||||
self.stdout.write(self.style.SUCCESS(' Password: moderator123'))
|
||||
self.stdout.write(self.style.SUCCESS(' Dashboard: /moderation/dashboard/'))
|
||||
self.stdout.write(self.style.SUCCESS('\n3. Administrator:'))
|
||||
self.stdout.write(self.style.SUCCESS(' Username: admin'))
|
||||
self.stdout.write(self.style.SUCCESS(' Password: admin123'))
|
||||
self.stdout.write(self.style.SUCCESS(' Dashboard: /analytics/dashboard/'))
|
||||
self.stdout.write(self.style.SUCCESS('\n' + '='*60))
|
||||
|
||||
279
accounts/middleware.py
Normal file
279
accounts/middleware.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Security middleware for enhanced protection.
|
||||
"""
|
||||
import time
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponseForbidden, JsonResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib import messages
|
||||
from accounts.models import FailedLoginAttempt, ActivityLog
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
|
||||
class RateLimitMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Rate limiting middleware to prevent brute force attacks.
|
||||
"""
|
||||
def process_request(self, request):
|
||||
# Skip rate limiting for static files and admin
|
||||
if request.path.startswith('/static/') or request.path.startswith('/media/'):
|
||||
return None
|
||||
|
||||
# Get client IP
|
||||
ip = self.get_client_ip(request)
|
||||
|
||||
# Rate limit login attempts
|
||||
if request.path == '/accounts/login/' and request.method == 'POST':
|
||||
cache_key = f'login_attempts_{ip}'
|
||||
attempts = cache.get(cache_key, 0)
|
||||
|
||||
if attempts >= 5: # Max 5 attempts per 15 minutes
|
||||
# Log failed attempt
|
||||
FailedLoginAttempt.objects.create(
|
||||
email_or_username=request.POST.get('username', ''),
|
||||
ip_address=ip,
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', ''),
|
||||
is_blocked=True
|
||||
)
|
||||
return JsonResponse({
|
||||
'error': 'Too many login attempts. Please try again in 15 minutes.'
|
||||
}, status=429)
|
||||
|
||||
# Increment attempts
|
||||
cache.set(cache_key, attempts + 1, 900) # 15 minutes
|
||||
|
||||
# Rate limit registration
|
||||
if request.path == '/accounts/register/' and request.method == 'POST':
|
||||
cache_key = f'register_attempts_{ip}'
|
||||
attempts = cache.get(cache_key, 0)
|
||||
|
||||
if attempts >= 3: # Max 3 registrations per hour
|
||||
return JsonResponse({
|
||||
'error': 'Too many registration attempts. Please try again later.'
|
||||
}, status=429)
|
||||
|
||||
cache.set(cache_key, attempts + 1, 3600) # 1 hour
|
||||
|
||||
# Rate limit report creation
|
||||
if request.path.startswith('/reports/create/') and request.method == 'POST':
|
||||
if request.user.is_authenticated:
|
||||
cache_key = f'report_creation_{request.user.id}'
|
||||
attempts = cache.get(cache_key, 0)
|
||||
|
||||
if attempts >= 10: # Max 10 reports per hour
|
||||
return JsonResponse({
|
||||
'error': 'Too many reports created. Please try again later.'
|
||||
}, status=429)
|
||||
|
||||
cache.set(cache_key, attempts + 1, 3600) # 1 hour
|
||||
|
||||
return None
|
||||
|
||||
def get_client_ip(self, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Add security headers to all responses.
|
||||
"""
|
||||
def process_response(self, request, response):
|
||||
# Content Security Policy
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"img-src 'self' data: https:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self';"
|
||||
)
|
||||
response['Content-Security-Policy'] = csp
|
||||
|
||||
# X-Content-Type-Options
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
|
||||
# X-Frame-Options
|
||||
response['X-Frame-Options'] = 'DENY'
|
||||
|
||||
# Referrer Policy
|
||||
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
|
||||
# Permissions Policy
|
||||
response['Permissions-Policy'] = (
|
||||
'geolocation=(), microphone=(), camera=(), '
|
||||
'payment=(), usb=(), magnetometer=(), gyroscope=()'
|
||||
)
|
||||
|
||||
# X-XSS-Protection (legacy but still useful)
|
||||
response['X-XSS-Protection'] = '1; mode=block'
|
||||
|
||||
# Remove server header
|
||||
if 'Server' in response:
|
||||
del response['Server']
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class IPWhitelistMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
IP whitelist/blacklist middleware (optional, for admin access).
|
||||
"""
|
||||
def process_request(self, request):
|
||||
# Only apply to admin area
|
||||
if not request.path.startswith('/admin/'):
|
||||
return None
|
||||
|
||||
ip = self.get_client_ip(request)
|
||||
|
||||
# Get blacklisted IPs from cache or database
|
||||
blacklisted = cache.get(f'blacklisted_ip_{ip}', False)
|
||||
|
||||
if blacklisted:
|
||||
return HttpResponseForbidden('Access denied.')
|
||||
|
||||
return None
|
||||
|
||||
def get_client_ip(self, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class SecurityLoggingMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Log security-related events.
|
||||
"""
|
||||
def process_response(self, request, response):
|
||||
# Log suspicious activities
|
||||
if response.status_code == 403:
|
||||
self.log_security_event(request, 'FORBIDDEN_ACCESS', {
|
||||
'path': request.path,
|
||||
'method': request.method,
|
||||
'status': 403
|
||||
})
|
||||
|
||||
if response.status_code == 429:
|
||||
self.log_security_event(request, 'RATE_LIMIT_EXCEEDED', {
|
||||
'path': request.path,
|
||||
'method': request.method,
|
||||
})
|
||||
|
||||
# Log failed login attempts
|
||||
if request.path == '/accounts/login/' and request.method == 'POST':
|
||||
if response.status_code != 200 or (hasattr(response, 'content') and b'error' in response.content):
|
||||
self.log_security_event(request, 'FAILED_LOGIN', {
|
||||
'username': request.POST.get('username', ''),
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
def log_security_event(self, request, event_type, details):
|
||||
"""Log security event to ActivityLog."""
|
||||
try:
|
||||
ip = self.get_client_ip(request)
|
||||
# Convert non-JSON-serializable objects to strings
|
||||
serializable_details = self.make_json_serializable({
|
||||
'event_type': event_type,
|
||||
**details
|
||||
})
|
||||
ActivityLog.objects.create(
|
||||
user=request.user if hasattr(request, 'user') and request.user.is_authenticated else None,
|
||||
action='security_event',
|
||||
ip_address=ip,
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', ''),
|
||||
details=serializable_details
|
||||
)
|
||||
except Exception:
|
||||
pass # Don't break the request if logging fails
|
||||
|
||||
def make_json_serializable(self, obj):
|
||||
"""Convert non-JSON-serializable objects to strings."""
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {k: self.make_json_serializable(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
return [self.make_json_serializable(item) for item in obj]
|
||||
elif isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
elif isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
elif hasattr(obj, '__dict__'):
|
||||
return str(obj)
|
||||
else:
|
||||
try:
|
||||
json.dumps(obj) # Test if it's JSON serializable
|
||||
return obj
|
||||
except (TypeError, ValueError):
|
||||
return str(obj)
|
||||
|
||||
def get_client_ip(self, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class SessionSecurityMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Enhanced session security.
|
||||
"""
|
||||
def process_request(self, request):
|
||||
# Check if user attribute exists (AuthenticationMiddleware must run first)
|
||||
if hasattr(request, 'user') and request.user.is_authenticated:
|
||||
# Check for session hijacking
|
||||
current_ip = self.get_client_ip(request)
|
||||
session_ip = request.session.get('ip_address')
|
||||
|
||||
if session_ip and session_ip != current_ip:
|
||||
# IP changed - potential session hijacking
|
||||
logout(request)
|
||||
messages.error(request, 'Security alert: Session terminated due to IP change.')
|
||||
return None
|
||||
|
||||
# Store IP in session
|
||||
request.session['ip_address'] = current_ip
|
||||
|
||||
# Check session age
|
||||
session_age = request.session.get('created_at')
|
||||
if session_age:
|
||||
# Convert string back to datetime if needed
|
||||
if isinstance(session_age, str):
|
||||
from django.utils.dateparse import parse_datetime
|
||||
session_age = parse_datetime(session_age)
|
||||
if isinstance(session_age, datetime):
|
||||
age = timezone.now() - session_age
|
||||
if age > timedelta(hours=24): # Max 24 hours
|
||||
logout(request)
|
||||
messages.info(request, 'Your session has expired. Please log in again.')
|
||||
return None
|
||||
else:
|
||||
request.session['created_at'] = timezone.now().isoformat()
|
||||
|
||||
return None
|
||||
|
||||
def get_client_ip(self, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
114
accounts/migrations/0001_initial.py
Normal file
114
accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-26 13:41
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('role', models.CharField(choices=[('normal', 'Normal User'), ('moderator', 'Moderator'), ('admin', 'Administrator')], default='normal', help_text='User role in the system', max_length=20)),
|
||||
('is_verified', models.BooleanField(default=False, help_text='Email verification status')),
|
||||
('mfa_enabled', models.BooleanField(default=False, help_text='Multi-factor authentication enabled')),
|
||||
('mfa_secret', models.CharField(blank=True, help_text='MFA secret key', max_length=32, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_login_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User',
|
||||
'verbose_name_plural': 'Users',
|
||||
'db_table': 'users_user',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FailedLoginAttempt',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email_or_username', models.CharField(max_length=255)),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('is_blocked', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Failed Login Attempt',
|
||||
'verbose_name_plural': 'Failed Login Attempts',
|
||||
'db_table': 'security_failedlogin',
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['email_or_username', 'timestamp'], name='security_fa_email_o_e830a6_idx'), models.Index(fields=['ip_address', 'timestamp'], name='security_fa_ip_addr_d5cb75_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('first_name', models.CharField(blank=True, max_length=100)),
|
||||
('last_name', models.CharField(blank=True, max_length=100)),
|
||||
('phone', models.CharField(blank=True, help_text='Encrypted phone number', max_length=17, null=True, validators=[django.core.validators.RegexValidator(message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.", regex='^\\+?1?\\d{9,15}$')])),
|
||||
('date_of_birth', models.DateField(blank=True, null=True)),
|
||||
('consent_given', models.BooleanField(default=False)),
|
||||
('consent_date', models.DateTimeField(blank=True, null=True)),
|
||||
('consent_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('preferred_language', models.CharField(choices=[('bg', 'Bulgarian'), ('en', 'English')], default='bg', max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Profile',
|
||||
'verbose_name_plural': 'User Profiles',
|
||||
'db_table': 'users_userprofile',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ActivityLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('login', 'Login'), ('logout', 'Logout'), ('register', 'Registration'), ('password_change', 'Password Change'), ('profile_update', 'Profile Update'), ('report_create', 'Report Created'), ('report_edit', 'Report Edited'), ('report_delete', 'Report Deleted')], max_length=50)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('details', models.JSONField(blank=True, default=dict)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_logs', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Activity Log',
|
||||
'verbose_name_plural': 'Activity Logs',
|
||||
'db_table': 'users_activitylog',
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['user', 'timestamp'], name='users_activ_user_id_049bc2_idx'), models.Index(fields=['action', 'timestamp'], name='users_activ_action_cdfe71_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
accounts/migrations/0002_alter_activitylog_action.py
Normal file
18
accounts/migrations/0002_alter_activitylog_action.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-26 14:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activitylog',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('login', 'Login'), ('logout', 'Logout'), ('register', 'Registration'), ('password_change', 'Password Change'), ('profile_update', 'Profile Update'), ('report_create', 'Report Created'), ('report_edit', 'Report Edited'), ('report_delete', 'Report Deleted'), ('security_event', 'Security Event'), ('failed_login', 'Failed Login'), ('suspicious_activity', 'Suspicious Activity')], max_length=50),
|
||||
),
|
||||
]
|
||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
175
accounts/models.py
Normal file
175
accounts/models.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
User management models for the fraud reporting platform.
|
||||
"""
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""
|
||||
Custom user model with role-based access control.
|
||||
"""
|
||||
ROLE_CHOICES = [
|
||||
('normal', 'Normal User'),
|
||||
('moderator', 'Moderator'),
|
||||
('admin', 'Administrator'),
|
||||
]
|
||||
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=ROLE_CHOICES,
|
||||
default='normal',
|
||||
help_text='User role in the system'
|
||||
)
|
||||
is_verified = models.BooleanField(
|
||||
default=False,
|
||||
help_text='Email verification status'
|
||||
)
|
||||
mfa_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text='Multi-factor authentication enabled'
|
||||
)
|
||||
mfa_secret = models.CharField(
|
||||
max_length=32,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='MFA secret key'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_login_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'users_user'
|
||||
verbose_name = 'User'
|
||||
verbose_name_plural = 'Users'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.role})"
|
||||
|
||||
def is_moderator(self):
|
||||
return self.role in ['moderator', 'admin']
|
||||
|
||||
def is_administrator(self):
|
||||
return self.role == 'admin'
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
"""
|
||||
Extended user profile information.
|
||||
"""
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='profile'
|
||||
)
|
||||
first_name = models.CharField(max_length=100, blank=True)
|
||||
last_name = models.CharField(max_length=100, blank=True)
|
||||
|
||||
phone_regex = RegexValidator(
|
||||
regex=r'^\+?1?\d{9,15}$',
|
||||
message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed."
|
||||
)
|
||||
phone = models.CharField(
|
||||
validators=[phone_regex],
|
||||
max_length=17,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Encrypted phone number'
|
||||
)
|
||||
date_of_birth = models.DateField(null=True, blank=True)
|
||||
|
||||
# GDPR Consent
|
||||
consent_given = models.BooleanField(default=False)
|
||||
consent_date = models.DateTimeField(null=True, blank=True)
|
||||
consent_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
# Preferences
|
||||
preferred_language = models.CharField(
|
||||
max_length=10,
|
||||
default='bg',
|
||||
choices=[('bg', 'Bulgarian'), ('en', 'English')]
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'users_userprofile'
|
||||
verbose_name = 'User Profile'
|
||||
verbose_name_plural = 'User Profiles'
|
||||
|
||||
def __str__(self):
|
||||
return f"Profile of {self.user.username}"
|
||||
|
||||
|
||||
class ActivityLog(models.Model):
|
||||
"""
|
||||
Log user activities for security and auditing.
|
||||
"""
|
||||
ACTION_CHOICES = [
|
||||
('login', 'Login'),
|
||||
('logout', 'Logout'),
|
||||
('register', 'Registration'),
|
||||
('password_change', 'Password Change'),
|
||||
('profile_update', 'Profile Update'),
|
||||
('report_create', 'Report Created'),
|
||||
('report_edit', 'Report Edited'),
|
||||
('report_delete', 'Report Deleted'),
|
||||
('security_event', 'Security Event'),
|
||||
('failed_login', 'Failed Login'),
|
||||
('suspicious_activity', 'Suspicious Activity'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='activity_logs'
|
||||
)
|
||||
action = models.CharField(max_length=50, choices=ACTION_CHOICES)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
details = models.JSONField(default=dict, blank=True)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'users_activitylog'
|
||||
verbose_name = 'Activity Log'
|
||||
verbose_name_plural = 'Activity Logs'
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'timestamp']),
|
||||
models.Index(fields=['action', 'timestamp']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.action} at {self.timestamp}"
|
||||
|
||||
|
||||
class FailedLoginAttempt(models.Model):
|
||||
"""
|
||||
Track failed login attempts for security.
|
||||
"""
|
||||
email_or_username = models.CharField(max_length=255)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(blank=True)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
is_blocked = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
db_table = 'security_failedlogin'
|
||||
verbose_name = 'Failed Login Attempt'
|
||||
verbose_name_plural = 'Failed Login Attempts'
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['email_or_username', 'timestamp']),
|
||||
models.Index(fields=['ip_address', 'timestamp']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Failed login: {self.email_or_username} from {self.ip_address}"
|
||||
134
accounts/security.py
Normal file
134
accounts/security.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Security utilities for encryption and data protection.
|
||||
"""
|
||||
from cryptography.fernet import Fernet
|
||||
from django.conf import settings
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
|
||||
class DataEncryption:
|
||||
"""
|
||||
Encrypt/decrypt sensitive data.
|
||||
"""
|
||||
@staticmethod
|
||||
def get_encryption_key():
|
||||
"""Get or generate encryption key."""
|
||||
key = getattr(settings, 'ENCRYPTION_KEY', None)
|
||||
if not key:
|
||||
# Generate a key (in production, this should be in environment)
|
||||
key = Fernet.generate_key()
|
||||
elif isinstance(key, str):
|
||||
key = key.encode()
|
||||
return key
|
||||
|
||||
@staticmethod
|
||||
def encrypt(data):
|
||||
"""Encrypt sensitive data."""
|
||||
if not data:
|
||||
return data
|
||||
try:
|
||||
key = DataEncryption.get_encryption_key()
|
||||
f = Fernet(key)
|
||||
encrypted = f.encrypt(data.encode() if isinstance(data, str) else data)
|
||||
return base64.urlsafe_b64encode(encrypted).decode()
|
||||
except Exception:
|
||||
return data # Return original if encryption fails
|
||||
|
||||
@staticmethod
|
||||
def decrypt(encrypted_data):
|
||||
"""Decrypt sensitive data."""
|
||||
if not encrypted_data:
|
||||
return encrypted_data
|
||||
try:
|
||||
key = DataEncryption.get_encryption_key()
|
||||
f = Fernet(key)
|
||||
decoded = base64.urlsafe_b64decode(encrypted_data.encode())
|
||||
decrypted = f.decrypt(decoded)
|
||||
return decrypted.decode()
|
||||
except Exception:
|
||||
return encrypted_data # Return original if decryption fails
|
||||
|
||||
|
||||
class InputSanitizer:
|
||||
"""
|
||||
Sanitize user input to prevent XSS and injection attacks.
|
||||
"""
|
||||
@staticmethod
|
||||
def sanitize_html(text):
|
||||
"""Remove potentially dangerous HTML."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
import html
|
||||
# Escape HTML entities
|
||||
text = html.escape(text)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def sanitize_sql(text):
|
||||
"""Basic SQL injection prevention (Django ORM handles this, but extra check)."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Remove SQL keywords
|
||||
dangerous = ['DROP', 'DELETE', 'INSERT', 'UPDATE', 'SELECT', 'UNION', '--', ';']
|
||||
text_upper = text.upper()
|
||||
for keyword in dangerous:
|
||||
if keyword in text_upper:
|
||||
# Log potential SQL injection attempt
|
||||
return None
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def validate_email(email):
|
||||
"""Validate email format."""
|
||||
import re
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return bool(re.match(pattern, email))
|
||||
|
||||
@staticmethod
|
||||
def validate_url(url):
|
||||
"""Validate URL format."""
|
||||
import re
|
||||
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
|
||||
return bool(re.match(pattern, url))
|
||||
|
||||
|
||||
class PasswordSecurity:
|
||||
"""
|
||||
Enhanced password security utilities.
|
||||
"""
|
||||
@staticmethod
|
||||
def check_password_strength(password):
|
||||
"""Check password strength."""
|
||||
if len(password) < 12:
|
||||
return False, "Password must be at least 12 characters long"
|
||||
|
||||
if not any(c.isupper() for c in password):
|
||||
return False, "Password must contain at least one uppercase letter"
|
||||
|
||||
if not any(c.islower() for c in password):
|
||||
return False, "Password must contain at least one lowercase letter"
|
||||
|
||||
if not any(c.isdigit() for c in password):
|
||||
return False, "Password must contain at least one number"
|
||||
|
||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
|
||||
return False, "Password must contain at least one special character"
|
||||
|
||||
# Check for common patterns
|
||||
common_patterns = ['123456', 'password', 'qwerty', 'abc123']
|
||||
password_lower = password.lower()
|
||||
for pattern in common_patterns:
|
||||
if pattern in password_lower:
|
||||
return False, "Password contains common patterns"
|
||||
|
||||
return True, "Password is strong"
|
||||
|
||||
@staticmethod
|
||||
def hash_sensitive_data(data):
|
||||
"""Hash sensitive data for storage."""
|
||||
return hashlib.sha256(data.encode()).hexdigest()
|
||||
|
||||
3
accounts/tests.py
Normal file
3
accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
33
accounts/urls.py
Normal file
33
accounts/urls.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
URL configuration for accounts app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from . import views
|
||||
|
||||
app_name = 'accounts'
|
||||
|
||||
urlpatterns = [
|
||||
# Authentication
|
||||
path('login/', views.LoginView.as_view(), name='login'),
|
||||
path('logout/', views.LogoutView.as_view(), name='logout'),
|
||||
path('register/', views.RegisterView.as_view(), name='register'),
|
||||
|
||||
# MFA
|
||||
path('mfa/verify/', views.MFAVerifyView.as_view(), name='mfa_verify'),
|
||||
path('mfa/setup/', views.MFASetupView.as_view(), name='mfa_setup'),
|
||||
path('mfa/enable/', views.MFAEnableView.as_view(), name='mfa_enable'),
|
||||
path('mfa/disable/', views.MFADisableView.as_view(), name='mfa_disable'),
|
||||
|
||||
# Profile
|
||||
path('profile/', views.ProfileView.as_view(), name='profile'),
|
||||
path('profile/edit/', views.ProfileEditView.as_view(), name='profile_edit'),
|
||||
|
||||
# Password management
|
||||
path('password/change/', views.PasswordChangeView.as_view(), name='password_change'),
|
||||
path('password/reset/', views.PasswordResetView.as_view(), name='password_reset'),
|
||||
path('password/reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
|
||||
path('password/reset/confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||
path('password/reset/complete/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
|
||||
]
|
||||
|
||||
433
accounts/views.py
Normal file
433
accounts/views.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
Views for accounts app.
|
||||
"""
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib.auth import login, logout
|
||||
from django.contrib.auth.views import LoginView, PasswordChangeView, PasswordResetView, PasswordResetConfirmView, PasswordResetDoneView
|
||||
from django.views.generic import CreateView, UpdateView, DetailView, TemplateView, FormView, View
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.contrib.messages import success, error
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.decorators import method_decorator
|
||||
from django_otp import devices_for_user
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from django_otp.decorators import otp_required
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.http import JsonResponse
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from .models import User, UserProfile, ActivityLog, FailedLoginAttempt
|
||||
from .forms import UserRegistrationForm, UserProfileForm, MFAVerifyForm, MFASetupForm
|
||||
from .security import InputSanitizer, PasswordSecurity
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class RegisterView(SuccessMessageMixin, CreateView):
|
||||
"""User registration view with security checks."""
|
||||
model = User
|
||||
form_class = UserRegistrationForm
|
||||
template_name = 'accounts/register.html'
|
||||
success_url = reverse_lazy('accounts:profile')
|
||||
success_message = "Registration successful! Please verify your email."
|
||||
|
||||
def get_form_kwargs(self):
|
||||
"""Pass request to form for rate limiting."""
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['request'] = self.request
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
# Sanitize input
|
||||
if form.cleaned_data.get('email'):
|
||||
form.cleaned_data['email'] = InputSanitizer.sanitize_html(form.cleaned_data['email'])
|
||||
|
||||
# Check password strength
|
||||
password = form.cleaned_data.get('password1')
|
||||
is_strong, message = PasswordSecurity.check_password_strength(password)
|
||||
if not is_strong:
|
||||
form.add_error('password1', message)
|
||||
return self.form_invalid(form)
|
||||
|
||||
response = super().form_valid(form)
|
||||
login(self.request, self.object)
|
||||
|
||||
# Log activity
|
||||
ActivityLog.objects.create(
|
||||
user=self.object,
|
||||
action='register',
|
||||
ip_address=self.get_client_ip(),
|
||||
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
return response
|
||||
|
||||
def get_client_ip(self):
|
||||
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = self.request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
def csrf_failure(request, reason=""):
|
||||
"""Custom CSRF failure view."""
|
||||
from django.contrib import messages
|
||||
messages.error(request, 'Security error: Invalid request. Please try again.')
|
||||
return redirect('accounts:login')
|
||||
|
||||
|
||||
class LoginView(SuccessMessageMixin, LoginView):
|
||||
"""Custom login view with MFA support and security checks."""
|
||||
template_name = 'accounts/login.html'
|
||||
redirect_authenticated_user = True
|
||||
success_message = "Welcome back!"
|
||||
|
||||
def form_valid(self, form):
|
||||
# Check for account lockout
|
||||
username = form.cleaned_data.get('username')
|
||||
ip = self.get_client_ip()
|
||||
|
||||
# Check if IP is blocked
|
||||
if self.is_ip_blocked(ip):
|
||||
error(self.request, 'Too many failed login attempts. Please try again later.')
|
||||
return redirect('accounts:login')
|
||||
|
||||
# Check if user account is locked
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
if self.is_account_locked(user):
|
||||
error(self.request, 'Account temporarily locked due to too many failed attempts.')
|
||||
return redirect('accounts:login')
|
||||
except User.DoesNotExist:
|
||||
pass # Don't reveal if user exists
|
||||
|
||||
# Authenticate user first
|
||||
response = super().form_valid(form)
|
||||
user = self.request.user
|
||||
|
||||
# Clear failed login attempts on successful login
|
||||
FailedLoginAttempt.objects.filter(
|
||||
email_or_username=username,
|
||||
ip_address=ip
|
||||
).delete()
|
||||
cache.delete(f'login_attempts_{ip}')
|
||||
|
||||
# Check if MFA is enabled
|
||||
if user.mfa_enabled:
|
||||
# Store user ID in session for MFA verification
|
||||
self.request.session['mfa_user_id'] = user.id
|
||||
# Don't log them in yet - redirect to MFA verification
|
||||
logout(self.request)
|
||||
return redirect('accounts:mfa_verify')
|
||||
|
||||
# Log activity for non-MFA users
|
||||
ActivityLog.objects.create(
|
||||
user=user,
|
||||
action='login',
|
||||
ip_address=ip,
|
||||
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
return response
|
||||
|
||||
def form_invalid(self, form):
|
||||
# Log failed login attempt
|
||||
username = form.data.get('username', '')
|
||||
ip = self.get_client_ip()
|
||||
|
||||
FailedLoginAttempt.objects.create(
|
||||
email_or_username=username,
|
||||
ip_address=ip,
|
||||
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
|
||||
# Check if should block IP
|
||||
failed_attempts = FailedLoginAttempt.objects.filter(
|
||||
ip_address=ip,
|
||||
timestamp__gte=timezone.now() - timedelta(minutes=15)
|
||||
).count()
|
||||
|
||||
if failed_attempts >= 10:
|
||||
cache.set(f'blocked_ip_{ip}', True, 3600) # Block for 1 hour
|
||||
|
||||
return super().form_invalid(form)
|
||||
|
||||
def is_ip_blocked(self, ip):
|
||||
"""Check if IP is blocked."""
|
||||
return cache.get(f'blocked_ip_{ip}', False)
|
||||
|
||||
def is_account_locked(self, user):
|
||||
"""Check if user account is locked."""
|
||||
failed_attempts = FailedLoginAttempt.objects.filter(
|
||||
email_or_username=user.username,
|
||||
timestamp__gte=timezone.now() - timedelta(minutes=30)
|
||||
).count()
|
||||
return failed_attempts >= 5
|
||||
|
||||
def get_client_ip(self):
|
||||
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = self.request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class LogoutView(View):
|
||||
"""Custom logout view that accepts GET requests."""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if request.user.is_authenticated:
|
||||
# Log activity
|
||||
ActivityLog.objects.create(
|
||||
user=request.user,
|
||||
action='logout',
|
||||
ip_address=self.get_client_ip(request),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
logout(request)
|
||||
success(request, "You have been logged out successfully.")
|
||||
return redirect('reports:home')
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_client_ip(self, request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class ProfileView(DetailView):
|
||||
"""User profile view."""
|
||||
model = User
|
||||
template_name = 'accounts/profile.html'
|
||||
context_object_name = 'user_obj'
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class ProfileEditView(SuccessMessageMixin, UpdateView):
|
||||
"""Edit user profile."""
|
||||
model = UserProfile
|
||||
form_class = UserProfileForm
|
||||
template_name = 'accounts/profile_edit.html'
|
||||
success_url = reverse_lazy('accounts:profile')
|
||||
success_message = "Profile updated successfully!"
|
||||
|
||||
def get_object(self):
|
||||
profile, created = UserProfile.objects.get_or_create(user=self.request.user)
|
||||
return profile
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
# Log activity
|
||||
ActivityLog.objects.create(
|
||||
user=self.request.user,
|
||||
action='profile_update',
|
||||
ip_address=self.get_client_ip(),
|
||||
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
return response
|
||||
|
||||
def get_client_ip(self):
|
||||
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = self.request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class PasswordChangeView(SuccessMessageMixin, PasswordChangeView):
|
||||
"""Change password view."""
|
||||
template_name = 'accounts/password_change.html'
|
||||
success_url = reverse_lazy('accounts:profile')
|
||||
success_message = "Password changed successfully!"
|
||||
|
||||
|
||||
class PasswordResetView(SuccessMessageMixin, PasswordResetView):
|
||||
"""Password reset view."""
|
||||
template_name = 'accounts/password_reset.html'
|
||||
email_template_name = 'accounts/password_reset_email.html'
|
||||
subject_template_name = 'accounts/password_reset_email_subject.txt'
|
||||
success_url = reverse_lazy('accounts:password_reset_done')
|
||||
success_message = "Password reset email sent!"
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Override to use SiteSettings for from_email."""
|
||||
try:
|
||||
from reports.models import SiteSettings
|
||||
site_settings = SiteSettings.get_settings()
|
||||
self.from_email = site_settings.default_from_email
|
||||
except:
|
||||
pass # Fallback to default
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_email_context_data(self, **kwargs):
|
||||
"""Override to ensure site_name is set for email template."""
|
||||
context = super().get_email_context_data(**kwargs)
|
||||
# Ensure site_name is set (Django uses sites framework, but provide fallback)
|
||||
if 'site_name' not in context or not context['site_name']:
|
||||
context['site_name'] = 'Портал за Докладване на Измами'
|
||||
return context
|
||||
|
||||
|
||||
class PasswordResetDoneView(TemplateView):
|
||||
"""Password reset done view - shows confirmation that email was sent."""
|
||||
template_name = 'accounts/password_reset_done.html'
|
||||
|
||||
|
||||
class PasswordResetConfirmView(SuccessMessageMixin, PasswordResetConfirmView):
|
||||
"""Password reset confirmation view."""
|
||||
template_name = 'accounts/password_reset_confirm.html'
|
||||
success_url = reverse_lazy('accounts:password_reset_complete')
|
||||
success_message = "Password reset successful!"
|
||||
|
||||
|
||||
class MFAVerifyView(FormView):
|
||||
"""MFA verification view after login."""
|
||||
template_name = 'accounts/mfa_verify.html'
|
||||
form_class = MFAVerifyForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
# Check if user ID is in session
|
||||
if 'mfa_user_id' not in request.session:
|
||||
error(request, 'Please log in first.')
|
||||
return redirect('accounts:login')
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
user_id = self.request.session.get('mfa_user_id')
|
||||
if user_id:
|
||||
kwargs['user'] = get_object_or_404(User, id=user_id)
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
user_id = self.request.session.get('mfa_user_id')
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
|
||||
# Verify the token
|
||||
if form.verify_token():
|
||||
# Login the user
|
||||
login(self.request, user)
|
||||
# Clear the session
|
||||
del self.request.session['mfa_user_id']
|
||||
# Log activity
|
||||
ActivityLog.objects.create(
|
||||
user=user,
|
||||
action='login',
|
||||
ip_address=self.get_client_ip(),
|
||||
user_agent=self.request.META.get('HTTP_USER_AGENT', '')
|
||||
)
|
||||
success(self.request, 'Login successful!')
|
||||
return redirect('accounts:profile')
|
||||
else:
|
||||
error(self.request, 'Invalid verification code. Please try again.')
|
||||
return self.form_invalid(form)
|
||||
|
||||
def get_client_ip(self):
|
||||
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = self.request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class MFASetupView(TemplateView):
|
||||
"""MFA setup view."""
|
||||
template_name = 'accounts/mfa_setup.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
|
||||
# Delete any existing unconfirmed devices
|
||||
TOTPDevice.objects.filter(user=user, confirmed=False).delete()
|
||||
|
||||
# Create new TOTP device
|
||||
device = TOTPDevice.objects.create(
|
||||
user=user,
|
||||
name='default',
|
||||
confirmed=False
|
||||
)
|
||||
|
||||
# Generate QR code
|
||||
config_url = device.config_url
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(config_url)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
context['qr_code'] = f'data:image/png;base64,{img_str}'
|
||||
context['secret_key'] = device.key
|
||||
context['device'] = device
|
||||
context['mfa_enabled'] = user.mfa_enabled
|
||||
return context
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class MFAEnableView(FormView):
|
||||
"""Enable MFA after verification."""
|
||||
template_name = 'accounts/mfa_enable.html'
|
||||
form_class = MFAVerifyForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['user'] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
user = self.request.user
|
||||
|
||||
# Verify the token
|
||||
if form.verify_token():
|
||||
# Enable MFA
|
||||
user.mfa_enabled = True
|
||||
user.save()
|
||||
|
||||
# Confirm the device
|
||||
device = TOTPDevice.objects.get(user=user, name='default')
|
||||
device.confirmed = True
|
||||
device.save()
|
||||
|
||||
success(self.request, 'MFA has been enabled successfully!')
|
||||
return redirect('accounts:profile')
|
||||
else:
|
||||
error(self.request, 'Invalid verification code. Please try again.')
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class MFADisableView(TemplateView):
|
||||
"""Disable MFA."""
|
||||
template_name = 'accounts/mfa_disable.html'
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
user.mfa_enabled = False
|
||||
user.mfa_secret = ''
|
||||
user.save()
|
||||
|
||||
# Delete TOTP devices
|
||||
TOTPDevice.objects.filter(user=user).delete()
|
||||
|
||||
success(request, 'MFA has been disabled.')
|
||||
return redirect('accounts:profile')
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return super().get(request, *args, **kwargs)
|
||||
Reference in New Issue
Block a user