Initial commit
This commit is contained in:
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
BIN
accounts/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/admin.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/apps.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/forms.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/forms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/models.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/tests.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/tests.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/urls.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/__pycache__/views.cpython-312.pyc
Normal file
BIN
accounts/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
17
accounts/admin.py
Normal file
17
accounts/admin.py
Normal 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
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'
|
||||
60
accounts/forms.py
Normal file
60
accounts/forms.py
Normal 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'
|
||||
31
accounts/migrations/0001_initial.py
Normal file
31
accounts/migrations/0001_initial.py
Normal 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': '用户资料',
|
||||
},
|
||||
),
|
||||
]
|
||||
31
accounts/migrations/0002_loginrecord.py
Normal file
31
accounts/migrations/0002_loginrecord.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
35
accounts/migrations/0003_loginthrottle.py
Normal file
35
accounts/migrations/0003_loginthrottle.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
BIN
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
BIN
accounts/migrations/__pycache__/0002_loginrecord.cpython-312.pyc
Normal file
BIN
accounts/migrations/__pycache__/0002_loginrecord.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
accounts/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
accounts/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
80
accounts/models.py
Normal file
80
accounts/models.py
Normal 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
3
accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
accounts/urls.py
Normal file
14
accounts/urls.py
Normal 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
226
accounts/views.py
Normal 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('需至少包含一个特殊字符(如 !@#¥% 等)')
|
||||
Reference in New Issue
Block a user