Skip to content

Commit ed1e869

Browse files
committed
feat(account): 实现双因素认证与密码重置功能
新增双因素认证(2FA)功能,支持TOTP验证器和恢复码 添加密码重置流程,包含邮件发送与密码修改页面 完善用户注册流程,增加邮箱字段验证 引入django-otp依赖并配置相关中间件 统一前端UI组件样式,使用DaisyUI规范 新增开发者API Token生成功能 优化用户设置中心,增加会话管理与安全设置
1 parent bdbd680 commit ed1e869

35 files changed

Lines changed: 1443 additions & 19 deletions

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ authors = [
88
dependencies = [
99
"django[argon2]>=6.0",
1010
"django-ninja>=1.5.3",
11+
"django-otp>=1.6.1",
1112
"django-multi-captcha-admin>=2.0.0",
1213
"django-simple-captcha>=0.6.3",
1314
"django-cors-headers>=4.9.0",
@@ -26,6 +27,7 @@ dependencies = [
2627
"requests>=2.32.5",
2728
"redis>=7.1.0",
2829
"pillow>=12.1.0",
30+
"qrcode>=8.0",
2931
"pydantic>=2.12.5",
3032
"django-simple-history>=3.11.0",
3133
"django-jazzmin>=3.0.1",

src/apps/account/UI.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Account 前端 UI 规范(DaisyUI)
2+
3+
本文件用于固定 account 应用的 UI 组件选型与类名组合,避免模板与表单样式分散。
4+
5+
## 表单(Form)
6+
7+
- 输入框:`input input-bordered w-full`
8+
- 输入框错误态:`input input-bordered input-error w-full`
9+
- 下拉:`select select-bordered w-full`
10+
- 上传:`file-input file-input-bordered w-full`
11+
- 开关:`toggle toggle-primary`
12+
- 校验:在控件上追加 `validator`,错误提示使用 `validator-hint`
13+
14+
## 按钮(Button)
15+
16+
- 主按钮:`btn btn-primary`
17+
- 次按钮:`btn btn-secondary`
18+
- 轻量按钮:`btn btn-ghost`
19+
- 链接按钮:`btn btn-link`
20+
- 禁用:`btn btn-disabled``aria-disabled="true"`
21+
22+
## 提示(Alert / Toast)
23+
24+
- Alert:`alert` + `alert-success|alert-info|alert-warning|alert-error`
25+
- Toast 容器:`toast toast-top toast-end`
26+
27+
## 对话框(Modal)
28+
29+
- 统一使用 `dialog.modal` + `div.modal-box` + `form.modal-backdrop`
30+
31+
## 导航(Menu / Tabs / Breadcrumbs)
32+
33+
- 设置中心侧边栏:`ul.menu menu-md`
34+
- Tabs:`div.tabs` + `button.tab`,当前项加 `tab-active`
35+
36+
## 布局(Card / Table / Steps)
37+
38+
- 内容卡片:`card bg-base-100 shadow-sm border border-base-200`
39+
- 表格:`table table-zebra`,外层包 `div.overflow-x-auto`
40+
- 步骤:`ul.steps steps-vertical lg:steps-horizontal`,激活项加 `step-primary`
41+

src/apps/account/apis/auth/apis.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ def register(request, data: RegisterSchema):
4040
if User.objects.filter(username=data.username).exists():
4141
return _resp.bad_request(request, '用户名已存在!')
4242

43+
if User.objects.filter(email=data.email).exists():
44+
return _resp.bad_request(request, '邮箱已存在!')
45+
4346
if data.phone:
4447
phone_pattern = '^(13[0-9]|14[5|7]|15[0|1|2|3|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\\d{8}$'
4548
if not re.match(phone_pattern, data.phone):
@@ -51,7 +54,7 @@ def register(request, data: RegisterSchema):
5154
if data.password != data.confirm_password:
5255
return _resp.bad_request(request, '密码不一致!')
5356

54-
user_obj: User = User.objects.create_user(data.username, None, data.password)
57+
user_obj: User = User.objects.create_user(data.username, data.email, data.password)
5558

5659
if data.phone:
5760
user_obj.profile.phone = data.phone

src/apps/account/apis/auth/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class LoginToken(Schema):
1515

1616

1717
class RegisterSchema(Schema):
18+
email: str
1819
username: str
1920
password: str
2021
confirm_password: str

src/apps/account/forms.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ class Meta:
1313
}
1414
widgets = {
1515
'full_name': forms.TextInput(attrs={
16-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
16+
'class': 'input input-bordered w-full',
17+
'autocomplete': 'name',
1718
}),
1819
'gender': forms.Select(attrs={
19-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
20+
'class': 'select select-bordered w-full',
2021
}),
2122
'phone': forms.TextInput(attrs={
22-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
23+
'class': 'input input-bordered w-full',
24+
'autocomplete': 'tel',
2325
}),
2426
}
2527

@@ -28,43 +30,58 @@ class LoginForm(forms.Form):
2830
username = forms.CharField(
2931
label='用户名',
3032
widget=forms.TextInput(attrs={
31-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500',
33+
'class': 'input input-bordered w-full',
3234
'placeholder': '请输入用户名',
35+
'autocomplete': 'username',
3336
}),
3437
min_length=4,
3538
)
3639
password = forms.CharField(
3740
label='密码',
3841
widget=forms.PasswordInput(attrs={
39-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500',
42+
'class': 'input input-bordered w-full',
4043
'placeholder': '请输入密码',
44+
'autocomplete': 'current-password',
4145
}),
4246
min_length=4,
4347
)
4448

4549

4650
class RegisterForm(forms.Form):
51+
email = forms.EmailField(
52+
label='邮箱',
53+
widget=forms.EmailInput(attrs={
54+
'class': 'input input-bordered w-full',
55+
'placeholder': 'name@example.com',
56+
'autocomplete': 'email',
57+
}),
58+
)
4759
username = forms.CharField(
4860
label='用户名',
4961
widget=forms.TextInput(attrs={
50-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500',
62+
'class': 'input input-bordered w-full',
5163
'placeholder': '请输入用户名',
64+
'autocomplete': 'username',
5265
}),
5366
min_length=4,
5467
)
5568
password = forms.CharField(
5669
label='密码',
5770
widget=forms.PasswordInput(attrs={
58-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500',
71+
'class': 'input input-bordered w-full',
5972
'placeholder': '请输入密码',
73+
'autocomplete': 'new-password',
74+
'x-model': 'pw',
6075
}),
6176
min_length=4,
6277
)
6378
confirm_password = forms.CharField(
6479
label='确认密码',
6580
widget=forms.PasswordInput(attrs={
66-
'class': 'bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500',
81+
'class': 'input input-bordered w-full',
6782
'placeholder': '请输入确认密码',
83+
'autocomplete': 'new-password',
84+
'x-model': 'confirm',
6885
}),
6986
min_length=4,
7087
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 6.0.1 on 2026-01-22 18:51
2+
3+
import django.db.models.deletion
4+
import simple_history.models
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('account', '0003_alter_userprofile_is_deleted'),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='HistoricalUserProfile',
19+
fields=[
20+
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
21+
('is_deleted', models.BooleanField(default=False, verbose_name='软删除标志')),
22+
('created_time', models.DateTimeField(blank=True, editable=False, verbose_name='创建时间')),
23+
('updated_time', models.DateTimeField(blank=True, editable=False, verbose_name='更新时间')),
24+
('full_name', models.CharField(default='', max_length=200, verbose_name='姓名')),
25+
('gender', models.CharField(choices=[('male', '男'), ('female', '女'), ('unknown', '未知')], default='unknown', max_length=20, verbose_name='性别')),
26+
('phone', models.CharField(default='', max_length=11, verbose_name='手机号')),
27+
('history_id', models.AutoField(primary_key=True, serialize=False)),
28+
('history_date', models.DateTimeField(db_index=True)),
29+
('history_change_reason', models.CharField(max_length=100, null=True)),
30+
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
31+
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
32+
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
33+
],
34+
options={
35+
'verbose_name': 'historical 用户资料',
36+
'verbose_name_plural': 'historical 用户资料',
37+
'ordering': ('-history_date', '-history_id'),
38+
'get_latest_by': ('history_date', 'history_id'),
39+
},
40+
bases=(simple_history.models.HistoricalChanges, models.Model),
41+
),
42+
]

src/apps/account/models.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ class Meta:
1414
verbose_name_plural = verbose_name
1515

1616

17-
# 创建用户的时候自动创建 profile
18-
# @receiver(signals.post_save, sender=User, dispatch_uid='django_user_post_save')
19-
# def create_user_profile(sender: User, instance: User, created, **kwargs):
20-
# profile, created = UserProfile.objects.get_or_create(user=instance)
17+
@receiver(signals.post_save, sender=User, dispatch_uid='account_user_post_save_create_profile')
18+
def create_user_profile(sender: type[User], instance: User, created: bool, **kwargs):
19+
"""
20+
确保 UserProfile 存在。
21+
22+
账号体系的多处逻辑依赖 `user.profile`,因此在用户创建时创建 profile,
23+
并在后续保存时保证其存在。
24+
"""
25+
if created:
26+
UserProfile.objects.get_or_create(user=instance)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{% extends '_base.html' %}
2+
{% load page_tags %}
3+
4+
{% block title %}关闭双因素认证{% endblock %}
5+
6+
{% block content %}
7+
{% page_header title breadcrumbs %}
8+
9+
<div class="mx-auto w-full max-w-md">
10+
<div class="card bg-base-100 shadow-sm border border-base-200">
11+
<div class="card-body gap-6">
12+
<div class="space-y-1">
13+
<h1 class="card-title text-2xl">关闭双因素认证</h1>
14+
<p class="text-sm opacity-70">需要验证密码与验证码,避免误操作。</p>
15+
</div>
16+
17+
<form method="post" class="space-y-4" novalidate>
18+
{% csrf_token %}
19+
<div class="space-y-1">
20+
<label class="label" for="password">
21+
<span class="label-text">密码</span>
22+
</label>
23+
<input id="password" name="password" type="password" autocomplete="current-password"
24+
class="input input-bordered w-full" required>
25+
</div>
26+
<div class="space-y-1">
27+
<label class="label" for="token">
28+
<span class="label-text">验证码 / 恢复码</span>
29+
</label>
30+
<input id="token" name="token" autocomplete="one-time-code"
31+
class="input input-bordered w-full" required>
32+
</div>
33+
34+
<button type="submit" class="btn btn-error btn-block">确认关闭</button>
35+
</form>
36+
37+
<div class="card-actions justify-end">
38+
<a class="btn btn-ghost" href="{% url 'account:settings' %}">返回设置</a>
39+
</div>
40+
</div>
41+
</div>
42+
</div>
43+
{% endblock %}
44+
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{% extends '_base.html' %}
2+
{% load page_tags %}
3+
4+
{% block title %}启用双因素认证{% endblock %}
5+
6+
{% block content %}
7+
{% page_header title breadcrumbs %}
8+
9+
<div class="mx-auto w-full max-w-3xl space-y-4">
10+
<div class="card bg-base-100 shadow-sm border border-base-200">
11+
<div class="card-body gap-6">
12+
<div class="space-y-1">
13+
<h1 class="card-title text-2xl">启用双因素认证(2FA)</h1>
14+
<p class="text-sm opacity-70">使用 Google Authenticator、Microsoft Authenticator 等应用扫码添加。</p>
15+
</div>
16+
17+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
18+
<div class="card bg-base-200/40 border border-base-300">
19+
<div class="card-body gap-3">
20+
<h2 class="font-semibold">1)扫码添加</h2>
21+
<div class="flex justify-center">
22+
<img src="{{ qr_data_uri }}" alt="2FA QR Code" class="rounded-lg border border-base-300 bg-base-100 p-2">
23+
</div>
24+
<div class="text-sm opacity-70">
25+
<p class="font-medium">手动输入密钥</p>
26+
<p class="font-mono break-all">{{ secret }}</p>
27+
</div>
28+
</div>
29+
</div>
30+
31+
<div class="card bg-base-200/40 border border-base-300">
32+
<div class="card-body gap-3">
33+
<h2 class="font-semibold">2)输入验证码确认</h2>
34+
<form method="post" class="space-y-3" novalidate>
35+
{% csrf_token %}
36+
<div class="space-y-1">
37+
<label class="label" for="token">
38+
<span class="label-text">验证码</span>
39+
</label>
40+
<input id="token" name="token" inputmode="numeric" autocomplete="one-time-code"
41+
class="input input-bordered w-full" placeholder="6 位数字" required>
42+
</div>
43+
<button type="submit" class="btn btn-primary btn-block">确认启用</button>
44+
</form>
45+
</div>
46+
</div>
47+
</div>
48+
49+
{% if recovery_codes %}
50+
<div class="divider"></div>
51+
<div class="card bg-base-200/40 border border-base-300">
52+
<div class="card-body gap-3" x-data="{ copied: false }">
53+
<div class="flex items-center justify-between gap-4">
54+
<h2 class="font-semibold">恢复码(仅显示一次)</h2>
55+
<button type="button" class="btn btn-outline btn-sm"
56+
@click="navigator.clipboard.writeText(Array.from($el.closest('.card-body').querySelectorAll('code')).map(e => e.innerText).join('\\n')).then(() => { copied = true; setTimeout(() => copied = false, 1200) })">
57+
复制全部
58+
</button>
59+
</div>
60+
<template x-if="copied">
61+
<p class="text-sm text-success">已复制到剪贴板</p>
62+
</template>
63+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
64+
{% for code in recovery_codes %}
65+
<code class="px-3 py-2 rounded-lg bg-base-100 border border-base-300 font-mono text-sm">{{ code }}</code>
66+
{% endfor %}
67+
</div>
68+
<p class="text-sm opacity-70">请妥善保存恢复码,遗失后可能无法找回账号。</p>
69+
</div>
70+
</div>
71+
{% endif %}
72+
</div>
73+
</div>
74+
</div>
75+
{% endblock %}
76+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{% extends '_base.html' %}
2+
{% load page_tags %}
3+
4+
{% block title %}双因素认证验证{% endblock %}
5+
6+
{% block content %}
7+
{% page_header title breadcrumbs %}
8+
9+
<div class="mx-auto w-full max-w-md">
10+
<div class="card bg-base-100 shadow-sm border border-base-200">
11+
<div class="card-body gap-6">
12+
<div class="space-y-1">
13+
<h1 class="card-title text-2xl">二次验证</h1>
14+
<p class="text-sm opacity-70">输入验证码或恢复码以完成登录。</p>
15+
</div>
16+
17+
<form method="post" class="space-y-4" novalidate>
18+
{% csrf_token %}
19+
<div class="space-y-1">
20+
<label class="label" for="token">
21+
<span class="label-text">验证码 / 恢复码</span>
22+
</label>
23+
<input id="token" name="token" autocomplete="one-time-code"
24+
class="input input-bordered w-full" placeholder="请输入验证码或恢复码" required>
25+
</div>
26+
<button type="submit" class="btn btn-primary btn-block">验证并登录</button>
27+
</form>
28+
29+
<p class="text-xs opacity-70">
30+
验证成功后将跳转至:{{ next }}
31+
</p>
32+
</div>
33+
</div>
34+
</div>
35+
{% endblock %}
36+

0 commit comments

Comments
 (0)