Initial commit

This commit is contained in:
2025-11-18 03:36:49 +08:00
commit d17c7efb3c
7078 changed files with 831480 additions and 0 deletions

0
accounts/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

17
accounts/admin.py Normal file
View File

@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import UserProfile, LoginRecord
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'display_name', 'contact_phone', 'default_free_traffic_gb_per_domain_override')
search_fields = ('user__username', 'display_name', 'contact_phone')
# Register your models here.
@admin.register(LoginRecord)
class LoginRecordAdmin(admin.ModelAdmin):
list_display = ('user', 'ip_address', 'created_at')
search_fields = ('user__username', 'ip_address')
list_filter = ('created_at',)

6
accounts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

60
accounts/forms.py Normal file
View File

@@ -0,0 +1,60 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import PasswordChangeForm
User = get_user_model()
class LoginForm(forms.Form):
login = forms.CharField(label='用户名或邮箱', max_length=150, widget=forms.TextInput(attrs={'class': 'form-control'}))
password = forms.CharField(label='密码', widget=forms.PasswordInput(attrs={'class': 'form-control'}))
captcha = forms.CharField(label='验证码', required=False, widget=forms.TextInput(attrs={'class': 'form-control'}))
def __init__(self, *args, **kwargs):
self.enable_captcha = kwargs.pop('enable_captcha', False)
super().__init__(*args, **kwargs)
if not self.enable_captcha:
# 隐藏或移除验证码字段
self.fields['captcha'].widget = forms.HiddenInput()
class RegisterForm(forms.Form):
username = forms.CharField(label='用户名', max_length=150, widget=forms.TextInput(attrs={'class': 'form-control'}))
email = forms.EmailField(label='邮箱', widget=forms.EmailInput(attrs={'class': 'form-control'}))
password1 = forms.CharField(label='密码', widget=forms.PasswordInput(attrs={'class': 'form-control'}))
password2 = forms.CharField(label='确认密码', widget=forms.PasswordInput(attrs={'class': 'form-control'}))
display_name = forms.CharField(label='显示名称', required=False, max_length=100, widget=forms.TextInput(attrs={'class': 'form-control'}))
contact_phone = forms.CharField(label='联系电话', required=False, max_length=30, widget=forms.TextInput(attrs={'class': 'form-control'}))
captcha = forms.CharField(label='验证码', required=False, widget=forms.TextInput(attrs={'class': 'form-control'}))
def __init__(self, *args, **kwargs):
self.enable_captcha = kwargs.pop('enable_captcha', False)
super().__init__(*args, **kwargs)
if not self.enable_captcha:
self.fields['captcha'].widget = forms.HiddenInput()
def clean(self):
cleaned = super().clean()
if cleaned.get('password1') != cleaned.get('password2'):
raise forms.ValidationError('两次密码不一致')
username = cleaned.get('username')
email = cleaned.get('email')
if username and User.objects.filter(username=username).exists():
raise forms.ValidationError('该用户名已存在')
if email and User.objects.filter(email=email).exists():
raise forms.ValidationError('该邮箱已注册')
return cleaned
class ProfileForm(forms.Form):
display_name = forms.CharField(label='显示名称', required=False, max_length=100, widget=forms.TextInput(attrs={'class': 'form-control'}))
contact_phone = forms.CharField(label='联系电话', required=False, max_length=30, widget=forms.TextInput(attrs={'class': 'form-control'}))
class SimplePasswordChangeForm(PasswordChangeForm):
# 仅用于注入 bootstrap 样式
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = 'form-control'

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.8 on 2025-11-07 07:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(blank=True, default='', max_length=100)),
('contact_phone', models.CharField(blank=True, default='', max_length=30)),
('default_free_traffic_gb_per_domain_override', models.PositiveIntegerField(blank=True, null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '用户资料',
'verbose_name_plural': '用户资料',
},
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.8 on 2025-11-09 08:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='LoginRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, default='', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_records', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '登录历史',
'verbose_name_plural': '登录历史',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.8 on 2025-11-17 12:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_loginrecord'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='LoginThrottle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('fail_count', models.PositiveIntegerField(default=0)),
('total_fail_count', models.PositiveIntegerField(default=0)),
('last_failed_at', models.DateTimeField(blank=True, null=True)),
('banned_until', models.DateTimeField(blank=True, null=True)),
('note', models.CharField(blank=True, default='', max_length=255)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='login_throttles', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '登录节流',
'verbose_name_plural': '登录节流',
'ordering': ['-last_failed_at'],
'indexes': [models.Index(fields=['ip_address'], name='accounts_lo_ip_addr_8398ca_idx'), models.Index(fields=['user'], name='accounts_lo_user_id_6fc439_idx'), models.Index(fields=['banned_until'], name='accounts_lo_banned__b750cc_idx')],
},
),
]

View File

80
accounts/models.py Normal file
View File

@@ -0,0 +1,80 @@
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
display_name = models.CharField(max_length=100, blank=True, default='')
contact_phone = models.CharField(max_length=30, blank=True, default='')
default_free_traffic_gb_per_domain_override = models.PositiveIntegerField(null=True, blank=True)
class Meta:
verbose_name = '用户资料'
verbose_name_plural = '用户资料'
def __str__(self):
return self.display_name or self.user.get_username()
# Create your models here.
class LoginRecord(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='login_records')
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=255, blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '登录历史'
verbose_name_plural = '登录历史'
ordering = ['-created_at']
def __str__(self):
return f"{self.user.get_username()} @ {self.created_at}"
class LoginThrottle(models.Model):
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name='login_throttles')
ip_address = models.GenericIPAddressField(null=True, blank=True)
fail_count = models.PositiveIntegerField(default=0)
total_fail_count = models.PositiveIntegerField(default=0)
last_failed_at = models.DateTimeField(null=True, blank=True)
banned_until = models.DateTimeField(null=True, blank=True)
note = models.CharField(max_length=255, blank=True, default='')
class Meta:
verbose_name = '登录节流'
verbose_name_plural = '登录节流'
ordering = ['-last_failed_at']
indexes = [
models.Index(fields=['ip_address']),
models.Index(fields=['user']),
models.Index(fields=['banned_until']),
]
def is_banned(self):
from django.utils import timezone
return bool(self.banned_until and self.banned_until > timezone.now())
def register_failure(self, ban_threshold=5, ban_minutes=15):
from django.utils import timezone
from datetime import timedelta
now = timezone.now()
self.fail_count += 1
self.total_fail_count += 1
self.last_failed_at = now
if self.fail_count >= ban_threshold:
self.banned_until = now + timedelta(minutes=int(ban_minutes))
self.note = f'自动封禁 {int(ban_minutes)} 分钟(超过失败阈值 {int(ban_threshold)}'
self.fail_count = 0
self.save(update_fields=['fail_count', 'total_fail_count', 'last_failed_at', 'banned_until', 'note'])
def register_success(self):
if self.fail_count or self.banned_until:
self.fail_count = 0
self.banned_until = None
self.note = ''
self.save(update_fields=['fail_count', 'banned_until', 'note'])

3
accounts/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
accounts/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = 'accounts'
urlpatterns = [
path('login/', views.login, name='login'),
path('register/', views.register, name='register'),
path('logout/', views.logout, name='logout'),
path('profile/', views.profile, name='profile'),
path('password-change/', views.password_change, name='password_change'),
path('login-history/', views.login_history, name='login_history'),
]

226
accounts/views.py Normal file
View File

@@ -0,0 +1,226 @@
from django.shortcuts import render, redirect
import logging
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.db import transaction
from django.db.models import Q
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.views.decorators.http import require_POST
from django.core.exceptions import ValidationError
import re
from core.models import SystemSettings
from .models import UserProfile, LoginRecord
from .forms import LoginForm, RegisterForm, ProfileForm, SimplePasswordChangeForm
User = get_user_model()
def _get_settings():
settings_obj = SystemSettings.objects.order_by('id').first()
if not settings_obj:
settings_obj = SystemSettings.objects.create()
return settings_obj
def _gen_captcha(request):
import random
a, b = random.randint(1, 9), random.randint(1, 9)
request.session['captcha_answer'] = str(a + b)
return f"{a} + {b} = ?"
def login(request):
settings_obj = _get_settings()
enable_captcha = getattr(settings_obj, 'captcha_enabled', False)
question = None
if request.method == 'POST':
form = LoginForm(request.POST, enable_captcha=enable_captcha)
if form.is_valid():
login_key = form.cleaned_data['login'].strip()
password = form.cleaned_data['password']
if enable_captcha:
cap = form.cleaned_data.get('captcha', '').strip()
if cap != request.session.get('captcha_answer'):
messages.error(request, '验证码错误')
question = _gen_captcha(request)
return render(request, 'accounts/login.html', {'form': form, 'captcha_question': question})
user = None
# 支持用户名或邮箱登录
try:
user = User.objects.filter(Q(username__iexact=login_key) | Q(email__iexact=login_key)).first()
except Exception:
user = None
ip = request.META.get('HTTP_X_FORWARDED_FOR')
if ip:
ip = ip.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR')
from .models import LoginThrottle
BAN_THRESHOLD = 5
BAN_MINUTES = 15
ip_throttle, _ = LoginThrottle.objects.get_or_create(user=None, ip_address=ip)
if ip_throttle.is_banned():
messages.error(request, '登录受限:该 IP 暂时被封禁,请稍后再试')
question = _gen_captcha(request) if enable_captcha else None
return render(request, 'accounts/login.html', {'form': form, 'captcha_question': question})
user_throttle = None
if user:
user_throttle, _ = LoginThrottle.objects.get_or_create(user=user, ip_address=None)
if user_throttle.is_banned():
messages.error(request, '登录受限:该账号暂时被封禁,请稍后再试')
question = _gen_captcha(request) if enable_captcha else None
return render(request, 'accounts/login.html', {'form': form, 'captcha_question': question})
auth_username = user.username if user else login_key
user_auth = authenticate(request, username=auth_username, password=password)
if user_auth is not None:
auth_login(request, user_auth)
# 记录登录历史
try:
ua = request.META.get('HTTP_USER_AGENT', '')[:255]
LoginRecord.objects.create(user=user_auth, ip_address=ip, user_agent=ua)
except Exception:
logger.exception('login record write failed')
try:
ip_throttle.register_success()
if user_throttle:
user_throttle.register_success()
except Exception:
logger.exception('login throttle cleanup failed')
messages.success(request, '登录成功')
next_url = request.GET.get('next') or '/domains/'
return redirect(next_url)
else:
messages.error(request, '登录失败:账号或密码错误')
try:
ip_throttle.register_failure(ban_threshold=BAN_THRESHOLD, ban_minutes=BAN_MINUTES)
if user:
user_throttle.register_failure(ban_threshold=BAN_THRESHOLD, ban_minutes=BAN_MINUTES)
except Exception:
logger.exception('login throttle write failed')
question = _gen_captcha(request) if enable_captcha else None
else:
form = LoginForm(enable_captcha=enable_captcha)
question = _gen_captcha(request) if enable_captcha else None
return render(request, 'accounts/login.html', {'form': form, 'captcha_question': question})
def register(request):
settings_obj = _get_settings()
enable_captcha = getattr(settings_obj, 'captcha_enabled', False)
question = None
if request.method == 'POST':
form = RegisterForm(request.POST, enable_captcha=enable_captcha)
if form.is_valid():
if enable_captcha:
cap = form.cleaned_data.get('captcha', '').strip()
if cap != request.session.get('captcha_answer'):
messages.error(request, '验证码错误')
question = _gen_captcha(request)
return render(request, 'accounts/register.html', {'form': form, 'captcha_question': question})
try:
_validate_password_strength(form.cleaned_data['password1'])
except ValidationError as e:
messages.error(request, f'密码不符合安全要求:{e.messages[0]}')
question = _gen_captcha(request) if enable_captcha else None
return render(request, 'accounts/register.html', {'form': form, 'captcha_question': question})
with transaction.atomic():
user = User.objects.create_user(
username=form.cleaned_data['username'],
email=form.cleaned_data['email'],
password=form.cleaned_data['password1'],
)
UserProfile.objects.create(
user=user,
display_name=form.cleaned_data.get('display_name', ''),
contact_phone=form.cleaned_data.get('contact_phone', ''),
)
messages.success(request, '注册成功,请登录')
return redirect('accounts:login')
else:
form = RegisterForm(enable_captcha=enable_captcha)
question = _gen_captcha(request) if enable_captcha else None
return render(request, 'accounts/register.html', {'form': form, 'captcha_question': question})
@login_required
@require_POST
def logout(request):
auth_logout(request)
messages.success(request, '您已退出登录')
return redirect('/accounts/login/')
@login_required
def profile(request):
profile = getattr(request.user, 'profile', None)
if not profile:
profile = UserProfile.objects.create(user=request.user)
if request.method == 'POST':
form = ProfileForm(request.POST)
if form.is_valid():
profile.display_name = form.cleaned_data.get('display_name', '')
profile.contact_phone = form.cleaned_data.get('contact_phone', '')
profile.save(update_fields=['display_name', 'contact_phone'])
messages.success(request, '资料已更新')
return redirect('accounts:profile')
else:
messages.error(request, '保存失败,请检查表单')
else:
form = ProfileForm(initial={
'display_name': profile.display_name,
'contact_phone': profile.contact_phone,
})
# 最近登录记录
records = LoginRecord.objects.filter(user=request.user).order_by('-created_at')[:20]
return render(request, 'accounts/profile.html', {'form': form, 'records': records})
@login_required
def password_change(request):
if request.method == 'POST':
form = SimplePasswordChangeForm(user=request.user, data=request.POST)
new_password = request.POST.get('new_password1')
try:
_validate_password_strength(new_password)
except ValidationError as e:
messages.error(request, f'密码不符合安全要求:{e.messages[0]}')
return render(request, 'accounts/password_change.html', {'form': form})
if form.is_valid():
form.save()
messages.success(request, '密码已修改,请重新登录')
return redirect('accounts:logout')
else:
messages.error(request, '修改失败,请检查表单')
else:
form = SimplePasswordChangeForm(user=request.user)
return render(request, 'accounts/password_change.html', {'form': form})
@login_required
def login_history(request):
records = LoginRecord.objects.filter(user=request.user).order_by('-created_at')[:50]
return render(request, 'accounts/login_history.html', {'records': records})
logger = logging.getLogger(__name__)
def _validate_password_strength(password: str):
common_weak = {
'password', '123456', '123456789', 'qwerty', 'abc123', '111111', '123123',
'password1', 'admin', 'letmein'
}
if not password or len(password) < 12:
raise ValidationError('密码过短,至少需要 12 位')
if password.lower() in common_weak:
raise ValidationError('密码过于常见,存在安全风险')
if re.fullmatch(r'(.)\1{7,}', password):
raise ValidationError('密码字符重复过多,存在安全风险')
if not re.search(r'[a-z]', password):
raise ValidationError('需至少包含一个小写字母')
if not re.search(r'[A-Z]', password):
raise ValidationError('需至少包含一个大写字母')
if not re.search(r'\d', password):
raise ValidationError('需至少包含一个数字')
if not re.search(r'[^\w\s]', password):
raise ValidationError('需至少包含一个特殊字符(如 !@#¥% 等)')