合并master更新
master
云涵 1 month ago
commit 8ebd9be17c

@ -1,60 +1,152 @@
"""
Django管理后台用户模型配置模块
本模块配置BlogUser模型在Django管理后台的显示和编辑行为
包括自定义表单验证列表显示字段搜索过滤等功能
主要组件
- BlogUserCreationForm: 用户创建表单处理密码验证和设置
- BlogUserChangeForm: 用户信息修改表单
- BlogUserAdmin: 用户模型管理后台配置类
"""
from django import forms from django import forms
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.forms import UsernameField from django.contrib.auth.forms import UsernameField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Register your models here. # 导入自定义用户模型
from .models import BlogUser from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm): class BlogUserCreationForm(forms.ModelForm):
"""
博客用户创建表单
扩展自ModelForm专门用于在Django管理后台创建新用户
提供密码确认验证和密码哈希处理功能
"""
# 密码输入字段1 - 使用PasswordInput控件隐藏输入
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
# 密码输入字段2 - 用于密码确认
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta: class Meta:
"""表单元数据配置"""
# 指定关联的模型
model = BlogUser model = BlogUser
# 指定表单中包含的字段 - 仅包含email字段
fields = ('email',) fields = ('email',)
def clean_password2(self): def clean_password2(self):
# Check that the two password entries match """
密码确认字段验证方法
验证两次输入的密码是否一致确保用户输入正确的密码
Returns:
str: 验证通过的密码
Raises:
forms.ValidationError: 当两次密码输入不一致时抛出验证错误
"""
# 从清洗后的数据中获取两个密码字段的值
password1 = self.cleaned_data.get("password1") password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2") password2 = self.cleaned_data.get("password2")
# 检查两个密码是否存在且相等
if password1 and password2 and password1 != password2: if password1 and password2 and password1 != password2:
# 密码不匹配时抛出验证错误
raise forms.ValidationError(_("passwords do not match")) raise forms.ValidationError(_("passwords do not match"))
# 返回验证通过的密码
return password2 return password2
def save(self, commit=True): def save(self, commit=True):
# Save the provided password in hashed format """
表单保存方法
重写保存逻辑处理密码哈希化和设置用户来源
Args:
commit (bool): 是否立即保存到数据库默认为True
Returns:
BlogUser: 保存后的用户实例
"""
# 调用父类保存方法,但不立即提交到数据库
user = super().save(commit=False) user = super().save(commit=False)
# 使用Django的密码哈希方法设置密码
user.set_password(self.cleaned_data["password1"]) user.set_password(self.cleaned_data["password1"])
# 如果设置为立即提交,则保存用户并设置来源
if commit: if commit:
# 标记用户创建来源为管理后台
user.source = 'adminsite' user.source = 'adminsite'
# 保存用户到数据库
user.save() user.save()
return user return user
class BlogUserChangeForm(UserChangeForm): class BlogUserChangeForm(UserChangeForm):
"""
博客用户信息修改表单
继承自Django的UserChangeForm用于在管理后台编辑现有用户信息
保持与Django原生用户管理表单的兼容性
"""
class Meta: class Meta:
"""表单元数据配置"""
# 指定关联的模型
model = BlogUser model = BlogUser
# 包含所有字段
fields = '__all__' fields = '__all__'
# 指定用户名字段使用Django的UsernameField类型
field_classes = {'username': UsernameField} field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""
表单初始化方法
可以在此处添加自定义的表单初始化逻辑
"""
# 调用父类初始化方法
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class BlogUserAdmin(UserAdmin): class BlogUserAdmin(UserAdmin):
"""
博客用户管理后台配置类
继承自Django的UserAdmin自定义BlogUser模型在管理后台的显示和行为
配置列表显示搜索排序等管理界面功能
"""
# 指定用户编辑表单
form = BlogUserChangeForm form = BlogUserChangeForm
# 指定用户创建表单
add_form = BlogUserCreationForm add_form = BlogUserCreationForm
# 配置列表页面显示的字段
list_display = ( list_display = (
'id', 'id', # 用户ID
'nickname', 'nickname', # 用户昵称
'username', 'username', # 用户名
'email', 'email', # 邮箱地址
'last_login', 'last_login', # 最后登录时间
'date_joined', 'date_joined', # 注册时间
'source') 'source' # 用户来源
)
# 配置列表中可点击跳转到编辑页面的字段
list_display_links = ('id', 'username') list_display_links = ('id', 'username')
# 配置默认排序规则 - 按ID倒序排列最新的在前
ordering = ('-id',) ordering = ('-id',)
# 配置搜索框可搜索的字段
search_fields = ('username', 'nickname', 'email') search_fields = ('username', 'nickname', 'email')

@ -1,5 +1,37 @@
"""
Django应用配置模块
本模块定义accounts应用的配置类用于配置应用级别的设置和元数据
"""
from django.apps import AppConfig from django.apps import AppConfig
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
"""
用户账户应用配置类
继承自Django的AppConfig类用于配置accounts应用的各项设置
包括应用名称显示名称初始化逻辑等
属性:
name (str): 应用的Python路径标识符Django使用此名称来识别应用
"""
# 应用名称 - 使用Python路径格式Django通过此名称识别应用
# 此名称必须与应用的目录名和INSTALLED_APPS中的配置一致
name = 'accounts' name = 'accounts'
# 可选应用的可读名称用于Django管理后台显示
# verbose_name = '用户账户'
# 可选:应用初始化方法
# def ready(self):
# """
# 应用初始化完成时调用的方法
#
# 当Django启动完成所有应用加载完毕后会自动调用此方法。
# 常用于信号注册、配置检查等初始化操作。
# """
# # 导入并注册信号处理器
# from . import signals

@ -1,3 +1,15 @@
"""
用户认证表单模块
本模块定义用户相关的Django表单包括
- 用户登录表单
- 用户注册表单
- 密码重置表单
- 验证码表单
所有表单都包含Bootstrap样式类提供一致的用户界面体验
"""
from django import forms from django import forms
from django.contrib.auth import get_user_model, password_validation from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
@ -9,109 +21,254 @@ from .models import BlogUser
class LoginForm(AuthenticationForm): class LoginForm(AuthenticationForm):
"""
用户登录表单
继承自Django的AuthenticationForm添加Bootstrap样式支持
用于用户通过用户名和密码登录系统
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""
初始化表单设置字段的widget属性添加Bootstrap样式
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类初始化方法
super(LoginForm, self).__init__(*args, **kwargs) super(LoginForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入控件和样式
self.fields['username'].widget = widgets.TextInput( self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"}) attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
self.fields['password'].widget = widgets.PasswordInput( self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"}) attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
class RegisterForm(UserCreationForm): class RegisterForm(UserCreationForm):
"""
用户注册表单
继承自Django的UserCreationForm扩展邮箱字段和样式支持
用于新用户注册账号包含用户名邮箱和密码确认功能
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""
初始化表单设置所有字段的widget属性添加Bootstrap样式
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类初始化方法
super(RegisterForm, self).__init__(*args, **kwargs) super(RegisterForm, self).__init__(*args, **kwargs)
# 设置用户名字段的输入控件和样式
self.fields['username'].widget = widgets.TextInput( self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"}) attrs={
'placeholder': "username", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置邮箱字段的输入控件和样式
self.fields['email'].widget = widgets.EmailInput( self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"}) attrs={
'placeholder': "email", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码字段的输入控件和样式
self.fields['password1'].widget = widgets.PasswordInput( self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"}) attrs={
'placeholder': "password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
# 设置密码确认字段的输入控件和样式
self.fields['password2'].widget = widgets.PasswordInput( self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"}) attrs={
'placeholder': "repeat password", # 输入框占位符文本
"class": "form-control" # Bootstrap表单控件样式类
})
def clean_email(self): def clean_email(self):
"""
邮箱字段验证方法
验证邮箱是否已被注册确保邮箱地址的唯一性
Returns:
str: 验证通过的邮箱地址
Raises:
ValidationError: 当邮箱已被注册时抛出验证错误
"""
# 获取清洗后的邮箱数据
email = self.cleaned_data['email'] email = self.cleaned_data['email']
# 检查邮箱是否已存在
if get_user_model().objects.filter(email=email).exists(): if get_user_model().objects.filter(email=email).exists():
# 抛出验证错误,提示邮箱已存在
raise ValidationError(_("email already exists")) raise ValidationError(_("email already exists"))
# 返回验证通过的邮箱
return email return email
class Meta: class Meta:
"""表单元数据配置"""
# 指定关联的用户模型
model = get_user_model() model = get_user_model()
# 指定表单中包含的字段
fields = ("username", "email") fields = ("username", "email")
class ForgetPasswordForm(forms.Form): class ForgetPasswordForm(forms.Form):
"""
忘记密码重置表单
用于用户通过邮箱和验证码重置密码包含密码强度验证和验证码校验
"""
# 新密码字段1
new_password1 = forms.CharField( new_password1 = forms.CharField(
label=_("New password"), label=_("New password"), # 字段标签
widget=forms.PasswordInput( widget=forms.PasswordInput(
attrs={ attrs={
"class": "form-control", "class": "form-control", # Bootstrap样式类
'placeholder': _("New password") 'placeholder': _("New password") # 占位符文本
} }
), ),
) )
# 新密码字段2 - 用于密码确认
new_password2 = forms.CharField( new_password2 = forms.CharField(
label="确认密码", label="确认密码", # 中文标签
widget=forms.PasswordInput( widget=forms.PasswordInput(
attrs={ attrs={
"class": "form-control", "class": "form-control", # Bootstrap样式类
'placeholder': _("Confirm password") 'placeholder': _("Confirm password") # 占位符文本
} }
), ),
) )
# 邮箱字段 - 用于标识用户和发送验证码
email = forms.EmailField( email = forms.EmailField(
label='邮箱', label='邮箱', # 中文标签
widget=forms.TextInput( widget=forms.TextInput(
attrs={ attrs={
'class': 'form-control', 'class': 'form-control', # Bootstrap样式类
'placeholder': _("Email") 'placeholder': _("Email") # 占位符文本
} }
), ),
) )
# 验证码字段 - 用于验证用户身份
code = forms.CharField( code = forms.CharField(
label=_('Code'), label=_('Code'), # 字段标签
widget=forms.TextInput( widget=forms.TextInput(
attrs={ attrs={
'class': 'form-control', 'class': 'form-control', # Bootstrap样式类
'placeholder': _("Code") 'placeholder': _("Code") # 占位符文本
} }
), ),
) )
def clean_new_password2(self): def clean_new_password2(self):
"""
密码确认字段验证方法
验证两次输入的密码是否一致并检查密码强度
Returns:
str: 验证通过的密码
Raises:
ValidationError: 当密码不匹配或强度不足时抛出验证错误
"""
# 从请求数据中获取两个密码字段的值
password1 = self.data.get("new_password1") password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2") password2 = self.data.get("new_password2")
# 检查两个密码是否存在且相等
if password1 and password2 and password1 != password2: if password1 and password2 and password1 != password2:
# 密码不匹配时抛出验证错误
raise ValidationError(_("passwords do not match")) raise ValidationError(_("passwords do not match"))
# 使用Django内置密码验证器验证密码强度
password_validation.validate_password(password2) password_validation.validate_password(password2)
# 返回验证通过的密码
return password2 return password2
def clean_email(self): def clean_email(self):
"""
邮箱字段验证方法
验证邮箱是否在系统中注册过
Returns:
str: 验证通过的邮箱地址
Raises:
ValidationError: 当邮箱未注册时抛出验证错误
"""
# 获取清洗后的邮箱数据
user_email = self.cleaned_data.get("email") user_email = self.cleaned_data.get("email")
if not BlogUser.objects.filter(
email=user_email # 检查邮箱是否存在于用户数据库中
).exists(): if not BlogUser.objects.filter(email=user_email).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 # TODO: 这里会暴露邮箱是否注册的信息,根据安全需求可修改提示
raise ValidationError(_("email does not exist")) raise ValidationError(_("email does not exist"))
return user_email return user_email
def clean_code(self): def clean_code(self):
"""
验证码字段验证方法
验证邮箱和验证码的匹配关系
Returns:
str: 验证通过的验证码
Raises:
ValidationError: 当验证码无效或过期时抛出验证错误
"""
# 获取清洗后的验证码数据
code = self.cleaned_data.get("code") code = self.cleaned_data.get("code")
# 调用utils模块的verify函数验证验证码
error = utils.verify( error = utils.verify(
email=self.cleaned_data.get("email"), email=self.cleaned_data.get("email"), # 邮箱地址
code=code, code=code, # 验证码
) )
# 如果验证返回错误信息,抛出验证错误
if error: if error:
raise ValidationError(error) raise ValidationError(error)
return code return code
class ForgetPasswordCodeForm(forms.Form): class ForgetPasswordCodeForm(forms.Form):
"""
忘记密码验证码请求表单
用于用户请求发送密码重置验证码仅包含邮箱字段
"""
# 邮箱字段 - 用于发送验证码
email = forms.EmailField( email = forms.EmailField(
label=_('Email'), label=_('Email'), # 字段标签
# 可以添加widget配置来设置样式
) )

@ -25,81 +25,113 @@ class Migration(migrations.Migration):
继承自migrations.Migration定义自定义用户模型的数据库表创建操作 继承自migrations.Migration定义自定义用户模型的数据库表创建操作
initial = True 表示这是该应用的第一个迁移文件 initial = True 表示这是该应用的第一个迁移文件
主要功能
- 创建自定义用户模型BlogUser的数据库表
- 继承Django认证系统的所有基础字段
- 添加博客系统特有的自定义字段
- 设置模型的管理器和配置选项
""" """
# 标记为初始迁移文件Django迁移系统会首先执行此文件
initial = True initial = True
# 定义迁移依赖关系
dependencies = [ dependencies = [
# 声明对Django认证系统组的依赖 # 声明对Django认证系统组的依赖
# 使用auth应用的0012迁移文件确保用户权限系统正常工作
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
] ]
# 定义迁移操作序列
operations = [ operations = [
# 创建博客用户表 # 创建博客用户表的迁移操作
migrations.CreateModel( migrations.CreateModel(
# 模型名称 - 对应数据库表名 accounts_bloguser
name='BlogUser', name='BlogUser',
# 定义模型字段列表
fields=[ fields=[
# 主键字段 - 使用BigAutoField作为自增主键 # 主键字段 - 使用BigAutoField作为自增主键
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段 - Django认证系统标准字段存储加密后的密码 # 密码字段 - Django认证系统标准字段存储加密后的密码
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
# 最后登录时间字段 - 记录用户最后一次登录的时间 # 最后登录时间字段 - 记录用户最后一次登录的时间
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
# 超级用户标志字段 - 标识用户是否拥有所有权限 # 超级用户标志字段 - 标识用户是否拥有所有权限
('is_superuser', models.BooleanField(default=False, ('is_superuser', models.BooleanField(default=False,
help_text='Designates that this user has all permissions without explicitly assigning them.', help_text='Designates that this user has all permissions without explicitly assigning them.',
verbose_name='superuser status')), verbose_name='superuser status')),
# 用户名字段 - 唯一标识用户的字段,包含验证器和错误消息 # 用户名字段 - 唯一标识用户的字段,包含验证器和错误消息
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'},
help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
max_length=150, unique=True, max_length=150, unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name='username')), verbose_name='username')),
# 名字字段 - 用户的名字(西方命名习惯) # 名字字段 - 用户的名字(西方命名习惯)
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('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')), ('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')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
# 员工状态字段 - 标识用户是否可以登录管理后台 # 员工状态字段 - 标识用户是否可以登录管理后台
('is_staff', models.BooleanField(default=False, ('is_staff', models.BooleanField(default=False,
help_text='Designates whether the user can log into this admin site.', help_text='Designates whether the user can log into this admin site.',
verbose_name='staff status')), verbose_name='staff status')),
# 活跃状态字段 - 标识用户账号是否激活(软删除机制) # 活跃状态字段 - 标识用户账号是否激活(软删除机制)
('is_active', models.BooleanField(default=True, ('is_active', models.BooleanField(default=True,
help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
verbose_name='active')), verbose_name='active')),
# 注册时间字段 - 记录用户账号创建的时间 # 注册时间字段 - 记录用户账号创建的时间
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# 昵称字段 - 博客系统自定义字段,用户显示名称 # 昵称字段 - 博客系统自定义字段,用户显示名称
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
# 创建时间字段 - 博客系统自定义字段,记录创建时间 # 创建时间字段 - 博客系统自定义字段,记录创建时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, 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='修改时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 来源字段 - 博客系统自定义字段记录用户创建来源如注册、OAuth等 # 来源字段 - 博客系统自定义字段记录用户创建来源如注册、OAuth等
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
# 组关联字段 - Django权限系统的组多对多关联 # 组关联字段 - Django权限系统的组多对多关联
('groups', models.ManyToManyField(blank=True, ('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.', 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', related_name='user_set', related_query_name='user', to='auth.group',
verbose_name='groups')), verbose_name='groups')),
# 权限关联字段 - Django权限系统的用户权限多对多关联 # 权限关联字段 - Django权限系统的用户权限多对多关联
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.',
related_name='user_set', related_query_name='user', related_name='user_set', related_query_name='user',
to='auth.permission', verbose_name='user permissions')), to='auth.permission', verbose_name='user permissions')),
], ],
# 模型元数据配置
options={ options={
# 管理后台显示名称(中文) # 管理后台单数显示名称(中文)
'verbose_name': '用户', 'verbose_name': '用户',
# 管理后台复数显示名称(中文)
'verbose_name_plural': '用户', 'verbose_name_plural': '用户',
# 默认排序规则 - 按ID倒序排列 # 默认排序规则 - 按ID倒序排列(最新的记录在前)
'ordering': ['-id'], 'ordering': ['-id'],
# 指定获取最新记录的字段 # 指定获取最新记录的字段 - 使用id字段确定最新记录
'get_latest_by': 'id', 'get_latest_by': 'id',
}, },
# 定义模型管理器
managers=[ managers=[
# 使用Django内置的UserManager管理用户对象 # 使用Django内置的UserManager管理用户对象
# 提供create_user、create_superuser等用户管理方法
('objects', django.contrib.auth.models.UserManager()), ('objects', django.contrib.auth.models.UserManager()),
], ],
), ),

@ -1,3 +1,14 @@
"""
用户账户应用数据库迁移文件 - 字段优化更新
本迁移文件对初始用户模型进行字段优化和国际化改进
- 重命名时间字段使用更清晰的英文命名
- 更新字段显示名称统一使用英文verbose_name
- 移除冗余字段优化数据库结构
这是对0001_initial迁移的后续更新依赖于初始迁移创建的表结构
"""
# Generated by Django 4.2.5 on 2023-09-06 13:13 # Generated by Django 4.2.5 on 2023-09-06 13:13
from django.db import migrations, models from django.db import migrations, models
@ -5,42 +16,85 @@ import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""
用户模型字段优化迁移类
对BlogUser模型进行字段级别的优化和改进
- 标准化字段命名约定
- 改进国际化支持
- 优化时间字段的语义清晰度
此迁移依赖于accounts应用的0001_initial迁移文件
"""
# 定义迁移依赖关系 - 依赖于本应用的初始迁移
dependencies = [ dependencies = [
# 依赖accounts应用的第一个迁移文件确保BlogUser表已创建
('accounts', '0001_initial'), ('accounts', '0001_initial'),
] ]
# 定义迁移操作序列 - 按顺序执行以下数据库变更
operations = [ operations = [
# 修改模型选项 - 更新管理后台显示名称
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='bloguser', name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, options={
# 指定获取最新记录的字段 - 保持使用id字段
'get_latest_by': 'id',
# 保持默认排序规则 - 按ID倒序排列
'ordering': ['-id'],
# 更新单数显示名称为英文
'verbose_name': 'user',
# 更新复数显示名称为英文
'verbose_name_plural': 'user'
},
), ),
# 移除字段 - 删除created_time字段
# 该字段功能被creation_time字段替代
migrations.RemoveField( migrations.RemoveField(
model_name='bloguser', model_name='bloguser',
name='created_time', name='created_time',
), ),
# 移除字段 - 删除last_mod_time字段
# 该字段功能被last_modify_time字段替代
migrations.RemoveField( migrations.RemoveField(
model_name='bloguser', model_name='bloguser',
name='last_mod_time', name='last_mod_time',
), ),
# 添加新字段 - 创建时间字段(新命名)
migrations.AddField( migrations.AddField(
model_name='bloguser', model_name='bloguser',
name='creation_time', name='creation_time',
# 使用DateTimeField存储完整的时间戳
# default参数使用Django的时区感知当前时间
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 添加新字段 - 最后修改时间字段(新命名)
migrations.AddField( migrations.AddField(
model_name='bloguser', model_name='bloguser',
name='last_modify_time', name='last_modify_time',
# 使用DateTimeField存储完整的时间戳
# default参数使用Django的时区感知当前时间
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
), ),
# 修改字段选项 - 更新昵称字段的显示名称
migrations.AlterField( migrations.AlterField(
model_name='bloguser', model_name='bloguser',
name='nickname', name='nickname',
# 保持字段类型和约束不变仅更新verbose_name为英文
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
), ),
# 修改字段选项 - 更新来源字段的显示名称
migrations.AlterField( migrations.AlterField(
model_name='bloguser', model_name='bloguser',
name='source', name='source',
# 保持字段类型和约束不变仅更新verbose_name为英文
field=models.CharField(blank=True, max_length=100, verbose_name='create source'), field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
), ),
] ]

@ -1,3 +1,10 @@
"""
自定义用户模型模块
本模块定义博客系统的自定义用户模型BlogUser扩展Django内置的AbstractUser模型
添加博客系统特有的用户字段和方法
"""
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -6,30 +13,113 @@ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser): class BlogUser(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) 继承自Django的AbstractUser在标准用户模型基础上添加博客系统特有的字段
- 昵称字段
- 创建时间字段
- 最后修改时间字段
- 用户来源字段
同时提供获取用户相关URL的便捷方法
"""
# 昵称字段 - 用户的显示名称,可以为空
nickname = models.CharField(
_('nick name'), # 字段显示名称(支持国际化)
max_length=100, # 最大长度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, # 最大长度100字符
blank=True # 允许为空(非必填字段)
)
def get_absolute_url(self): def get_absolute_url(self):
"""
获取用户的绝对URL相对路径
用于Django的通用视图和模板中生成用户详情页链接
Returns:
str: 用户详情页的URL路径
Example:
>>> user.get_absolute_url()
'/author/admin/'
"""
# 使用reverse函数通过URL名称和参数生成URL路径
return reverse( return reverse(
'blog:author_detail', kwargs={ 'blog:author_detail', # URL配置的名称
'author_name': self.username}) kwargs={
'author_name': self.username # URL参数作者用户名
})
def __str__(self): def __str__(self):
"""
对象字符串表示方法
定义模型实例在Django管理后台和shell中的显示内容
Returns:
str: 用户的邮箱地址
"""
return self.email return self.email
def get_full_url(self): def get_full_url(self):
"""
获取用户的完整URL包含域名
生成包含协议和域名的完整用户详情页URL用于外部链接
Returns:
str: 完整的用户详情页URL
Example:
>>> user.get_full_url()
'https://example.com/author/admin/'
"""
# 获取当前站点的域名
site = get_current_site().domain site = get_current_site().domain
url = "https://{site}{path}".format(site=site, # 生成完整的URL包含HTTPS协议和域名
path=self.get_absolute_url()) url = "https://{site}{path}".format(
site=site, # 站点域名
path=self.get_absolute_url() # 相对路径
)
return url return url
class Meta: class Meta:
"""
模型元数据配置类
定义模型的数据库表配置和Django管理后台显示选项
"""
# 默认排序规则 - 按ID倒序排列最新的记录在前
ordering = ['-id'] ordering = ['-id']
# 管理后台单数显示名称(支持国际化)
verbose_name = _('user') verbose_name = _('user')
# 管理后台复数显示名称 - 使用与单数相同的名称
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
# 指定获取最新记录的字段 - 使用id字段确定最新记录1
get_latest_by = 'id' get_latest_by = 'id'

@ -1,3 +1,10 @@
"""
用户账户应用测试模块
本模块包含用户账户相关的所有测试用例覆盖用户注册登录密码重置
邮箱验证等核心功能的测试
"""
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -9,128 +16,203 @@ from djangoblog.utils import *
from . import utils from . import utils
# Create your tests here.
class AccountTest(TestCase): class AccountTest(TestCase):
"""
用户账户功能测试类
测试用户账户相关的所有功能包括
- 用户认证和登录
- 用户注册流程
- 邮箱验证码功能
- 密码重置流程
- 权限访问控制
"""
def setUp(self): def setUp(self):
"""
测试初始化方法
在每个测试方法执行前运行创建测试所需的初始数据和环境
"""
# 创建测试客户端用于模拟HTTP请求
self.client = Client() self.client = Client()
# 创建请求工厂,用于构建请求对象
self.factory = RequestFactory() self.factory = RequestFactory()
# 创建测试用户
self.blog_user = BlogUser.objects.create_user( self.blog_user = BlogUser.objects.create_user(
username="test", username="test",
email="admin@admin.com", email="admin@admin.com",
password="12345678" password="12345678"
) )
# 设置测试用的新密码
self.new_test = "xxx123--=" self.new_test = "xxx123--="
def test_validate_account(self): def test_validate_account(self):
"""
测试账户验证和权限功能
验证超级用户的创建登录管理后台访问和文章管理权限
"""
# 创建超级用户
site = get_current_site().domain site = get_current_site().domain
user = BlogUser.objects.create_superuser( user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com", email="liangliangyy1@gmail.com",
username="liangliangyy1", username="liangliangyy1",
password="qwer!@#$ggg") password="qwer!@#$ggg")
# 从数据库获取刚创建的用户
testuser = BlogUser.objects.get(username='liangliangyy1') testuser = BlogUser.objects.get(username='liangliangyy1')
# 测试用户登录功能
loginresult = self.client.login( loginresult = self.client.login(
username='liangliangyy1', username='liangliangyy1',
password='qwer!@#$ggg') password='qwer!@#$ggg')
# 断言登录成功
self.assertEqual(loginresult, True) self.assertEqual(loginresult, True)
# 测试管理后台访问权限
response = self.client.get('/admin/') response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 创建测试分类
category = Category() category = Category()
category.name = "categoryaaa" category.name = "categoryaaa"
category.creation_time = timezone.now() category.creation_time = timezone.now()
category.last_modify_time = timezone.now() category.last_modify_time = timezone.now()
category.save() category.save()
# 创建测试文章
article = Article() article = Article()
article.title = "nicetitleaaa" article.title = "nicetitleaaa"
article.body = "nicecontentaaa" article.body = "nicecontentaaa"
article.author = user article.author = user
article.category = category article.category = category
article.type = 'a' article.type = 'a' # 文章类型
article.status = 'p' article.status = 'p' # 发布状态
article.save() article.save()
# 测试文章管理页面访问权限
response = self.client.get(article.get_admin_url()) response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_validate_register(self): def test_validate_register(self):
"""
测试用户注册完整流程
验证用户注册邮箱验证登录权限提升和文章管理的完整流程
"""
# 验证注册前用户不存在
self.assertEquals( self.assertEquals(
0, len( 0, len(
BlogUser.objects.filter( BlogUser.objects.filter(
email='user123@user.com'))) email='user123@user.com')))
# 提交用户注册表单
response = self.client.post(reverse('account:register'), { response = self.client.post(reverse('account:register'), {
'username': 'user1233', 'username': 'user1233',
'email': 'user123@user.com', 'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T', 'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T', 'password2': 'password123!q@wE#R$T',
}) })
# 验证用户已成功创建
self.assertEquals( self.assertEquals(
1, len( 1, len(
BlogUser.objects.filter( BlogUser.objects.filter(
email='user123@user.com'))) email='user123@user.com')))
# 获取新创建的用户
user = BlogUser.objects.filter(email='user123@user.com')[0] user = BlogUser.objects.filter(email='user123@user.com')[0]
# 生成邮箱验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result') path = reverse('accounts:result')
url = '{path}?type=validation&id={id}&sign={sign}'.format( url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign) path=path, id=user.id, sign=sign)
# 访问验证结果页面
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 测试用户登录
self.client.login(username='user1233', password='password123!q@wE#R$T') self.client.login(username='user1233', password='password123!q@wE#R$T')
# 提升用户权限为超级用户
user = BlogUser.objects.filter(email='user123@user.com')[0] user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True user.is_superuser = True
user.is_staff = True user.is_staff = True
user.save() user.save()
# 清理侧边栏缓存
delete_sidebar_cache() delete_sidebar_cache()
# 创建测试分类
category = Category() category = Category()
category.name = "categoryaaa" category.name = "categoryaaa"
category.creation_time = timezone.now() category.creation_time = timezone.now()
category.last_modify_time = timezone.now() category.last_modify_time = timezone.now()
category.save() category.save()
# 创建测试文章
article = Article() article = Article()
article.category = category article.category = category
article.title = "nicetitle333" article.title = "nicetitle333"
article.body = "nicecontentttt" article.body = "nicecontentttt"
article.author = user article.author = user
article.type = 'a' # 文章类型
article.type = 'a' article.status = 'p' # 发布状态
article.status = 'p'
article.save() article.save()
# 测试文章管理页面访问
response = self.client.get(article.get_admin_url()) response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# 测试用户登出
response = self.client.get(reverse('account:logout')) response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200]) self.assertIn(response.status_code, [301, 302, 200])
# 验证登出后无法访问管理页面
response = self.client.get(article.get_admin_url()) response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200]) self.assertIn(response.status_code, [301, 302, 200])
# 测试错误密码登录
response = self.client.post(reverse('account:login'), { response = self.client.post(reverse('account:login'), {
'username': 'user1233', 'username': 'user1233',
'password': 'password123' 'password': 'password123' # 错误密码
}) })
self.assertIn(response.status_code, [301, 302, 200]) self.assertIn(response.status_code, [301, 302, 200])
# 验证错误密码登录后仍无法访问管理页面
response = self.client.get(article.get_admin_url()) response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200]) self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self): def test_verify_email_code(self):
"""
测试邮箱验证码功能
验证验证码的生成发送存储和验证流程
"""
to_email = "admin@admin.com" to_email = "admin@admin.com"
# 生成验证码
code = generate_code() code = generate_code()
# 存储验证码
utils.set_code(to_email, code) utils.set_code(to_email, code)
# 发送验证邮件
utils.send_verify_email(to_email, code) utils.send_verify_email(to_email, code)
# 测试正确验证码验证
err = utils.verify("admin@admin.com", code) err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None) self.assertEqual(err, None) # 验证成功应返回None
# 测试错误邮箱验证
err = utils.verify("admin@123.com", code) err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str) self.assertEqual(type(err), str) # 验证失败应返回错误信息字符串
def test_forget_password_email_code_success(self): def test_forget_password_email_code_success(self):
"""
测试忘记密码验证码请求成功场景
验证正确邮箱地址的验证码请求处理
"""
resp = self.client.post( resp = self.client.post(
path=reverse("account:forget_password_code"), path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com") data=dict(email="admin@admin.com")
@ -140,32 +222,49 @@ class AccountTest(TestCase):
self.assertEqual(resp.content.decode("utf-8"), "ok") self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self): def test_forget_password_email_code_fail(self):
"""
测试忘记密码验证码请求失败场景
验证空邮箱和错误格式邮箱的请求处理
"""
# 测试空邮箱提交
resp = self.client.post( resp = self.client.post(
path=reverse("account:forget_password_code"), path=reverse("account:forget_password_code"),
data=dict() data=dict() # 空数据
) )
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 测试错误格式邮箱提交
resp = self.client.post( resp = self.client.post(
path=reverse("account:forget_password_code"), path=reverse("account:forget_password_code"),
data=dict(email="admin@com") data=dict(email="admin@com") # 无效邮箱格式
) )
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self): def test_forget_password_email_success(self):
"""
测试忘记密码重置成功场景
验证正确的验证码和密码重置流程
"""
# 生成并设置验证码
code = generate_code() code = generate_code()
utils.set_code(self.blog_user.email, code) utils.set_code(self.blog_user.email, code)
# 准备密码重置数据
data = dict( data = dict(
new_password1=self.new_test, new_password1=self.new_test,
new_password2=self.new_test, new_password2=self.new_test,
email=self.blog_user.email, email=self.blog_user.email,
code=code, code=code,
) )
# 提交密码重置请求
resp = self.client.post( resp = self.client.post(
path=reverse("account:forget_password"), path=reverse("account:forget_password"),
data=data data=data
) )
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 302) # 重定向响应
# 验证用户密码是否修改成功 # 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter( blog_user = BlogUser.objects.filter(
@ -175,10 +274,15 @@ class AccountTest(TestCase):
self.assertEqual(blog_user.check_password(data["new_password1"]), True) self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self): def test_forget_password_email_not_user(self):
"""
测试不存在的用户密码重置
验证对不存在用户的密码重置请求处理
"""
data = dict( data = dict(
new_password1=self.new_test, new_password1=self.new_test,
new_password2=self.new_test, new_password2=self.new_test,
email="123@123.com", email="123@123.com", # 不存在的邮箱
code="123456", code="123456",
) )
resp = self.client.post( resp = self.client.post(
@ -186,22 +290,25 @@ class AccountTest(TestCase):
data=data data=data
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200) # 应返回表单错误页面
def test_forget_password_email_code_error(self): def test_forget_password_email_code_error(self):
"""
测试错误验证码的密码重置
验证错误验证码的密码重置请求处理
"""
code = generate_code() code = generate_code()
utils.set_code(self.blog_user.email, code) utils.set_code(self.blog_user.email, code)
data = dict( data = dict(
new_password1=self.new_test, new_password1=self.new_test,
new_password2=self.new_test, new_password2=self.new_test,
email=self.blog_user.email, email=self.blog_user.email,
code="111111", code="111111", # 错误的验证码
) )
resp = self.client.post( resp = self.client.post(
path=reverse("account:forget_password"), path=reverse("account:forget_password"),
data=data data=data
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200) # 应返回表单错误页面

@ -1,28 +1,60 @@
"""
用户账户应用URL配置模块
本模块定义用户账户相关的所有URL路由包括登录注册登出
密码重置等用户认证相关的端点
URL模式使用正则表达式和路径转换器来匹配不同的用户操作请求
"""
from django.urls import path from django.urls import path
from django.urls import re_path from django.urls import re_path
# 导入视图模块
from . import views from . import views
# 导入自定义登录表单
from .forms import LoginForm from .forms import LoginForm
# 应用命名空间用于URL反向解析
app_name = "accounts" app_name = "accounts"
urlpatterns = [re_path(r'^login/$', # URL模式列表定义请求URL与视图的映射关系
urlpatterns = [
# 用户登录URL
re_path(r'^login/$', # 匹配 /login/ 路径
# 使用类视图设置登录成功后的重定向URL为首页
views.LoginView.as_view(success_url='/'), views.LoginView.as_view(success_url='/'),
name='login', name='login', # URL名称用于反向解析
# 传递额外参数,指定使用自定义登录表单
kwargs={'authentication_form': LoginForm}), kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
# 用户注册URL
re_path(r'^register/$', # 匹配 /register/ 路径
# 使用类视图设置注册成功后的重定向URL为首页
views.RegisterView.as_view(success_url="/"), views.RegisterView.as_view(success_url="/"),
name='register'), name='register'), # URL名称用于反向解析
re_path(r'^logout/$',
# 用户登出URL
re_path(r'^logout/$', # 匹配 /logout/ 路径
# 使用类视图,处理用户登出逻辑
views.LogoutView.as_view(), views.LogoutView.as_view(),
name='logout'), name='logout'), # URL名称用于反向解析
path(r'account/result.html',
# 账户操作结果页面URL
path(r'account/result.html', # 匹配 /account/result.html 路径
# 使用函数视图,显示账户操作结果(如注册成功、验证结果等)
views.account_result, views.account_result,
name='result'), name='result'), # URL名称用于反向解析
re_path(r'^forget_password/$',
# 忘记密码页面URL
re_path(r'^forget_password/$', # 匹配 /forget_password/ 路径
# 使用类视图,处理密码重置请求
views.ForgetPasswordView.as_view(), views.ForgetPasswordView.as_view(),
name='forget_password'), name='forget_password'), # URL名称用于反向解析
re_path(r'^forget_password_code/$',
# 忘记密码验证码请求URL
re_path(r'^forget_password_code/$', # 匹配 /forget_password_code/ 路径
# 使用类视图,处理发送密码重置验证码的请求
views.ForgetPasswordEmailCode.as_view(), views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'), name='forget_password_code'), # URL名称用于反向解析
] ]

@ -1,26 +1,91 @@
"""
自定义用户认证后端模块
本模块提供扩展的用户认证功能支持使用用户名或邮箱进行登录
扩展了Django标准的ModelBackend认证后端
"""
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend): class EmailOrUsernameModelBackend(ModelBackend):
""" """
允许使用用户名或邮箱登录 自定义用户认证后端 - 支持用户名或邮箱登录
继承自Django的ModelBackend扩展认证功能
- 允许用户使用用户名或邮箱地址进行登录
- 自动检测输入的是用户名还是邮箱格式
- 保持与Django原生认证系统的兼容性
使用场景
当用户输入包含'@'符号时系统将其识别为邮箱进行认证
否则将其识别为用户名进行认证
""" """
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None, **kwargs):
"""
用户认证方法
重写认证逻辑支持通过用户名或邮箱进行用户身份验证
Args:
request: HttpRequest对象包含请求信息
username (str): 用户输入的用户名或邮箱地址
password (str): 用户输入的密码
**kwargs: 其他关键字参数
Returns:
User: 认证成功的用户对象
None: 认证失败返回None
Example:
>>> backend = EmailOrUsernameModelBackend()
>>> # 使用用户名认证
>>> user = backend.authenticate(request, username='admin', password='password')
>>> # 使用邮箱认证
>>> user = backend.authenticate(request, username='admin@example.com', password='password')
"""
# 判断输入的是邮箱还是用户名
if '@' in username: if '@' in username:
# 如果包含'@'符号,按邮箱处理
kwargs = {'email': username} kwargs = {'email': username}
else: else:
# 否则按用户名处理
kwargs = {'username': username} kwargs = {'username': username}
try: try:
# 根据用户名或邮箱查找用户
user = get_user_model().objects.get(**kwargs) user = get_user_model().objects.get(**kwargs)
# 验证密码是否正确
if user.check_password(password): if user.check_password(password):
# 密码验证成功,返回用户对象
return user return user
except get_user_model().DoesNotExist: except get_user_model().DoesNotExist:
# 用户不存在返回None表示认证失败
return None return None
def get_user(self, username): def get_user(self, username):
"""
根据用户ID获取用户对象
重写用户获取方法通过用户ID主键获取用户实例
Args:
username (int/str): 用户的ID主键值
Returns:
User: 对应的用户对象
None: 用户不存在时返回None
Note:
这里的参数名username实际上是用户ID这是为了保持与父类接口一致
"""
try: try:
# 根据主键用户ID查找用户
return get_user_model().objects.get(pk=username) return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist: except get_user_model().DoesNotExist:
# 用户不存在返回None
return None return None

@ -1,3 +1,15 @@
"""
邮箱验证码工具模块
本模块提供邮箱验证码的生成发送验证和缓存管理功能
用于用户注册密码重置等需要邮箱验证的场景
主要功能
- 发送验证码邮件
- 验证码的存储和读取
- 验证码有效性验证
"""
import typing import typing
from datetime import timedelta from datetime import timedelta
@ -7,43 +19,109 @@ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email from djangoblog.utils import send_email
# 验证码有效期配置 - 5分钟
_code_ttl = timedelta(minutes=5) _code_ttl = timedelta(minutes=5)
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码 """
发送验证码邮件
向指定邮箱发送包含验证码的邮件用于用户身份验证
Args: Args:
to_mail: 接受邮箱 to_mail (str): 接收邮件的邮箱地址
subject: 邮件主题 code (str): 要发送的验证码
code: 验证码 subject (str): 邮件主题默认为"Verify Email"
Example:
>>> send_verify_email("user@example.com", "123456")
# 向user@example.com发送验证码123456
""" """
# 构建邮件HTML内容包含验证码信息
html_content = _( html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it " "You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code} "properly") % {'code': code}
# 调用邮件发送工具发送邮件
send_email([to_mail], subject, html_content) send_email([to_mail], subject, html_content)
def verify(email: str, code: str) -> typing.Optional[str]: def verify(email: str, code: str) -> typing.Optional[str]:
"""验证code是否有效 """
验证验证码是否有效
检查用户输入的验证码与缓存中存储的是否一致并验证有效性
Args: Args:
email: 请求邮箱 email (str): 用户邮箱地址作为缓存键
code: 验证码 code (str): 用户输入的验证码
Return:
如果有错误就返回错误str Returns:
Node: typing.Optional[str]:
这里的错误处理不太合理应该采用raise抛出 - None: 验证码正确且有效
否测调用方也需要对error进行处理 - str: 错误信息字符串验证码错误或无效
Note:
当前错误处理方式不够合理应该使用异常抛出机制
这样调用方可以通过try-except处理错误而不是检查返回值
Example:
>>> result = verify("user@example.com", "123456")
>>> if result:
>>> print(f"验证失败: {result}")
>>> else:
>>> print("验证成功")
""" """
# 从缓存中获取该邮箱对应的验证码
cache_code = get_code(email) cache_code = get_code(email)
# 比较用户输入的验证码与缓存中的验证码
if cache_code != code: if cache_code != code:
# 验证码不匹配,返回错误信息
return gettext("Verification code error") return gettext("Verification code error")
# 验证成功返回None
def set_code(email: str, code: str): def set_code(email: str, code: str):
"""设置code""" """
设置验证码到缓存
将验证码存储到Django缓存系统中并设置有效期
Args:
email (str): 邮箱地址作为缓存键
code (str): 要存储的验证码
Example:
>>> set_code("user@example.com", "123456")
# 将验证码123456存储到缓存键为"user@example.com"
"""
# 使用Django缓存系统存储验证码设置5分钟有效期
cache.set(email, code, _code_ttl.seconds) cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]: def get_code(email: str) -> typing.Optional[str]:
"""获取code""" """
从缓存中获取验证码
根据邮箱地址从缓存中获取对应的验证码
Args:
email (str): 邮箱地址作为缓存键
Returns:
typing.Optional[str]:
- str: 找到的验证码
- None: 验证码不存在或已过期
Example:
>>> code = get_code("user@example.com")
>>> if code:
>>> print(f"验证码是: {code}")
>>> else:
>>> print("验证码不存在或已过期")
"""
# 从Django缓存系统中获取验证码
return cache.get(email) return cache.get(email)

@ -1,3 +1,15 @@
"""
用户账户视图模块
本模块包含用户账户相关的所有视图处理逻辑包括
- 用户注册登录登出
- 邮箱验证
- 密码重置
- 验证码发送
使用类视图和函数视图结合的方式处理用户认证流程
"""
import logging import logging
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
@ -26,34 +38,68 @@ from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
from .models import BlogUser from .models import BlogUser
# 配置日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Create your views here.
class RegisterView(FormView): class RegisterView(FormView):
"""
用户注册视图
处理新用户注册流程包括表单验证用户创建邮箱验证邮件发送等
"""
# 指定使用的表单类
form_class = RegisterForm form_class = RegisterForm
# 指定注册页面模板
template_name = 'account/registration_form.html' template_name = 'account/registration_form.html'
@method_decorator(csrf_protect) @method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
"""
请求分发方法添加CSRF保护装饰器
确保注册请求受到CSRF保护防止跨站请求伪造攻击
"""
return super(RegisterView, self).dispatch(*args, **kwargs) return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理逻辑
创建新用户发送验证邮件重定向到结果页面
Args:
form: 验证通过的注册表单实例
Returns:
HttpResponseRedirect: 重定向到结果页面
"""
if form.is_valid(): if form.is_valid():
# 创建用户但不立即保存到数据库
user = form.save(False) user = form.save(False)
# 设置用户为非激活状态,等待邮箱验证
user.is_active = False user.is_active = False
# 记录用户来源为注册页面
user.source = 'Register' user.source = 'Register'
# 保存用户到数据库
user.save(True) user.save(True)
# 获取当前站点域名
site = get_current_site().domain site = get_current_site().domain
# 生成邮箱验证签名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 调试模式下使用本地地址
if settings.DEBUG: if settings.DEBUG:
site = '127.0.0.1:8000' site = '127.0.0.1:8000'
# 构建验证URL
path = reverse('account:result') path = reverse('account:result')
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign) site=site, path=path, id=user.id, sign=sign)
# 构建邮件内容
content = """ content = """
<p>请点击下面链接验证您的邮箱</p> <p>请点击下面链接验证您的邮箱</p>
@ -64,6 +110,8 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器 如果上面链接无法打开请将此链接复制至浏览器
{url} {url}
""".format(url=url) """.format(url=url)
# 发送验证邮件
send_email( send_email(
emailto=[ emailto=[
user.email, user.email,
@ -71,43 +119,88 @@ class RegisterView(FormView):
title='验证您的电子邮箱', title='验证您的电子邮箱',
content=content) content=content)
# 重定向到注册结果页面
url = reverse('accounts:result') + \ url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id) '?type=register&id=' + str(user.id)
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
else: else:
# 表单验证失败,重新渲染表单页面
return self.render_to_response({ return self.render_to_response({
'form': form 'form': form
}) })
class LogoutView(RedirectView): class LogoutView(RedirectView):
"""
用户登出视图
处理用户登出逻辑清理会话和缓存
"""
# 登出后重定向到的URL
url = '/login/' url = '/login/'
@method_decorator(never_cache) @method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""
请求分发方法添加不缓存装饰器
确保登出页面不会被浏览器缓存
"""
return super(LogoutView, self).dispatch(request, *args, **kwargs) return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""
处理GET请求的登出逻辑
执行用户登出操作清理侧边栏缓存
"""
# 执行用户登出
logout(request) logout(request)
# 清理侧边栏缓存
delete_sidebar_cache() delete_sidebar_cache()
# 调用父类方法进行重定向
return super(LogoutView, self).get(request, *args, **kwargs) return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView): class LoginView(FormView):
"""
用户登录视图
处理用户登录认证支持记住登录状态功能
"""
# 指定使用的表单类
form_class = LoginForm form_class = LoginForm
# 指定登录页面模板
template_name = 'account/login.html' template_name = 'account/login.html'
# 登录成功后的默认重定向URL
success_url = '/' success_url = '/'
# 重定向字段名称
redirect_field_name = REDIRECT_FIELD_NAME redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间 # 记住登录状态的会话有效期(一个月)
login_ttl = 2626560
@method_decorator(sensitive_post_parameters('password')) @method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect) @method_decorator(csrf_protect)
@method_decorator(never_cache) @method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""
请求分发方法添加安全装饰器
- sensitive_post_parameters: 保护密码参数
- csrf_protect: CSRF保护
- never_cache: 禁止缓存
"""
return super(LoginView, self).dispatch(request, *args, **kwargs) return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""
获取模板上下文数据
添加重定向URL到上下文
"""
# 从GET参数获取重定向URL
redirect_to = self.request.GET.get(self.redirect_field_name) redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None: if redirect_to is None:
redirect_to = '/' redirect_to = '/'
@ -116,25 +209,43 @@ class LoginView(FormView):
return super(LoginView, self).get_context_data(**kwargs) return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理逻辑
执行用户登录认证处理记住登录状态
"""
# 使用Django认证表单进行验证
form = AuthenticationForm(data=self.request.POST, request=self.request) form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid(): if form.is_valid():
# 清理侧边栏缓存
delete_sidebar_cache() delete_sidebar_cache()
logger.info(self.redirect_field_name) logger.info(self.redirect_field_name)
# 执行用户登录
auth.login(self.request, form.get_user()) auth.login(self.request, form.get_user())
# 处理记住登录状态
if self.request.POST.get("remember"): if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl) self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(form) return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else: else:
# 登录失败,重新渲染登录页面
return self.render_to_response({ return self.render_to_response({
'form': form 'form': form
}) })
def get_success_url(self): def get_success_url(self):
"""
获取登录成功后的重定向URL
验证重定向URL的安全性防止开放重定向攻击
"""
# 从POST数据获取重定向URL
redirect_to = self.request.POST.get(self.redirect_field_name) redirect_to = self.request.POST.get(self.redirect_field_name)
# 验证URL是否安全同源策略
if not url_has_allowed_host_and_scheme( if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[ url=redirect_to, allowed_hosts=[
self.request.get_host()]): self.request.get_host()]):
@ -143,62 +254,124 @@ class LoginView(FormView):
def account_result(request): def account_result(request):
"""
账户操作结果页面视图
显示注册结果或处理邮箱验证
Args:
request: HTTP请求对象
Returns:
HttpResponse: 结果页面响应
"""
# 获取操作类型和用户ID
type = request.GET.get('type') type = request.GET.get('type')
id = request.GET.get('id') id = request.GET.get('id')
# 获取用户对象不存在则返回404
user = get_object_or_404(get_user_model(), id=id) user = get_object_or_404(get_user_model(), id=id)
logger.info(type) logger.info(type)
# 如果用户已激活,重定向到首页
if user.is_active: if user.is_active:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
# 处理注册和验证类型
if type and type in ['register', 'validation']: if type and type in ['register', 'validation']:
if type == 'register': if type == 'register':
# 注册成功页面
content = ''' content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站 恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
''' '''
title = '注册成功' title = '注册成功'
else: else:
# 邮箱验证处理
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign') sign = request.GET.get('sign')
# 验证签名
if sign != c_sign: if sign != c_sign:
return HttpResponseForbidden() return HttpResponseForbidden()
# 激活用户账号
user.is_active = True user.is_active = True
user.save() user.save()
content = ''' content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站 恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
''' '''
title = '验证成功' title = '验证成功'
# 渲染结果页面
return render(request, 'account/result.html', { return render(request, 'account/result.html', {
'title': title, 'title': title,
'content': content 'content': content
}) })
else: else:
# 无效类型,重定向到首页
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
class ForgetPasswordView(FormView): class ForgetPasswordView(FormView):
"""
忘记密码重置视图
处理用户密码重置请求验证验证码并更新密码
"""
# 指定使用的表单类
form_class = ForgetPasswordForm form_class = ForgetPasswordForm
# 指定模板名称
template_name = 'account/forget_password.html' template_name = 'account/forget_password.html'
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理逻辑
重置用户密码并重定向到登录页面
"""
if form.is_valid(): if form.is_valid():
# 根据邮箱查找用户
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
# 使用新密码的哈希值更新用户密码
blog_user.password = make_password(form.cleaned_data["new_password2"]) blog_user.password = make_password(form.cleaned_data["new_password2"])
# 保存用户信息
blog_user.save() blog_user.save()
# 重定向到登录页面
return HttpResponseRedirect('/login/') return HttpResponseRedirect('/login/')
else: else:
# 表单验证失败,重新渲染表单
return self.render_to_response({'form': form}) return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View): class ForgetPasswordEmailCode(View):
"""
忘记密码验证码发送视图
处理密码重置验证码的发送请求
"""
def post(self, request: HttpRequest): def post(self, request: HttpRequest):
"""
处理POST请求发送密码重置验证码
Args:
request: HTTP请求对象
Returns:
HttpResponse: 操作结果响应
"""
# 验证表单数据
form = ForgetPasswordCodeForm(request.POST) form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid(): if not form.is_valid():
return HttpResponse("错误的邮箱") return HttpResponse("错误的邮箱")
# 获取邮箱地址
to_email = form.cleaned_data["email"] to_email = form.cleaned_data["email"]
# 生成验证码
code = generate_code() code = generate_code()
# 发送验证邮件
utils.send_verify_email(to_email, code) utils.send_verify_email(to_email, code)
# 存储验证码到缓存
utils.set_code(to_email, code) utils.set_code(to_email, code)
return HttpResponse("ok") return HttpResponse("ok")

@ -1,54 +1,144 @@
"""
Django Admin 管理站点配置模块 - OAuth 认证
该模块用于配置OAuth相关模型在Django Admin管理站点的显示和操作方式
包含OAuth用户和OAuth配置两个管理类用于自定义管理界面
"""
import logging import logging
# 导入Django Admin管理模块
from django.contrib import admin from django.contrib import admin
# Register your models here. # 导入URL反向解析功能
from django.urls import reverse from django.urls import reverse
# 导入HTML格式化工具
from django.utils.html import format_html from django.utils.html import format_html
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OAuthUserAdmin(admin.ModelAdmin): class OAuthUserAdmin(admin.ModelAdmin):
"""
OAuth用户模型的管理配置类
自定义OAuthUser模型在Django Admin中的显示和行为
包括搜索列表显示过滤等功能
"""
# 设置可搜索的字段
search_fields = ('nickname', 'email') search_fields = ('nickname', 'email')
# 设置每页显示的项目数量
list_per_page = 20 list_per_page = 20
# 设置列表页面显示的字段
list_display = ( list_display = (
'id', 'id', # 用户ID
'nickname', 'nickname', # 昵称
'link_to_usermodel', 'link_to_usermodel', # 自定义方法:关联本地用户链接
'show_user_image', 'show_user_image', # 自定义方法:显示用户头像
'type', 'type', # OAuth类型
'email', 'email', # 邮箱
) )
# 设置可作为链接点击的字段(跳转到编辑页面)
list_display_links = ('id', 'nickname') list_display_links = ('id', 'nickname')
# 设置右侧过滤侧边栏的过滤字段
list_filter = ('author', 'type',) list_filter = ('author', 'type',)
# 初始化只读字段列表
readonly_fields = [] readonly_fields = []
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
"""
动态获取只读字段列表
重写方法使所有字段在Admin中均为只读防止在管理界面修改OAuth用户数据
Args:
request: HttpRequest对象
obj: 模型实例对象
Returns:
list: 包含所有字段名的列表使所有字段只读
"""
# 返回所有字段名称的列表,包括普通字段和多对多字段
return list(self.readonly_fields) + \ return list(self.readonly_fields) + \
[field.name for field in obj._meta.fields] + \ [field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many] [field.name for field in obj._meta.many_to_many]
def has_add_permission(self, request): def has_add_permission(self, request):
"""
禁用添加权限
防止在Admin界面手动添加OAuth用户OAuth用户只能通过认证流程自动创建
Args:
request: HttpRequest对象
Returns:
bool: 始终返回False禁止添加新记录
"""
return False return False
def link_to_usermodel(self, obj): def link_to_usermodel(self, obj):
"""
自定义方法生成关联本地用户的链接
在Admin列表中显示关联的本地用户并提供跳转到用户编辑页面的链接
Args:
obj: OAuthUser实例对象
Returns:
str: 格式化的HTML链接包含用户昵称或邮箱显示
"""
# 检查是否存在关联的本地用户
if obj.author: if obj.author:
# 获取关联用户模型的app和model信息
info = (obj.author._meta.app_label, obj.author._meta.model_name) info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 生成编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回格式化的HTML链接显示用户昵称或邮箱
return format_html( return format_html(
u'<a href="%s">%s</a>' % u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email)) (link, obj.author.nickname if obj.author.nickname else obj.author.email))
def show_user_image(self, obj): def show_user_image(self, obj):
"""
自定义方法显示用户头像
在Admin列表中以缩略图形式显示用户的第三方平台头像
Args:
obj: OAuthUser实例对象
Returns:
str: 格式化的HTML图片标签
"""
# 获取用户头像URL
img = obj.picture img = obj.picture
# 返回格式化的HTML图片标签设置固定尺寸
return format_html( return format_html(
u'<img src="%s" style="width:50px;height:50px"></img>' % u'<img src="%s" style="width:50px;height:50px"></img>' %
(img)) (img))
link_to_usermodel.short_description = '用户' # 设置自定义方法在Admin中的显示名称
show_user_image.short_description = '用户头像' link_to_usermodel.short_description = '用户' # 关联用户列的显示名称
show_user_image.short_description = '用户头像' # 用户头像列的显示名称
class OAuthConfigAdmin(admin.ModelAdmin): class OAuthConfigAdmin(admin.ModelAdmin):
list_display = ('type', 'appkey', 'appsecret', 'is_enable') """
list_filter = ('type',) OAuth配置模型的管理配置类
自定义OAuthConfig模型在Django Admin中的显示方式
用于管理不同第三方平台的OAuth应用配置
"""
# 设置列表页面显示的字段
list_display = (
'type', # OAuth类型
'appkey', # 应用Key
'appsecret', # 应用Secret
'is_enable' # 是否启用
)
# 设置右侧过滤侧边栏的过滤字段
list_filter = ('type',) # 按OAuth类型过滤

@ -1,12 +1,46 @@
"""
OAuth 认证表单模块 - 邮箱补充表单
该模块定义了在OAuth认证过程中需要用户补充邮箱信息时使用的表单
当第三方登录未返回邮箱地址时使用此表单让用户手动输入邮箱
"""
# 导入Django表单相关模块
from django.contrib.auth.forms import forms from django.contrib.auth.forms import forms
from django.forms import widgets from django.forms import widgets
class RequireEmailForm(forms.Form): class RequireEmailForm(forms.Form):
"""
邮箱补充表单类
用于OAuth登录过程中当第三方平台未提供用户邮箱时
要求用户手动输入邮箱地址的表单
"""
# 邮箱字段,必填字段,用于用户输入电子邮箱
email = forms.EmailField(label='电子邮箱', required=True) email = forms.EmailField(label='电子邮箱', required=True)
# 隐藏的OAuth用户ID字段用于关联OAuth用户记录
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""
初始化表单自定义字段控件属性
重写初始化方法为邮箱字段添加HTML属性和样式类
改善用户体验和界面美观
Args:
*args: 可变位置参数
**kwargs: 可变关键字参数
"""
# 调用父类的初始化方法
super(RequireEmailForm, self).__init__(*args, **kwargs) super(RequireEmailForm, self).__init__(*args, **kwargs)
# 自定义邮箱字段的widget添加placeholder和CSS类
self.fields['email'].widget = widgets.EmailInput( self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"}) attrs={
'placeholder': "email", # 输入框内的提示文本
"class": "form-control" # Bootstrap样式类用于表单控件样式
})

@ -1,4 +1,7 @@
""" """
<<<<<<< HEAD
Django 数据库迁移模块 - OAuth 认证配置
=======
OAuth应用数据库迁移文件 OAuth应用数据库迁移文件
本迁移文件由Django自动生成用于创建OAuth认证相关的数据库表结构 本迁移文件由Django自动生成用于创建OAuth认证相关的数据库表结构
@ -13,29 +16,74 @@ OAuth应用数据库迁移文件
""" """
# Generated by Django 4.1.7 on 2023-03-07 09:53 # Generated by Django 4.1.7 on 2023-03-07 09:53
>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5
from django.conf import settings 该模块用于创建OAuth认证相关的数据库表结构包含OAuth服务提供商配置和OAuth用户信息两个主要模型
from django.db import migrations, models 这是Django迁移系统自动生成的迁移文件在Django 4.1.7版本中创建于2023-03-07
import django.db.models.deletion """
import django.utils.timezone
# 导入Django核心模块
from django.conf import settings # 导入Django设置
from django.db import migrations, models # 导入数据库迁移和模型相关功能
import django.db.models.deletion # 导入外键删除操作
import django.utils.timezone # 导入时区工具
class Migration(migrations.Migration): class Migration(migrations.Migration):
""" """
<<<<<<< HEAD
OAuth认证系统的数据库迁移类
=======
OAuth应用初始迁移类 OAuth应用初始迁移类
继承自migrations.Migration定义数据库表结构的创建操作 继承自migrations.Migration定义数据库表结构的创建操作
initial = True 表示这是该应用的第一个迁移文件 initial = True 表示这是该应用的第一个迁移文件
""" """
>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5
这个迁移类负责创建OAuth认证功能所需的数据库表结构
包括OAuth服务提供商配置和第三方登录用户信息存储
"""
# 标记为初始迁移
initial = True initial = True
# 定义依赖关系 - 依赖于可切换的用户模型
dependencies = [ dependencies = [
# 声明对Django用户模型的依赖 # 声明对Django用户模型的依赖
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
# 定义要执行的数据库操作
operations = [ operations = [
<<<<<<< HEAD
# 创建OAuthConfig模型对应的数据库表
migrations.CreateModel(
name='OAuthConfig',
fields=[
# 主键ID字段自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# OAuth服务类型选择字段支持多种第三方登录
('type', models.CharField(
choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'),
('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
# OAuth应用的AppKey字段
('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
# OAuth应用的AppSecret字段用于安全认证
('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
# OAuth回调地址字段用于接收授权码
('callback_url',
models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),
# 是否启用该OAuth配置的布尔字段
('is_enable', models.BooleanField(default=True, 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='修改时间')),
],
options={
# 设置模型在Admin中的单数显示名称
=======
# 创建OAuth配置表 # 创建OAuth配置表
migrations.CreateModel( migrations.CreateModel(
name='OAuthConfig', name='OAuthConfig',
@ -62,8 +110,41 @@ class Migration(migrations.Migration):
], ],
options={ options={
# 管理后台显示名称 # 管理后台显示名称
>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5
'verbose_name': 'oauth配置', 'verbose_name': 'oauth配置',
# 设置模型在Admin中的复数显示名称
'verbose_name_plural': 'oauth配置', 'verbose_name_plural': 'oauth配置',
<<<<<<< HEAD
# 设置默认排序字段,按创建时间降序排列
'ordering': ['-created_time'],
},
),
# 创建OAuthUser模型对应的数据库表
migrations.CreateModel(
name='OAuthUser',
fields=[
# 主键ID字段自增BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 第三方平台的用户唯一标识
('openid', models.CharField(max_length=50)),
# 用户在第三方平台的昵称
('nickname', models.CharField(max_length=50, verbose_name='昵称')),
# OAuth访问令牌可为空
('token', models.CharField(blank=True, max_length=150, null=True)),
# 用户头像URL可为空
('picture', models.CharField(blank=True, max_length=350, null=True)),
# OAuth服务类型
('type', models.CharField(max_length=50)),
# 用户邮箱,可为空
('email', models.CharField(blank=True, max_length=50, null=True)),
# 存储额外的元数据信息使用Text字段
('metadata', models.TextField(blank=True, null=True)),
# 记录创建时间,默认使用当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 记录最后修改时间,默认使用当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 外键关联到本地用户模型,建立第三方账号与本地用户的关联
=======
# 默认排序规则 - 按创建时间倒序 # 默认排序规则 - 按创建时间倒序
'ordering': ['-created_time'], 'ordering': ['-created_time'],
}, },
@ -93,14 +174,24 @@ class Migration(migrations.Migration):
# 最后修改时间 # 最后修改时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 外键关联到本地用户 - 建立第三方账号与本地账号的关联 # 外键关联到本地用户 - 建立第三方账号与本地账号的关联
>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL, verbose_name='用户')), to=settings.AUTH_USER_MODEL, verbose_name='用户')),
], ],
options={ options={
<<<<<<< HEAD
# 设置模型在Admin中的单数显示名称
=======
# 管理后台显示名称 # 管理后台显示名称
>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5
'verbose_name': 'oauth用户', 'verbose_name': 'oauth用户',
# 设置模型在Admin中的复数显示名称
'verbose_name_plural': 'oauth用户', 'verbose_name_plural': 'oauth用户',
<<<<<<< HEAD
# 设置默认排序字段,按创建时间降序排列
=======
# 默认排序规则 # 默认排序规则
>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5
'ordering': ['-created_time'], 'ordering': ['-created_time'],
}, },
), ),

@ -1,86 +1,138 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13 """
Django 数据库迁移模块 - OAuth 认证配置更新
from django.conf import settings 该模块是OAuth认证系统的第二次迁移主要用于优化字段命名和国际化显示
from django.db import migrations, models 对已有的OAuthConfig和OAuthUser模型进行字段调整和选项更新
import django.db.models.deletion 这是Django迁移系统自动生成的迁移文件在Django 4.2.5版本中创建于2023-09-06
import django.utils.timezone """
# 导入Django核心模块
from django.conf import settings # 导入Django设置
from django.db import migrations, models # 导入数据库迁移和模型相关功能
import django.db.models.deletion # 导入外键删除操作
import django.utils.timezone # 导入时区工具
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""
OAuth认证系统的数据库迁移更新类
这个迁移类负责对已有的OAuth相关模型进行字段优化和国际化改进
主要涉及时间字段重命名和verbose_name的英文标准化
"""
# 定义依赖关系 - 依赖于初始迁移和用户模型
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth', '0001_initial'), ('oauth', '0001_initial'), # 依赖于oauth应用的初始迁移
] ]
# 定义要执行的数据库操作序列
operations = [ operations = [
# 修改OAuthConfig模型的选项配置
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='oauthconfig', name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'}, options={
# 更新排序字段为新的creation_time字段
'ordering': ['-creation_time'],
# 保持中文显示名称不变
'verbose_name': 'oauth配置',
'verbose_name_plural': 'oauth配置'
},
), ),
# 修改OAuthUser模型的选项配置
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='oauthuser', name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'}, options={
# 更新排序字段为新的creation_time字段
'ordering': ['-creation_time'],
# 将显示名称改为英文
'verbose_name': 'oauth user',
'verbose_name_plural': 'oauth user'
},
), ),
# 移除OAuthConfig模型的旧时间字段
migrations.RemoveField( migrations.RemoveField(
model_name='oauthconfig', model_name='oauthconfig',
name='created_time', name='created_time', # 删除旧的创建时间字段
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='oauthconfig', model_name='oauthconfig',
name='last_mod_time', name='last_mod_time', # 删除旧的修改时间字段
), ),
# 移除OAuthUser模型的旧时间字段
migrations.RemoveField( migrations.RemoveField(
model_name='oauthuser', model_name='oauthuser',
name='created_time', name='created_time', # 删除旧的创建时间字段
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='oauthuser', model_name='oauthuser',
name='last_mod_time', name='last_mod_time', # 删除旧的修改时间字段
), ),
# 为OAuthConfig模型添加新的创建时间字段
migrations.AddField( migrations.AddField(
model_name='oauthconfig', model_name='oauthconfig',
name='creation_time', name='creation_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为OAuthConfig模型添加新的修改时间字段
migrations.AddField( migrations.AddField(
model_name='oauthconfig', model_name='oauthconfig',
name='last_modify_time', name='last_modify_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
), ),
# 为OAuthUser模型添加新的创建时间字段
migrations.AddField( migrations.AddField(
model_name='oauthuser', model_name='oauthuser',
name='creation_time', name='creation_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
), ),
# 为OAuthUser模型添加新的修改时间字段
migrations.AddField( migrations.AddField(
model_name='oauthuser', model_name='oauthuser',
name='last_modify_time', name='last_modify_time',
# 使用当前时间作为默认值,字段标签改为英文
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
), ),
# 修改OAuthConfig回调地址字段的配置
migrations.AlterField( migrations.AlterField(
model_name='oauthconfig', model_name='oauthconfig',
name='callback_url', name='callback_url',
# 将默认回调地址改为空字符串,字段标签改为英文
field=models.CharField(default='', max_length=200, verbose_name='callback url'), field=models.CharField(default='', max_length=200, verbose_name='callback url'),
), ),
# 修改OAuthConfig启用状态字段的标签
migrations.AlterField( migrations.AlterField(
model_name='oauthconfig', model_name='oauthconfig',
name='is_enable', name='is_enable',
# 保持字段定义不变只修改verbose_name为英文
field=models.BooleanField(default=True, verbose_name='is enable'), field=models.BooleanField(default=True, verbose_name='is enable'),
), ),
# 修改OAuthConfig类型字段的选项和标签
migrations.AlterField( migrations.AlterField(
model_name='oauthconfig', model_name='oauthconfig',
name='type', name='type',
field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), # 将微博和谷歌的显示名称改为英文,其他保持不变
field=models.CharField(
choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'),
('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
), ),
# 修改OAuthUser作者字段的标签
migrations.AlterField( migrations.AlterField(
model_name='oauthuser', model_name='oauthuser',
name='author', name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), # 保持外键关系不变修改verbose_name为英文
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL, verbose_name='author'),
), ),
# 修改OAuthUser昵称字段的标签
migrations.AlterField( migrations.AlterField(
model_name='oauthuser', model_name='oauthuser',
name='nickname', name='nickname',
# 保持字段定义不变只修改verbose_name为英文
field=models.CharField(max_length=50, verbose_name='nickname'), field=models.CharField(max_length=50, verbose_name='nickname'),
), ),
] ]

@ -1,18 +1,37 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41 """
Django 数据库迁移模块 - OAuth 用户昵称字段优化
from django.db import migrations, models 该模块是OAuth认证系统的第三次迁移主要用于微调OAuthUser模型中昵称字段的显示标签
这是一个小的优化迁移仅修改字段的verbose_name以改善可读性
这是Django迁移系统自动生成的迁移文件在Django 4.2.7版本中创建于2024-01-26
"""
# 导入Django核心模块
from django.db import migrations, models # 导入数据库迁移和模型相关功能
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""
OAuth认证系统的数据库微调迁移类
这个迁移类负责对OAuthUser模型的昵称字段进行显示标签优化
'nickname'改为'nick name'以改善管理界面的可读性
"""
# 定义依赖关系 - 依赖于前一次迁移
dependencies = [ dependencies = [
# 依赖于oauth应用的第二次迁移字段重命名和国际化迁移
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
] ]
# 定义要执行的数据库操作序列
operations = [ operations = [
# 修改OAuthUser模型昵称字段的显示标签
migrations.AlterField( migrations.AlterField(
model_name='oauthuser', model_name='oauthuser', # 指定要修改的模型名称
name='nickname', name='nickname', # 指定要修改的字段名称
# 保持字段类型和约束不变仅优化verbose_name显示
# 将'nickname'改为'nick name',增加空格提高可读性
field=models.CharField(max_length=50, verbose_name='nick name'), field=models.CharField(max_length=50, verbose_name='nick name'),
), ),
] ]

@ -1,4 +1,11 @@
# Create your models here. """
OAuth 认证数据模型模块
该模块定义了OAuth认证系统所需的数据模型包括OAuth用户信息和OAuth服务商配置
用于存储第三方登录的用户数据和OAuth应用配置信息
"""
# 导入Django核心模块
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -7,61 +14,135 @@ from django.utils.translation import gettext_lazy as _
class OAuthUser(models.Model): class OAuthUser(models.Model):
"""
OAuth用户模型
存储通过第三方OAuth服务登录的用户信息包括用户基本信息
认证令牌以及与本地用户的关联关系
"""
# 关联本地用户模型的外键,可为空(未绑定本地用户时)
author = models.ForeignKey( author = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL, # 使用Django的可切换用户模型
verbose_name=_('author'), verbose_name=_('author'), # 字段显示名称(支持国际化)
blank=True, blank=True, # 允许表单中为空
null=True, null=True, # 允许数据库中为NULL
on_delete=models.CASCADE) on_delete=models.CASCADE # 关联用户删除时级联删除OAuth用户
)
# 第三方平台的用户唯一标识符
openid = models.CharField(max_length=50) openid = models.CharField(max_length=50)
# 用户在第三方平台的昵称
nickname = models.CharField(max_length=50, verbose_name=_('nick name')) nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
# OAuth访问令牌用于调用第三方API
token = models.CharField(max_length=150, null=True, blank=True) token = models.CharField(max_length=150, null=True, blank=True)
# 用户头像的URL地址
picture = models.CharField(max_length=350, blank=True, null=True) picture = models.CharField(max_length=350, blank=True, null=True)
# OAuth服务类型weibo, github等
type = models.CharField(blank=False, null=False, max_length=50) type = models.CharField(blank=False, null=False, max_length=50)
# 用户邮箱地址,可能为空(某些平台不提供邮箱)
email = models.CharField(max_length=50, null=True, blank=True) email = models.CharField(max_length=50, null=True, blank=True)
# 存储额外的元数据信息使用JSON格式
metadata = models.TextField(null=True, blank=True) metadata = models.TextField(null=True, blank=True)
# 记录创建时间,默认使用当前时间
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('creation time'), default=now)
# 记录最后修改时间,默认使用当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def __str__(self): def __str__(self):
"""
定义模型的字符串表示形式
Returns:
str: 返回用户的昵称用于Admin和其他显示场景
"""
return self.nickname return self.nickname
class Meta: class Meta:
verbose_name = _('oauth user') """模型元数据配置"""
verbose_name_plural = verbose_name verbose_name = _('oauth user') # 模型在Admin中的单数显示名称
ordering = ['-creation_time'] verbose_name_plural = verbose_name # 模型在Admin中的复数显示名称
ordering = ['-creation_time'] # 默认按创建时间降序排列
class OAuthConfig(models.Model): class OAuthConfig(models.Model):
"""
OAuth服务配置模型
存储不同第三方OAuth服务的应用配置信息包括AppKeyAppSecret等
用于管理多个OAuth服务的认证参数
"""
# OAuth服务类型选择项
TYPE = ( TYPE = (
('weibo', _('weibo')), ('weibo', _('weibo')), # 微博OAuth
('google', _('google')), ('google', _('google')), # 谷歌OAuth
('github', 'GitHub'), ('github', 'GitHub'), # GitHub OAuth
('facebook', 'FaceBook'), ('facebook', 'FaceBook'), # Facebook OAuth
('qq', 'QQ'), ('qq', 'QQ'), # QQ OAuth
) )
# OAuth服务类型字段使用选择项限制输入
type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
# OAuth应用的客户端IDApp Key
appkey = models.CharField(max_length=200, verbose_name='AppKey') appkey = models.CharField(max_length=200, verbose_name='AppKey')
# OAuth应用的客户端密钥App Secret
appsecret = models.CharField(max_length=200, verbose_name='AppSecret') appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
# OAuth认证成功后的回调地址
callback_url = models.CharField( callback_url = models.CharField(
max_length=200, max_length=200,
verbose_name=_('callback url'), verbose_name=_('callback url'),
blank=False, blank=False, # 不允许为空
default='') default='' # 默认值为空字符串
)
# 标识该OAuth配置是否启用
is_enable = models.BooleanField( is_enable = models.BooleanField(
_('is enable'), default=True, blank=False, null=False) _('is enable'), default=True, blank=False, null=False)
# 记录配置创建时间
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('creation time'), default=now)
# 记录配置最后修改时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def clean(self): def clean(self):
"""
模型验证方法
确保同一类型的OAuth配置只能存在一个防止重复配置
Raises:
ValidationError: 当同一类型的配置已存在时抛出异常
"""
# 检查是否已存在相同类型的配置(排除当前记录)
if OAuthConfig.objects.filter( if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count(): type=self.type).exclude(id=self.id).count():
# 抛出验证错误,提示该类型配置已存在
raise ValidationError(_(self.type + _('already exists'))) raise ValidationError(_(self.type + _('already exists')))
def __str__(self): def __str__(self):
"""
定义模型的字符串表示形式
Returns:
str: 返回OAuth服务类型名称
"""
return self.type return self.type
class Meta: class Meta:
verbose_name = 'oauth配置' """模型元数据配置"""
verbose_name_plural = verbose_name verbose_name = 'oauth配置' # 模型在Admin中的中文显示名称
ordering = ['-creation_time'] verbose_name_plural = verbose_name # 复数显示名称
ordering = ['-creation_time'] # 默认按创建时间降序排列

@ -1,3 +1,11 @@
"""
OAuth 认证管理器模块
该模块实现了多平台OAuth认证的核心逻辑包含基类定义和具体平台实现
支持微博谷歌GitHubFacebookQQ等主流第三方登录平台
采用抽象基类和混合类设计模式提供统一的OAuth认证接口
"""
import json import json
import logging import logging
import os import os
@ -9,79 +17,139 @@ import requests
from djangoblog.utils import cache_decorator from djangoblog.utils import cache_decorator
from oauth.models import OAuthUser, OAuthConfig from oauth.models import OAuthUser, OAuthConfig
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OAuthAccessTokenException(Exception): class OAuthAccessTokenException(Exception):
''' '''
oauth授权失败异常 OAuth授权令牌获取异常类
当从OAuth服务商获取访问令牌失败时抛出此异常
通常由于错误的授权码应用配置问题或网络问题导致
''' '''
class BaseOauthManager(metaclass=ABCMeta): class BaseOauthManager(metaclass=ABCMeta):
"""获取用户授权""" """
OAuth认证管理器抽象基类
定义所有OAuth平台必须实现的接口和方法
提供统一的OAuth认证流程模板
"""
# OAuth授权页面URL需要子类实现
AUTH_URL = None AUTH_URL = None
"""获取token""" # 获取访问令牌的URL需要子类实现
TOKEN_URL = None TOKEN_URL = None
"""获取用户信息""" # 获取用户信息的API URL需要子类实现
API_URL = None API_URL = None
'''icon图标名''' # 平台图标名称,用于标识和显示(需要子类实现)
ICON_NAME = None ICON_NAME = None
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""
初始化OAuth管理器
Args:
access_token: 已存在的访问令牌可选
openid: 已存在的用户OpenID可选
"""
self.access_token = access_token self.access_token = access_token
self.openid = openid self.openid = openid
@property @property
def is_access_token_set(self): def is_access_token_set(self):
"""检查访问令牌是否已设置"""
return self.access_token is not None return self.access_token is not None
@property @property
def is_authorized(self): def is_authorized(self):
"""检查是否已完成授权拥有令牌和OpenID"""
return self.is_access_token_set and self.access_token is not None and self.openid is not None return self.is_access_token_set and self.access_token is not None and self.openid is not None
@abstractmethod @abstractmethod
def get_authorization_url(self, nexturl='/'): def get_authorization_url(self, nexturl='/'):
"""获取授权页面URL抽象方法子类必须实现"""
pass pass
@abstractmethod @abstractmethod
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""通过授权码获取访问令牌(抽象方法,子类必须实现)"""
pass pass
@abstractmethod @abstractmethod
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
"""获取OAuth用户信息抽象方法子类必须实现"""
pass pass
@abstractmethod @abstractmethod
def get_picture(self, metadata): def get_picture(self, metadata):
"""从元数据中提取用户头像URL抽象方法子类必须实现"""
pass pass
def do_get(self, url, params, headers=None): def do_get(self, url, params, headers=None):
"""
执行GET请求的通用方法
Args:
url: 请求URL
params: 请求参数
headers: 请求头可选
Returns:
str: 响应文本内容
"""
rsp = requests.get(url=url, params=params, headers=headers) rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text) logger.info(rsp.text) # 记录响应日志
return rsp.text return rsp.text
def do_post(self, url, params, headers=None): def do_post(self, url, params, headers=None):
"""
执行POST请求的通用方法
Args:
url: 请求URL
params: 请求参数
headers: 请求头可选
Returns:
str: 响应文本内容
"""
rsp = requests.post(url, params, headers=headers) rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text) logger.info(rsp.text) # 记录响应日志
return rsp.text return rsp.text
def get_config(self): def get_config(self):
"""
从数据库获取当前平台的OAuth配置
Returns:
OAuthConfig: 配置对象如果不存在则返回None
"""
value = OAuthConfig.objects.filter(type=self.ICON_NAME) value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None return value[0] if value else None
class WBOauthManager(BaseOauthManager): class WBOauthManager(BaseOauthManager):
"""
微博OAuth认证管理器
实现微博平台的OAuth2.0认证流程包括授权令牌获取和用户信息获取
"""
# 微博OAuth接口地址
AUTH_URL = 'https://api.weibo.com/oauth2/authorize' AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json' API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo' ICON_NAME = 'weibo'
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""初始化微博OAuth配置"""
config = self.get_config() config = self.get_config()
self.client_id = config.appkey if config else '' self.client_id = config.appkey if config else '' # 应用Key
self.client_secret = config.appsecret if config else '' self.client_secret = config.appsecret if config else '' # 应用Secret
self.callback_url = config.callback_url if config else '' self.callback_url = config.callback_url if config else '' # 回调地址
super( super(
WBOauthManager, WBOauthManager,
self).__init__( self).__init__(
@ -89,6 +157,15 @@ class WBOauthManager(BaseOauthManager):
openid=openid) openid=openid)
def get_authorization_url(self, nexturl='/'): def get_authorization_url(self, nexturl='/'):
"""
生成微博授权页面URL
Args:
nexturl: 授权成功后跳转的URL
Returns:
str: 完整的授权URL
"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'response_type': 'code', 'response_type': 'code',
@ -98,7 +175,18 @@ class WBOauthManager(BaseOauthManager):
return url return url
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""
使用授权码获取访问令牌
Args:
code: OAuth授权码
Returns:
OAuthUser: 用户信息对象
Raises:
OAuthAccessTokenException: 令牌获取失败时抛出
"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'client_secret': self.client_secret, 'client_secret': self.client_secret,
@ -110,13 +198,20 @@ class WBOauthManager(BaseOauthManager):
obj = json.loads(rsp) obj = json.loads(rsp)
if 'access_token' in obj: if 'access_token' in obj:
# 设置访问令牌和用户ID
self.access_token = str(obj['access_token']) self.access_token = str(obj['access_token'])
self.openid = str(obj['uid']) self.openid = str(obj['uid'])
return self.get_oauth_userinfo() return self.get_oauth_userinfo() # 获取并返回用户信息
else: else:
raise OAuthAccessTokenException(rsp) raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
"""
获取微博用户信息
Returns:
OAuthUser: 包含用户信息的对象获取失败返回None
"""
if not self.is_authorized: if not self.is_authorized:
return None return None
params = { params = {
@ -127,14 +222,14 @@ class WBOauthManager(BaseOauthManager):
try: try:
datas = json.loads(rsp) datas = json.loads(rsp)
user = OAuthUser() user = OAuthUser()
user.metadata = rsp user.metadata = rsp # 存储原始响应数据
user.picture = datas['avatar_large'] user.picture = datas['avatar_large'] # 用户头像
user.nickname = datas['screen_name'] user.nickname = datas['screen_name'] # 用户昵称
user.openid = datas['id'] user.openid = datas['id'] # 用户OpenID
user.type = 'weibo' user.type = 'weibo' # 平台类型
user.token = self.access_token user.token = self.access_token # 访问令牌
if 'email' in datas and datas['email']: if 'email' in datas and datas['email']:
user.email = datas['email'] user.email = datas['email'] # 用户邮箱
return user return user
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
@ -142,13 +237,30 @@ class WBOauthManager(BaseOauthManager):
return None return None
def get_picture(self, metadata): def get_picture(self, metadata):
"""
从元数据中提取微博用户头像
Args:
metadata: 用户元数据JSON字符串
Returns:
str: 用户头像URL
"""
datas = json.loads(metadata) datas = json.loads(metadata)
return datas['avatar_large'] return datas['avatar_large']
class ProxyManagerMixin: class ProxyManagerMixin:
"""
代理管理器混合类
为OAuth管理器添加HTTP代理支持用于网络访问受限的环境
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""初始化代理配置"""
if os.environ.get("HTTP_PROXY"): if os.environ.get("HTTP_PROXY"):
# 设置HTTP和HTTPS代理
self.proxies = { self.proxies = {
"http": os.environ.get("HTTP_PROXY"), "http": os.environ.get("HTTP_PROXY"),
"https": os.environ.get("HTTP_PROXY") "https": os.environ.get("HTTP_PROXY")
@ -157,23 +269,32 @@ class ProxyManagerMixin:
self.proxies = None self.proxies = None
def do_get(self, url, params, headers=None): def do_get(self, url, params, headers=None):
"""带代理支持的GET请求"""
rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies) rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
logger.info(rsp.text) logger.info(rsp.text)
return rsp.text return rsp.text
def do_post(self, url, params, headers=None): def do_post(self, url, params, headers=None):
"""带代理支持的POST请求"""
rsp = requests.post(url, params, headers=headers, proxies=self.proxies) rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text) logger.info(rsp.text)
return rsp.text return rsp.text
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
"""
谷歌OAuth认证管理器
实现谷歌平台的OAuth2.0认证流程支持代理访问
"""
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
ICON_NAME = 'google' ICON_NAME = 'google'
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""初始化谷歌OAuth配置"""
config = self.get_config() config = self.get_config()
self.client_id = config.appkey if config else '' self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else '' self.client_secret = config.appsecret if config else ''
@ -185,22 +306,23 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid) openid=openid)
def get_authorization_url(self, nexturl='/'): def get_authorization_url(self, nexturl='/'):
"""生成谷歌授权页面URL"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'response_type': 'code', 'response_type': 'code',
'redirect_uri': self.callback_url, 'redirect_uri': self.callback_url,
'scope': 'openid email', 'scope': 'openid email', # 请求openid和email权限
} }
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url return url
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""使用授权码获取谷歌访问令牌"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'client_secret': self.client_secret, 'client_secret': self.client_secret,
'grant_type': 'authorization_code', 'grant_type': 'authorization_code',
'code': code, 'code': code,
'redirect_uri': self.callback_url 'redirect_uri': self.callback_url
} }
rsp = self.do_post(self.TOKEN_URL, params) rsp = self.do_post(self.TOKEN_URL, params)
@ -216,6 +338,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp) raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
"""获取谷歌用户信息"""
if not self.is_authorized: if not self.is_authorized:
return None return None
params = { params = {
@ -223,17 +346,16 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
} }
rsp = self.do_get(self.API_URL, params) rsp = self.do_get(self.API_URL, params)
try: try:
datas = json.loads(rsp) datas = json.loads(rsp)
user = OAuthUser() user = OAuthUser()
user.metadata = rsp user.metadata = rsp
user.picture = datas['picture'] user.picture = datas['picture'] # 谷歌用户头像
user.nickname = datas['name'] user.nickname = datas['name'] # 谷歌用户姓名
user.openid = datas['sub'] user.openid = datas['sub'] # 谷歌用户唯一标识
user.token = self.access_token user.token = self.access_token
user.type = 'google' user.type = 'google'
if datas['email']: if datas['email']:
user.email = datas['email'] user.email = datas['email'] # 谷歌邮箱
return user return user
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
@ -241,17 +363,25 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
return None return None
def get_picture(self, metadata): def get_picture(self, metadata):
"""从元数据中提取谷歌用户头像"""
datas = json.loads(metadata) datas = json.loads(metadata)
return datas['picture'] return datas['picture']
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
"""
GitHub OAuth认证管理器
实现GitHub平台的OAuth2.0认证流程支持代理访问
"""
AUTH_URL = 'https://github.com/login/oauth/authorize' AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token' TOKEN_URL = 'https://github.com/login/oauth/access_token'
API_URL = 'https://api.github.com/user' API_URL = 'https://api.github.com/user'
ICON_NAME = 'github' ICON_NAME = 'github'
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""初始化GitHub OAuth配置"""
config = self.get_config() config = self.get_config()
self.client_id = config.appkey if config else '' self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else '' self.client_secret = config.appsecret if config else ''
@ -263,28 +393,29 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid) openid=openid)
def get_authorization_url(self, next_url='/'): def get_authorization_url(self, next_url='/'):
"""生成GitHub授权页面URL"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'response_type': 'code', 'response_type': 'code',
'redirect_uri': f'{self.callback_url}&next_url={next_url}', 'redirect_uri': f'{self.callback_url}&next_url={next_url}',
'scope': 'user' 'scope': 'user' # 请求用户信息权限
} }
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url return url
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""使用授权码获取GitHub访问令牌"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'client_secret': self.client_secret, 'client_secret': self.client_secret,
'grant_type': 'authorization_code', 'grant_type': 'authorization_code',
'code': code, 'code': code,
'redirect_uri': self.callback_url 'redirect_uri': self.callback_url
} }
rsp = self.do_post(self.TOKEN_URL, params) rsp = self.do_post(self.TOKEN_URL, params)
from urllib import parse from urllib import parse
r = parse.parse_qs(rsp) r = parse.parse_qs(rsp) # 解析查询字符串格式的响应
if 'access_token' in r: if 'access_token' in r:
self.access_token = (r['access_token'][0]) self.access_token = (r['access_token'][0])
return self.access_token return self.access_token
@ -292,21 +423,22 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp) raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
"""获取GitHub用户信息"""
# 使用Bearer Token认证方式调用GitHub API
rsp = self.do_get(self.API_URL, params={}, headers={ rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token "Authorization": "token " + self.access_token
}) })
try: try:
datas = json.loads(rsp) datas = json.loads(rsp)
user = OAuthUser() user = OAuthUser()
user.picture = datas['avatar_url'] user.picture = datas['avatar_url'] # GitHub头像
user.nickname = datas['name'] user.nickname = datas['name'] # GitHub姓名
user.openid = datas['id'] user.openid = datas['id'] # GitHub用户ID
user.type = 'github' user.type = 'github'
user.token = self.access_token user.token = self.access_token
user.metadata = rsp user.metadata = rsp
if 'email' in datas and datas['email']: if 'email' in datas and datas['email']:
user.email = datas['email'] user.email = datas['email'] # GitHub邮箱
return user return user
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
@ -314,17 +446,25 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
return None return None
def get_picture(self, metadata): def get_picture(self, metadata):
"""从元数据中提取GitHub用户头像"""
datas = json.loads(metadata) datas = json.loads(metadata)
return datas['avatar_url'] return datas['avatar_url']
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
"""
Facebook OAuth认证管理器
实现Facebook平台的OAuth2.0认证流程支持代理访问
"""
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth' AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token' TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
API_URL = 'https://graph.facebook.com/me' API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook' ICON_NAME = 'facebook'
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""初始化Facebook OAuth配置"""
config = self.get_config() config = self.get_config()
self.client_id = config.appkey if config else '' self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else '' self.client_secret = config.appsecret if config else ''
@ -336,22 +476,22 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid) openid=openid)
def get_authorization_url(self, next_url='/'): def get_authorization_url(self, next_url='/'):
"""生成Facebook授权页面URL"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'response_type': 'code', 'response_type': 'code',
'redirect_uri': self.callback_url, 'redirect_uri': self.callback_url,
'scope': 'email,public_profile' 'scope': 'email,public_profile' # 请求邮箱和公开资料权限
} }
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url return url
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""使用授权码获取Facebook访问令牌"""
params = { params = {
'client_id': self.client_id, 'client_id': self.client_id,
'client_secret': self.client_secret, 'client_secret': self.client_secret,
# 'grant_type': 'authorization_code',
'code': code, 'code': code,
'redirect_uri': self.callback_url 'redirect_uri': self.callback_url
} }
rsp = self.do_post(self.TOKEN_URL, params) rsp = self.do_post(self.TOKEN_URL, params)
@ -365,21 +505,23 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp) raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
"""获取Facebook用户信息"""
params = { params = {
'access_token': self.access_token, 'access_token': self.access_token,
'fields': 'id,name,picture,email' 'fields': 'id,name,picture,email' # 指定需要返回的字段
} }
try: try:
rsp = self.do_get(self.API_URL, params) rsp = self.do_get(self.API_URL, params)
datas = json.loads(rsp) datas = json.loads(rsp)
user = OAuthUser() user = OAuthUser()
user.nickname = datas['name'] user.nickname = datas['name'] # Facebook姓名
user.openid = datas['id'] user.openid = datas['id'] # Facebook用户ID
user.type = 'facebook' user.type = 'facebook'
user.token = self.access_token user.token = self.access_token
user.metadata = rsp user.metadata = rsp
if 'email' in datas and datas['email']: if 'email' in datas and datas['email']:
user.email = datas['email'] user.email = datas['email'] # Facebook邮箱
# 处理嵌套的头像数据结构
if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']: if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']:
user.picture = str(datas['picture']['data']['url']) user.picture = str(datas['picture']['data']['url'])
return user return user
@ -388,18 +530,26 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
return None return None
def get_picture(self, metadata): def get_picture(self, metadata):
"""从元数据中提取Facebook用户头像"""
datas = json.loads(metadata) datas = json.loads(metadata)
return str(datas['picture']['data']['url']) return str(datas['picture']['data']['url'])
class QQOauthManager(BaseOauthManager): class QQOauthManager(BaseOauthManager):
"""
QQ OAuth认证管理器
实现QQ平台的OAuth2.0认证流程包含特殊的OpenID获取步骤
"""
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize' AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token' TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
API_URL = 'https://graph.qq.com/user/get_user_info' API_URL = 'https://graph.qq.com/user/get_user_info'
OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me' OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me' # QQ特有的OpenID获取接口
ICON_NAME = 'qq' ICON_NAME = 'qq'
def __init__(self, access_token=None, openid=None): def __init__(self, access_token=None, openid=None):
"""初始化QQ OAuth配置"""
config = self.get_config() config = self.get_config()
self.client_id = config.appkey if config else '' self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else '' self.client_secret = config.appsecret if config else ''
@ -411,6 +561,7 @@ class QQOauthManager(BaseOauthManager):
openid=openid) openid=openid)
def get_authorization_url(self, next_url='/'): def get_authorization_url(self, next_url='/'):
"""生成QQ授权页面URL"""
params = { params = {
'response_type': 'code', 'response_type': 'code',
'client_id': self.client_id, 'client_id': self.client_id,
@ -420,6 +571,7 @@ class QQOauthManager(BaseOauthManager):
return url return url
def get_access_token_by_code(self, code): def get_access_token_by_code(self, code):
"""使用授权码获取QQ访问令牌"""
params = { params = {
'grant_type': 'authorization_code', 'grant_type': 'authorization_code',
'client_id': self.client_id, 'client_id': self.client_id,
@ -429,7 +581,7 @@ class QQOauthManager(BaseOauthManager):
} }
rsp = self.do_get(self.TOKEN_URL, params) rsp = self.do_get(self.TOKEN_URL, params)
if rsp: if rsp:
d = urllib.parse.parse_qs(rsp) d = urllib.parse.parse_qs(rsp) # 解析查询字符串响应
if 'access_token' in d: if 'access_token' in d:
token = d['access_token'] token = d['access_token']
self.access_token = token[0] self.access_token = token[0]
@ -438,23 +590,27 @@ class QQOauthManager(BaseOauthManager):
raise OAuthAccessTokenException(rsp) raise OAuthAccessTokenException(rsp)
def get_open_id(self): def get_open_id(self):
"""
获取QQ用户的OpenID
QQ平台需要额外调用接口获取用户OpenID
"""
if self.is_access_token_set: if self.is_access_token_set:
params = { params = {
'access_token': self.access_token 'access_token': self.access_token
} }
rsp = self.do_get(self.OPEN_ID_URL, params) rsp = self.do_get(self.OPEN_ID_URL, params)
if rsp: if rsp:
rsp = rsp.replace( # 清理JSONP响应格式
'callback(', '').replace( rsp = rsp.replace('callback(', '').replace(')', '').replace(';', '')
')', '').replace(
';', '')
obj = json.loads(rsp) obj = json.loads(rsp)
openid = str(obj['openid']) openid = str(obj['openid'])
self.openid = openid self.openid = openid
return openid return openid
def get_oauth_userinfo(self): def get_oauth_userinfo(self):
openid = self.get_open_id() """获取QQ用户信息"""
openid = self.get_open_id() # 先获取OpenID
if openid: if openid:
params = { params = {
'access_token': self.access_token, 'access_token': self.access_token,
@ -465,36 +621,56 @@ class QQOauthManager(BaseOauthManager):
logger.info(rsp) logger.info(rsp)
obj = json.loads(rsp) obj = json.loads(rsp)
user = OAuthUser() user = OAuthUser()
user.nickname = obj['nickname'] user.nickname = obj['nickname'] # QQ昵称
user.openid = openid user.openid = openid # QQ OpenID
user.type = 'qq' user.type = 'qq'
user.token = self.access_token user.token = self.access_token
user.metadata = rsp user.metadata = rsp
if 'email' in obj: if 'email' in obj:
user.email = obj['email'] user.email = obj['email'] # QQ邮箱
if 'figureurl' in obj: if 'figureurl' in obj:
user.picture = str(obj['figureurl']) user.picture = str(obj['figureurl']) # QQ头像
return user return user
def get_picture(self, metadata): def get_picture(self, metadata):
"""从元数据中提取QQ用户头像"""
datas = json.loads(metadata) datas = json.loads(metadata)
return str(datas['figureurl']) return str(datas['figureurl'])
@cache_decorator(expiration=100 * 60) @cache_decorator(expiration=100 * 60)
def get_oauth_apps(): def get_oauth_apps():
"""
获取所有启用的OAuth应用配置
使用缓存装饰器缓存100分钟减少数据库查询
Returns:
list: 启用的OAuth管理器实例列表
"""
configs = OAuthConfig.objects.filter(is_enable=True).all() configs = OAuthConfig.objects.filter(is_enable=True).all()
if not configs: if not configs:
return [] return []
configtypes = [x.type for x in configs] configtypes = [x.type for x in configs] # 提取启用的平台类型
applications = BaseOauthManager.__subclasses__() applications = BaseOauthManager.__subclasses__() # 获取所有子类
# 过滤出已启用的平台管理器实例
apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes] apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes]
return apps return apps
def get_manager_by_type(type): def get_manager_by_type(type):
"""
根据平台类型获取对应的OAuth管理器
Args:
type: 平台类型字符串'weibo', 'github'
Returns:
BaseOauthManager: 对应平台的OAuth管理器实例未找到返回None
"""
applications = get_oauth_apps() applications = get_oauth_apps()
if applications: if applications:
# 查找匹配平台类型的管理器
finds = list( finds = list(
filter( filter(
lambda x: x.ICON_NAME.lower() == type.lower(), lambda x: x.ICON_NAME.lower() == type.lower(),

@ -1,22 +1,64 @@
"""
OAuth 认证模板标签模块
该模块提供Django模板标签用于在模板中动态加载和显示OAuth第三方登录应用列表
主要功能是生成可用的OAuth应用链接并在模板中渲染
"""
# 导入Django模板模块
from django import template from django import template
# 导入URL反向解析功能
from django.urls import reverse from django.urls import reverse
# 导入自定义的OAuth管理器用于获取可用的OAuth应用
from oauth.oauthmanager import get_oauth_apps from oauth.oauthmanager import get_oauth_apps
# 创建模板库实例
register = template.Library() register = template.Library()
@register.inclusion_tag('oauth/oauth_applications.html') @register.inclusion_tag('oauth/oauth_applications.html')
def load_oauth_applications(request): def load_oauth_applications(request):
"""
自定义包含标签 - 加载OAuth应用列表
该模板标签用于在页面中渲染OAuth第三方登录的应用图标和链接
它会获取所有可用的OAuth应用并生成对应的登录URL
Args:
request: HttpRequest对象用于获取当前请求的完整路径
Returns:
dict: 包含应用列表的字典用于模板渲染
- 'apps': 包含OAuth应用信息的列表每个元素为(应用类型, 登录URL)的元组
"""
# 获取所有可用的OAuth应用配置
applications = get_oauth_apps() applications = get_oauth_apps()
# 检查是否存在可用的OAuth应用
if applications: if applications:
# 生成OAuth登录的基础URL不包含参数
baseurl = reverse('oauth:oauthlogin') baseurl = reverse('oauth:oauthlogin')
# 获取当前请求的完整路径,用于登录成功后跳转回原页面
path = request.get_full_path() path = request.get_full_path()
apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format( # 使用map和lambda函数处理每个OAuth应用生成应用信息列表
baseurl=baseurl, type=x.ICON_NAME, next=path)), applications)) # 每个应用信息包含应用类型图标名称和完整的登录URL
apps = list(map(lambda x: (
# OAuth应用的类型/图标名称weibo, github等
x.ICON_NAME,
# 生成完整的登录URL包含应用类型和回调地址参数
'{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, # 基础登录URL
type=x.ICON_NAME, # OAuth应用类型
next=path # 登录成功后的回调地址
)),
applications)) # 遍历的应用列表
else: else:
# 如果没有可用的OAuth应用返回空列表
apps = [] apps = []
# 返回模板渲染所需的上下文数据
return { return {
'apps': apps 'apps': apps # OAuth应用列表传递给模板进行渲染
} }

@ -1,55 +1,112 @@
"""
OAuth 认证测试模块
该模块包含OAuth认证系统的完整测试用例覆盖所有支持的第三方登录平台
测试包括配置验证授权流程用户信息获取和异常处理等场景
"""
import json import json
from unittest.mock import patch from unittest.mock import patch
# 导入Django测试相关模块
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
# 导入项目工具函数和模型
from djangoblog.utils import get_sha256 from djangoblog.utils import get_sha256
from oauth.models import OAuthConfig from oauth.models import OAuthConfig
from oauth.oauthmanager import BaseOauthManager from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
class OAuthConfigTest(TestCase): class OAuthConfigTest(TestCase):
"""
OAuth配置模型测试类
测试OAuth配置的基本功能包括配置保存和基础授权流程
"""
def setUp(self): def setUp(self):
self.client = Client() """
self.factory = RequestFactory() 测试初始化方法
在每个测试方法执行前运行创建测试所需的客户端和工厂对象
"""
self.client = Client() # 创建测试客户端
self.factory = RequestFactory() # 创建请求工厂
def test_oauth_login_test(self): def test_oauth_login_test(self):
"""
测试OAuth登录流程
验证微博OAuth配置的创建和基础授权重定向功能
"""
# 创建微博OAuth配置
c = OAuthConfig() c = OAuthConfig()
c.type = 'weibo' c.type = 'weibo'
c.appkey = 'appkey' c.appkey = 'appkey'
c.appsecret = 'appsecret' c.appsecret = 'appsecret'
c.save() c.save()
# 测试OAuth登录请求应该重定向到微博授权页面
response = self.client.get('/oauth/oauthlogin?type=weibo') response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302) # 验证重定向状态码
self.assertTrue("api.weibo.com" in response.url) self.assertTrue("api.weibo.com" in response.url) # 验证重定向到微博
# 测试授权回调请求(模拟授权码流程)
response = self.client.get('/oauth/authorize?type=weibo&code=code') response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302) # 验证重定向状态码
self.assertEqual(response.url, '/') self.assertEqual(response.url, '/') # 验证重定向到首页
class OauthLoginTest(TestCase): class OauthLoginTest(TestCase):
"""
OAuth登录流程测试类
测试所有支持的OAuth平台的完整登录流程包括模拟API调用和用户认证
"""
def setUp(self) -> None: def setUp(self) -> None:
self.client = Client() """
self.factory = RequestFactory() 测试初始化方法
self.apps = self.init_apps()
创建测试环境初始化所有OAuth应用配置
"""
self.client = Client() # 创建测试客户端
self.factory = RequestFactory() # 创建请求工厂
self.apps = self.init_apps() # 初始化所有OAuth应用配置
def init_apps(self): def init_apps(self):
"""
初始化所有OAuth应用配置
为每个OAuth平台创建测试配置数据
Returns:
list: 初始化的OAuth管理器实例列表
"""
# 获取所有OAuth管理器的子类并实例化
applications = [p() for p in BaseOauthManager.__subclasses__()] applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications: for application in applications:
# 为每个平台创建测试配置
c = OAuthConfig() c = OAuthConfig()
c.type = application.ICON_NAME.lower() c.type = application.ICON_NAME.lower() # 设置平台类型
c.appkey = 'appkey' c.appkey = 'appkey' # 测试应用Key
c.appsecret = 'appsecret' c.appsecret = 'appsecret' # 测试应用Secret
c.save() c.save()
return applications return applications
def get_app_by_type(self, type): def get_app_by_type(self, type):
"""
根据类型获取OAuth应用
Args:
type: 平台类型字符串
Returns:
BaseOauthManager: 对应平台的OAuth管理器实例
"""
for app in self.apps: for app in self.apps:
if app.ICON_NAME.lower() == type: if app.ICON_NAME.lower() == type:
return app return app
@ -57,73 +114,129 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get") @patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post): def test_weibo_login(self, mock_do_get, mock_do_post):
"""
测试微博OAuth登录流程
使用模拟对象测试微博授权码获取令牌和用户信息的完整流程
Args:
mock_do_get: 模拟GET请求的mock对象
mock_do_post: 模拟POST请求的mock对象
"""
weibo_app = self.get_app_by_type('weibo') weibo_app = self.get_app_by_type('weibo')
assert weibo_app assert weibo_app # 验证微博应用存在
# 测试授权URL生成
url = weibo_app.get_authorization_url() url = weibo_app.get_authorization_url()
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({"access_token": "access_token", mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid" "uid": "uid"
}) })
# 设置模拟返回值 - 用户信息获取响应
mock_do_get.return_value = json.dumps({ mock_do_get.return_value = json.dumps({
"avatar_large": "avatar_large", "avatar_large": "avatar_large",
"screen_name": "screen_name", "screen_name": "screen_name",
"id": "id", "id": "id",
"email": "email", "email": "email",
}) })
# 执行授权码换取用户信息流程
userinfo = weibo_app.get_access_token_by_code('code') userinfo = weibo_app.get_access_token_by_code('code')
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'id') # 验证返回的用户信息正确性
self.assertEqual(userinfo.token, 'access_token') # 验证访问令牌
self.assertEqual(userinfo.openid, 'id') # 验证用户OpenID
@patch("oauth.oauthmanager.GoogleOauthManager.do_post") @patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get") @patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post): def test_google_login(self, mock_do_get, mock_do_post):
"""
测试谷歌OAuth登录流程
验证谷歌OAuth的令牌获取和用户信息解析
"""
google_app = self.get_app_by_type('google') google_app = self.get_app_by_type('google')
assert google_app assert google_app # 验证谷歌应用存在
# 测试授权URL生成
url = google_app.get_authorization_url() url = google_app.get_authorization_url()
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({ mock_do_post.return_value = json.dumps({
"access_token": "access_token", "access_token": "access_token",
"id_token": "id_token", "id_token": "id_token",
}) })
# 设置模拟返回值 - 用户信息获取响应
mock_do_get.return_value = json.dumps({ mock_do_get.return_value = json.dumps({
"picture": "picture", "picture": "picture",
"name": "name", "name": "name",
"sub": "sub", "sub": "sub",
"email": "email", "email": "email",
}) })
# 执行授权流程
token = google_app.get_access_token_by_code('code') token = google_app.get_access_token_by_code('code')
userinfo = google_app.get_oauth_userinfo() userinfo = google_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'access_token')
self.assertEqual(userinfo.openid, 'sub') # 验证用户信息正确性
self.assertEqual(userinfo.token, 'access_token') # 验证访问令牌
self.assertEqual(userinfo.openid, 'sub') # 验证用户唯一标识
@patch("oauth.oauthmanager.GitHubOauthManager.do_post") @patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get") @patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post): def test_github_login(self, mock_do_get, mock_do_post):
"""
测试GitHub OAuth登录流程
验证GitHub的特殊令牌响应格式和用户信息获取
"""
github_app = self.get_app_by_type('github') github_app = self.get_app_by_type('github')
assert github_app assert github_app # 验证GitHub应用存在
# 测试授权URL生成
url = github_app.get_authorization_url() url = github_app.get_authorization_url()
self.assertTrue("github.com" in url) self.assertTrue("github.com" in url) # 验证URL包含GitHub域名
self.assertTrue("client_id" in url) self.assertTrue("client_id" in url) # 验证URL包含客户端ID
# 设置模拟返回值 - GitHub特殊的查询字符串格式响应
mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer" mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer"
# 设置模拟返回值 - 用户信息获取响应
mock_do_get.return_value = json.dumps({ mock_do_get.return_value = json.dumps({
"avatar_url": "avatar_url", "avatar_url": "avatar_url",
"name": "name", "name": "name",
"id": "id", "id": "id",
"email": "email", "email": "email",
}) })
# 执行授权流程
token = github_app.get_access_token_by_code('code') token = github_app.get_access_token_by_code('code')
userinfo = github_app.get_oauth_userinfo() userinfo = github_app.get_oauth_userinfo()
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
self.assertEqual(userinfo.openid, 'id') # 验证用户信息正确性
self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') # 验证GitHub令牌
self.assertEqual(userinfo.openid, 'id') # 验证用户ID
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post") @patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get") @patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post): def test_facebook_login(self, mock_do_get, mock_do_post):
"""
测试Facebook OAuth登录流程
验证Facebook的令牌获取和嵌套头像数据结构处理
"""
facebook_app = self.get_app_by_type('facebook') facebook_app = self.get_app_by_type('facebook')
assert facebook_app assert facebook_app # 验证Facebook应用存在
# 测试授权URL生成
url = facebook_app.get_authorization_url() url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url) self.assertTrue("facebook.com" in url) # 验证URL包含Facebook域名
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({ mock_do_post.return_value = json.dumps({
"access_token": "access_token", "access_token": "access_token",
}) })
# 设置模拟返回值 - 用户信息获取响应(包含嵌套的头像数据)
mock_do_get.return_value = json.dumps({ mock_do_get.return_value = json.dumps({
"name": "name", "name": "name",
"id": "id", "id": "id",
@ -134,14 +247,18 @@ class OauthLoginTest(TestCase):
} }
} }
}) })
# 执行授权流程
token = facebook_app.get_access_token_by_code('code') token = facebook_app.get_access_token_by_code('code')
userinfo = facebook_app.get_oauth_userinfo() userinfo = facebook_app.get_oauth_userinfo()
# 验证访问令牌正确性
self.assertEqual(userinfo.token, 'access_token') self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[ @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
'access_token=access_token&expires_in=3600', 'access_token=access_token&expires_in=3600', # 第一次调用:令牌获取响应
'callback({"client_id":"appid","openid":"openid"} );', 'callback({"client_id":"appid","openid":"openid"} );', # 第二次调用OpenID获取响应
json.dumps({ json.dumps({ # 第三次调用:用户信息获取响应
"nickname": "nickname", "nickname": "nickname",
"email": "email", "email": "email",
"figureurl": "figureurl", "figureurl": "figureurl",
@ -149,21 +266,41 @@ class OauthLoginTest(TestCase):
}) })
]) ])
def test_qq_login(self, mock_do_get): def test_qq_login(self, mock_do_get):
"""
测试QQ OAuth登录流程
验证QQ的特殊三步骤流程获取令牌 获取OpenID 获取用户信息
Args:
mock_do_get: 配置了side_effect的模拟GET请求对象
"""
qq_app = self.get_app_by_type('qq') qq_app = self.get_app_by_type('qq')
assert qq_app assert qq_app # 验证QQ应用存在
# 测试授权URL生成
url = qq_app.get_authorization_url() url = qq_app.get_authorization_url()
self.assertTrue("qq.com" in url) self.assertTrue("qq.com" in url) # 验证URL包含QQ域名
# 执行授权流程mock_do_get会根据side_effect顺序返回不同的响应
token = qq_app.get_access_token_by_code('code') token = qq_app.get_access_token_by_code('code')
userinfo = qq_app.get_oauth_userinfo() userinfo = qq_app.get_oauth_userinfo()
# 验证访问令牌正确性
self.assertEqual(userinfo.token, 'access_token') self.assertEqual(userinfo.token, 'access_token')
@patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get") @patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post): def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
"""
测试带邮箱的微博授权登录完整流程
验证用户首次登录和重复登录的场景确保用户认证状态正确
"""
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({"access_token": "access_token", mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid" "uid": "uid"
}) })
# 设置模拟用户信息(包含邮箱)
mock_user_info = { mock_user_info = {
"avatar_large": "avatar_large", "avatar_large": "avatar_large",
"screen_name": "screen_name1", "screen_name": "screen_name1",
@ -172,25 +309,32 @@ class OauthLoginTest(TestCase):
} }
mock_do_get.return_value = json.dumps(mock_user_info) mock_do_get.return_value = json.dumps(mock_user_info)
# 第一步发起OAuth登录请求
response = self.client.get('/oauth/oauthlogin?type=weibo') response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302) # 验证重定向
self.assertTrue("api.weibo.com" in response.url) self.assertTrue("api.weibo.com" in response.url) # 验证重定向到微博
# 第二步:模拟授权回调(首次登录)
response = self.client.get('/oauth/authorize?type=weibo&code=code') response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302) # 验证重定向
self.assertEqual(response.url, '/') self.assertEqual(response.url, '/') # 验证重定向到首页
# 验证用户认证状态
user = auth.get_user(self.client) user = auth.get_user(self.client)
assert user.is_authenticated assert user.is_authenticated # 验证用户已认证
self.assertTrue(user.is_authenticated) self.assertTrue(user.is_authenticated)
self.assertEqual(user.username, mock_user_info['screen_name']) self.assertEqual(user.username, mock_user_info['screen_name']) # 验证用户名
self.assertEqual(user.email, mock_user_info['email']) self.assertEqual(user.email, mock_user_info['email']) # 验证邮箱
# 登出用户
self.client.logout() self.client.logout()
# 第三步:模拟再次登录(测试重复登录场景)
response = self.client.get('/oauth/authorize?type=weibo&code=code') response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/') self.assertEqual(response.url, '/')
# 验证用户再次认证状态
user = auth.get_user(self.client) user = auth.get_user(self.client)
assert user.is_authenticated assert user.is_authenticated
self.assertTrue(user.is_authenticated) self.assertTrue(user.is_authenticated)
@ -200,10 +344,16 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get") @patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post): def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
"""
测试不带邮箱的微博授权登录完整流程
验证需要补充邮箱的场景包括邮箱表单提交和邮箱确认流程
"""
# 设置模拟返回值 - 令牌获取响应
mock_do_post.return_value = json.dumps({"access_token": "access_token", mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid" "uid": "uid"
}) })
# 设置模拟用户信息(不包含邮箱)
mock_user_info = { mock_user_info = {
"avatar_large": "avatar_large", "avatar_large": "avatar_large",
"screen_name": "screen_name1", "screen_name": "screen_name1",
@ -211,28 +361,34 @@ class OauthLoginTest(TestCase):
} }
mock_do_get.return_value = json.dumps(mock_user_info) mock_do_get.return_value = json.dumps(mock_user_info)
# 第一步发起OAuth登录请求
response = self.client.get('/oauth/oauthlogin?type=weibo') response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url) self.assertTrue("api.weibo.com" in response.url)
# 第二步:模拟授权回调(应该重定向到邮箱补充页面)
response = self.client.get('/oauth/authorize?type=weibo&code=code') response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
# 解析OAuth用户ID从重定向URL中
oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html') self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
# 第三步:提交邮箱表单
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id}) response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
# 生成邮箱确认签名
sign = get_sha256(settings.SECRET_KEY + sign = get_sha256(settings.SECRET_KEY +
str(oauth_user_id) + settings.SECRET_KEY) str(oauth_user_id) + settings.SECRET_KEY)
# 验证重定向到绑定成功页面
url = reverse('oauth:bindsuccess', kwargs={ url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauth_user_id, 'oauthid': oauth_user_id,
}) })
self.assertEqual(response.url, f'{url}?type=email') self.assertEqual(response.url, f'{url}?type=email')
# 第四步:模拟邮箱确认链接点击
path = reverse('oauth:email_confirm', kwargs={ path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id, 'id': oauth_user_id,
'sign': sign 'sign': sign
@ -240,10 +396,12 @@ class OauthLoginTest(TestCase):
response = self.client.get(path) response = self.client.get(path)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
# 验证最终用户认证状态和关联信息
user = auth.get_user(self.client) user = auth.get_user(self.client)
from oauth.models import OAuthUser from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user) oauth_user = OAuthUser.objects.get(author=user)
self.assertTrue(user.is_authenticated) self.assertTrue(user.is_authenticated) # 验证用户已认证
self.assertEqual(user.username, mock_user_info['screen_name']) self.assertEqual(user.username, mock_user_info['screen_name']) # 验证用户名
self.assertEqual(user.email, 'test@gmail.com') self.assertEqual(user.email, 'test@gmail.com') # 验证补充的邮箱
self.assertEqual(oauth_user.pk, oauth_user_id) self.assertEqual(oauth_user.pk, oauth_user_id) # 验证OAuth用户关联正确

@ -1,25 +1,53 @@
"""
OAuth 认证URL路由配置模块
该模块定义了OAuth认证系统的所有URL路由包括授权回调邮箱验证绑定成功等端点
这些路由处理第三方登录的完整流程从初始授权到最终的用户绑定
"""
# 导入Django URL路由相关模块
from django.urls import path from django.urls import path
# 导入当前应用的视图模块
from . import views from . import views
# 定义应用命名空间用于URL反向解析
app_name = "oauth" app_name = "oauth"
# 定义URL模式列表将URL路径映射到对应的视图处理函数
urlpatterns = [ urlpatterns = [
# OAuth授权回调端点 - 处理第三方平台返回的授权码
path( path(
r'oauth/authorize', r'oauth/authorize', # URL路径/oauth/authorize
views.authorize), views.authorize, # 对应的视图函数
# 名称未指定使用默认可通过views.authorize.__name__访问
),
# 邮箱补充页面 - 当第三方登录未提供邮箱时显示
path( path(
r'oauth/requireemail/<int:oauthid>.html', r'oauth/requireemail/<int:oauthid>.html', # URL路径包含OAuth用户ID参数
views.RequireEmailView.as_view(), views.RequireEmailView.as_view(), # 类视图需要调用as_view()方法
name='require_email'), name='require_email' # URL名称用于反向解析
),
# 邮箱确认端点 - 验证用户提交的邮箱地址
path( path(
r'oauth/emailconfirm/<int:id>/<sign>.html', r'oauth/emailconfirm/<int:id>/<sign>.html', # URL路径包含用户ID和签名参数
views.emailconfirm, views.emailconfirm, # 对应的视图函数
name='email_confirm'), name='email_confirm' # URL名称用于反向解析
),
# 绑定成功页面 - 显示OAuth账号绑定成功信息
path( path(
r'oauth/bindsuccess/<int:oauthid>.html', r'oauth/bindsuccess/<int:oauthid>.html', # URL路径包含OAuth用户ID参数
views.bindsuccess, views.bindsuccess, # 对应的视图函数
name='bindsuccess'), name='bindsuccess' # URL名称用于反向解析
),
# OAuth登录入口 - 初始化第三方登录流程
path( path(
r'oauth/oauthlogin', r'oauth/oauthlogin', # URL路径/oauth/oauthlogin
views.oauthlogin, views.oauthlogin, # 对应的视图函数
name='oauthlogin')] name='oauthlogin' # URL名称用于反向解析
)
]

@ -1,119 +1,223 @@
"""
OAuth 认证视图模块
该模块实现了OAuth认证系统的核心视图逻辑处理第三方登录的完整流程
包括授权初始化回调处理邮箱验证用户绑定等功能
"""
import logging import logging
# Create your views here. # 导入日志模块
from urllib.parse import urlparse from urllib.parse import urlparse # 导入URL解析工具
# 导入Django核心模块
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model # 获取用户模型
from django.contrib.auth import login from django.contrib.auth import login # 用户登录功能
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist # 对象不存在异常
from django.db import transaction from django.db import transaction # 数据库事务
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden # 403禁止访问响应
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect # 重定向响应
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404 # 获取对象或404
from django.shortcuts import render from django.shortcuts import render # 模板渲染
from django.urls import reverse from django.urls import reverse # URL反向解析
from django.utils import timezone from django.utils import timezone # 时区工具
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _ # 国际化翻译
from django.views.generic import FormView from django.views.generic import FormView # 表单视图基类
from djangoblog.blog_signals import oauth_user_login_signal # 导入项目自定义模块
from djangoblog.utils import get_current_site from djangoblog.blog_signals import oauth_user_login_signal # 信号量
from djangoblog.utils import send_email, get_sha256 from djangoblog.utils import get_current_site # 获取当前站点
from oauth.forms import RequireEmailForm from djangoblog.utils import send_email, get_sha256 # 邮件发送和加密工具
from .models import OAuthUser from oauth.forms import RequireEmailForm # 邮箱表单
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException from .models import OAuthUser # OAuth用户模型
from .oauthmanager import get_manager_by_type, OAuthAccessTokenException # OAuth管理器
# 获取当前模块的日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_redirecturl(request): def get_redirecturl(request):
"""
获取安全的重定向URL
验证next_url参数的安全性防止开放重定向漏洞
Args:
request: HttpRequest对象
Returns:
str: 安全的跳转URL默认为首页
"""
# 从请求参数获取跳转URL
nexturl = request.GET.get('next_url', None) nexturl = request.GET.get('next_url', None)
# 如果nexturl为空或是登录页面则重定向到首页
if not nexturl or nexturl == '/login/' or nexturl == '/login': if not nexturl or nexturl == '/login/' or nexturl == '/login':
nexturl = '/' nexturl = '/'
return nexturl return nexturl
# 解析URL以验证安全性
p = urlparse(nexturl) p = urlparse(nexturl)
# 检查URL是否指向外部域名防止开放重定向攻击
if p.netloc: if p.netloc:
site = get_current_site().domain site = get_current_site().domain
# 比较域名忽略www前缀如果不匹配则视为非法URL
if not p.netloc.replace('www.', '') == site.replace('www.', ''): if not p.netloc.replace('www.', '') == site.replace('www.', ''):
logger.info('非法url:' + nexturl) logger.info('非法url:' + nexturl)
return "/" return "/" # 重定向到首页
return nexturl return nexturl
def oauthlogin(request): def oauthlogin(request):
"""
OAuth登录入口视图
根据平台类型初始化第三方登录流程重定向到对应平台的授权页面
Args:
request: HttpRequest对象
Returns:
HttpResponseRedirect: 重定向到第三方授权页面或首页
"""
# 从请求参数获取OAuth平台类型
type = request.GET.get('type', None) type = request.GET.get('type', None)
if not type: if not type:
return HttpResponseRedirect('/') return HttpResponseRedirect('/') # 类型为空则重定向到首页
# 获取对应平台的OAuth管理器
manager = get_manager_by_type(type) manager = get_manager_by_type(type)
if not manager: if not manager:
return HttpResponseRedirect('/') return HttpResponseRedirect('/') # 管理器不存在则重定向到首页
# 获取安全的跳转URL
nexturl = get_redirecturl(request) nexturl = get_redirecturl(request)
# 生成第三方平台的授权URL
authorizeurl = manager.get_authorization_url(nexturl) authorizeurl = manager.get_authorization_url(nexturl)
# 重定向到第三方授权页面
return HttpResponseRedirect(authorizeurl) return HttpResponseRedirect(authorizeurl)
def authorize(request): def authorize(request):
"""
OAuth授权回调视图
处理第三方平台返回的授权码获取访问令牌和用户信息
完成用户认证或引导用户补充信息
Args:
request: HttpRequest对象
Returns:
HttpResponseRedirect: 重定向到相应页面
"""
# 从请求参数获取OAuth平台类型
type = request.GET.get('type', None) type = request.GET.get('type', None)
if not type: if not type:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
# 获取对应平台的OAuth管理器
manager = get_manager_by_type(type) manager = get_manager_by_type(type)
if not manager: if not manager:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
# 从请求参数获取授权码
code = request.GET.get('code', None) code = request.GET.get('code', None)
try: try:
# 使用授权码获取访问令牌和用户信息
rsp = manager.get_access_token_by_code(code) rsp = manager.get_access_token_by_code(code)
except OAuthAccessTokenException as e: except OAuthAccessTokenException as e:
# 处理令牌获取异常
logger.warning("OAuthAccessTokenException:" + str(e)) logger.warning("OAuthAccessTokenException:" + str(e))
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
except Exception as e: except Exception as e:
# 处理其他异常
logger.error(e) logger.error(e)
rsp = None rsp = None
# 获取安全的跳转URL
nexturl = get_redirecturl(request) nexturl = get_redirecturl(request)
# 如果获取用户信息失败,重新跳转到授权页面
if not rsp: if not rsp:
return HttpResponseRedirect(manager.get_authorization_url(nexturl)) return HttpResponseRedirect(manager.get_authorization_url(nexturl))
# 获取OAuth用户信息
user = manager.get_oauth_userinfo() user = manager.get_oauth_userinfo()
if user: if user:
# 处理昵称为空的情况,生成默认昵称
if not user.nickname or not user.nickname.strip(): if not user.nickname or not user.nickname.strip():
user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
try: try:
# 检查是否已存在相同平台和OpenID的用户
temp = OAuthUser.objects.get(type=type, openid=user.openid) temp = OAuthUser.objects.get(type=type, openid=user.openid)
# 更新现有用户信息
temp.picture = user.picture temp.picture = user.picture
temp.metadata = user.metadata temp.metadata = user.metadata
temp.nickname = user.nickname temp.nickname = user.nickname
user = temp user = temp
except ObjectDoesNotExist: except ObjectDoesNotExist:
# 用户不存在,使用新用户对象
pass pass
# facebook的token过长
# Facebook的token过长清空存储
if type == 'facebook': if type == 'facebook':
user.token = '' user.token = ''
# 如果用户有邮箱,直接完成登录流程
if user.email: if user.email:
with transaction.atomic(): with transaction.atomic(): # 使用事务保证数据一致性
author = None author = None
try: try:
# 尝试获取已关联的本地用户
author = get_user_model().objects.get(id=user.author_id) author = get_user_model().objects.get(id=user.author_id)
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
# 如果没有关联的本地用户
if not author: if not author:
# 根据邮箱获取或创建本地用户
result = get_user_model().objects.get_or_create(email=user.email) result = get_user_model().objects.get_or_create(email=user.email)
author = result[0] author = result[0]
# 如果是新创建的用户
if result[1]: if result[1]:
try: try:
# 检查昵称是否已被使用
get_user_model().objects.get(username=user.nickname) get_user_model().objects.get(username=user.nickname)
except ObjectDoesNotExist: except ObjectDoesNotExist:
# 昵称可用,设置为用户名
author.username = user.nickname author.username = user.nickname
else: else:
# 昵称已被使用,生成唯一用户名
author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
# 设置用户来源和保存
author.source = 'authorize' author.source = 'authorize'
author.save() author.save()
# 关联OAuth用户和本地用户
user.author = author user.author = author
user.save() user.save()
# 发送用户登录信号
oauth_user_login_signal.send( oauth_user_login_signal.send(
sender=authorize.__class__, id=user.id) sender=authorize.__class__, id=user.id)
# 登录用户
login(request, author) login(request, author)
# 重定向到目标页面
return HttpResponseRedirect(nexturl) return HttpResponseRedirect(nexturl)
else: else:
# 用户没有邮箱,保存用户信息并跳转到邮箱补充页面
user.save() user.save()
url = reverse('oauth:require_email', kwargs={ url = reverse('oauth:require_email', kwargs={
'oauthid': user.id 'oauthid': user.id
@ -121,35 +225,68 @@ def authorize(request):
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
else: else:
# 获取用户信息失败,重定向到目标页面
return HttpResponseRedirect(nexturl) return HttpResponseRedirect(nexturl)
def emailconfirm(request, id, sign): def emailconfirm(request, id, sign):
"""
邮箱确认视图
验证邮箱确认链接的签名完成OAuth用户与本地用户的绑定
Args:
request: HttpRequest对象
id: OAuth用户ID
sign: 安全签名
Returns:
HttpResponseRedirect: 重定向到绑定成功页面
"""
# 验证签名是否存在
if not sign: if not sign:
return HttpResponseForbidden() return HttpResponseForbidden()
# 验证签名是否正确
if not get_sha256(settings.SECRET_KEY + if not get_sha256(settings.SECRET_KEY +
str(id) + str(id) +
settings.SECRET_KEY).upper() == sign.upper(): settings.SECRET_KEY).upper() == sign.upper():
return HttpResponseForbidden() return HttpResponseForbidden()
# 获取OAuth用户对象
oauthuser = get_object_or_404(OAuthUser, pk=id) oauthuser = get_object_or_404(OAuthUser, pk=id)
with transaction.atomic():
with transaction.atomic(): # 使用事务保证数据一致性
# 处理用户关联
if oauthuser.author: if oauthuser.author:
# 已有关联用户,直接获取
author = get_user_model().objects.get(pk=oauthuser.author_id) author = get_user_model().objects.get(pk=oauthuser.author_id)
else: else:
# 没有关联用户,根据邮箱创建或获取用户
result = get_user_model().objects.get_or_create(email=oauthuser.email) result = get_user_model().objects.get_or_create(email=oauthuser.email)
author = result[0] author = result[0]
# 如果是新创建的用户
if result[1]: if result[1]:
author.source = 'emailconfirm' author.source = 'emailconfirm' # 设置用户来源
# 设置用户名(使用昵称或生成唯一用户名)
author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip( author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') ) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save() author.save()
# 保存用户关联关系
oauthuser.author = author oauthuser.author = author
oauthuser.save() oauthuser.save()
# 发送用户登录信号
oauth_user_login_signal.send( oauth_user_login_signal.send(
sender=emailconfirm.__class__, sender=emailconfirm.__class__,
id=oauthuser.id) id=oauthuser.id)
# 登录用户
login(request, author) login(request, author)
# 准备邮件内容
site = 'http://' + get_current_site().domain site = 'http://' + get_current_site().domain
content = _(''' content = _('''
<p>Congratulations, you have successfully bound your email address. You can use <p>Congratulations, you have successfully bound your email address. You can use
@ -162,7 +299,10 @@ def emailconfirm(request, id, sign):
%(site)s %(site)s
''') % {'oauthuser_type': oauthuser.type, 'site': site} ''') % {'oauthuser_type': oauthuser.type, 'site': site}
# 发送绑定成功邮件
send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content) send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
# 重定向到绑定成功页面
url = reverse('oauth:bindsuccess', kwargs={ url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id 'oauthid': id
}) })
@ -171,49 +311,96 @@ def emailconfirm(request, id, sign):
class RequireEmailView(FormView): class RequireEmailView(FormView):
form_class = RequireEmailForm """
template_name = 'oauth/require_email.html' 邮箱补充表单视图
当第三方登录未提供邮箱时显示表单让用户输入邮箱地址
"""
form_class = RequireEmailForm # 使用的表单类
template_name = 'oauth/require_email.html' # 模板名称
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
oauthid = self.kwargs['oauthid'] """
GET请求处理
检查OAuth用户是否已有邮箱如有则跳过此步骤
"""
oauthid = self.kwargs['oauthid'] # 获取OAuth用户ID
oauthuser = get_object_or_404(OAuthUser, pk=oauthid) oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# 如果用户已有邮箱,理论上应该跳过此步骤
if oauthuser.email: if oauthuser.email:
pass pass
# return HttpResponseRedirect('/') # 这里可以添加重定向逻辑:return HttpResponseRedirect('/')
return super(RequireEmailView, self).get(request, *args, **kwargs) return super(RequireEmailView, self).get(request, *args, **kwargs)
def get_initial(self): def get_initial(self):
"""
设置表单初始值
Returns:
dict: 包含初始值的字典
"""
oauthid = self.kwargs['oauthid'] oauthid = self.kwargs['oauthid']
return { return {
'email': '', 'email': '', # 邮箱初始值为空
'oauthid': oauthid 'oauthid': oauthid # 隐藏的OAuth用户ID
} }
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""
添加上下文数据
将OAuth用户的头像URL添加到模板上下文
"""
oauthid = self.kwargs['oauthid'] oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid) oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# 如果用户有头像,添加到上下文
if oauthuser.picture: if oauthuser.picture:
kwargs['picture'] = oauthuser.picture kwargs['picture'] = oauthuser.picture
return super(RequireEmailView, self).get_context_data(**kwargs) return super(RequireEmailView, self).get_context_data(**kwargs)
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理
保存用户邮箱发送确认邮件
Args:
form: 验证通过的表单实例
Returns:
HttpResponseRedirect: 重定向到邮件发送提示页面
"""
# 获取表单数据
email = form.cleaned_data['email'] email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid'] oauthid = form.cleaned_data['oauthid']
# 获取OAuth用户并更新邮箱
oauthuser = get_object_or_404(OAuthUser, pk=oauthid) oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
oauthuser.email = email oauthuser.email = email
oauthuser.save() oauthuser.save()
# 生成安全签名
sign = get_sha256(settings.SECRET_KEY + sign = get_sha256(settings.SECRET_KEY +
str(oauthuser.id) + settings.SECRET_KEY) str(oauthuser.id) + settings.SECRET_KEY)
# 构建确认链接
site = get_current_site().domain site = get_current_site().domain
if settings.DEBUG: if settings.DEBUG:
site = '127.0.0.1:8000' site = '127.0.0.1:8000' # 调试模式使用本地地址
path = reverse('oauth:email_confirm', kwargs={ path = reverse('oauth:email_confirm', kwargs={
'id': oauthid, 'id': oauthid,
'sign': sign 'sign': sign
}) })
url = "http://{site}{path}".format(site=site, path=path) url = "http://{site}{path}".format(site=site, path=path)
# 准备邮件内容
content = _(""" content = _("""
<p>Please click the link below to bind your email</p> <p>Please click the link below to bind your email</p>
@ -225,28 +412,51 @@ class RequireEmailView(FormView):
<br /> <br />
%(url)s %(url)s
""") % {'url': url} """) % {'url': url}
# 发送确认邮件
send_email(emailto=[email, ], title=_('Bind your email'), content=content) send_email(emailto=[email, ], title=_('Bind your email'), content=content)
# 重定向到提示页面
url = reverse('oauth:bindsuccess', kwargs={ url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid 'oauthid': oauthid
}) })
url = url + '?type=email' url = url + '?type=email' # 添加类型参数
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
def bindsuccess(request, oauthid): def bindsuccess(request, oauthid):
"""
绑定成功页面视图
根据绑定状态显示不同的成功信息
Args:
request: HttpRequest对象
oauthid: OAuth用户ID
Returns:
HttpResponse: 渲染的绑定成功页面
"""
# 获取绑定类型
type = request.GET.get('type', None) type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid) oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
# 根据类型设置不同的显示内容
if type == 'email': if type == 'email':
# 邮箱已发送状态
title = _('Bind your email') title = _('Bind your email')
content = _( content = _(
'Congratulations, the binding is just one step away. ' 'Congratulations, the binding is just one step away. '
'Please log in to your email to check the email to complete the binding. Thank you.') 'Please log in to your email to check the email to complete the binding. Thank you.')
else: else:
# 绑定完成状态
title = _('Binding successful') title = _('Binding successful')
content = _( content = _(
"Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s" "Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s"
" to directly log in to this website without a password. You are welcome to continue to follow this site." % { " to directly log in to this website without a password. You are welcome to continue to follow this site." % {
'oauthuser_type': oauthuser.type}) 'oauthuser_type': oauthuser.type})
# 渲染绑定成功页面
return render(request, 'oauth/bindsuccess.html', { return render(request, 'oauth/bindsuccess.html', {
'title': title, 'title': title,
'content': content 'content': content

Loading…
Cancel
Save