diff --git a/src/accounts/__init__.py b/src/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/__pycache__/__init__.cpython-312.pyc b/src/accounts/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a8d19b8 Binary files /dev/null and b/src/accounts/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/admin.cpython-312.pyc b/src/accounts/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000..2e9919a Binary files /dev/null and b/src/accounts/__pycache__/admin.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/apps.cpython-312.pyc b/src/accounts/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000..2406f98 Binary files /dev/null and b/src/accounts/__pycache__/apps.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/forms.cpython-312.pyc b/src/accounts/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000..67a1db9 Binary files /dev/null and b/src/accounts/__pycache__/forms.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/models.cpython-312.pyc b/src/accounts/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..72d2328 Binary files /dev/null and b/src/accounts/__pycache__/models.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/urls.cpython-312.pyc b/src/accounts/__pycache__/urls.cpython-312.pyc new file mode 100644 index 0000000..d616a53 Binary files /dev/null and b/src/accounts/__pycache__/urls.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/user_login_backend.cpython-312.pyc b/src/accounts/__pycache__/user_login_backend.cpython-312.pyc new file mode 100644 index 0000000..216a07f Binary files /dev/null and b/src/accounts/__pycache__/user_login_backend.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/utils.cpython-312.pyc b/src/accounts/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..653df06 Binary files /dev/null and b/src/accounts/__pycache__/utils.cpython-312.pyc differ diff --git a/src/accounts/__pycache__/views.cpython-312.pyc b/src/accounts/__pycache__/views.cpython-312.pyc new file mode 100644 index 0000000..f9aa826 Binary files /dev/null and b/src/accounts/__pycache__/views.cpython-312.pyc differ diff --git a/src/accounts/admin.py b/src/accounts/admin.py new file mode 100644 index 0000000..d2ef475 --- /dev/null +++ b/src/accounts/admin.py @@ -0,0 +1,113 @@ +#hyt: + +from django import forms +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.forms import UserChangeForm +from django.contrib.auth.forms import UsernameField +from django.utils.translation import gettext_lazy as _ + +# Register your models here. +from .models import BlogUser + + +class BlogUserCreationForm(forms.ModelForm): + """ + 博客用户创建表单 + + 用于管理员后台创建新用户,提供密码验证和哈希处理功能 + 扩展了 Django 原生用户创建逻辑,支持自定义字段和来源标记 + """ + + password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) + + class Meta: + model = BlogUser + fields = ('email',) # 创建用户时只要求填写邮箱字段 + + def clean_password2(self): + """ + 密码确认验证 + + 检查两次输入的密码是否一致 + 确保用户在创建账户时正确确认密码 + + 返回: + 验证通过的密码 + + 异常: + ValidationError: 当两次密码不匹配时抛出 + """ + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError(_("passwords do not match")) + return password2 + + def save(self, commit=True): + """ + 保存用户信息 + + 重写保存方法,对密码进行哈希处理并标记用户来源 + + 参数: + commit: 是否立即保存到数据库 + + 返回: + 保存后的用户对象 + """ + # 保存提供的密码为哈希格式 + user = super().save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.source = 'adminsite' # 标记用户来源为管理员后台 + user.save() + return user + + +class BlogUserChangeForm(UserChangeForm): + """ + 博客用户信息修改表单 + + 用于管理员后台编辑现有用户信息 + 继承 Django 原生用户修改表单,支持所有字段编辑 + """ + + class Meta: + model = BlogUser + fields = '__all__' # 包含所有模型字段 + field_classes = {'username': UsernameField} # 用户名字段使用特定字段类 + + def __init__(self, *args, **kwargs): + """ + 初始化表单 + + 调用父类初始化方法,可在此处添加自定义初始化逻辑 + """ + super().__init__(*args, **kwargs) + + +class BlogUserAdmin(UserAdmin): + """ + 博客用户管理后台配置 + + 自定义用户模型在 Django Admin 后台的显示和编辑配置 + 扩展了默认的用户管理功能,添加了自定义字段显示 + """ + + form = BlogUserChangeForm # 用户编辑表单 + add_form = BlogUserCreationForm # 用户创建表单 + + # 列表页面显示的字段 + list_display = ( + 'id', + 'nickname', + 'username', + 'email', + 'last_login', + 'date_joined', + 'source' + ) + + list_display_links = ('id', 'username') # 可点击链接的字段 + ordering = ('-id',) # 按 ID 降序排列 \ No newline at end of file diff --git a/src/accounts/apps.py b/src/accounts/apps.py new file mode 100644 index 0000000..2148ee0 --- /dev/null +++ b/src/accounts/apps.py @@ -0,0 +1,20 @@ +#hyt: + +from django.apps import AppConfig + +class AccountsConfig(AppConfig): + """ + 账户管理应用配置类 + + 负责配置 accounts 应用在 Django 项目中的行为 + 包括应用名称、初始化逻辑、信号注册等配置项 + + 功能特性: + - 用户认证和权限管理 + - 用户资料管理 + - 登录注册功能 + - 会话管理 + """ + + # 应用路径标识 - Django 用于识别应用的完整 Python 路径 + name = 'accounts' \ No newline at end of file diff --git a/src/accounts/forms.py b/src/accounts/forms.py new file mode 100644 index 0000000..f68134e --- /dev/null +++ b/src/accounts/forms.py @@ -0,0 +1,210 @@ +#hyt: + +from django import forms +from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.core.exceptions import ValidationError +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from . import utils +from .models import BlogUser + + +class LoginForm(AuthenticationForm): + """ + 用户登录表单 + + 继承 Django 原生认证表单,自定义界面样式和占位符文本 + 提供用户名和密码的登录验证功能 + """ + + def __init__(self, *args, **kwargs): + """ + 初始化表单控件 + + 设置表单字段的样式类和占位符文本,优化用户体验 + """ + super(LoginForm, self).__init__(*args, **kwargs) + # 设置用户名字段的输入框样式 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + # 设置密码字段的输入框样式 + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + """ + 用户注册表单 + + 处理新用户注册流程,包括用户名、邮箱验证和密码确认 + 扩展了 Django 原生用户创建表单,添加邮箱字段和样式定制 + """ + + def __init__(self, *args, **kwargs): + """ + 初始化注册表单控件 + + 为所有表单字段设置统一的样式类和占位符提示 + """ + super(RegisterForm, self).__init__(*args, **kwargs) + + # 设置各字段的输入框样式和占位符 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + self.fields['password2'].widget = widgets.PasswordInput( + attrs={'placeholder': "repeat password", "class": "form-control"}) + + def clean_email(self): + """ + 邮箱唯一性验证 + + 检查邮箱是否已被其他用户注册,确保邮箱地址的唯一性 + + 返回: + 验证通过的邮箱地址 + + 异常: + ValidationError: 当邮箱已存在时抛出 + """ + email = self.cleaned_data['email'] + if get_user_model().objects.filter(email=email).exists(): + raise ValidationError(_("email already exists")) + return email + + class Meta: + """表单元数据配置""" + model = get_user_model() # 使用当前激活的用户模型 + fields = ("username", "email") # 注册时需要填写的字段 + + +class ForgetPasswordForm(forms.Form): + """ + 密码重置表单 + + 处理用户忘记密码时的重置流程,包含邮箱验证、验证码校验和新密码设置 + 通过多步骤验证确保账户安全 + """ + + # 新密码字段 - 第一次输入 + new_password1 = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("New password") + } + ), + ) + + # 新密码字段 - 确认输入 + new_password2 = forms.CharField( + label="确认密码", + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("Confirm password") + } + ), + ) + + # 邮箱字段 - 用于身份验证 + email = forms.EmailField( + label='邮箱', + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Email") + } + ), + ) + + # 验证码字段 - 安全验证 + code = forms.CharField( + label=_('Code'), + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Code") + } + ), + ) + + def clean_new_password2(self): + """ + 新密码确认验证 + + 检查两次输入的新密码是否一致,并验证密码强度 + + 返回: + 验证通过的密码 + + 异常: + ValidationError: 当密码不匹配或强度不足时抛出 + """ + password1 = self.data.get("new_password1") + password2 = self.data.get("new_password2") + # 检查密码是否匹配 + if password1 and password2 and password1 != password2: + raise ValidationError(_("passwords do not match")) + # 验证密码强度 + password_validation.validate_password(password2) + + return password2 + + def clean_email(self): + """ + 邮箱存在性验证 + + 验证输入的邮箱是否在系统中已注册 + + 返回: + 验证通过的邮箱地址 + + 异常: + ValidationError: 当邮箱不存在时抛出 + """ + user_email = self.cleaned_data.get("email") + if not BlogUser.objects.filter(email=user_email).exists(): + # TODO: 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + raise ValidationError(_("email does not exist")) + return user_email + + def clean_code(self): + """ + 验证码校验 + + 验证邮箱和验证码的匹配关系,确保重置请求的合法性 + + 返回: + 验证通过的验证码 + + 异常: + ValidationError: 当验证码无效时抛出 + """ + code = self.cleaned_data.get("code") + error = utils.verify( + email=self.cleaned_data.get("email"), + code=code, + ) + if error: + raise ValidationError(error) + return code + + +class ForgetPasswordCodeForm(forms.Form): + """ + 密码重置验证码请求表单 + + 用于请求发送密码重置验证码,只需提供邮箱地址 + 是密码重置流程的第一步 + """ + + # 邮箱字段 - 用于发送验证码 + email = forms.EmailField( + label=_('Email'), + ) \ No newline at end of file diff --git a/src/accounts/migrations/0001_initial.py b/src/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..e13c533 --- /dev/null +++ b/src/accounts/migrations/0001_initial.py @@ -0,0 +1,104 @@ +#hyt: +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + """ + BlogUser 模型的初始迁移文件 + + 创建自定义用户模型 BlogUser,扩展 Django 内置 User 模型 + 添加了昵称、时间戳和来源字段,支持中文显示和自定义排序 + """ + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='BlogUser', + fields=[ + # 主键字段 - 使用 BigAutoField 作为自增主键 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + + # 认证相关字段 - Django 内置用户认证系统必需字段 + ('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')), + + # 自定义扩展字段 - 博客用户特有字段 + ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), + + # 权限关联字段 - 用户组和权限的多对多关系 + ('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={ + # 模型元选项 - 定义模型在 admin 中的显示和排序 + 'verbose_name': '用户', # 单数显示名称 + 'verbose_name_plural': '用户', # 复数显示名称 + 'ordering': ['-id'], # 按 ID 降序排列 + 'get_latest_by': 'id', # 获取最新记录的依据字段 + }, + managers=[ + # 模型管理器 - 使用 Django 默认的用户管理器 + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] \ No newline at end of file diff --git a/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 0000000..22eb985 --- /dev/null +++ b/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -0,0 +1,81 @@ +#hyt: +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + """ + BlogUser 模型结构调整迁移文件 + + 对 accounts 应用的 BlogUser 模型进行字段优化和国际化改进: + 1. 调整时间字段命名,统一使用英文命名规范 + 2. 更新模型选项,改进 Admin 后台显示 + 3. 字段标签国际化,为多语言支持做准备 + """ + + dependencies = [ + ('accounts', '0001_initial'), # 依赖于初始迁移文件 + ] + + operations = [ + # 模型选项调整 - 更新 Admin 后台显示配置 + migrations.AlterModelOptions( + name='bloguser', + options={ + 'get_latest_by': 'id', # 按 ID 获取最新记录 + 'ordering': ['-id'], # 按 ID 降序排列 + 'verbose_name': 'user', # 单数显示名称(英文) + 'verbose_name_plural': 'user', # 复数显示名称(英文) + }, + ), + + # 字段清理 - 移除旧的时间字段 + migrations.RemoveField( + model_name='bloguser', + name='created_time', # 移除旧的创建时间字段 + ), + migrations.RemoveField( + model_name='bloguser', + name='last_mod_time', # 移除旧的修改时间字段 + ), + + # 字段添加 - 新增标准化时间字段 + migrations.AddField( + model_name='bloguser', + name='creation_time', + field=models.DateTimeField( + default=django.utils.timezone.now, # 默认值为当前时间 + verbose_name='creation time' # 字段显示名称(英文) + ), + ), + migrations.AddField( + model_name='bloguser', + name='last_modify_time', + field=models.DateTimeField( + default=django.utils.timezone.now, # 默认值为当前时间 + verbose_name='last modify time' # 字段显示名称(英文) + ), + ), + + # 字段调整 - 更新字段标签为英文 + migrations.AlterField( + model_name='bloguser', + name='nickname', + field=models.CharField( + blank=True, # 允许为空 + max_length=100, # 最大长度100字符 + verbose_name='nick name' # 字段显示名称(英文) + ), + ), + migrations.AlterField( + model_name='bloguser', + name='source', + field=models.CharField( + blank=True, # 允许为空 + max_length=100, # 最大长度100字符 + verbose_name='create source' # 字段显示名称(英文) + ), + ), + ] \ No newline at end of file diff --git a/src/accounts/migrations/__init__.py b/src/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc b/src/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc new file mode 100644 index 0000000..95a7532 Binary files /dev/null and b/src/accounts/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/src/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-312.pyc b/src/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-312.pyc new file mode 100644 index 0000000..82e80ca Binary files /dev/null and b/src/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-312.pyc differ diff --git a/src/accounts/migrations/__pycache__/__init__.cpython-312.pyc b/src/accounts/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7090758 Binary files /dev/null and b/src/accounts/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/accounts/models.py b/src/accounts/models.py new file mode 100644 index 0000000..33769bd --- /dev/null +++ b/src/accounts/models.py @@ -0,0 +1,81 @@ +#hyt: + +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from djangoblog.utils import get_current_site + + +# Create your models here. + +class BlogUser(AbstractUser): + """ + 博客用户模型 + + 扩展 Django 内置 AbstractUser 模型,添加博客系统特有的用户字段 + 支持用户昵称、时间戳跟踪和来源标识等功能 + """ + + # 昵称字段 - 用户显示名称,可为空 + nickname = models.CharField(_('nick name'), max_length=100, blank=True) + + # 创建时间 - 用户账户创建的时间戳 + creation_time = models.DateTimeField(_('creation time'), default=now) + + # 最后修改时间 - 用户信息最后更新的时间戳 + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + + # 来源标识 - 用户注册来源(如网站、管理员创建等) + source = models.CharField(_('create source'), max_length=100, blank=True) + + def get_absolute_url(self): + """ + 获取用户详情页的绝对URL + + 用于生成用户个人主页的链接,支持反向解析 + + 返回: + 用户详情页的URL路径 + """ + return reverse( + 'blog:author_detail', kwargs={ + 'author_name': self.username}) + + def __str__(self): + """ + 对象字符串表示 + + 在Admin后台和Shell中显示用户的邮箱地址 + + 返回: + 用户的邮箱地址 + """ + return self.email + + def get_full_url(self): + """ + 获取用户的完整URL(包含域名) + + 生成包含协议和域名的完整用户主页链接 + + 返回: + 用户的完整主页URL + """ + site = get_current_site().domain + url = "https://{site}{path}".format(site=site, + path=self.get_absolute_url()) + return url + + class Meta: + """ + 模型元数据配置 + + 定义模型在数据库和Admin后台中的行为 + """ + + ordering = ['-id'] # 默认按ID降序排列 + verbose_name = _('user') # 单数显示名称 + verbose_name_plural = verbose_name # 复数显示名称(与单数相同) + get_latest_by = 'id' # 获取最新记录的依据字段 \ No newline at end of file diff --git a/src/accounts/templatetags/__init__.py b/src/accounts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/templatetags/__pycache__/__init__.cpython-312.pyc b/src/accounts/templatetags/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..3061f4f Binary files /dev/null and b/src/accounts/templatetags/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/accounts/tests.py b/src/accounts/tests.py new file mode 100644 index 0000000..30e64d9 --- /dev/null +++ b/src/accounts/tests.py @@ -0,0 +1,296 @@ +#hyt: + +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from accounts.models import BlogUser +from blog.models import Article, Category +from djangoblog.utils import * +from . import utils + + +# Create your tests here. + +class AccountTest(TestCase): + """ + 账户功能测试类 + + 测试用户认证、注册、密码重置等核心账户功能 + 包含完整的用户生命周期测试用例 + """ + + def setUp(self): + """ + 测试初始化方法 + + 在每个测试方法执行前运行,创建测试所需的客户端和用户数据 + """ + self.client = Client() # Django 测试客户端 + self.factory = RequestFactory() # 请求工厂,用于模拟请求 + # 创建测试用户 + self.blog_user = BlogUser.objects.create_user( + username="test", + email="admin@admin.com", + password="12345678" + ) + self.new_test = "xxx123--=" # 测试用的新密码 + + def test_validate_account(self): + """ + 测试账户验证功能 + + 验证超级用户创建、登录、文章管理权限等核心功能 + 包括管理员权限验证和文章操作测试 + """ + site = get_current_site().domain + # 创建超级用户用于测试管理员功能 + user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="qwer!@#$ggg") + testuser = BlogUser.objects.get(username='liangliangyy1') + + # 测试用户登录功能 + loginresult = self.client.login( + username='liangliangyy1', + password='qwer!@#$ggg') + self.assertEqual(loginresult, True) + + # 测试管理员后台访问权限 + response = self.client.get('/admin/') + self.assertEqual(response.status_code, 200) + + # 创建测试分类 + category = Category() + category.name = "categoryaaa" + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() + category.save() + + # 创建测试文章 + article = Article() + article.title = "nicetitleaaa" + article.body = "nicecontentaaa" + article.author = user + article.category = category + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 + article.save() + + # 测试文章管理页面访问 + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) + + def test_validate_register(self): + """ + 测试用户注册流程 + + 验证用户注册、邮箱验证、权限升级等完整注册流程 + 包括注册后的登录状态和功能权限测试 + """ + # 验证注册前邮箱不存在 + self.assertEquals( + 0, len( + BlogUser.objects.filter( + email='user123@user.com'))) + + # 提交注册表单 + response = self.client.post(reverse('account:register'), { + 'username': 'user1233', + 'email': 'user123@user.com', + 'password1': 'password123!q@wE#R$T', + 'password2': 'password123!q@wE#R$T', + }) + + # 验证注册后用户创建成功 + self.assertEquals( + 1, len( + BlogUser.objects.filter( + email='user123@user.com'))) + user = BlogUser.objects.filter(email='user123@user.com')[0] + + # 生成邮箱验证签名 + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + path = reverse('accounts:result') + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # 测试用户登录功能 + self.client.login(username='user1233', password='password123!q@wE#R$T') + user = BlogUser.objects.filter(email='user123@user.com')[0] + + # 升级用户权限为管理员 + user.is_superuser = True + user.is_staff = True + user.save() + delete_sidebar_cache() # 清理侧边栏缓存 + + # 创建测试数据 + category = Category() + category.name = "categoryaaa" + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() + category.save() + + article = Article() + article.category = category + article.title = "nicetitle333" + article.body = "nicecontentttt" + article.author = user + article.type = 'a' + article.status = 'p' + article.save() + + # 测试管理员功能访问 + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) + + # 测试退出登录 + response = self.client.get(reverse('account:logout')) + self.assertIn(response.status_code, [301, 302, 200]) + + # 验证退出后权限失效 + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + # 测试错误密码登录 + response = self.client.post(reverse('account:login'), { + 'username': 'user1233', + 'password': 'password123' # 错误的密码 + }) + self.assertIn(response.status_code, [301, 302, 200]) + + # 验证登录失败后权限仍受限 + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + def test_verify_email_code(self): + """ + 测试邮箱验证码功能 + + 验证验证码生成、发送、校验的完整流程 + 包括正确和错误验证码的测试用例 + """ + to_email = "admin@admin.com" + code = generate_code() # 生成验证码 + + # 设置并发送验证码 + utils.set_code(to_email, code) + utils.send_verify_email(to_email, code) + + # 测试正确验证码验证 + err = utils.verify("admin@admin.com", code) + self.assertEqual(err, None) + + # 测试错误邮箱验证 + err = utils.verify("admin@123.com", code) + self.assertEqual(type(err), str) # 应返回错误信息 + + def test_forget_password_email_code_success(self): + """ + 测试忘记密码验证码发送成功场景 + + 验证正确邮箱地址的验证码发送功能 + """ + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@admin.com") + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content.decode("utf-8"), "ok") + + def test_forget_password_email_code_fail(self): + """ + 测试忘记密码验证码发送失败场景 + + 验证空邮箱和错误邮箱格式的处理 + """ + # 测试空邮箱提交 + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + # 测试错误邮箱格式 + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@com") + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + def test_forget_password_email_success(self): + """ + 测试密码重置成功场景 + + 验证完整的密码重置流程,包括验证码校验和新密码设置 + """ + code = generate_code() + utils.set_code(self.blog_user.email, code) + + # 准备密码重置数据 + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code=code, + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + self.assertEqual(resp.status_code, 302) # 重定向表示成功 + + # 验证用户密码是否修改成功 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, + ).first() # type: BlogUser + self.assertNotEqual(blog_user, None) + self.assertEqual(blog_user.check_password(data["new_password1"]), True) + + def test_forget_password_email_not_user(self): + """ + 测试不存在的用户密码重置 + + 验证对不存在邮箱地址的密码重置请求处理 + """ + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email="123@123.com", # 不存在的邮箱 + code="123456", + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) # 应返回错误页面 + + def test_forget_password_email_code_error(self): + """ + 测试验证码错误的密码重置 + + 验证错误验证码情况下的密码重置失败处理 + """ + code = generate_code() + utils.set_code(self.blog_user.email, code) + + # 使用错误的验证码 + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code="111111", # 错误的验证码 + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) # 应返回错误页面 \ No newline at end of file diff --git a/src/accounts/urls.py b/src/accounts/urls.py new file mode 100644 index 0000000..d6c0d54 --- /dev/null +++ b/src/accounts/urls.py @@ -0,0 +1,50 @@ +#hyt: + +from django.urls import path +from django.urls import re_path + +from . import views +from .forms import LoginForm + +# 应用命名空间 - 用于URL反向解析时区分不同应用的URL +app_name = "accounts" + +# URL模式配置 - 定义账户管理相关的所有路由规则 +urlpatterns = [ + # 用户登录路由 + # 使用正则表达式匹配 /login/ 路径 + re_path(r'^login/$', + views.LoginView.as_view(success_url='/'), # 登录成功后跳转到首页 + name='login', # URL名称,用于反向解析 + kwargs={'authentication_form': LoginForm}), # 传入自定义登录表单 + + # 用户注册路由 + # 使用正则表达式匹配 /register/ 路径 + re_path(r'^register/$', + views.RegisterView.as_view(success_url="/"), # 注册成功后跳转到首页 + name='register'), # URL名称,用于反向解析 + + # 用户退出登录路由 + # 使用正则表达式匹配 /logout/ 路径 + re_path(r'^logout/$', + views.LogoutView.as_view(), # 使用Django内置的退出视图 + name='logout'), # URL名称,用于反向解析 + + # 账户操作结果页面路由 + # 使用path匹配固定路径 /account/result.html + path(r'account/result.html', + views.account_result, # 函数视图,显示账户操作结果 + name='result'), # URL名称,用于反向解析 + + # 忘记密码页面路由 + # 使用正则表达式匹配 /forget_password/ 路径 + re_path(r'^forget_password/$', + views.ForgetPasswordView.as_view(), # 类视图,处理密码重置 + name='forget_password'), # URL名称,用于反向解析 + + # 忘记密码验证码请求路由 + # 使用正则表达式匹配 /forget_password_code/ 路径 + re_path(r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), # 类视图,发送验证码 + name='forget_password_code'), # URL名称,用于反向解析 +] \ No newline at end of file diff --git a/src/accounts/user_login_backend.py b/src/accounts/user_login_backend.py new file mode 100644 index 0000000..c851bd0 --- /dev/null +++ b/src/accounts/user_login_backend.py @@ -0,0 +1,67 @@ +#hyt: + +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +class EmailOrUsernameModelBackend(ModelBackend): + """ + 自定义认证后端 - 支持邮箱或用户名登录 + + 扩展 Django 原生认证系统,允许用户使用用户名或邮箱地址进行登录 + 提供更灵活的用户认证方式,提升用户体验 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + """ + 用户认证方法 + + 根据输入的用户名判断是邮箱还是用户名,并进行相应的认证处理 + 支持两种登录方式: + - 用户名登录:username = "admin" + - 邮箱登录:username = "admin@example.com" + + 参数: + request: HttpRequest 对象 + username: 用户输入的用户名或邮箱地址 + password: 用户输入的密码 + **kwargs: 其他关键字参数 + + 返回: + User 对象: 认证成功时返回用户对象 + None: 认证失败时返回 None + """ + # 根据输入内容判断是邮箱还是用户名 + if '@' in username: + kwargs = {'email': username} # 按邮箱查询 + else: + kwargs = {'username': username} # 按用户名查询 + + try: + # 根据查询条件获取用户对象 + user = get_user_model().objects.get(**kwargs) + # 验证密码是否正确 + if user.check_password(password): + return user + except get_user_model().DoesNotExist: + # 用户不存在时返回 None + return None + + def get_user(self, user_id): + """ + 根据用户ID获取用户对象 + + 用于会话认证期间从用户ID获取用户对象 + 保持与 Django 原生认证后端的兼容性 + + 参数: + user_id: 用户的主键ID + + 返回: + User 对象: 用户存在时返回用户对象 + None: 用户不存在时返回 None + """ + try: + return get_user_model().objects.get(pk=user_id) + except get_user_model().DoesNotExist: + return None \ No newline at end of file diff --git a/src/accounts/utils.py b/src/accounts/utils.py new file mode 100644 index 0000000..265471f --- /dev/null +++ b/src/accounts/utils.py @@ -0,0 +1,85 @@ +#hyt: + +import typing +from datetime import timedelta + +from django.core.cache import cache +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ + +from djangoblog.utils import send_email + +# 验证码有效期配置 - 5分钟过期时间 +_code_ttl = timedelta(minutes=5) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """ + 发送邮箱验证邮件 + + 用于密码重置、邮箱验证等场景,向指定邮箱发送包含验证码的邮件 + 邮件内容支持国际化,验证码有效期为5分钟 + + 参数: + to_mail: 接收邮件的邮箱地址 + code: 需要发送的验证码 + subject: 邮件主题,默认为"Verify Email" + """ + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """ + 验证验证码是否有效 + + 检查用户输入的验证码与缓存中的验证码是否匹配 + 用于验证邮箱验证码的正确性 + + 参数: + email: 请求验证的邮箱地址 + code: 用户输入的验证码 + + 返回: + str: 验证失败时返回错误信息 + None: 验证成功时返回None + + 注意: + 这里的错误处理不太合理,应该采用raise抛出异常 + 否则调用方也需要对error进行处理,增加了调用复杂度 + """ + cache_code = get_code(email) + if cache_code != code: + return gettext("Verification code error") + + +def set_code(email: str, code: str): + """ + 设置验证码到缓存 + + 将验证码与邮箱关联并存储到缓存中,设置5分钟的有效期 + + 参数: + email: 邮箱地址,作为缓存的键 + code: 验证码,作为缓存的值 + """ + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """ + 从缓存获取验证码 + + 根据邮箱地址从缓存中获取对应的验证码 + 如果验证码不存在或已过期,返回None + + 参数: + email: 邮箱地址,作为缓存的键 + + 返回: + str: 找到的验证码 + None: 验证码不存在或已过期 + """ + return cache.get(email) diff --git a/src/accounts/views.py b/src/accounts/views.py new file mode 100644 index 0000000..87e1714 --- /dev/null +++ b/src/accounts/views.py @@ -0,0 +1,316 @@ +#hyt: + +import logging +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import get_user_model +from django.contrib.auth import logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.hashers import make_password +from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, RedirectView + +from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +from . import utils +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +from .models import BlogUser + +# 日志记录器 +logger = logging.getLogger(__name__) + + +# Create your views here. + +class RegisterView(FormView): + """ + 用户注册视图 + + 处理新用户注册流程,包括表单验证、用户创建和邮箱验证邮件发送 + 注册后用户处于未激活状态,需要邮箱验证后才能登录 + """ + form_class = RegisterForm # 注册表单类 + template_name = 'account/registration_form.html' # 注册页面模板 + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + """ + 请求分发方法 + + 添加CSRF保护装饰器,防止跨站请求伪造攻击 + """ + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + """ + 表单验证通过处理 + + 保存用户信息,发送邮箱验证邮件,跳转到结果页面 + """ + if form.is_valid(): + # 保存用户信息但不立即提交到数据库 + user = form.save(False) + user.is_active = False # 设置用户为未激活状态 + user.source = 'Register' # 标记用户来源为注册 + user.save(True) # 保存到数据库 + + # 生成邮箱验证链接 + site = get_current_site().domain + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + # 开发环境下使用本地地址 + if settings.DEBUG: + site = '127.0.0.1:8000' + + path = reverse('account:result') + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + # 构建邮件内容 + content = """ +

请点击下面链接验证您的邮箱

+ + {url} + + 再次感谢您! +
+ 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + # 发送验证邮件 + send_email( + emailto=[ + user.email, + ], + title='验证您的电子邮箱', + content=content) + + # 跳转到注册结果页面 + url = reverse('accounts:result') + \ + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + # 表单验证失败,重新渲染表单页面 + return self.render_to_response({ + 'form': form + }) + + +class LogoutView(RedirectView): + """ + 用户退出登录视图 + + 处理用户退出登录流程,清理会话和缓存,重定向到登录页面 + """ + url = '/login/' # 退出后重定向的URL + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + """ + 请求分发方法 + + 添加不缓存装饰器,确保退出后页面不被缓存 + """ + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + """ + GET请求处理 + + 执行退出登录操作,清理用户会话和侧边栏缓存 + """ + logout(request) # Django内置退出登录方法 + delete_sidebar_cache() # 清理侧边栏缓存 + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + """ + 用户登录视图 + + 处理用户登录认证,支持记住登录状态和重定向功能 + """ + form_class = LoginForm # 登录表单类 + template_name = 'account/login.html' # 登录页面模板 + success_url = '/' # 登录成功默认跳转URL + redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名 + login_ttl = 2626560 # 记住登录状态的会话有效期(一个月) + + @method_decorator(sensitive_post_parameters('password')) # 保护密码参数 + @method_decorator(csrf_protect) # CSRF保护 + @method_decorator(never_cache) # 禁止缓存 + def dispatch(self, request, *args, **kwargs): + """ + 请求分发方法 + + 添加安全相关的装饰器,保护登录过程的安全性 + """ + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """ + 获取模板上下文数据 + + 处理重定向参数,确保登录后能正确跳转 + """ + redirect_to = self.request.GET.get(self.redirect_field_name) + if redirect_to is None: + redirect_to = '/' # 默认重定向到首页 + kwargs['redirect_to'] = redirect_to + + return super(LoginView, self).get_context_data(**kwargs) + + def form_valid(self, form): + """ + 表单验证通过处理 + + 执行用户登录认证,处理记住登录状态选项 + """ + form = AuthenticationForm(data=self.request.POST, request=self.request) + + if form.is_valid(): + # 登录成功,清理缓存并记录日志 + delete_sidebar_cache() + logger.info(self.redirect_field_name) + + # 执行登录操作 + auth.login(self.request, form.get_user()) + + # 处理"记住我"选项 + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) # 设置会话有效期 + return super(LoginView, self).form_valid(form) + else: + # 登录失败,重新显示表单 + return self.render_to_response({ + 'form': form + }) + + def get_success_url(self): + """ + 获取登录成功后的跳转URL + + 验证重定向URL的安全性,防止开放重定向攻击 + """ + redirect_to = self.request.POST.get(self.redirect_field_name) + # 验证重定向URL是否安全 + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[ + self.request.get_host()]): + redirect_to = self.success_url # 不安全则使用默认URL + return redirect_to + + +def account_result(request): + """ + 账户操作结果页面视图 + + 显示注册成功或邮箱验证成功的结果信息 + 处理邮箱验证链接的验证逻辑 + """ + type = request.GET.get('type') # 操作类型:register或validation + id = request.GET.get('id') # 用户ID + + user = get_object_or_404(get_user_model(), id=id) + logger.info(type) + + # 如果用户已激活,直接跳转到首页 + if user.is_active: + return HttpResponseRedirect('/') + + # 处理注册结果或邮箱验证 + if type and type in ['register', 'validation']: + if type == 'register': + # 注册成功页面 + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + # 邮箱验证处理 + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') + # 验证签名防止篡改 + if sign != c_sign: + return HttpResponseForbidden() + # 激活用户账户 + user.is_active = True + user.save() + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + # 渲染结果页面 + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + # 无效的操作类型,跳转到首页 + return HttpResponseRedirect('/') + + +class ForgetPasswordView(FormView): + """ + 忘记密码重置视图 + + 处理用户忘记密码后的密码重置流程 + 验证验证码后更新用户密码 + """ + form_class = ForgetPasswordForm # 忘记密码表单 + template_name = 'account/forget_password.html' # 模板路径 + + def form_valid(self, form): + """ + 表单验证通过处理 + + 重置用户密码并重定向到登录页面 + """ + if form.is_valid(): + # 获取用户并更新密码 + blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + blog_user.password = make_password(form.cleaned_data["new_password2"]) # 哈希密码 + blog_user.save() + return HttpResponseRedirect('/login/') # 重定向到登录页面 + else: + # 表单验证失败,重新显示表单 + return self.render_to_response({'form': form}) + + +class ForgetPasswordEmailCode(View): + """ + 忘记密码验证码发送视图 + + 处理忘记密码流程中的验证码发送请求 + """ + + def post(self, request: HttpRequest): + """ + POST请求处理 + + 验证邮箱地址并发送密码重置验证码 + """ + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") # 邮箱格式错误 + + to_email = form.cleaned_data["email"] + + # 生成并发送验证码 + code = generate_code() + utils.send_verify_email(to_email, code) + utils.set_code(to_email, code) + + return HttpResponse("ok") # 发送成功 \ No newline at end of file