master
gzs 4 months ago
parent dc982a969e
commit 5a940dd6f1

@ -9,14 +9,19 @@ from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm):
"""后台创建用户表单,用于管理员在后台创建新用户"""
# 密码字段
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):
"""验证两次输入的密码是否一致"""
# Check that the two password entries match
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
@ -25,19 +30,25 @@ class BlogUserCreationForm(forms.ModelForm):
return password2
def save(self, commit=True):
"""保存用户,使用哈希后的密码"""
# Save the provided password in hashed format
user = super().save(commit=False)
# 使用 Django 的密码哈希方法设置密码
user.set_password(self.cleaned_data["password1"])
if commit:
# 标记用户来源为后台管理
user.source = 'adminsite'
user.save()
return user
class BlogUserChangeForm(UserChangeForm):
"""后台修改用户表单,用于管理员编辑用户信息"""
class Meta:
model = BlogUser
# 包含所有字段
fields = '__all__'
# 用户名字段使用自定义的 UsernameField
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
@ -45,8 +56,12 @@ class BlogUserChangeForm(UserChangeForm):
class BlogUserAdmin(UserAdmin):
"""博客用户后台管理类,自定义了用户列表显示和搜索功能"""
# 编辑用户时使用的表单
form = BlogUserChangeForm
# 创建用户时使用的表单
add_form = BlogUserCreationForm
# 列表页显示的字段
list_display = (
'id',
'nickname',
@ -55,6 +70,9 @@ class BlogUserAdmin(UserAdmin):
'last_login',
'date_joined',
'source')
# 可点击跳转的字段
list_display_links = ('id', 'username')
# 列表排序方式(按 ID 降序)
ordering = ('-id',)
# 可搜索的字段
search_fields = ('username', 'nickname', 'email')

@ -2,4 +2,6 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
"""账户应用配置类"""
# 应用名称,用于 Django 识别和加载该应用
name = 'accounts'

@ -9,39 +9,54 @@ from .models import BlogUser
class LoginForm(AuthenticationForm):
"""登录表单,继承自 Django 的 AuthenticationForm自定义了输入框样式"""
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入框样式(占位符和 CSS 类)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 设置密码字段的输入框样式(占位符和 CSS 类)
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
"""注册表单,继承自 Django 的 UserCreationForm用于新用户注册"""
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):
"""验证邮箱是否已被注册"""
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(
@ -52,6 +67,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 确认密码字段
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
@ -62,6 +78,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 邮箱字段
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
@ -72,6 +89,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 验证码字段
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
@ -83,16 +101,21 @@ class ForgetPasswordForm(forms.Form):
)
def clean_new_password2(self):
"""验证两次输入的密码是否一致,并使用 Django 的密码验证器检查强度"""
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"))
# 使用 Django 的密码验证器验证密码强度
password_validation.validate_password(password2)
return password2
def clean_email(self):
"""验证邮箱是否已注册"""
user_email = self.cleaned_data.get("email")
# 检查邮箱是否存在于数据库中
if not BlogUser.objects.filter(
email=user_email
).exists():
@ -101,7 +124,9 @@ class ForgetPasswordForm(forms.Form):
return user_email
def clean_code(self):
"""验证邮箱验证码是否正确"""
code = self.cleaned_data.get("code")
# 调用工具函数验证验证码
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
@ -112,6 +137,8 @@ class ForgetPasswordForm(forms.Form):
class ForgetPasswordCodeForm(forms.Form):
"""忘记密码验证码申请表单,用于请求发送验证码"""
# 邮箱字段,用于接收验证码
email = forms.EmailField(
label=_('Email'),
)

@ -9,27 +9,40 @@ from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser):
"""博客用户模型,继承自 Django 的 AbstractUser扩展了用户信息字段"""
# 用户昵称,可选字段,最大长度 100 字符
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)
# 账户来源标识Register、adminsite、OAuth 等),用于追踪用户注册渠道
source = models.CharField(_('create source'), max_length=100, blank=True)
def get_absolute_url(self):
"""获取用户详情页的相对 URL"""
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
def __str__(self):
"""返回用户的邮箱地址作为字符串表示"""
return self.email
def get_full_url(self):
"""获取用户详情页的完整 URL包含域名"""
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
# 按 ID 降序排列(最新的在前)
ordering = ['-id']
# 单数形式的模型名称(用于后台显示)
verbose_name = _('user')
# 复数形式的模型名称(与单数相同)
verbose_name_plural = verbose_name
# 指定获取最新记录时使用的字段
get_latest_by = 'id'

@ -12,17 +12,25 @@ from . import utils
# Create your tests here.
class AccountTest(TestCase):
"""账户功能测试类,测试用户注册、登录、密码重置等功能"""
def setUp(self):
"""测试前的初始化设置,创建测试用的客户端和用户"""
# 创建测试客户端
self.client = Client()
# 创建请求工厂(用于创建测试请求)
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",
@ -56,6 +64,7 @@ class AccountTest(TestCase):
self.assertEqual(response.status_code, 200)
def test_validate_register(self):
"""测试用户注册功能:注册流程、邮箱验证、登录等完整流程"""
self.assertEquals(
0, len(
BlogUser.objects.filter(
@ -119,6 +128,7 @@ class AccountTest(TestCase):
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)
@ -131,6 +141,7 @@ class AccountTest(TestCase):
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")
@ -140,6 +151,7 @@ class AccountTest(TestCase):
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()
@ -153,6 +165,7 @@ class AccountTest(TestCase):
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(
@ -175,6 +188,7 @@ class AccountTest(TestCase):
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,
@ -190,6 +204,7 @@ class AccountTest(TestCase):
def test_forget_password_email_code_error(self):
"""测试忘记密码重置功能(失败场景:验证码错误)"""
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(

@ -4,25 +4,33 @@ from django.urls import re_path
from . import views
from .forms import LoginForm
# 应用命名空间,用于 URL 反向解析
app_name = "accounts"
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]
urlpatterns = [
# 登录页面路由
re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
# 注册页面路由
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
# 登出页面路由
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
# 账户操作结果页面路由(如注册成功、邮箱验证等)
path(r'account/result.html',
views.account_result,
name='result'),
# 忘记密码页面路由
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
# 忘记密码验证码申请路由
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]

@ -4,22 +4,48 @@ from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
自定义认证后端允许使用用户名或邮箱登录
继承自 ModelBackend扩展了登录方式
"""
def authenticate(self, request, username=None, password=None, **kwargs):
"""
认证用户支持用户名或邮箱登录
Args:
request: HTTP 请求对象
username: 用户名或邮箱
password: 密码
**kwargs: 其他参数
Returns:
认证成功返回用户对象失败返回 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, username):
"""
根据用户 ID 获取用户对象
Args:
username: 用户 ID注意这里参数名是 username但实际是用户 ID
Returns:
用户对象不存在时返回 None
"""
try:
return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist:

@ -7,43 +7,66 @@ 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")):
"""发送重设密码验证码
"""发送重设密码验证码邮件
Args:
to_mail: 受邮箱
subject: 邮件主题
to_mail: 收邮箱地址
subject: 邮件主题默认为"验证邮箱"
code: 验证码
"""
# 构造邮件内容,包含验证码和有效期提示
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]:
"""验证code是否有效
"""验证邮箱验证码是否有效
Args:
email: 请求邮箱
code: 验证码
Return:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
email: 请求邮箱地址
code: 用户输入的验证码
Returns:
如果验证码错误返回错误信息字符串验证成功返回 None
Note:
这里的错误处理不太合理应该采用 raise 抛出异常
否则调用方也需要对 error 进行处理
"""
# 从缓存中获取该邮箱对应的验证码
cache_code = get_code(email)
# 比较缓存中的验证码和用户输入的验证码
if cache_code != code:
return gettext("Verification code error")
# 验证成功返回 None
def set_code(email: str, code: str):
"""设置code"""
"""将验证码存储到缓存中
Args:
email: 邮箱地址作为缓存键
code: 验证码作为缓存值
"""
# 使用邮箱作为键,验证码作为值,设置过期时间为 _code_ttl 秒
cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
"""从缓存中获取验证码
Args:
email: 邮箱地址作为缓存键
Returns:
验证码字符串如果不存在或已过期返回 None
"""
return cache.get(email)

@ -32,28 +32,42 @@ 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():
# 保存用户但不提交到数据库commit=False
user = form.save(False)
# 设置用户为非激活状态,需要邮箱验证后才能激活
user.is_active = False
# 标记用户来源为注册
user.source = 'Register'
# 保存用户到数据库
user.save(True)
# 获取当前站点域名
site = get_current_site().domain
# 生成邮箱验证的签名(双重 SHA256 哈希,使用 SECRET_KEY + 用户 ID
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 = """
<p>请点击下面链接验证您的邮箱</p>
@ -64,6 +78,7 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
# 发送验证邮件
send_email(
emailto=[
user.email,
@ -71,43 +86,60 @@ class RegisterView(FormView):
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
url = '/login/'
@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)
# 删除侧边栏缓存
delete_sidebar_cache()
# 重定向到登录页面
return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView):
"""用户登录视图,处理用户登录逻辑"""
# 使用的表单类
form_class = LoginForm
# 渲染的模板文件
template_name = 'account/login.html'
# 登录成功后的默认重定向 URL
success_url = '/'
# 重定向字段名称(用于记录登录前要访问的页面)
redirect_field_name = REDIRECT_FIELD_NAME
# 登录会话有效期(一个月的时间,单位:秒)
login_ttl = 2626560 # 一个月的时间
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
"""分发请求添加安全装饰器密码敏感参数保护、CSRF 保护、禁用缓存"""
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""获取模板上下文数据,包含重定向 URL"""
# 从 GET 参数中获取重定向目标 URL
redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None:
redirect_to = '/'
@ -116,25 +148,33 @@ class LoginView(FormView):
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)
# return HttpResponseRedirect('/')
else:
# 表单验证失败,重新渲染登录页面
return self.render_to_response({
'form': form
})
def get_success_url(self):
"""获取登录成功后的重定向 URL检查安全性"""
# 从 POST 数据中获取重定向目标
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()]):
@ -143,62 +183,92 @@ class LoginView(FormView):
def account_result(request):
"""账户操作结果页面视图,显示注册成功或邮箱验证结果"""
# 获取操作类型register 或 validation
type = request.GET.get('type')
# 获取用户 ID
id = request.GET.get('id')
# 根据 ID 获取用户对象,不存在则返回 404
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()
# 使用 Django 的密码哈希函数加密新密码
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"]
# 生成 6 位随机验证码
code = generate_code()
# 发送验证码邮件
utils.send_verify_email(to_email, code)
# 将验证码存储到缓存中(有效期 5 分钟)
utils.set_code(to_email, code)
return HttpResponse("ok")

Loading…
Cancel
Save