diff --git a/src/DjangoBlog/accounts/admin.py b/src/DjangoBlog/accounts/admin.py index 29d162a..fef734b 100644 --- a/src/DjangoBlog/accounts/admin.py +++ b/src/DjangoBlog/accounts/admin.py @@ -1,60 +1,152 @@ +""" +Django管理后台用户模型配置模块 + +本模块配置BlogUser模型在Django管理后台的显示和编辑行为, +包括自定义表单验证、列表显示字段、搜索过滤等功能。 + +主要组件: +- BlogUserCreationForm: 用户创建表单,处理密码验证和设置 +- BlogUserChangeForm: 用户信息修改表单 +- BlogUserAdmin: 用户模型管理后台配置类 +""" + from django import forms from django.contrib.auth.admin import UserAdmin from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UsernameField from django.utils.translation import gettext_lazy as _ -# Register your models here. +# 导入自定义用户模型 from .models import BlogUser class BlogUserCreationForm(forms.ModelForm): + """ + 博客用户创建表单 + + 扩展自ModelForm,专门用于在Django管理后台创建新用户。 + 提供密码确认验证和密码哈希处理功能。 + """ + + # 密码输入字段1 - 使用PasswordInput控件隐藏输入 password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + # 密码输入字段2 - 用于密码确认 password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) class Meta: + """表单元数据配置""" + # 指定关联的模型 model = BlogUser + # 指定表单中包含的字段 - 仅包含email字段 fields = ('email',) def clean_password2(self): - # Check that the two password entries match + """ + 密码确认字段验证方法 + + 验证两次输入的密码是否一致,确保用户输入正确的密码。 + + Returns: + str: 验证通过的密码 + + Raises: + forms.ValidationError: 当两次密码输入不一致时抛出验证错误 + """ + # 从清洗后的数据中获取两个密码字段的值 password1 = self.cleaned_data.get("password1") password2 = self.cleaned_data.get("password2") + + # 检查两个密码是否存在且相等 if password1 and password2 and password1 != password2: + # 密码不匹配时抛出验证错误 raise forms.ValidationError(_("passwords do not match")) + + # 返回验证通过的密码 return password2 def save(self, commit=True): - # Save the provided password in hashed format + """ + 表单保存方法 + + 重写保存逻辑,处理密码哈希化和设置用户来源。 + + Args: + commit (bool): 是否立即保存到数据库,默认为True + + Returns: + BlogUser: 保存后的用户实例 + """ + # 调用父类保存方法,但不立即提交到数据库 user = super().save(commit=False) + # 使用Django的密码哈希方法设置密码 user.set_password(self.cleaned_data["password1"]) + + # 如果设置为立即提交,则保存用户并设置来源 if commit: + # 标记用户创建来源为管理后台 user.source = 'adminsite' + # 保存用户到数据库 user.save() + return user class BlogUserChangeForm(UserChangeForm): + """ + 博客用户信息修改表单 + + 继承自Django的UserChangeForm,用于在管理后台编辑现有用户信息。 + 保持与Django原生用户管理表单的兼容性。 + """ + class Meta: + """表单元数据配置""" + # 指定关联的模型 model = BlogUser + # 包含所有字段 fields = '__all__' + # 指定用户名字段使用Django的UsernameField类型 field_classes = {'username': UsernameField} def __init__(self, *args, **kwargs): + """ + 表单初始化方法 + + 可以在此处添加自定义的表单初始化逻辑。 + """ + # 调用父类初始化方法 super().__init__(*args, **kwargs) class BlogUserAdmin(UserAdmin): + """ + 博客用户管理后台配置类 + + 继承自Django的UserAdmin,自定义BlogUser模型在管理后台的显示和行为。 + 配置列表显示、搜索、排序等管理界面功能。 + """ + + # 指定用户编辑表单 form = BlogUserChangeForm + # 指定用户创建表单 add_form = BlogUserCreationForm + + # 配置列表页面显示的字段 list_display = ( - 'id', - 'nickname', - 'username', - 'email', - 'last_login', - 'date_joined', - 'source') + 'id', # 用户ID + 'nickname', # 用户昵称 + 'username', # 用户名 + 'email', # 邮箱地址 + 'last_login', # 最后登录时间 + 'date_joined', # 注册时间 + 'source' # 用户来源 + ) + + # 配置列表中可点击跳转到编辑页面的字段 list_display_links = ('id', 'username') + + # 配置默认排序规则 - 按ID倒序排列(最新的在前) ordering = ('-id',) - search_fields = ('username', 'nickname', 'email') + + # 配置搜索框可搜索的字段 + search_fields = ('username', 'nickname', 'email') \ No newline at end of file diff --git a/src/DjangoBlog/accounts/apps.py b/src/DjangoBlog/accounts/apps.py index 9b3fc5a..9e1f7e0 100644 --- a/src/DjangoBlog/accounts/apps.py +++ b/src/DjangoBlog/accounts/apps.py @@ -1,5 +1,37 @@ +""" +Django应用配置模块 + +本模块定义accounts应用的配置类,用于配置应用级别的设置和元数据。 +""" + from django.apps import AppConfig class AccountsConfig(AppConfig): + """ + 用户账户应用配置类 + + 继承自Django的AppConfig类,用于配置accounts应用的各项设置。 + 包括应用名称、显示名称、初始化逻辑等。 + + 属性: + name (str): 应用的Python路径标识符,Django使用此名称来识别应用 + """ + + # 应用名称 - 使用Python路径格式,Django通过此名称识别应用 + # 此名称必须与应用的目录名和INSTALLED_APPS中的配置一致 name = 'accounts' + + # 可选:应用的可读名称(用于Django管理后台显示) + # verbose_name = '用户账户' + + # 可选:应用初始化方法 + # def ready(self): + # """ + # 应用初始化完成时调用的方法 + # + # 当Django启动完成,所有应用加载完毕后会自动调用此方法。 + # 常用于信号注册、配置检查等初始化操作。 + # """ + # # 导入并注册信号处理器 + # from . import signals \ No newline at end of file diff --git a/src/DjangoBlog/accounts/forms.py b/src/DjangoBlog/accounts/forms.py index fce4137..de03466 100644 --- a/src/DjangoBlog/accounts/forms.py +++ b/src/DjangoBlog/accounts/forms.py @@ -1,3 +1,15 @@ +""" +用户认证表单模块 + +本模块定义用户相关的Django表单,包括: +- 用户登录表单 +- 用户注册表单 +- 密码重置表单 +- 验证码表单 + +所有表单都包含Bootstrap样式类,提供一致的用户界面体验。 +""" + from django import forms from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, UserCreationForm @@ -9,109 +21,254 @@ from .models import BlogUser class LoginForm(AuthenticationForm): + """ + 用户登录表单 + + 继承自Django的AuthenticationForm,添加Bootstrap样式支持。 + 用于用户通过用户名和密码登录系统。 + """ + def __init__(self, *args, **kwargs): + """ + 初始化表单,设置字段的widget属性添加Bootstrap样式 + + Args: + *args: 可变位置参数 + **kwargs: 可变关键字参数 + """ + # 调用父类初始化方法 super(LoginForm, self).__init__(*args, **kwargs) + + # 设置用户名字段的输入控件和样式 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( - attrs={'placeholder': "password", "class": "form-control"}) + attrs={ + 'placeholder': "password", # 输入框占位符文本 + "class": "form-control" # Bootstrap表单控件样式类 + }) class RegisterForm(UserCreationForm): + """ + 用户注册表单 + + 继承自Django的UserCreationForm,扩展邮箱字段和样式支持。 + 用于新用户注册账号,包含用户名、邮箱和密码确认功能。 + """ + def __init__(self, *args, **kwargs): + """ + 初始化表单,设置所有字段的widget属性添加Bootstrap样式 + + Args: + *args: 可变位置参数 + **kwargs: 可变关键字参数 + """ + # 调用父类初始化方法 super(RegisterForm, self).__init__(*args, **kwargs) + # 设置用户名字段的输入控件和样式 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( - attrs={'placeholder': "email", "class": "form-control"}) + attrs={ + 'placeholder': "email", # 输入框占位符文本 + "class": "form-control" # Bootstrap表单控件样式类 + }) + + # 设置密码字段的输入控件和样式 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( - attrs={'placeholder': "repeat password", "class": "form-control"}) + attrs={ + 'placeholder': "repeat password", # 输入框占位符文本 + "class": "form-control" # Bootstrap表单控件样式类 + }) def clean_email(self): + """ + 邮箱字段验证方法 + + 验证邮箱是否已被注册,确保邮箱地址的唯一性。 + + Returns: + str: 验证通过的邮箱地址 + + Raises: + ValidationError: 当邮箱已被注册时抛出验证错误 + """ + # 获取清洗后的邮箱数据 email = self.cleaned_data['email'] + + # 检查邮箱是否已存在 if get_user_model().objects.filter(email=email).exists(): + # 抛出验证错误,提示邮箱已存在 raise ValidationError(_("email already exists")) + + # 返回验证通过的邮箱 return email class Meta: + """表单元数据配置""" + # 指定关联的用户模型 model = get_user_model() + # 指定表单中包含的字段 fields = ("username", "email") class ForgetPasswordForm(forms.Form): + """ + 忘记密码重置表单 + + 用于用户通过邮箱和验证码重置密码,包含密码强度验证和验证码校验。 + """ + + # 新密码字段1 new_password1 = forms.CharField( - label=_("New password"), + label=_("New password"), # 字段标签 widget=forms.PasswordInput( attrs={ - "class": "form-control", - 'placeholder': _("New password") + "class": "form-control", # Bootstrap样式类 + 'placeholder': _("New password") # 占位符文本 } ), ) + # 新密码字段2 - 用于密码确认 new_password2 = forms.CharField( - label="确认密码", + label="确认密码", # 中文标签 widget=forms.PasswordInput( attrs={ - "class": "form-control", - 'placeholder': _("Confirm password") + "class": "form-control", # Bootstrap样式类 + 'placeholder': _("Confirm password") # 占位符文本 } ), ) + # 邮箱字段 - 用于标识用户和发送验证码 email = forms.EmailField( - label='邮箱', + label='邮箱', # 中文标签 widget=forms.TextInput( attrs={ - 'class': 'form-control', - 'placeholder': _("Email") + 'class': 'form-control', # Bootstrap样式类 + 'placeholder': _("Email") # 占位符文本 } ), ) + # 验证码字段 - 用于验证用户身份 code = forms.CharField( - label=_('Code'), + label=_('Code'), # 字段标签 widget=forms.TextInput( attrs={ - 'class': 'form-control', - 'placeholder': _("Code") + 'class': 'form-control', # Bootstrap样式类 + 'placeholder': _("Code") # 占位符文本 } ), ) def clean_new_password2(self): + """ + 密码确认字段验证方法 + + 验证两次输入的密码是否一致,并检查密码强度。 + + Returns: + str: 验证通过的密码 + + Raises: + ValidationError: 当密码不匹配或强度不足时抛出验证错误 + """ + # 从请求数据中获取两个密码字段的值 password1 = self.data.get("new_password1") password2 = self.data.get("new_password2") + + # 检查两个密码是否存在且相等 if password1 and password2 and password1 != password2: + # 密码不匹配时抛出验证错误 raise ValidationError(_("passwords do not match")) + + # 使用Django内置密码验证器验证密码强度 password_validation.validate_password(password2) + # 返回验证通过的密码 return password2 def clean_email(self): + """ + 邮箱字段验证方法 + + 验证邮箱是否在系统中注册过。 + + Returns: + str: 验证通过的邮箱地址 + + Raises: + ValidationError: 当邮箱未注册时抛出验证错误 + """ + # 获取清洗后的邮箱数据 user_email = self.cleaned_data.get("email") - if not BlogUser.objects.filter( - email=user_email - ).exists(): - # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + + # 检查邮箱是否存在于用户数据库中 + if not BlogUser.objects.filter(email=user_email).exists(): + # TODO: 这里会暴露邮箱是否注册的信息,根据安全需求可修改提示 raise ValidationError(_("email does not exist")) + return user_email def clean_code(self): + """ + 验证码字段验证方法 + + 验证邮箱和验证码的匹配关系。 + + Returns: + str: 验证通过的验证码 + + Raises: + ValidationError: 当验证码无效或过期时抛出验证错误 + """ + # 获取清洗后的验证码数据 code = self.cleaned_data.get("code") + + # 调用utils模块的verify函数验证验证码 error = utils.verify( - email=self.cleaned_data.get("email"), - code=code, + email=self.cleaned_data.get("email"), # 邮箱地址 + code=code, # 验证码 ) + + # 如果验证返回错误信息,抛出验证错误 if error: raise ValidationError(error) + return code class ForgetPasswordCodeForm(forms.Form): + """ + 忘记密码验证码请求表单 + + 用于用户请求发送密码重置验证码,仅包含邮箱字段。 + """ + + # 邮箱字段 - 用于发送验证码 email = forms.EmailField( - label=_('Email'), - ) + label=_('Email'), # 字段标签 + # 可以添加widget配置来设置样式 + ) \ No newline at end of file diff --git a/src/DjangoBlog/accounts/migrations/0001_initial.py b/src/DjangoBlog/accounts/migrations/0001_initial.py index 88f5173..7b19380 100644 --- a/src/DjangoBlog/accounts/migrations/0001_initial.py +++ b/src/DjangoBlog/accounts/migrations/0001_initial.py @@ -25,81 +25,113 @@ class Migration(migrations.Migration): 继承自migrations.Migration,定义自定义用户模型的数据库表创建操作。 initial = True 表示这是该应用的第一个迁移文件。 + + 主要功能: + - 创建自定义用户模型BlogUser的数据库表 + - 继承Django认证系统的所有基础字段 + - 添加博客系统特有的自定义字段 + - 设置模型的管理器和配置选项 """ + # 标记为初始迁移文件,Django迁移系统会首先执行此文件 initial = True + # 定义迁移依赖关系 dependencies = [ # 声明对Django认证系统组的依赖 + # 使用auth应用的0012迁移文件,确保用户权限系统正常工作 ('auth', '0012_alter_user_first_name_max_length'), ] + # 定义迁移操作序列 operations = [ - # 创建博客用户表 + # 创建博客用户表的迁移操作 migrations.CreateModel( + # 模型名称 - 对应数据库表名 accounts_bloguser name='BlogUser', + # 定义模型字段列表 fields=[ # 主键字段 - 使用BigAutoField作为自增主键 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 密码字段 - Django认证系统标准字段,存储加密后的密码 ('password', models.CharField(max_length=128, verbose_name='password')), + # 最后登录时间字段 - 记录用户最后一次登录的时间 ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + # 超级用户标志字段 - 标识用户是否拥有所有权限 ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + # 用户名字段 - 唯一标识用户的字段,包含验证器和错误消息 ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + # 名字字段 - 用户的名字(西方命名习惯) ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + # 姓氏字段 - 用户的姓氏(西方命名习惯) ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + # 邮箱字段 - 用户的电子邮箱地址 ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + # 员工状态字段 - 标识用户是否可以登录管理后台 ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + # 活跃状态字段 - 标识用户账号是否激活(软删除机制) ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + # 注册时间字段 - 记录用户账号创建的时间 ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + # 昵称字段 - 博客系统自定义字段,用户显示名称 ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), + # 创建时间字段 - 博客系统自定义字段,记录创建时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间字段 - 博客系统自定义字段,记录最后修改时间 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 来源字段 - 博客系统自定义字段,记录用户创建来源(如注册、OAuth等) ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), + # 组关联字段 - Django权限系统的组多对多关联 ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + # 权限关联字段 - Django权限系统的用户权限多对多关联 ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], + # 模型元数据配置 options={ - # 管理后台显示名称(中文) + # 管理后台单数显示名称(中文) 'verbose_name': '用户', + # 管理后台复数显示名称(中文) 'verbose_name_plural': '用户', - # 默认排序规则 - 按ID倒序排列 + # 默认排序规则 - 按ID倒序排列(最新的记录在前) 'ordering': ['-id'], - # 指定获取最新记录的字段 + # 指定获取最新记录的字段 - 使用id字段确定最新记录 'get_latest_by': 'id', }, + # 定义模型管理器 managers=[ # 使用Django内置的UserManager管理用户对象 + # 提供create_user、create_superuser等用户管理方法 ('objects', django.contrib.auth.models.UserManager()), ], ), diff --git a/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py index 1a9f509..825a10e 100644 --- a/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py +++ b/src/DjangoBlog/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -1,3 +1,14 @@ +""" +用户账户应用数据库迁移文件 - 字段优化更新 + +本迁移文件对初始用户模型进行字段优化和国际化改进: +- 重命名时间字段,使用更清晰的英文命名 +- 更新字段显示名称,统一使用英文verbose_name +- 移除冗余字段,优化数据库结构 + +这是对0001_initial迁移的后续更新,依赖于初始迁移创建的表结构。 +""" + # Generated by Django 4.2.5 on 2023-09-06 13:13 from django.db import migrations, models @@ -5,42 +16,85 @@ import django.utils.timezone class Migration(migrations.Migration): + """ + 用户模型字段优化迁移类 + 对BlogUser模型进行字段级别的优化和改进: + - 标准化字段命名约定 + - 改进国际化支持 + - 优化时间字段的语义清晰度 + + 此迁移依赖于accounts应用的0001_initial迁移文件。 + """ + + # 定义迁移依赖关系 - 依赖于本应用的初始迁移 dependencies = [ + # 依赖accounts应用的第一个迁移文件,确保BlogUser表已创建 ('accounts', '0001_initial'), ] + # 定义迁移操作序列 - 按顺序执行以下数据库变更 operations = [ + # 修改模型选项 - 更新管理后台显示名称 migrations.AlterModelOptions( 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( model_name='bloguser', name='created_time', ), + + # 移除字段 - 删除last_mod_time字段 + # 该字段功能被last_modify_time字段替代 migrations.RemoveField( model_name='bloguser', name='last_mod_time', ), + + # 添加新字段 - 创建时间字段(新命名) migrations.AddField( model_name='bloguser', name='creation_time', + # 使用DateTimeField存储完整的时间戳 + # default参数使用Django的时区感知当前时间 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + + # 添加新字段 - 最后修改时间字段(新命名) migrations.AddField( model_name='bloguser', name='last_modify_time', + # 使用DateTimeField存储完整的时间戳 + # default参数使用Django的时区感知当前时间 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + + # 修改字段选项 - 更新昵称字段的显示名称 migrations.AlterField( model_name='bloguser', name='nickname', + # 保持字段类型和约束不变,仅更新verbose_name为英文 field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), ), + + # 修改字段选项 - 更新来源字段的显示名称 migrations.AlterField( model_name='bloguser', name='source', + # 保持字段类型和约束不变,仅更新verbose_name为英文 field=models.CharField(blank=True, max_length=100, verbose_name='create source'), ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/accounts/models.py b/src/DjangoBlog/accounts/models.py index 3baddbb..6fb5cb0 100644 --- a/src/DjangoBlog/accounts/models.py +++ b/src/DjangoBlog/accounts/models.py @@ -1,3 +1,10 @@ +""" +自定义用户模型模块 + +本模块定义博客系统的自定义用户模型BlogUser,扩展Django内置的AbstractUser模型, +添加博客系统特有的用户字段和方法。 +""" + from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse @@ -6,30 +13,113 @@ from django.utils.translation import gettext_lazy as _ from djangoblog.utils import get_current_site -# Create your models here. - 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): + """ + 获取用户的绝对URL(相对路径) + + 用于Django的通用视图和模板中生成用户详情页链接。 + + Returns: + str: 用户详情页的URL路径 + + Example: + >>> user.get_absolute_url() + '/author/admin/' + """ + # 使用reverse函数通过URL名称和参数生成URL路径 return reverse( - 'blog:author_detail', kwargs={ - 'author_name': self.username}) + 'blog:author_detail', # URL配置的名称 + kwargs={ + 'author_name': self.username # URL参数:作者用户名 + }) def __str__(self): + """ + 对象字符串表示方法 + + 定义模型实例在Django管理后台和shell中的显示内容。 + + Returns: + str: 用户的邮箱地址 + """ return self.email 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 - url = "https://{site}{path}".format(site=site, - path=self.get_absolute_url()) + # 生成完整的URL,包含HTTPS协议和域名 + url = "https://{site}{path}".format( + site=site, # 站点域名 + path=self.get_absolute_url() # 相对路径 + ) return url class Meta: + """ + 模型元数据配置类 + + 定义模型的数据库表配置和Django管理后台显示选项。 + """ + + # 默认排序规则 - 按ID倒序排列(最新的记录在前) ordering = ['-id'] + + # 管理后台单数显示名称(支持国际化) verbose_name = _('user') + + # 管理后台复数显示名称 - 使用与单数相同的名称 verbose_name_plural = verbose_name - get_latest_by = 'id' + + # 指定获取最新记录的字段 - 使用id字段确定最新记录1 + get_latest_by = 'id' \ No newline at end of file diff --git a/src/DjangoBlog/accounts/tests.py b/src/DjangoBlog/accounts/tests.py index 6893411..5232e2b 100644 --- a/src/DjangoBlog/accounts/tests.py +++ b/src/DjangoBlog/accounts/tests.py @@ -1,3 +1,10 @@ +""" +用户账户应用测试模块 + +本模块包含用户账户相关的所有测试用例,覆盖用户注册、登录、密码重置、 +邮箱验证等核心功能的测试。 +""" + from django.test import Client, RequestFactory, TestCase from django.urls import reverse from django.utils import timezone @@ -9,128 +16,203 @@ from djangoblog.utils import * from . import utils -# Create your tests here. - class AccountTest(TestCase): + """ + 用户账户功能测试类 + + 测试用户账户相关的所有功能,包括: + - 用户认证和登录 + - 用户注册流程 + - 邮箱验证码功能 + - 密码重置流程 + - 权限访问控制 + """ + def setUp(self): + """ + 测试初始化方法 + + 在每个测试方法执行前运行,创建测试所需的初始数据和环境。 + """ + # 创建测试客户端,用于模拟HTTP请求 self.client = Client() + # 创建请求工厂,用于构建请求对象 self.factory = RequestFactory() + # 创建测试用户 self.blog_user = BlogUser.objects.create_user( username="test", email="admin@admin.com", password="12345678" ) + # 设置测试用的新密码 self.new_test = "xxx123--=" def test_validate_account(self): + """ + 测试账户验证和权限功能 + + 验证超级用户的创建、登录、管理后台访问和文章管理权限。 + """ + # 创建超级用户 site = get_current_site().domain user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="qwer!@#$ggg") + # 从数据库获取刚创建的用户 testuser = BlogUser.objects.get(username='liangliangyy1') + # 测试用户登录功能 loginresult = self.client.login( username='liangliangyy1', password='qwer!@#$ggg') + # 断言登录成功 self.assertEqual(loginresult, True) + + # 测试管理后台访问权限 response = self.client.get('/admin/') self.assertEqual(response.status_code, 200) + # 创建测试分类 category = Category() category.name = "categoryaaa" category.creation_time = timezone.now() category.last_modify_time = timezone.now() category.save() + # 创建测试文章 article = Article() article.title = "nicetitleaaa" article.body = "nicecontentaaa" article.author = user article.category = category - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() + # 测试文章管理页面访问权限 response = self.client.get(article.get_admin_url()) self.assertEqual(response.status_code, 200) def test_validate_register(self): + """ + 测试用户注册完整流程 + + 验证用户注册、邮箱验证、登录、权限提升和文章管理的完整流程。 + """ + # 验证注册前用户不存在 self.assertEquals( 0, len( BlogUser.objects.filter( email='user123@user.com'))) + + # 提交用户注册表单 response = self.client.post(reverse('account:register'), { 'username': 'user1233', 'email': 'user123@user.com', 'password1': 'password123!q@wE#R$T', 'password2': 'password123!q@wE#R$T', }) + + # 验证用户已成功创建 self.assertEquals( 1, len( BlogUser.objects.filter( email='user123@user.com'))) + + # 获取新创建的用户 user = BlogUser.objects.filter(email='user123@user.com')[0] + + # 生成邮箱验证签名 sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) path = reverse('accounts:result') url = '{path}?type=validation&id={id}&sign={sign}'.format( path=path, id=user.id, sign=sign) + + # 访问验证结果页面 response = self.client.get(url) self.assertEqual(response.status_code, 200) + # 测试用户登录 self.client.login(username='user1233', password='password123!q@wE#R$T') + + # 提升用户权限为超级用户 user = BlogUser.objects.filter(email='user123@user.com')[0] user.is_superuser = True user.is_staff = True user.save() + + # 清理侧边栏缓存 delete_sidebar_cache() + + # 创建测试分类 category = Category() category.name = "categoryaaa" category.creation_time = timezone.now() category.last_modify_time = timezone.now() category.save() + # 创建测试文章 article = Article() article.category = category article.title = "nicetitle333" article.body = "nicecontentttt" article.author = user - - article.type = 'a' - article.status = 'p' + article.type = 'a' # 文章类型 + article.status = 'p' # 发布状态 article.save() + # 测试文章管理页面访问 response = self.client.get(article.get_admin_url()) self.assertEqual(response.status_code, 200) + # 测试用户登出 response = self.client.get(reverse('account:logout')) self.assertIn(response.status_code, [301, 302, 200]) + # 验证登出后无法访问管理页面 response = self.client.get(article.get_admin_url()) self.assertIn(response.status_code, [301, 302, 200]) + # 测试错误密码登录 response = self.client.post(reverse('account:login'), { 'username': 'user1233', - 'password': 'password123' + 'password': 'password123' # 错误密码 }) self.assertIn(response.status_code, [301, 302, 200]) + # 验证错误密码登录后仍无法访问管理页面 response = self.client.get(article.get_admin_url()) self.assertIn(response.status_code, [301, 302, 200]) def test_verify_email_code(self): + """ + 测试邮箱验证码功能 + + 验证验证码的生成、发送、存储和验证流程。 + """ to_email = "admin@admin.com" + # 生成验证码 code = generate_code() + # 存储验证码 utils.set_code(to_email, code) + # 发送验证邮件 utils.send_verify_email(to_email, code) + # 测试正确验证码验证 err = utils.verify("admin@admin.com", code) - self.assertEqual(err, None) + self.assertEqual(err, None) # 验证成功应返回None + # 测试错误邮箱验证 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): + """ + 测试忘记密码验证码请求成功场景 + + 验证正确邮箱地址的验证码请求处理。 + """ resp = self.client.post( path=reverse("account:forget_password_code"), data=dict(email="admin@admin.com") @@ -140,32 +222,49 @@ class AccountTest(TestCase): self.assertEqual(resp.content.decode("utf-8"), "ok") def test_forget_password_email_code_fail(self): + """ + 测试忘记密码验证码请求失败场景 + + 验证空邮箱和错误格式邮箱的请求处理。 + """ + # 测试空邮箱提交 resp = self.client.post( path=reverse("account:forget_password_code"), - data=dict() + data=dict() # 空数据 ) self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + # 测试错误格式邮箱提交 resp = self.client.post( path=reverse("account:forget_password_code"), - data=dict(email="admin@com") + data=dict(email="admin@com") # 无效邮箱格式 ) self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") def test_forget_password_email_success(self): + """ + 测试忘记密码重置成功场景 + + 验证正确的验证码和密码重置流程。 + """ + # 生成并设置验证码 code = generate_code() utils.set_code(self.blog_user.email, code) + + # 准备密码重置数据 data = dict( new_password1=self.new_test, new_password2=self.new_test, email=self.blog_user.email, code=code, ) + + # 提交密码重置请求 resp = self.client.post( path=reverse("account:forget_password"), data=data ) - self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.status_code, 302) # 重定向响应 # 验证用户密码是否修改成功 blog_user = BlogUser.objects.filter( @@ -175,10 +274,15 @@ class AccountTest(TestCase): self.assertEqual(blog_user.check_password(data["new_password1"]), True) def test_forget_password_email_not_user(self): + """ + 测试不存在的用户密码重置 + + 验证对不存在用户的密码重置请求处理。 + """ data = dict( new_password1=self.new_test, new_password2=self.new_test, - email="123@123.com", + email="123@123.com", # 不存在的邮箱 code="123456", ) resp = self.client.post( @@ -186,22 +290,25 @@ class AccountTest(TestCase): data=data ) - self.assertEqual(resp.status_code, 200) - + self.assertEqual(resp.status_code, 200) # 应返回表单错误页面 def test_forget_password_email_code_error(self): + """ + 测试错误验证码的密码重置 + + 验证错误验证码的密码重置请求处理。 + """ code = generate_code() utils.set_code(self.blog_user.email, code) data = dict( new_password1=self.new_test, new_password2=self.new_test, email=self.blog_user.email, - code="111111", + code="111111", # 错误的验证码 ) resp = self.client.post( path=reverse("account:forget_password"), data=data ) - self.assertEqual(resp.status_code, 200) - + self.assertEqual(resp.status_code, 200) # 应返回表单错误页面 \ No newline at end of file diff --git a/src/DjangoBlog/accounts/urls.py b/src/DjangoBlog/accounts/urls.py index 107a801..501f7f1 100644 --- a/src/DjangoBlog/accounts/urls.py +++ b/src/DjangoBlog/accounts/urls.py @@ -1,28 +1,60 @@ +""" +用户账户应用URL配置模块 + +本模块定义用户账户相关的所有URL路由,包括登录、注册、登出、 +密码重置等用户认证相关的端点。 + +URL模式使用正则表达式和路径转换器来匹配不同的用户操作请求。 +""" + from django.urls import path from django.urls import re_path +# 导入视图模块 from . import views +# 导入自定义登录表单 from .forms import LoginForm +# 应用命名空间,用于URL反向解析 app_name = "accounts" -urlpatterns = [re_path(r'^login/$', - views.LoginView.as_view(success_url='/'), - name='login', - kwargs={'authentication_form': LoginForm}), - re_path(r'^register/$', - views.RegisterView.as_view(success_url="/"), - name='register'), - re_path(r'^logout/$', - views.LogoutView.as_view(), - name='logout'), - path(r'account/result.html', - views.account_result, - name='result'), - re_path(r'^forget_password/$', - views.ForgetPasswordView.as_view(), - name='forget_password'), - re_path(r'^forget_password_code/$', - views.ForgetPasswordEmailCode.as_view(), - name='forget_password_code'), - ] +# URL模式列表,定义请求URL与视图的映射关系 +urlpatterns = [ + # 用户登录URL + re_path(r'^login/$', # 匹配 /login/ 路径 + # 使用类视图,设置登录成功后的重定向URL为首页 + views.LoginView.as_view(success_url='/'), + name='login', # URL名称,用于反向解析 + # 传递额外参数,指定使用自定义登录表单 + kwargs={'authentication_form': LoginForm}), + + # 用户注册URL + re_path(r'^register/$', # 匹配 /register/ 路径 + # 使用类视图,设置注册成功后的重定向URL为首页 + views.RegisterView.as_view(success_url="/"), + name='register'), # URL名称,用于反向解析 + + # 用户登出URL + re_path(r'^logout/$', # 匹配 /logout/ 路径 + # 使用类视图,处理用户登出逻辑 + views.LogoutView.as_view(), + name='logout'), # URL名称,用于反向解析 + + # 账户操作结果页面URL + path(r'account/result.html', # 匹配 /account/result.html 路径 + # 使用函数视图,显示账户操作结果(如注册成功、验证结果等) + views.account_result, + name='result'), # URL名称,用于反向解析 + + # 忘记密码页面URL + re_path(r'^forget_password/$', # 匹配 /forget_password/ 路径 + # 使用类视图,处理密码重置请求 + views.ForgetPasswordView.as_view(), + name='forget_password'), # URL名称,用于反向解析 + + # 忘记密码验证码请求URL + re_path(r'^forget_password_code/$', # 匹配 /forget_password_code/ 路径 + # 使用类视图,处理发送密码重置验证码的请求 + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code'), # URL名称,用于反向解析 +] \ No newline at end of file diff --git a/src/DjangoBlog/accounts/user_login_backend.py b/src/DjangoBlog/accounts/user_login_backend.py index 73cdca1..b90f615 100644 --- a/src/DjangoBlog/accounts/user_login_backend.py +++ b/src/DjangoBlog/accounts/user_login_backend.py @@ -1,26 +1,91 @@ +""" +自定义用户认证后端模块 + +本模块提供扩展的用户认证功能,支持使用用户名或邮箱进行登录。 +扩展了Django标准的ModelBackend认证后端。 +""" + from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend class EmailOrUsernameModelBackend(ModelBackend): """ - 允许使用用户名或邮箱登录 + 自定义用户认证后端 - 支持用户名或邮箱登录 + + 继承自Django的ModelBackend,扩展认证功能: + - 允许用户使用用户名或邮箱地址进行登录 + - 自动检测输入的是用户名还是邮箱格式 + - 保持与Django原生认证系统的兼容性 + + 使用场景: + 当用户输入包含'@'符号时,系统将其识别为邮箱进行认证; + 否则将其识别为用户名进行认证。 """ 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: + # 如果包含'@'符号,按邮箱处理 kwargs = {'email': username} else: + # 否则按用户名处理 kwargs = {'username': username} + try: + # 根据用户名或邮箱查找用户 user = get_user_model().objects.get(**kwargs) + + # 验证密码是否正确 if user.check_password(password): + # 密码验证成功,返回用户对象 return user + except get_user_model().DoesNotExist: + # 用户不存在,返回None表示认证失败 return None def get_user(self, username): + """ + 根据用户ID获取用户对象 + + 重写用户获取方法,通过用户ID(主键)获取用户实例。 + + Args: + username (int/str): 用户的ID(主键值) + + Returns: + User: 对应的用户对象 + None: 用户不存在时返回None + + Note: + 这里的参数名username实际上是用户ID,这是为了保持与父类接口一致 + """ try: + # 根据主键(用户ID)查找用户 return get_user_model().objects.get(pk=username) except get_user_model().DoesNotExist: - return None + # 用户不存在,返回None + return None \ No newline at end of file diff --git a/src/DjangoBlog/accounts/utils.py b/src/DjangoBlog/accounts/utils.py index 4b94bdf..365afb7 100644 --- a/src/DjangoBlog/accounts/utils.py +++ b/src/DjangoBlog/accounts/utils.py @@ -1,3 +1,15 @@ +""" +邮箱验证码工具模块 + +本模块提供邮箱验证码的生成、发送、验证和缓存管理功能。 +用于用户注册、密码重置等需要邮箱验证的场景。 + +主要功能: +- 发送验证码邮件 +- 验证码的存储和读取 +- 验证码有效性验证 +""" + import typing from datetime import timedelta @@ -7,43 +19,109 @@ from django.utils.translation import gettext_lazy as _ from djangoblog.utils import send_email +# 验证码有效期配置 - 5分钟 _code_ttl = timedelta(minutes=5) def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): - """发送重设密码验证码 + """ + 发送验证码邮件 + + 向指定邮箱发送包含验证码的邮件,用于用户身份验证。 + Args: - to_mail: 接受邮箱 - subject: 邮件主题 - code: 验证码 + to_mail (str): 接收邮件的邮箱地址 + code (str): 要发送的验证码 + subject (str): 邮件主题,默认为"Verify Email" + + Example: + >>> send_verify_email("user@example.com", "123456") + # 向user@example.com发送验证码123456 """ + # 构建邮件HTML内容,包含验证码信息 html_content = _( "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " "properly") % {'code': code} + + # 调用邮件发送工具发送邮件 send_email([to_mail], subject, html_content) def verify(email: str, code: str) -> typing.Optional[str]: - """验证code是否有效 + """ + 验证验证码是否有效 + + 检查用户输入的验证码与缓存中存储的是否一致,并验证有效性。 + Args: - email: 请求邮箱 - code: 验证码 - Return: - 如果有错误就返回错误str - Node: - 这里的错误处理不太合理,应该采用raise抛出 - 否测调用方也需要对error进行处理 + email (str): 用户邮箱地址,作为缓存键 + code (str): 用户输入的验证码 + + Returns: + typing.Optional[str]: + - None: 验证码正确且有效 + - str: 错误信息字符串(验证码错误或无效) + + Note: + 当前错误处理方式不够合理,应该使用异常抛出机制, + 这样调用方可以通过try-except处理错误,而不是检查返回值。 + + Example: + >>> result = verify("user@example.com", "123456") + >>> if result: + >>> print(f"验证失败: {result}") + >>> else: + >>> print("验证成功") """ + # 从缓存中获取该邮箱对应的验证码 cache_code = get_code(email) + + # 比较用户输入的验证码与缓存中的验证码 if cache_code != code: + # 验证码不匹配,返回错误信息 return gettext("Verification code error") + # 验证成功,返回None + 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) def get_code(email: str) -> typing.Optional[str]: - """获取code""" - return cache.get(email) + """ + 从缓存中获取验证码 + + 根据邮箱地址从缓存中获取对应的验证码。 + + 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) \ No newline at end of file diff --git a/src/DjangoBlog/accounts/views.py b/src/DjangoBlog/accounts/views.py index ae67aec..ccef460 100644 --- a/src/DjangoBlog/accounts/views.py +++ b/src/DjangoBlog/accounts/views.py @@ -1,3 +1,15 @@ +""" +用户账户视图模块 + +本模块包含用户账户相关的所有视图处理逻辑,包括: +- 用户注册、登录、登出 +- 邮箱验证 +- 密码重置 +- 验证码发送 + +使用类视图和函数视图结合的方式处理用户认证流程。 +""" + import logging from django.utils.translation import gettext_lazy as _ from django.conf import settings @@ -26,34 +38,68 @@ from . import utils from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm from .models import BlogUser +# 配置日志记录器 logger = logging.getLogger(__name__) -# Create your views here. - class RegisterView(FormView): + """ + 用户注册视图 + + 处理新用户注册流程,包括表单验证、用户创建、邮箱验证邮件发送等。 + """ + + # 指定使用的表单类 form_class = RegisterForm + # 指定注册页面模板 template_name = 'account/registration_form.html' @method_decorator(csrf_protect) def dispatch(self, *args, **kwargs): + """ + 请求分发方法,添加CSRF保护装饰器 + + 确保注册请求受到CSRF保护,防止跨站请求伪造攻击。 + """ return super(RegisterView, self).dispatch(*args, **kwargs) def form_valid(self, form): + """ + 表单验证通过后的处理逻辑 + + 创建新用户、发送验证邮件、重定向到结果页面。 + + Args: + form: 验证通过的注册表单实例 + + Returns: + HttpResponseRedirect: 重定向到结果页面 + """ if form.is_valid(): + # 创建用户但不立即保存到数据库 user = form.save(False) + # 设置用户为非激活状态,等待邮箱验证 user.is_active = False + # 记录用户来源为注册页面 user.source = 'Register' + # 保存用户到数据库 user.save(True) + + # 获取当前站点域名 site = get_current_site().domain + # 生成邮箱验证签名 sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + # 调试模式下使用本地地址 if settings.DEBUG: site = '127.0.0.1:8000' + + # 构建验证URL path = reverse('account:result') url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( site=site, path=path, id=user.id, sign=sign) + # 构建邮件内容 content = """

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

@@ -64,6 +110,8 @@ class RegisterView(FormView): 如果上面链接无法打开,请将此链接复制至浏览器。 {url} """.format(url=url) + + # 发送验证邮件 send_email( emailto=[ user.email, @@ -71,43 +119,88 @@ class RegisterView(FormView): title='验证您的电子邮箱', content=content) + # 重定向到注册结果页面 url = reverse('accounts:result') + \ '?type=register&id=' + str(user.id) return HttpResponseRedirect(url) else: + # 表单验证失败,重新渲染表单页面 return self.render_to_response({ 'form': form }) class LogoutView(RedirectView): + """ + 用户登出视图 + + 处理用户登出逻辑,清理会话和缓存。 + """ + + # 登出后重定向到的URL url = '/login/' @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): + """ + 请求分发方法,添加不缓存装饰器 + + 确保登出页面不会被浏览器缓存。 + """ return super(LogoutView, self).dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): + """ + 处理GET请求的登出逻辑 + + 执行用户登出操作,清理侧边栏缓存。 + """ + # 执行用户登出 logout(request) + # 清理侧边栏缓存 delete_sidebar_cache() + # 调用父类方法进行重定向 return super(LogoutView, self).get(request, *args, **kwargs) class LoginView(FormView): + """ + 用户登录视图 + + 处理用户登录认证,支持记住登录状态功能。 + """ + + # 指定使用的表单类 form_class = LoginForm + # 指定登录页面模板 template_name = 'account/login.html' + # 登录成功后的默认重定向URL success_url = '/' + # 重定向字段名称 redirect_field_name = REDIRECT_FIELD_NAME - login_ttl = 2626560 # 一个月的时间 + # 记住登录状态的会话有效期(一个月) + login_ttl = 2626560 @method_decorator(sensitive_post_parameters('password')) @method_decorator(csrf_protect) @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): + """ + 请求分发方法,添加安全装饰器 + - sensitive_post_parameters: 保护密码参数 + - csrf_protect: CSRF保护 + - never_cache: 禁止缓存 + """ return super(LoginView, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): + """ + 获取模板上下文数据 + + 添加重定向URL到上下文。 + """ + # 从GET参数获取重定向URL redirect_to = self.request.GET.get(self.redirect_field_name) if redirect_to is None: redirect_to = '/' @@ -116,25 +209,43 @@ class LoginView(FormView): return super(LoginView, self).get_context_data(**kwargs) def form_valid(self, form): + """ + 表单验证通过后的处理逻辑 + + 执行用户登录认证,处理记住登录状态。 + """ + # 使用Django认证表单进行验证 form = AuthenticationForm(data=self.request.POST, request=self.request) if form.is_valid(): + # 清理侧边栏缓存 delete_sidebar_cache() logger.info(self.redirect_field_name) + # 执行用户登录 auth.login(self.request, form.get_user()) + + # 处理记住登录状态 if self.request.POST.get("remember"): self.request.session.set_expiry(self.login_ttl) + return super(LoginView, self).form_valid(form) - # return HttpResponseRedirect('/') else: + # 登录失败,重新渲染登录页面 return self.render_to_response({ 'form': form }) def get_success_url(self): + """ + 获取登录成功后的重定向URL + 验证重定向URL的安全性,防止开放重定向攻击。 + """ + # 从POST数据获取重定向URL redirect_to = self.request.POST.get(self.redirect_field_name) + + # 验证URL是否安全(同源策略) if not url_has_allowed_host_and_scheme( url=redirect_to, allowed_hosts=[ self.request.get_host()]): @@ -143,62 +254,124 @@ class LoginView(FormView): def account_result(request): + """ + 账户操作结果页面视图 + + 显示注册结果或处理邮箱验证。 + + Args: + request: HTTP请求对象 + + Returns: + HttpResponse: 结果页面响应 + """ + # 获取操作类型和用户ID type = request.GET.get('type') id = request.GET.get('id') + # 获取用户对象,不存在则返回404 user = get_object_or_404(get_user_model(), id=id) logger.info(type) + + # 如果用户已激活,重定向到首页 if user.is_active: return HttpResponseRedirect('/') + + # 处理注册和验证类型 if type and type in ['register', 'validation']: if type == 'register': + # 注册成功页面 content = ''' 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 ''' title = '注册成功' else: + # 邮箱验证处理 c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) sign = request.GET.get('sign') + # 验证签名 if sign != c_sign: return HttpResponseForbidden() + # 激活用户账号 user.is_active = True user.save() content = ''' 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 ''' title = '验证成功' + + # 渲染结果页面 return render(request, 'account/result.html', { 'title': title, 'content': content }) else: + # 无效类型,重定向到首页 return HttpResponseRedirect('/') class ForgetPasswordView(FormView): + """ + 忘记密码重置视图 + + 处理用户密码重置请求,验证验证码并更新密码。 + """ + + # 指定使用的表单类 form_class = ForgetPasswordForm + # 指定模板名称 template_name = 'account/forget_password.html' def form_valid(self, form): + """ + 表单验证通过后的处理逻辑 + + 重置用户密码并重定向到登录页面。 + """ if form.is_valid(): + # 根据邮箱查找用户 blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + # 使用新密码的哈希值更新用户密码 blog_user.password = make_password(form.cleaned_data["new_password2"]) + # 保存用户信息 blog_user.save() + # 重定向到登录页面 return HttpResponseRedirect('/login/') else: + # 表单验证失败,重新渲染表单 return self.render_to_response({'form': form}) class ForgetPasswordEmailCode(View): + """ + 忘记密码验证码发送视图 + + 处理密码重置验证码的发送请求。 + """ def post(self, request: HttpRequest): + """ + 处理POST请求,发送密码重置验证码 + + Args: + request: HTTP请求对象 + + Returns: + HttpResponse: 操作结果响应 + """ + # 验证表单数据 form = ForgetPasswordCodeForm(request.POST) if not form.is_valid(): return HttpResponse("错误的邮箱") + + # 获取邮箱地址 to_email = form.cleaned_data["email"] + # 生成验证码 code = generate_code() + # 发送验证邮件 utils.send_verify_email(to_email, code) + # 存储验证码到缓存 utils.set_code(to_email, code) - return HttpResponse("ok") + return HttpResponse("ok") \ No newline at end of file diff --git a/src/DjangoBlog/oauth/admin.py b/src/DjangoBlog/oauth/admin.py index 57eab5f..8136cf9 100644 --- a/src/DjangoBlog/oauth/admin.py +++ b/src/DjangoBlog/oauth/admin.py @@ -1,54 +1,144 @@ +""" +Django Admin 管理站点配置模块 - OAuth 认证 + +该模块用于配置OAuth相关模型在Django Admin管理站点的显示和操作方式。 +包含OAuth用户和OAuth配置两个管理类,用于自定义管理界面。 +""" + import logging +# 导入Django Admin管理模块 from django.contrib import admin -# Register your models here. +# 导入URL反向解析功能 from django.urls import reverse +# 导入HTML格式化工具 from django.utils.html import format_html +# 获取当前模块的日志记录器 logger = logging.getLogger(__name__) class OAuthUserAdmin(admin.ModelAdmin): + """ + OAuth用户模型的管理配置类 + + 自定义OAuthUser模型在Django Admin中的显示和行为, + 包括搜索、列表显示、过滤等功能。 + """ + + # 设置可搜索的字段 search_fields = ('nickname', 'email') + # 设置每页显示的项目数量 list_per_page = 20 + # 设置列表页面显示的字段 list_display = ( - 'id', - 'nickname', - 'link_to_usermodel', - 'show_user_image', - 'type', - 'email', + 'id', # 用户ID + 'nickname', # 昵称 + 'link_to_usermodel', # 自定义方法:关联本地用户链接 + 'show_user_image', # 自定义方法:显示用户头像 + 'type', # OAuth类型 + 'email', # 邮箱 ) + # 设置可作为链接点击的字段(跳转到编辑页面) list_display_links = ('id', 'nickname') + # 设置右侧过滤侧边栏的过滤字段 list_filter = ('author', 'type',) + # 初始化只读字段列表 readonly_fields = [] def get_readonly_fields(self, request, obj=None): + """ + 动态获取只读字段列表 + + 重写方法使所有字段在Admin中均为只读,防止在管理界面修改OAuth用户数据。 + + Args: + request: HttpRequest对象 + obj: 模型实例对象 + + Returns: + list: 包含所有字段名的列表,使所有字段只读 + """ + # 返回所有字段名称的列表,包括普通字段和多对多字段 return list(self.readonly_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.fields] + \ + [field.name for field in obj._meta.many_to_many] def has_add_permission(self, request): + """ + 禁用添加权限 + + 防止在Admin界面手动添加OAuth用户,OAuth用户只能通过认证流程自动创建。 + + Args: + request: HttpRequest对象 + + Returns: + bool: 始终返回False,禁止添加新记录 + """ return False def link_to_usermodel(self, obj): + """ + 自定义方法:生成关联本地用户的链接 + + 在Admin列表中显示关联的本地用户,并提供跳转到用户编辑页面的链接。 + + Args: + obj: OAuthUser实例对象 + + Returns: + str: 格式化的HTML链接,包含用户昵称或邮箱显示 + """ + # 检查是否存在关联的本地用户 if obj.author: + # 获取关联用户模型的app和model信息 info = (obj.author._meta.app_label, obj.author._meta.model_name) + # 生成编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + # 返回格式化的HTML链接,显示用户昵称或邮箱 return format_html( u'%s' % (link, obj.author.nickname if obj.author.nickname else obj.author.email)) def show_user_image(self, obj): + """ + 自定义方法:显示用户头像 + + 在Admin列表中以缩略图形式显示用户的第三方平台头像。 + + Args: + obj: OAuthUser实例对象 + + Returns: + str: 格式化的HTML图片标签 + """ + # 获取用户头像URL img = obj.picture + # 返回格式化的HTML图片标签,设置固定尺寸 return format_html( u'' % (img)) - link_to_usermodel.short_description = '用户' - show_user_image.short_description = '用户头像' + # 设置自定义方法在Admin中的显示名称 + link_to_usermodel.short_description = '用户' # 关联用户列的显示名称 + show_user_image.short_description = '用户头像' # 用户头像列的显示名称 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类型过滤 \ No newline at end of file diff --git a/src/DjangoBlog/oauth/forms.py b/src/DjangoBlog/oauth/forms.py index 0e4ede3..b678125 100644 --- a/src/DjangoBlog/oauth/forms.py +++ b/src/DjangoBlog/oauth/forms.py @@ -1,12 +1,46 @@ +""" +OAuth 认证表单模块 - 邮箱补充表单 + +该模块定义了在OAuth认证过程中需要用户补充邮箱信息时使用的表单。 +当第三方登录未返回邮箱地址时,使用此表单让用户手动输入邮箱。 +""" + +# 导入Django表单相关模块 from django.contrib.auth.forms import forms from django.forms import widgets class RequireEmailForm(forms.Form): + """ + 邮箱补充表单类 + + 用于OAuth登录过程中,当第三方平台未提供用户邮箱时, + 要求用户手动输入邮箱地址的表单。 + """ + + # 邮箱字段,必填字段,用于用户输入电子邮箱 email = forms.EmailField(label='电子邮箱', required=True) + + # 隐藏的OAuth用户ID字段,用于关联OAuth用户记录 oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) def __init__(self, *args, **kwargs): + """ + 初始化表单,自定义字段控件属性 + + 重写初始化方法,为邮箱字段添加HTML属性和样式类, + 改善用户体验和界面美观。 + + Args: + *args: 可变位置参数 + **kwargs: 可变关键字参数 + """ + # 调用父类的初始化方法 super(RequireEmailForm, self).__init__(*args, **kwargs) + + # 自定义邮箱字段的widget,添加placeholder和CSS类 self.fields['email'].widget = widgets.EmailInput( - attrs={'placeholder': "email", "class": "form-control"}) + attrs={ + 'placeholder': "email", # 输入框内的提示文本 + "class": "form-control" # Bootstrap样式类,用于表单控件样式 + }) \ No newline at end of file diff --git a/src/DjangoBlog/oauth/migrations/0001_initial.py b/src/DjangoBlog/oauth/migrations/0001_initial.py index bb26801..e8776b0 100644 --- a/src/DjangoBlog/oauth/migrations/0001_initial.py +++ b/src/DjangoBlog/oauth/migrations/0001_initial.py @@ -1,4 +1,7 @@ """ +<<<<<<< HEAD +Django 数据库迁移模块 - OAuth 认证配置 +======= OAuth应用数据库迁移文件 本迁移文件由Django自动生成,用于创建OAuth认证相关的数据库表结构。 @@ -13,29 +16,74 @@ OAuth应用数据库迁移文件 """ # Generated by Django 4.1.7 on 2023-03-07 09:53 +>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5 -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone +该模块用于创建OAuth认证相关的数据库表结构,包含OAuth服务提供商配置和OAuth用户信息两个主要模型。 +这是Django迁移系统自动生成的迁移文件,在Django 4.1.7版本中创建于2023-03-07。 +""" + +# 导入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): """ +<<<<<<< HEAD + OAuth认证系统的数据库迁移类 +======= OAuth应用初始迁移类 继承自migrations.Migration,定义数据库表结构的创建操作。 initial = True 表示这是该应用的第一个迁移文件。 """ +>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5 + 这个迁移类负责创建OAuth认证功能所需的数据库表结构, + 包括OAuth服务提供商配置和第三方登录用户信息存储。 + """ + + # 标记为初始迁移 initial = True + # 定义依赖关系 - 依赖于可切换的用户模型 dependencies = [ # 声明对Django用户模型的依赖 migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] + # 定义要执行的数据库操作 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配置表 migrations.CreateModel( name='OAuthConfig', @@ -62,8 +110,41 @@ class Migration(migrations.Migration): ], options={ # 管理后台显示名称 +>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5 'verbose_name': 'oauth配置', + # 设置模型在Admin中的复数显示名称 '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'], }, @@ -93,14 +174,24 @@ class Migration(migrations.Migration): # 最后修改时间 ('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, to=settings.AUTH_USER_MODEL, verbose_name='用户')), ], options={ +<<<<<<< HEAD + # 设置模型在Admin中的单数显示名称 +======= # 管理后台显示名称 +>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5 'verbose_name': 'oauth用户', + # 设置模型在Admin中的复数显示名称 'verbose_name_plural': 'oauth用户', +<<<<<<< HEAD + # 设置默认排序字段,按创建时间降序排列 +======= # 默认排序规则 +>>>>>>> d4786ee23b15aa002b21504f1056098d46f303c5 'ordering': ['-created_time'], }, ), diff --git a/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py index d5cc70e..091fd59 100644 --- a/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py +++ b/src/DjangoBlog/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py @@ -1,86 +1,138 @@ -# Generated by Django 4.2.5 on 2023-09-06 13:13 +""" +Django 数据库迁移模块 - OAuth 认证配置更新 -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone +该模块是OAuth认证系统的第二次迁移,主要用于优化字段命名和国际化显示。 +对已有的OAuthConfig和OAuthUser模型进行字段调整和选项更新。 +这是Django迁移系统自动生成的迁移文件,在Django 4.2.5版本中创建于2023-09-06。 +""" + +# 导入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): + """ + OAuth认证系统的数据库迁移更新类 + + 这个迁移类负责对已有的OAuth相关模型进行字段优化和国际化改进, + 主要涉及时间字段重命名和verbose_name的英文标准化。 + """ + # 定义依赖关系 - 依赖于初始迁移和用户模型 dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oauth', '0001_initial'), + ('oauth', '0001_initial'), # 依赖于oauth应用的初始迁移 ] + # 定义要执行的数据库操作序列 operations = [ + # 修改OAuthConfig模型的选项配置 migrations.AlterModelOptions( 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( 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( model_name='oauthconfig', - name='created_time', + name='created_time', # 删除旧的创建时间字段 ), migrations.RemoveField( model_name='oauthconfig', - name='last_mod_time', + name='last_mod_time', # 删除旧的修改时间字段 ), + # 移除OAuthUser模型的旧时间字段 migrations.RemoveField( model_name='oauthuser', - name='created_time', + name='created_time', # 删除旧的创建时间字段 ), migrations.RemoveField( model_name='oauthuser', - name='last_mod_time', + name='last_mod_time', # 删除旧的修改时间字段 ), + # 为OAuthConfig模型添加新的创建时间字段 migrations.AddField( model_name='oauthconfig', name='creation_time', + # 使用当前时间作为默认值,字段标签改为英文 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 为OAuthConfig模型添加新的修改时间字段 migrations.AddField( model_name='oauthconfig', name='last_modify_time', + # 使用当前时间作为默认值,字段标签改为英文 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + # 为OAuthUser模型添加新的创建时间字段 migrations.AddField( model_name='oauthuser', name='creation_time', + # 使用当前时间作为默认值,字段标签改为英文 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), + # 为OAuthUser模型添加新的修改时间字段 migrations.AddField( model_name='oauthuser', name='last_modify_time', + # 使用当前时间作为默认值,字段标签改为英文 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + # 修改OAuthConfig回调地址字段的配置 migrations.AlterField( model_name='oauthconfig', name='callback_url', + # 将默认回调地址改为空字符串,字段标签改为英文 field=models.CharField(default='', max_length=200, verbose_name='callback url'), ), + # 修改OAuthConfig启用状态字段的标签 migrations.AlterField( model_name='oauthconfig', name='is_enable', + # 保持字段定义不变,只修改verbose_name为英文 field=models.BooleanField(default=True, verbose_name='is enable'), ), + # 修改OAuthConfig类型字段的选项和标签 migrations.AlterField( model_name='oauthconfig', 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( model_name='oauthuser', 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( model_name='oauthuser', name='nickname', + # 保持字段定义不变,只修改verbose_name为英文 field=models.CharField(max_length=50, verbose_name='nickname'), ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py b/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py index 6af08eb..3586462 100644 --- a/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py +++ b/src/DjangoBlog/oauth/migrations/0003_alter_oauthuser_nickname.py @@ -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): + """ + OAuth认证系统的数据库微调迁移类 + + 这个迁移类负责对OAuthUser模型的昵称字段进行显示标签优化, + 将'nickname'改为'nick name'以改善管理界面的可读性。 + """ + # 定义依赖关系 - 依赖于前一次迁移 dependencies = [ + # 依赖于oauth应用的第二次迁移(字段重命名和国际化迁移) ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), ] + # 定义要执行的数据库操作序列 operations = [ + # 修改OAuthUser模型昵称字段的显示标签 migrations.AlterField( - model_name='oauthuser', - name='nickname', + model_name='oauthuser', # 指定要修改的模型名称 + name='nickname', # 指定要修改的字段名称 + # 保持字段类型和约束不变,仅优化verbose_name显示 + # 将'nickname'改为'nick name',增加空格提高可读性 field=models.CharField(max_length=50, verbose_name='nick name'), ), - ] + ] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/models.py b/src/DjangoBlog/oauth/models.py index be838ed..e530107 100644 --- a/src/DjangoBlog/oauth/models.py +++ b/src/DjangoBlog/oauth/models.py @@ -1,4 +1,11 @@ -# Create your models here. +""" +OAuth 认证数据模型模块 + +该模块定义了OAuth认证系统所需的数据模型,包括OAuth用户信息和OAuth服务商配置。 +用于存储第三方登录的用户数据和OAuth应用配置信息。 +""" + +# 导入Django核心模块 from django.conf import settings from django.core.exceptions import ValidationError from django.db import models @@ -7,61 +14,135 @@ from django.utils.translation import gettext_lazy as _ class OAuthUser(models.Model): + """ + OAuth用户模型 + + 存储通过第三方OAuth服务登录的用户信息,包括用户基本信息、 + 认证令牌以及与本地用户的关联关系。 + """ + + # 关联本地用户模型的外键,可为空(未绑定本地用户时) author = models.ForeignKey( - settings.AUTH_USER_MODEL, - verbose_name=_('author'), - blank=True, - null=True, - on_delete=models.CASCADE) + settings.AUTH_USER_MODEL, # 使用Django的可切换用户模型 + verbose_name=_('author'), # 字段显示名称(支持国际化) + blank=True, # 允许表单中为空 + null=True, # 允许数据库中为NULL + on_delete=models.CASCADE # 关联用户删除时级联删除OAuth用户 + ) + + # 第三方平台的用户唯一标识符 openid = models.CharField(max_length=50) + + # 用户在第三方平台的昵称 nickname = models.CharField(max_length=50, verbose_name=_('nick name')) + + # OAuth访问令牌,用于调用第三方API token = models.CharField(max_length=150, null=True, blank=True) + + # 用户头像的URL地址 picture = models.CharField(max_length=350, blank=True, null=True) + + # OAuth服务类型(如:weibo, github等) type = models.CharField(blank=False, null=False, max_length=50) + + # 用户邮箱地址,可能为空(某些平台不提供邮箱) email = models.CharField(max_length=50, null=True, blank=True) + + # 存储额外的元数据信息,使用JSON格式 metadata = models.TextField(null=True, blank=True) + + # 记录创建时间,默认使用当前时间 creation_time = models.DateTimeField(_('creation time'), default=now) + + # 记录最后修改时间,默认使用当前时间 last_modify_time = models.DateTimeField(_('last modify time'), default=now) def __str__(self): + """ + 定义模型的字符串表示形式 + + Returns: + str: 返回用户的昵称,用于Admin和其他显示场景 + """ return self.nickname class Meta: - verbose_name = _('oauth user') - verbose_name_plural = verbose_name - ordering = ['-creation_time'] + """模型元数据配置""" + verbose_name = _('oauth user') # 模型在Admin中的单数显示名称 + verbose_name_plural = verbose_name # 模型在Admin中的复数显示名称 + ordering = ['-creation_time'] # 默认按创建时间降序排列 class OAuthConfig(models.Model): + """ + OAuth服务配置模型 + + 存储不同第三方OAuth服务的应用配置信息,包括AppKey、AppSecret等。 + 用于管理多个OAuth服务的认证参数。 + """ + + # OAuth服务类型选择项 TYPE = ( - ('weibo', _('weibo')), - ('google', _('google')), - ('github', 'GitHub'), - ('facebook', 'FaceBook'), - ('qq', 'QQ'), + ('weibo', _('weibo')), # 微博OAuth + ('google', _('google')), # 谷歌OAuth + ('github', 'GitHub'), # GitHub OAuth + ('facebook', 'FaceBook'), # Facebook OAuth + ('qq', 'QQ'), # QQ OAuth ) + + # OAuth服务类型字段,使用选择项限制输入 type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') + + # OAuth应用的客户端ID(App Key) appkey = models.CharField(max_length=200, verbose_name='AppKey') + + # OAuth应用的客户端密钥(App Secret) appsecret = models.CharField(max_length=200, verbose_name='AppSecret') + + # OAuth认证成功后的回调地址 callback_url = models.CharField( max_length=200, verbose_name=_('callback url'), - blank=False, - default='') + blank=False, # 不允许为空 + default='' # 默认值为空字符串 + ) + + # 标识该OAuth配置是否启用 is_enable = models.BooleanField( _('is enable'), default=True, blank=False, null=False) + + # 记录配置创建时间 creation_time = models.DateTimeField(_('creation time'), default=now) + + # 记录配置最后修改时间 last_modify_time = models.DateTimeField(_('last modify time'), default=now) def clean(self): + """ + 模型验证方法 + + 确保同一类型的OAuth配置只能存在一个,防止重复配置。 + + Raises: + ValidationError: 当同一类型的配置已存在时抛出异常 + """ + # 检查是否已存在相同类型的配置(排除当前记录) if OAuthConfig.objects.filter( type=self.type).exclude(id=self.id).count(): + # 抛出验证错误,提示该类型配置已存在 raise ValidationError(_(self.type + _('already exists'))) def __str__(self): + """ + 定义模型的字符串表示形式 + + Returns: + str: 返回OAuth服务类型名称 + """ return self.type class Meta: - verbose_name = 'oauth配置' - verbose_name_plural = verbose_name - ordering = ['-creation_time'] + """模型元数据配置""" + verbose_name = 'oauth配置' # 模型在Admin中的中文显示名称 + verbose_name_plural = verbose_name # 复数显示名称 + ordering = ['-creation_time'] # 默认按创建时间降序排列 \ No newline at end of file diff --git a/src/DjangoBlog/oauth/oauthmanager.py b/src/DjangoBlog/oauth/oauthmanager.py index 2e7ceef..48f5afa 100644 --- a/src/DjangoBlog/oauth/oauthmanager.py +++ b/src/DjangoBlog/oauth/oauthmanager.py @@ -1,3 +1,11 @@ +""" +OAuth 认证管理器模块 + +该模块实现了多平台OAuth认证的核心逻辑,包含基类定义和具体平台实现。 +支持微博、谷歌、GitHub、Facebook、QQ等主流第三方登录平台。 +采用抽象基类和混合类设计模式,提供统一的OAuth认证接口。 +""" + import json import logging import os @@ -9,79 +17,139 @@ import requests from djangoblog.utils import cache_decorator from oauth.models import OAuthUser, OAuthConfig +# 获取当前模块的日志记录器 logger = logging.getLogger(__name__) class OAuthAccessTokenException(Exception): ''' - oauth授权失败异常 + OAuth授权令牌获取异常类 + + 当从OAuth服务商获取访问令牌失败时抛出此异常, + 通常由于错误的授权码、应用配置问题或网络问题导致。 ''' class BaseOauthManager(metaclass=ABCMeta): - """获取用户授权""" + """ + OAuth认证管理器抽象基类 + + 定义所有OAuth平台必须实现的接口和方法, + 提供统一的OAuth认证流程模板。 + """ + + # OAuth授权页面URL(需要子类实现) AUTH_URL = None - """获取token""" + # 获取访问令牌的URL(需要子类实现) TOKEN_URL = None - """获取用户信息""" + # 获取用户信息的API URL(需要子类实现) API_URL = None - '''icon图标名''' + # 平台图标名称,用于标识和显示(需要子类实现) ICON_NAME = None def __init__(self, access_token=None, openid=None): + """ + 初始化OAuth管理器 + + Args: + access_token: 已存在的访问令牌(可选) + openid: 已存在的用户OpenID(可选) + """ self.access_token = access_token self.openid = openid @property def is_access_token_set(self): + """检查访问令牌是否已设置""" return self.access_token is not None @property def is_authorized(self): + """检查是否已完成授权(拥有令牌和OpenID)""" return self.is_access_token_set and self.access_token is not None and self.openid is not None @abstractmethod def get_authorization_url(self, nexturl='/'): + """获取授权页面URL(抽象方法,子类必须实现)""" pass @abstractmethod def get_access_token_by_code(self, code): + """通过授权码获取访问令牌(抽象方法,子类必须实现)""" pass @abstractmethod def get_oauth_userinfo(self): + """获取OAuth用户信息(抽象方法,子类必须实现)""" pass @abstractmethod def get_picture(self, metadata): + """从元数据中提取用户头像URL(抽象方法,子类必须实现)""" pass 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) - logger.info(rsp.text) + logger.info(rsp.text) # 记录响应日志 return rsp.text def do_post(self, url, params, headers=None): + """ + 执行POST请求的通用方法 + + Args: + url: 请求URL + params: 请求参数 + headers: 请求头(可选) + + Returns: + str: 响应文本内容 + """ rsp = requests.post(url, params, headers=headers) - logger.info(rsp.text) + logger.info(rsp.text) # 记录响应日志 return rsp.text def get_config(self): + """ + 从数据库获取当前平台的OAuth配置 + + Returns: + OAuthConfig: 配置对象,如果不存在则返回None + """ value = OAuthConfig.objects.filter(type=self.ICON_NAME) return value[0] if value else None class WBOauthManager(BaseOauthManager): + """ + 微博OAuth认证管理器 + + 实现微博平台的OAuth2.0认证流程,包括授权、令牌获取和用户信息获取。 + """ + + # 微博OAuth接口地址 AUTH_URL = 'https://api.weibo.com/oauth2/authorize' TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' API_URL = 'https://api.weibo.com/2/users/show.json' ICON_NAME = 'weibo' def __init__(self, access_token=None, openid=None): + """初始化微博OAuth配置""" config = self.get_config() - self.client_id = config.appkey if config else '' - self.client_secret = config.appsecret if config else '' - self.callback_url = config.callback_url if config else '' + self.client_id = config.appkey if config else '' # 应用Key + self.client_secret = config.appsecret if config else '' # 应用Secret + self.callback_url = config.callback_url if config else '' # 回调地址 super( WBOauthManager, self).__init__( @@ -89,6 +157,15 @@ class WBOauthManager(BaseOauthManager): openid=openid) def get_authorization_url(self, nexturl='/'): + """ + 生成微博授权页面URL + + Args: + nexturl: 授权成功后跳转的URL + + Returns: + str: 完整的授权URL + """ params = { 'client_id': self.client_id, 'response_type': 'code', @@ -98,7 +175,18 @@ class WBOauthManager(BaseOauthManager): return url def get_access_token_by_code(self, code): + """ + 使用授权码获取访问令牌 + + Args: + code: OAuth授权码 + Returns: + OAuthUser: 用户信息对象 + + Raises: + OAuthAccessTokenException: 令牌获取失败时抛出 + """ params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -110,13 +198,20 @@ class WBOauthManager(BaseOauthManager): obj = json.loads(rsp) if 'access_token' in obj: + # 设置访问令牌和用户ID self.access_token = str(obj['access_token']) self.openid = str(obj['uid']) - return self.get_oauth_userinfo() + return self.get_oauth_userinfo() # 获取并返回用户信息 else: raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + """ + 获取微博用户信息 + + Returns: + OAuthUser: 包含用户信息的对象,获取失败返回None + """ if not self.is_authorized: return None params = { @@ -127,14 +222,14 @@ class WBOauthManager(BaseOauthManager): try: datas = json.loads(rsp) user = OAuthUser() - user.metadata = rsp - user.picture = datas['avatar_large'] - user.nickname = datas['screen_name'] - user.openid = datas['id'] - user.type = 'weibo' - user.token = self.access_token + user.metadata = rsp # 存储原始响应数据 + user.picture = datas['avatar_large'] # 用户头像 + user.nickname = datas['screen_name'] # 用户昵称 + user.openid = datas['id'] # 用户OpenID + user.type = 'weibo' # 平台类型 + user.token = self.access_token # 访问令牌 if 'email' in datas and datas['email']: - user.email = datas['email'] + user.email = datas['email'] # 用户邮箱 return user except Exception as e: logger.error(e) @@ -142,13 +237,30 @@ class WBOauthManager(BaseOauthManager): return None def get_picture(self, metadata): + """ + 从元数据中提取微博用户头像 + + Args: + metadata: 用户元数据JSON字符串 + + Returns: + str: 用户头像URL + """ datas = json.loads(metadata) return datas['avatar_large'] class ProxyManagerMixin: + """ + 代理管理器混合类 + + 为OAuth管理器添加HTTP代理支持,用于网络访问受限的环境。 + """ + def __init__(self, *args, **kwargs): + """初始化代理配置""" if os.environ.get("HTTP_PROXY"): + # 设置HTTP和HTTPS代理 self.proxies = { "http": os.environ.get("HTTP_PROXY"), "https": os.environ.get("HTTP_PROXY") @@ -157,23 +269,32 @@ class ProxyManagerMixin: self.proxies = None def do_get(self, url, params, headers=None): + """带代理支持的GET请求""" rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies) logger.info(rsp.text) return rsp.text def do_post(self, url, params, headers=None): + """带代理支持的POST请求""" rsp = requests.post(url, params, headers=headers, proxies=self.proxies) logger.info(rsp.text) return rsp.text class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): + """ + 谷歌OAuth认证管理器 + + 实现谷歌平台的OAuth2.0认证流程,支持代理访问。 + """ + AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' ICON_NAME = 'google' def __init__(self, access_token=None, openid=None): + """初始化谷歌OAuth配置""" config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -185,22 +306,23 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, nexturl='/'): + """生成谷歌授权页面URL""" params = { 'client_id': self.client_id, 'response_type': 'code', 'redirect_uri': self.callback_url, - 'scope': 'openid email', + 'scope': 'openid email', # 请求openid和email权限 } url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) return url def get_access_token_by_code(self, code): + """使用授权码获取谷歌访问令牌""" params = { 'client_id': self.client_id, 'client_secret': self.client_secret, 'grant_type': 'authorization_code', 'code': code, - 'redirect_uri': self.callback_url } rsp = self.do_post(self.TOKEN_URL, params) @@ -216,6 +338,7 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + """获取谷歌用户信息""" if not self.is_authorized: return None params = { @@ -223,17 +346,16 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): } rsp = self.do_get(self.API_URL, params) try: - datas = json.loads(rsp) user = OAuthUser() user.metadata = rsp - user.picture = datas['picture'] - user.nickname = datas['name'] - user.openid = datas['sub'] + user.picture = datas['picture'] # 谷歌用户头像 + user.nickname = datas['name'] # 谷歌用户姓名 + user.openid = datas['sub'] # 谷歌用户唯一标识 user.token = self.access_token user.type = 'google' if datas['email']: - user.email = datas['email'] + user.email = datas['email'] # 谷歌邮箱 return user except Exception as e: logger.error(e) @@ -241,17 +363,25 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + """从元数据中提取谷歌用户头像""" datas = json.loads(metadata) return datas['picture'] class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): + """ + GitHub OAuth认证管理器 + + 实现GitHub平台的OAuth2.0认证流程,支持代理访问。 + """ + AUTH_URL = 'https://github.com/login/oauth/authorize' TOKEN_URL = 'https://github.com/login/oauth/access_token' API_URL = 'https://api.github.com/user' ICON_NAME = 'github' def __init__(self, access_token=None, openid=None): + """初始化GitHub OAuth配置""" config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -263,28 +393,29 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + """生成GitHub授权页面URL""" params = { 'client_id': self.client_id, 'response_type': 'code', 'redirect_uri': f'{self.callback_url}&next_url={next_url}', - 'scope': 'user' + 'scope': 'user' # 请求用户信息权限 } url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) return url def get_access_token_by_code(self, code): + """使用授权码获取GitHub访问令牌""" params = { 'client_id': self.client_id, 'client_secret': self.client_secret, 'grant_type': 'authorization_code', 'code': code, - 'redirect_uri': self.callback_url } rsp = self.do_post(self.TOKEN_URL, params) from urllib import parse - r = parse.parse_qs(rsp) + r = parse.parse_qs(rsp) # 解析查询字符串格式的响应 if 'access_token' in r: self.access_token = (r['access_token'][0]) return self.access_token @@ -292,21 +423,22 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): - + """获取GitHub用户信息""" + # 使用Bearer Token认证方式调用GitHub API rsp = self.do_get(self.API_URL, params={}, headers={ "Authorization": "token " + self.access_token }) try: datas = json.loads(rsp) user = OAuthUser() - user.picture = datas['avatar_url'] - user.nickname = datas['name'] - user.openid = datas['id'] + user.picture = datas['avatar_url'] # GitHub头像 + user.nickname = datas['name'] # GitHub姓名 + user.openid = datas['id'] # GitHub用户ID user.type = 'github' user.token = self.access_token user.metadata = rsp if 'email' in datas and datas['email']: - user.email = datas['email'] + user.email = datas['email'] # GitHub邮箱 return user except Exception as e: logger.error(e) @@ -314,17 +446,25 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + """从元数据中提取GitHub用户头像""" datas = json.loads(metadata) return datas['avatar_url'] class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): + """ + Facebook OAuth认证管理器 + + 实现Facebook平台的OAuth2.0认证流程,支持代理访问。 + """ + AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth' TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token' API_URL = 'https://graph.facebook.com/me' ICON_NAME = 'facebook' def __init__(self, access_token=None, openid=None): + """初始化Facebook OAuth配置""" config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -336,22 +476,22 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + """生成Facebook授权页面URL""" params = { 'client_id': self.client_id, 'response_type': 'code', 'redirect_uri': self.callback_url, - 'scope': 'email,public_profile' + 'scope': 'email,public_profile' # 请求邮箱和公开资料权限 } url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) return url def get_access_token_by_code(self, code): + """使用授权码获取Facebook访问令牌""" params = { 'client_id': self.client_id, 'client_secret': self.client_secret, - # 'grant_type': 'authorization_code', 'code': code, - 'redirect_uri': self.callback_url } rsp = self.do_post(self.TOKEN_URL, params) @@ -365,21 +505,23 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + """获取Facebook用户信息""" params = { 'access_token': self.access_token, - 'fields': 'id,name,picture,email' + 'fields': 'id,name,picture,email' # 指定需要返回的字段 } try: rsp = self.do_get(self.API_URL, params) datas = json.loads(rsp) user = OAuthUser() - user.nickname = datas['name'] - user.openid = datas['id'] + user.nickname = datas['name'] # Facebook姓名 + user.openid = datas['id'] # Facebook用户ID user.type = 'facebook' user.token = self.access_token user.metadata = rsp 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']: user.picture = str(datas['picture']['data']['url']) return user @@ -388,18 +530,26 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + """从元数据中提取Facebook用户头像""" datas = json.loads(metadata) return str(datas['picture']['data']['url']) class QQOauthManager(BaseOauthManager): + """ + QQ OAuth认证管理器 + + 实现QQ平台的OAuth2.0认证流程,包含特殊的OpenID获取步骤。 + """ + AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize' TOKEN_URL = 'https://graph.qq.com/oauth2.0/token' 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' def __init__(self, access_token=None, openid=None): + """初始化QQ OAuth配置""" config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -411,6 +561,7 @@ class QQOauthManager(BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + """生成QQ授权页面URL""" params = { 'response_type': 'code', 'client_id': self.client_id, @@ -420,6 +571,7 @@ class QQOauthManager(BaseOauthManager): return url def get_access_token_by_code(self, code): + """使用授权码获取QQ访问令牌""" params = { 'grant_type': 'authorization_code', 'client_id': self.client_id, @@ -429,7 +581,7 @@ class QQOauthManager(BaseOauthManager): } rsp = self.do_get(self.TOKEN_URL, params) if rsp: - d = urllib.parse.parse_qs(rsp) + d = urllib.parse.parse_qs(rsp) # 解析查询字符串响应 if 'access_token' in d: token = d['access_token'] self.access_token = token[0] @@ -438,23 +590,27 @@ class QQOauthManager(BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_open_id(self): + """ + 获取QQ用户的OpenID + + QQ平台需要额外调用接口获取用户OpenID + """ if self.is_access_token_set: params = { 'access_token': self.access_token } rsp = self.do_get(self.OPEN_ID_URL, params) if rsp: - rsp = rsp.replace( - 'callback(', '').replace( - ')', '').replace( - ';', '') + # 清理JSONP响应格式 + rsp = rsp.replace('callback(', '').replace(')', '').replace(';', '') obj = json.loads(rsp) openid = str(obj['openid']) self.openid = openid return openid def get_oauth_userinfo(self): - openid = self.get_open_id() + """获取QQ用户信息""" + openid = self.get_open_id() # 先获取OpenID if openid: params = { 'access_token': self.access_token, @@ -465,40 +621,60 @@ class QQOauthManager(BaseOauthManager): logger.info(rsp) obj = json.loads(rsp) user = OAuthUser() - user.nickname = obj['nickname'] - user.openid = openid + user.nickname = obj['nickname'] # QQ昵称 + user.openid = openid # QQ OpenID user.type = 'qq' user.token = self.access_token user.metadata = rsp if 'email' in obj: - user.email = obj['email'] + user.email = obj['email'] # QQ邮箱 if 'figureurl' in obj: - user.picture = str(obj['figureurl']) + user.picture = str(obj['figureurl']) # QQ头像 return user def get_picture(self, metadata): + """从元数据中提取QQ用户头像""" datas = json.loads(metadata) return str(datas['figureurl']) @cache_decorator(expiration=100 * 60) def get_oauth_apps(): + """ + 获取所有启用的OAuth应用配置 + + 使用缓存装饰器,缓存100分钟,减少数据库查询 + + Returns: + list: 启用的OAuth管理器实例列表 + """ configs = OAuthConfig.objects.filter(is_enable=True).all() if not configs: return [] - configtypes = [x.type for x in configs] - applications = BaseOauthManager.__subclasses__() + configtypes = [x.type for x in configs] # 提取启用的平台类型 + applications = BaseOauthManager.__subclasses__() # 获取所有子类 + # 过滤出已启用的平台管理器实例 apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes] return apps def get_manager_by_type(type): + """ + 根据平台类型获取对应的OAuth管理器 + + Args: + type: 平台类型字符串(如:'weibo', 'github') + + Returns: + BaseOauthManager: 对应平台的OAuth管理器实例,未找到返回None + """ applications = get_oauth_apps() if applications: + # 查找匹配平台类型的管理器 finds = list( filter( lambda x: x.ICON_NAME.lower() == type.lower(), applications)) if finds: return finds[0] - return None + return None \ No newline at end of file diff --git a/src/DjangoBlog/oauth/templatetags/oauth_tags.py b/src/DjangoBlog/oauth/templatetags/oauth_tags.py index 7b687d5..eeae7a7 100644 --- a/src/DjangoBlog/oauth/templatetags/oauth_tags.py +++ b/src/DjangoBlog/oauth/templatetags/oauth_tags.py @@ -1,22 +1,64 @@ +""" +OAuth 认证模板标签模块 + +该模块提供Django模板标签,用于在模板中动态加载和显示OAuth第三方登录应用列表。 +主要功能是生成可用的OAuth应用链接并在模板中渲染。 +""" + +# 导入Django模板模块 from django import template +# 导入URL反向解析功能 from django.urls import reverse +# 导入自定义的OAuth管理器,用于获取可用的OAuth应用 from oauth.oauthmanager import get_oauth_apps +# 创建模板库实例 register = template.Library() @register.inclusion_tag('oauth/oauth_applications.html') def load_oauth_applications(request): + """ + 自定义包含标签 - 加载OAuth应用列表 + + 该模板标签用于在页面中渲染OAuth第三方登录的应用图标和链接。 + 它会获取所有可用的OAuth应用,并生成对应的登录URL。 + + Args: + request: HttpRequest对象,用于获取当前请求的完整路径 + + Returns: + dict: 包含应用列表的字典,用于模板渲染 + - 'apps': 包含OAuth应用信息的列表,每个元素为(应用类型, 登录URL)的元组 + """ + # 获取所有可用的OAuth应用配置 applications = get_oauth_apps() + + # 检查是否存在可用的OAuth应用 if applications: + # 生成OAuth登录的基础URL(不包含参数) baseurl = reverse('oauth:oauthlogin') + # 获取当前请求的完整路径,用于登录成功后跳转回原页面 path = request.get_full_path() - apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format( - baseurl=baseurl, type=x.ICON_NAME, next=path)), applications)) + # 使用map和lambda函数处理每个OAuth应用,生成应用信息列表 + # 每个应用信息包含:应用类型图标名称和完整的登录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: + # 如果没有可用的OAuth应用,返回空列表 apps = [] + + # 返回模板渲染所需的上下文数据 return { - 'apps': apps - } + 'apps': apps # OAuth应用列表,传递给模板进行渲染 + } \ No newline at end of file diff --git a/src/DjangoBlog/oauth/tests.py b/src/DjangoBlog/oauth/tests.py index bb23b9b..50973bc 100644 --- a/src/DjangoBlog/oauth/tests.py +++ b/src/DjangoBlog/oauth/tests.py @@ -1,55 +1,112 @@ +""" +OAuth 认证测试模块 + +该模块包含OAuth认证系统的完整测试用例,覆盖所有支持的第三方登录平台。 +测试包括配置验证、授权流程、用户信息获取和异常处理等场景。 +""" + import json from unittest.mock import patch +# 导入Django测试相关模块 from django.conf import settings from django.contrib import auth from django.test import Client, RequestFactory, TestCase from django.urls import reverse +# 导入项目工具函数和模型 from djangoblog.utils import get_sha256 from oauth.models import OAuthConfig from oauth.oauthmanager import BaseOauthManager -# Create your tests here. class OAuthConfigTest(TestCase): + """ + OAuth配置模型测试类 + + 测试OAuth配置的基本功能,包括配置保存和基础授权流程。 + """ + def setUp(self): - self.client = Client() - self.factory = RequestFactory() + """ + 测试初始化方法 + + 在每个测试方法执行前运行,创建测试所需的客户端和工厂对象。 + """ + self.client = Client() # 创建测试客户端 + self.factory = RequestFactory() # 创建请求工厂 def test_oauth_login_test(self): + """ + 测试OAuth登录流程 + + 验证微博OAuth配置的创建和基础授权重定向功能。 + """ + # 创建微博OAuth配置 c = OAuthConfig() c.type = 'weibo' c.appkey = 'appkey' c.appsecret = 'appsecret' c.save() + # 测试OAuth登录请求,应该重定向到微博授权页面 response = self.client.get('/oauth/oauthlogin?type=weibo') - self.assertEqual(response.status_code, 302) - self.assertTrue("api.weibo.com" in response.url) + self.assertEqual(response.status_code, 302) # 验证重定向状态码 + self.assertTrue("api.weibo.com" in response.url) # 验证重定向到微博 + # 测试授权回调请求(模拟授权码流程) response = self.client.get('/oauth/authorize?type=weibo&code=code') - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, '/') + self.assertEqual(response.status_code, 302) # 验证重定向状态码 + self.assertEqual(response.url, '/') # 验证重定向到首页 class OauthLoginTest(TestCase): + """ + OAuth登录流程测试类 + + 测试所有支持的OAuth平台的完整登录流程,包括模拟API调用和用户认证。 + """ + 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): + """ + 初始化所有OAuth应用配置 + + 为每个OAuth平台创建测试配置数据。 + + Returns: + list: 初始化的OAuth管理器实例列表 + """ + # 获取所有OAuth管理器的子类并实例化 applications = [p() for p in BaseOauthManager.__subclasses__()] for application in applications: + # 为每个平台创建测试配置 c = OAuthConfig() - c.type = application.ICON_NAME.lower() - c.appkey = 'appkey' - c.appsecret = 'appsecret' + c.type = application.ICON_NAME.lower() # 设置平台类型 + c.appkey = 'appkey' # 测试应用Key + c.appsecret = 'appsecret' # 测试应用Secret c.save() return applications def get_app_by_type(self, type): + """ + 根据类型获取OAuth应用 + + Args: + type: 平台类型字符串 + + Returns: + BaseOauthManager: 对应平台的OAuth管理器实例 + """ for app in self.apps: if app.ICON_NAME.lower() == type: return app @@ -57,73 +114,129 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") 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') - assert weibo_app + assert weibo_app # 验证微博应用存在 + + # 测试授权URL生成 url = weibo_app.get_authorization_url() + + # 设置模拟返回值 - 令牌获取响应 mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid" }) + # 设置模拟返回值 - 用户信息获取响应 mock_do_get.return_value = json.dumps({ "avatar_large": "avatar_large", "screen_name": "screen_name", "id": "id", "email": "email", }) + + # 执行授权码换取用户信息流程 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_get") def test_google_login(self, mock_do_get, mock_do_post): + """ + 测试谷歌OAuth登录流程 + + 验证谷歌OAuth的令牌获取和用户信息解析。 + """ google_app = self.get_app_by_type('google') - assert google_app + assert google_app # 验证谷歌应用存在 + + # 测试授权URL生成 url = google_app.get_authorization_url() + + # 设置模拟返回值 - 令牌获取响应 mock_do_post.return_value = json.dumps({ "access_token": "access_token", "id_token": "id_token", }) + # 设置模拟返回值 - 用户信息获取响应 mock_do_get.return_value = json.dumps({ "picture": "picture", "name": "name", "sub": "sub", "email": "email", }) + + # 执行授权流程 token = google_app.get_access_token_by_code('code') 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_get") def test_github_login(self, mock_do_get, mock_do_post): + """ + 测试GitHub OAuth登录流程 + + 验证GitHub的特殊令牌响应格式和用户信息获取。 + """ github_app = self.get_app_by_type('github') - assert github_app + assert github_app # 验证GitHub应用存在 + + # 测试授权URL生成 url = github_app.get_authorization_url() - self.assertTrue("github.com" in url) - self.assertTrue("client_id" in url) + self.assertTrue("github.com" in url) # 验证URL包含GitHub域名 + 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_get.return_value = json.dumps({ "avatar_url": "avatar_url", "name": "name", "id": "id", "email": "email", }) + + # 执行授权流程 token = github_app.get_access_token_by_code('code') 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_get") def test_facebook_login(self, mock_do_get, mock_do_post): + """ + 测试Facebook OAuth登录流程 + + 验证Facebook的令牌获取和嵌套头像数据结构处理。 + """ facebook_app = self.get_app_by_type('facebook') - assert facebook_app + assert facebook_app # 验证Facebook应用存在 + + # 测试授权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({ "access_token": "access_token", }) + # 设置模拟返回值 - 用户信息获取响应(包含嵌套的头像数据) mock_do_get.return_value = json.dumps({ "name": "name", "id": "id", @@ -134,14 +247,18 @@ class OauthLoginTest(TestCase): } } }) + + # 执行授权流程 token = facebook_app.get_access_token_by_code('code') userinfo = facebook_app.get_oauth_userinfo() + + # 验证访问令牌正确性 self.assertEqual(userinfo.token, 'access_token') @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[ - 'access_token=access_token&expires_in=3600', - 'callback({"client_id":"appid","openid":"openid"} );', - json.dumps({ + 'access_token=access_token&expires_in=3600', # 第一次调用:令牌获取响应 + 'callback({"client_id":"appid","openid":"openid"} );', # 第二次调用:OpenID获取响应 + json.dumps({ # 第三次调用:用户信息获取响应 "nickname": "nickname", "email": "email", "figureurl": "figureurl", @@ -149,21 +266,41 @@ class OauthLoginTest(TestCase): }) ]) 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') - assert qq_app + assert qq_app # 验证QQ应用存在 + + # 测试授权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') userinfo = qq_app.get_oauth_userinfo() + + # 验证访问令牌正确性 self.assertEqual(userinfo.token, 'access_token') @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") 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", "uid": "uid" }) + # 设置模拟用户信息(包含邮箱) mock_user_info = { "avatar_large": "avatar_large", "screen_name": "screen_name1", @@ -172,25 +309,32 @@ class OauthLoginTest(TestCase): } mock_do_get.return_value = json.dumps(mock_user_info) + # 第一步:发起OAuth登录请求 response = self.client.get('/oauth/oauthlogin?type=weibo') - self.assertEqual(response.status_code, 302) - self.assertTrue("api.weibo.com" in response.url) + self.assertEqual(response.status_code, 302) # 验证重定向 + self.assertTrue("api.weibo.com" in response.url) # 验证重定向到微博 + # 第二步:模拟授权回调(首次登录) response = self.client.get('/oauth/authorize?type=weibo&code=code') - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, '/') + self.assertEqual(response.status_code, 302) # 验证重定向 + self.assertEqual(response.url, '/') # 验证重定向到首页 + # 验证用户认证状态 user = auth.get_user(self.client) - assert user.is_authenticated + assert user.is_authenticated # 验证用户已认证 self.assertTrue(user.is_authenticated) - self.assertEqual(user.username, mock_user_info['screen_name']) - self.assertEqual(user.email, mock_user_info['email']) + self.assertEqual(user.username, mock_user_info['screen_name']) # 验证用户名 + self.assertEqual(user.email, mock_user_info['email']) # 验证邮箱 + + # 登出用户 self.client.logout() + # 第三步:模拟再次登录(测试重复登录场景) response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') + # 验证用户再次认证状态 user = auth.get_user(self.client) assert 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_get") 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", "uid": "uid" }) + # 设置模拟用户信息(不包含邮箱) mock_user_info = { "avatar_large": "avatar_large", "screen_name": "screen_name1", @@ -211,28 +361,34 @@ class OauthLoginTest(TestCase): } mock_do_get.return_value = json.dumps(mock_user_info) + # 第一步:发起OAuth登录请求 response = self.client.get('/oauth/oauthlogin?type=weibo') self.assertEqual(response.status_code, 302) self.assertTrue("api.weibo.com" in response.url) + # 第二步:模拟授权回调(应该重定向到邮箱补充页面) response = self.client.get('/oauth/authorize?type=weibo&code=code') - self.assertEqual(response.status_code, 302) + # 解析OAuth用户ID从重定向URL中 oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) 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}) - self.assertEqual(response.status_code, 302) + + # 生成邮箱确认签名 sign = get_sha256(settings.SECRET_KEY + str(oauth_user_id) + settings.SECRET_KEY) + # 验证重定向到绑定成功页面 url = reverse('oauth:bindsuccess', kwargs={ 'oauthid': oauth_user_id, }) self.assertEqual(response.url, f'{url}?type=email') + # 第四步:模拟邮箱确认链接点击 path = reverse('oauth:email_confirm', kwargs={ 'id': oauth_user_id, 'sign': sign @@ -240,10 +396,12 @@ class OauthLoginTest(TestCase): response = self.client.get(path) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') + + # 验证最终用户认证状态和关联信息 user = auth.get_user(self.client) from oauth.models import OAuthUser oauth_user = OAuthUser.objects.get(author=user) - self.assertTrue(user.is_authenticated) - self.assertEqual(user.username, mock_user_info['screen_name']) - self.assertEqual(user.email, 'test@gmail.com') - self.assertEqual(oauth_user.pk, oauth_user_id) + self.assertTrue(user.is_authenticated) # 验证用户已认证 + self.assertEqual(user.username, mock_user_info['screen_name']) # 验证用户名 + self.assertEqual(user.email, 'test@gmail.com') # 验证补充的邮箱 + self.assertEqual(oauth_user.pk, oauth_user_id) # 验证OAuth用户关联正确 \ No newline at end of file diff --git a/src/DjangoBlog/oauth/urls.py b/src/DjangoBlog/oauth/urls.py index c4a12a0..fce2abb 100644 --- a/src/DjangoBlog/oauth/urls.py +++ b/src/DjangoBlog/oauth/urls.py @@ -1,25 +1,53 @@ +""" +OAuth 认证URL路由配置模块 + +该模块定义了OAuth认证系统的所有URL路由,包括授权回调、邮箱验证、绑定成功等端点。 +这些路由处理第三方登录的完整流程,从初始授权到最终的用户绑定。 +""" + +# 导入Django URL路由相关模块 from django.urls import path +# 导入当前应用的视图模块 from . import views +# 定义应用命名空间,用于URL反向解析 app_name = "oauth" + +# 定义URL模式列表,将URL路径映射到对应的视图处理函数 urlpatterns = [ + # OAuth授权回调端点 - 处理第三方平台返回的授权码 path( - r'oauth/authorize', - views.authorize), + r'oauth/authorize', # URL路径:/oauth/authorize + views.authorize, # 对应的视图函数 + # 名称:未指定,使用默认(可通过views.authorize.__name__访问) + ), + + # 邮箱补充页面 - 当第三方登录未提供邮箱时显示 path( - r'oauth/requireemail/.html', - views.RequireEmailView.as_view(), - name='require_email'), + r'oauth/requireemail/.html', # URL路径,包含OAuth用户ID参数 + views.RequireEmailView.as_view(), # 类视图,需要调用as_view()方法 + name='require_email' # URL名称,用于反向解析 + ), + + # 邮箱确认端点 - 验证用户提交的邮箱地址 path( - r'oauth/emailconfirm//.html', - views.emailconfirm, - name='email_confirm'), + r'oauth/emailconfirm//.html', # URL路径,包含用户ID和签名参数 + views.emailconfirm, # 对应的视图函数 + name='email_confirm' # URL名称,用于反向解析 + ), + + # 绑定成功页面 - 显示OAuth账号绑定成功信息 path( - r'oauth/bindsuccess/.html', - views.bindsuccess, - name='bindsuccess'), + r'oauth/bindsuccess/.html', # URL路径,包含OAuth用户ID参数 + views.bindsuccess, # 对应的视图函数 + name='bindsuccess' # URL名称,用于反向解析 + ), + + # OAuth登录入口 - 初始化第三方登录流程 path( - r'oauth/oauthlogin', - views.oauthlogin, - name='oauthlogin')] + r'oauth/oauthlogin', # URL路径:/oauth/oauthlogin + views.oauthlogin, # 对应的视图函数 + name='oauthlogin' # URL名称,用于反向解析 + ) +] \ No newline at end of file diff --git a/src/DjangoBlog/oauth/views.py b/src/DjangoBlog/oauth/views.py index 12e3a6e..f34a9e4 100644 --- a/src/DjangoBlog/oauth/views.py +++ b/src/DjangoBlog/oauth/views.py @@ -1,119 +1,223 @@ +""" +OAuth 认证视图模块 + +该模块实现了OAuth认证系统的核心视图逻辑,处理第三方登录的完整流程。 +包括授权初始化、回调处理、邮箱验证、用户绑定等功能。 +""" + 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.contrib.auth import get_user_model -from django.contrib.auth import login -from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction -from django.http import HttpResponseForbidden -from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404 -from django.shortcuts import render -from django.urls import reverse -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from django.views.generic import FormView - -from djangoblog.blog_signals import oauth_user_login_signal -from djangoblog.utils import get_current_site -from djangoblog.utils import send_email, get_sha256 -from oauth.forms import RequireEmailForm -from .models import OAuthUser -from .oauthmanager import get_manager_by_type, OAuthAccessTokenException +from django.contrib.auth import get_user_model # 获取用户模型 +from django.contrib.auth import login # 用户登录功能 +from django.core.exceptions import ObjectDoesNotExist # 对象不存在异常 +from django.db import transaction # 数据库事务 +from django.http import HttpResponseForbidden # 403禁止访问响应 +from django.http import HttpResponseRedirect # 重定向响应 +from django.shortcuts import get_object_or_404 # 获取对象或404 +from django.shortcuts import render # 模板渲染 +from django.urls import reverse # URL反向解析 +from django.utils import timezone # 时区工具 +from django.utils.translation import gettext_lazy as _ # 国际化翻译 +from django.views.generic import FormView # 表单视图基类 + +# 导入项目自定义模块 +from djangoblog.blog_signals import oauth_user_login_signal # 信号量 +from djangoblog.utils import get_current_site # 获取当前站点 +from djangoblog.utils import send_email, get_sha256 # 邮件发送和加密工具 +from oauth.forms import RequireEmailForm # 邮箱表单 +from .models import OAuthUser # OAuth用户模型 +from .oauthmanager import get_manager_by_type, OAuthAccessTokenException # OAuth管理器 +# 获取当前模块的日志记录器 logger = logging.getLogger(__name__) def get_redirecturl(request): + """ + 获取安全的重定向URL + + 验证next_url参数的安全性,防止开放重定向漏洞。 + + Args: + request: HttpRequest对象 + + Returns: + str: 安全的跳转URL,默认为首页 + """ + # 从请求参数获取跳转URL nexturl = request.GET.get('next_url', None) + + # 如果nexturl为空或是登录页面,则重定向到首页 if not nexturl or nexturl == '/login/' or nexturl == '/login': nexturl = '/' return nexturl + + # 解析URL以验证安全性 p = urlparse(nexturl) + + # 检查URL是否指向外部域名(防止开放重定向攻击) if p.netloc: site = get_current_site().domain + # 比较域名(忽略www前缀),如果不匹配则视为非法URL if not p.netloc.replace('www.', '') == site.replace('www.', ''): logger.info('非法url:' + nexturl) - return "/" + return "/" # 重定向到首页 + return nexturl def oauthlogin(request): + """ + OAuth登录入口视图 + + 根据平台类型初始化第三方登录流程,重定向到对应平台的授权页面。 + + Args: + request: HttpRequest对象 + + Returns: + HttpResponseRedirect: 重定向到第三方授权页面或首页 + """ + # 从请求参数获取OAuth平台类型 type = request.GET.get('type', None) if not type: - return HttpResponseRedirect('/') + return HttpResponseRedirect('/') # 类型为空则重定向到首页 + + # 获取对应平台的OAuth管理器 manager = get_manager_by_type(type) if not manager: - return HttpResponseRedirect('/') + return HttpResponseRedirect('/') # 管理器不存在则重定向到首页 + + # 获取安全的跳转URL nexturl = get_redirecturl(request) + + # 生成第三方平台的授权URL authorizeurl = manager.get_authorization_url(nexturl) + + # 重定向到第三方授权页面 return HttpResponseRedirect(authorizeurl) def authorize(request): + """ + OAuth授权回调视图 + + 处理第三方平台返回的授权码,获取访问令牌和用户信息, + 完成用户认证或引导用户补充信息。 + + Args: + request: HttpRequest对象 + + Returns: + HttpResponseRedirect: 重定向到相应页面 + """ + # 从请求参数获取OAuth平台类型 type = request.GET.get('type', None) if not type: return HttpResponseRedirect('/') + + # 获取对应平台的OAuth管理器 manager = get_manager_by_type(type) if not manager: return HttpResponseRedirect('/') + + # 从请求参数获取授权码 code = request.GET.get('code', None) + try: + # 使用授权码获取访问令牌和用户信息 rsp = manager.get_access_token_by_code(code) except OAuthAccessTokenException as e: + # 处理令牌获取异常 logger.warning("OAuthAccessTokenException:" + str(e)) return HttpResponseRedirect('/') except Exception as e: + # 处理其他异常 logger.error(e) rsp = None + + # 获取安全的跳转URL nexturl = get_redirecturl(request) + + # 如果获取用户信息失败,重新跳转到授权页面 if not rsp: return HttpResponseRedirect(manager.get_authorization_url(nexturl)) + + # 获取OAuth用户信息 user = manager.get_oauth_userinfo() + if user: + # 处理昵称为空的情况,生成默认昵称 if not user.nickname or not user.nickname.strip(): user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') + try: + # 检查是否已存在相同平台和OpenID的用户 temp = OAuthUser.objects.get(type=type, openid=user.openid) + # 更新现有用户信息 temp.picture = user.picture temp.metadata = user.metadata temp.nickname = user.nickname user = temp except ObjectDoesNotExist: + # 用户不存在,使用新用户对象 pass - # facebook的token过长 + + # Facebook的token过长,清空存储 if type == 'facebook': user.token = '' + + # 如果用户有邮箱,直接完成登录流程 if user.email: - with transaction.atomic(): + with transaction.atomic(): # 使用事务保证数据一致性 author = None try: + # 尝试获取已关联的本地用户 author = get_user_model().objects.get(id=user.author_id) except ObjectDoesNotExist: pass + + # 如果没有关联的本地用户 if not author: + # 根据邮箱获取或创建本地用户 result = get_user_model().objects.get_or_create(email=user.email) author = result[0] + + # 如果是新创建的用户 if result[1]: try: + # 检查昵称是否已被使用 get_user_model().objects.get(username=user.nickname) except ObjectDoesNotExist: + # 昵称可用,设置为用户名 author.username = user.nickname else: + # 昵称已被使用,生成唯一用户名 author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') + + # 设置用户来源和保存 author.source = 'authorize' author.save() + # 关联OAuth用户和本地用户 user.author = author user.save() + # 发送用户登录信号 oauth_user_login_signal.send( sender=authorize.__class__, id=user.id) + + # 登录用户 login(request, author) + + # 重定向到目标页面 return HttpResponseRedirect(nexturl) else: + # 用户没有邮箱,保存用户信息并跳转到邮箱补充页面 user.save() url = reverse('oauth:require_email', kwargs={ 'oauthid': user.id @@ -121,35 +225,68 @@ def authorize(request): return HttpResponseRedirect(url) else: + # 获取用户信息失败,重定向到目标页面 return HttpResponseRedirect(nexturl) def emailconfirm(request, id, sign): + """ + 邮箱确认视图 + + 验证邮箱确认链接的签名,完成OAuth用户与本地用户的绑定。 + + Args: + request: HttpRequest对象 + id: OAuth用户ID + sign: 安全签名 + + Returns: + HttpResponseRedirect: 重定向到绑定成功页面 + """ + # 验证签名是否存在 if not sign: return HttpResponseForbidden() + + # 验证签名是否正确 if not get_sha256(settings.SECRET_KEY + str(id) + settings.SECRET_KEY).upper() == sign.upper(): return HttpResponseForbidden() + + # 获取OAuth用户对象 oauthuser = get_object_or_404(OAuthUser, pk=id) - with transaction.atomic(): + + with transaction.atomic(): # 使用事务保证数据一致性 + # 处理用户关联 if oauthuser.author: + # 已有关联用户,直接获取 author = get_user_model().objects.get(pk=oauthuser.author_id) else: + # 没有关联用户,根据邮箱创建或获取用户 result = get_user_model().objects.get_or_create(email=oauthuser.email) author = result[0] + + # 如果是新创建的用户 if result[1]: - author.source = 'emailconfirm' + author.source = 'emailconfirm' # 设置用户来源 + # 设置用户名(使用昵称或生成唯一用户名) author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip( ) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') author.save() + + # 保存用户关联关系 oauthuser.author = author oauthuser.save() + + # 发送用户登录信号 oauth_user_login_signal.send( sender=emailconfirm.__class__, id=oauthuser.id) + + # 登录用户 login(request, author) + # 准备邮件内容 site = 'http://' + get_current_site().domain content = _('''

Congratulations, you have successfully bound your email address. You can use @@ -162,7 +299,10 @@ def emailconfirm(request, id, sign): %(site)s ''') % {'oauthuser_type': oauthuser.type, 'site': site} + # 发送绑定成功邮件 send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content) + + # 重定向到绑定成功页面 url = reverse('oauth:bindsuccess', kwargs={ 'oauthid': id }) @@ -171,49 +311,96 @@ def emailconfirm(request, id, sign): 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): - oauthid = self.kwargs['oauthid'] + """ + GET请求处理 + + 检查OAuth用户是否已有邮箱,如有则跳过此步骤。 + """ + oauthid = self.kwargs['oauthid'] # 获取OAuth用户ID oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + + # 如果用户已有邮箱,理论上应该跳过此步骤 if oauthuser.email: pass - # return HttpResponseRedirect('/') + # 这里可以添加重定向逻辑:return HttpResponseRedirect('/') return super(RequireEmailView, self).get(request, *args, **kwargs) def get_initial(self): + """ + 设置表单初始值 + + Returns: + dict: 包含初始值的字典 + """ oauthid = self.kwargs['oauthid'] return { - 'email': '', - 'oauthid': oauthid + 'email': '', # 邮箱初始值为空 + 'oauthid': oauthid # 隐藏的OAuth用户ID } def get_context_data(self, **kwargs): + """ + 添加上下文数据 + + 将OAuth用户的头像URL添加到模板上下文。 + """ oauthid = self.kwargs['oauthid'] oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + + # 如果用户有头像,添加到上下文 if oauthuser.picture: kwargs['picture'] = oauthuser.picture + return super(RequireEmailView, self).get_context_data(**kwargs) def form_valid(self, form): + """ + 表单验证通过后的处理 + + 保存用户邮箱,发送确认邮件。 + + Args: + form: 验证通过的表单实例 + + Returns: + HttpResponseRedirect: 重定向到邮件发送提示页面 + """ + # 获取表单数据 email = form.cleaned_data['email'] oauthid = form.cleaned_data['oauthid'] + + # 获取OAuth用户并更新邮箱 oauthuser = get_object_or_404(OAuthUser, pk=oauthid) oauthuser.email = email oauthuser.save() + + # 生成安全签名 sign = get_sha256(settings.SECRET_KEY + str(oauthuser.id) + settings.SECRET_KEY) + + # 构建确认链接 site = get_current_site().domain if settings.DEBUG: - site = '127.0.0.1:8000' + site = '127.0.0.1:8000' # 调试模式使用本地地址 + path = reverse('oauth:email_confirm', kwargs={ 'id': oauthid, 'sign': sign }) url = "http://{site}{path}".format(site=site, path=path) + # 准备邮件内容 content = _("""

Please click the link below to bind your email

@@ -225,29 +412,52 @@ class RequireEmailView(FormView):
%(url)s """) % {'url': url} + + # 发送确认邮件 send_email(emailto=[email, ], title=_('Bind your email'), content=content) + + # 重定向到提示页面 url = reverse('oauth:bindsuccess', kwargs={ 'oauthid': oauthid }) - url = url + '?type=email' + url = url + '?type=email' # 添加类型参数 return HttpResponseRedirect(url) def bindsuccess(request, oauthid): + """ + 绑定成功页面视图 + + 根据绑定状态显示不同的成功信息。 + + Args: + request: HttpRequest对象 + oauthid: OAuth用户ID + + Returns: + HttpResponse: 渲染的绑定成功页面 + """ + # 获取绑定类型 type = request.GET.get('type', None) oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + + # 根据类型设置不同的显示内容 if type == 'email': + # 邮箱已发送状态 title = _('Bind your email') content = _( 'Congratulations, the binding is just one step away. ' 'Please log in to your email to check the email to complete the binding. Thank you.') else: + # 绑定完成状态 title = _('Binding successful') content = _( "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." % { 'oauthuser_type': oauthuser.type}) + + # 渲染绑定成功页面 return render(request, 'oauth/bindsuccess.html', { 'title': title, 'content': content - }) + }) \ No newline at end of file