From 44e267d82263cb95ee3d63274809ba2fadfdb5ad Mon Sep 17 00:00:00 2001 From: yna1217 <2863674419@qq.com> Date: Wed, 5 Nov 2025 19:15:55 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=B8=83=E2=80=94=E5=85=AB=E5=91=A8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=B3=A8=E9=87=8Aoauth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- oauth/__init__.py | 0 oauth/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 161 bytes oauth/__pycache__/admin.cpython-312.pyc | Bin 0 -> 3045 bytes oauth/__pycache__/apps.cpython-312.pyc | Bin 0 -> 404 bytes oauth/__pycache__/forms.cpython-312.pyc | Bin 0 -> 1204 bytes oauth/__pycache__/models.cpython-312.pyc | Bin 0 -> 4241 bytes .../__pycache__/oauthmanager.cpython-312.pyc | Bin 0 -> 22686 bytes oauth/__pycache__/urls.cpython-312.pyc | Bin 0 -> 915 bytes oauth/__pycache__/views.cpython-312.pyc | Bin 0 -> 12733 bytes oauth/admin.py | 93 +++ oauth/apps.py | 11 + oauth/forms.py | 26 + oauth/migrations/0001_initial.py | 84 +++ ...ptions_alter_oauthuser_options_and_more.py | 111 ++++ .../0003_alter_oauthuser_nickname.py | 23 + oauth/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-312.pyc | Bin 0 -> 3634 bytes ...oauthuser_options_and_more.cpython-312.pyc | Bin 0 -> 3520 bytes ...3_alter_oauthuser_nickname.cpython-312.pyc | Bin 0 -> 833 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 172 bytes oauth/models.py | 99 +++ oauth/oauthmanager.py | 593 ++++++++++++++++++ oauth/templatetags/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 174 bytes .../__pycache__/oauth_tags.cpython-312.pyc | Bin 0 -> 1308 bytes oauth/templatetags/oauth_tags.py | 54 ++ oauth/tests.py | 319 ++++++++++ oauth/urls.py | 40 ++ oauth/views.py | 313 +++++++++ 29 files changed, 1767 insertions(+) create mode 100644 oauth/__init__.py create mode 100644 oauth/__pycache__/__init__.cpython-312.pyc create mode 100644 oauth/__pycache__/admin.cpython-312.pyc create mode 100644 oauth/__pycache__/apps.cpython-312.pyc create mode 100644 oauth/__pycache__/forms.cpython-312.pyc create mode 100644 oauth/__pycache__/models.cpython-312.pyc create mode 100644 oauth/__pycache__/oauthmanager.cpython-312.pyc create mode 100644 oauth/__pycache__/urls.cpython-312.pyc create mode 100644 oauth/__pycache__/views.cpython-312.pyc create mode 100644 oauth/admin.py create mode 100644 oauth/apps.py create mode 100644 oauth/forms.py create mode 100644 oauth/migrations/0001_initial.py create mode 100644 oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py create mode 100644 oauth/migrations/0003_alter_oauthuser_nickname.py create mode 100644 oauth/migrations/__init__.py create mode 100644 oauth/migrations/__pycache__/0001_initial.cpython-312.pyc create mode 100644 oauth/migrations/__pycache__/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.cpython-312.pyc create mode 100644 oauth/migrations/__pycache__/0003_alter_oauthuser_nickname.cpython-312.pyc create mode 100644 oauth/migrations/__pycache__/__init__.cpython-312.pyc create mode 100644 oauth/models.py create mode 100644 oauth/oauthmanager.py create mode 100644 oauth/templatetags/__init__.py create mode 100644 oauth/templatetags/__pycache__/__init__.cpython-312.pyc create mode 100644 oauth/templatetags/__pycache__/oauth_tags.cpython-312.pyc create mode 100644 oauth/templatetags/oauth_tags.py create mode 100644 oauth/tests.py create mode 100644 oauth/urls.py create mode 100644 oauth/views.py diff --git a/oauth/__init__.py b/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/oauth/__pycache__/__init__.cpython-312.pyc b/oauth/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3669ef78c633b900caa6d96a38b6a9a70403098a GIT binary patch literal 161 zcmX@j%ge<81OnxkGeGoX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a&Wea2`x@7Dvqhl zOEfexG>-|Wbk0aD%Jt1GP7Wx_&q_@$iE+tF%uCOA%E?cU$xkdT$%u)M&&JlNNPYNuMktYN=%<6783h9J?@s&)eLb> zKWGm82hDIzNC!U0X)9^LQ*^Uvmw@q8>-!dE7OMMO8K>RWW7NXDX^cN7Ml`V*0NNYQP-0O4Ohk02(rbK*MIp3Tct9HDZRX z@V->@r3byii;Gn^{|2+@BHy3r4U}wSt)v$%PqB)+?R<{jNc%j|Ht7Jc-jHLL)-<=Q zRbf`KY+8;NW%=@DE^BsC&sm=A)+!ba;7pN*pyN~1qUY0LxiX~~juoTpRNx800HeJ3 zFCbgQ?S_VsbyDd01HSZHFLdi-LGF$1_XU7Ye_JBgBty&*L;N1BenncB3V5Erc3u2Q zOa`4RQXRe=Vx7bE(TV?$_19q?fvA?~KI0`8U_o4H<;TDn;-IW;Y7TaL**MJrma z?)kc+%mWoJ>ZKY+1pBn?$;@)H2(-7V78sGrmkOTJnSs7aLi^|Zx!F|=J6o-l^k<)Y z_NCR=YUlDgEiT*4c#W0|mf@~074%ZB{IXNdt(Gxxs~p5rm6{jNS+3T%x%$MTV46BW zXB^gNYd{+0ZZy8R)Jmk9iPTOs-Ard z#f;|76xPK$XdPnnfCR}3zZ7qE&wNF4_lpuF;!g!rKE%=ib(@a<_qwH#Lv&-klkE4f z3*~1@uduvMYEbr8>cbrKvy)7NpOJ@zz|t-2G{@?JIX#)DR(5`(E>2|X^4uvslMc`* z*rZrCy?|b+XpVjfYGja?N(~d1BGx)(5i!AQW2ack9n@i#c(Pr}maz_$))-E}wB;D2 zVUX&{KF~c@l_x-py$b{|7aF`a`}S-*F}C&U^;cVoW6i{|R${7|nEF7v9sG5$HNDWB zUT969Z%&`zj$hbVx*H$gK62`VW6dL{e`|bH_^|L%@x$Ww{PHL9m5rrO<4^v1Xyk_R ze&J@JHMMwWYH^co)vwoEV<+y6o%kL5ef{Hl>y_DBlg5qS!n2IjC700^QEZ#@X^2MkiT{`{O>%q1B$<|iiEM3T8R$C{tGY@2R= z_QmPS55AeV>|EaE{6uE%6liDQUh-FL!>v+_VnEW;Lt&6WgW;lIx*wrif3na0eK+6< z&|zN#0?r>Cy0-S?wGHWRbi5sYxgCi?DT@q)OJZ>>RB`T$DyH1u-o5eHjoVd>Z->42 z^WWV2(KR{*18`XnXdFJ8<^?qk;8b-i;KQ2sR#kTpSRh51rBfr{yDuVmjbN?PDtIr4 zxoFy~;^;MR_+d%oK=VR;ENeTiMQI$?rC&voM1uRGczSOLj!Rw5w3tEdiVH=MVu|A? z>0lHq8byM|m#qM~O4^BZW4WCeZ7lDK{)De_VK+v`pV^j2+ryKM^X<_m8W;CuNm&r? z2L)y1zR#zq!u^=6#COBwG$}{sV2eo z7~`QkS1x7kT<6sz^Y;imPAZi(tH!gISq8OSFJLpxD(ROTNczsBC9TktV4FUJI? z$n!$o!76naFCN}dZ^X47HiZvJj$AJQckTr+1;N8zdOoykQ7^cR4>f*T9u>Z=zWsUw za~)7+=mN||SF&#dffN>mKas(EBzccK^(jez78u_cYX!!ef${fdb^^y5z6ZXq3omWH h(Hfa-j!ZrvAUybvAPPq}tF5u)&9UQuCm`?x{~IEDs_p;) literal 0 HcmV?d00001 diff --git a/oauth/__pycache__/apps.cpython-312.pyc b/oauth/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e184d29ff0f4acb81f49f0409f9e7ee24bf1e985 GIT binary patch literal 404 zcmXv~O-sW-5S__~)~3Ot{sAvSE`@p#qzIaVCu=WWLSR|4F=@${n-!Hfzd`&d z9=%AwlP7ON^y10gB%Q;X_n3J*v-4`2T@cZKdkPR?k7(t8!gB92%j`6{p9Nl1ZV+2h^#$304-PVL6o!skD3)3pw{*D( z8)K=bQcNtZ>=2UB*e9gy5)!9u8A;wF(CP8`?rGZ;da%q<3gRJ24=QU`8M;CF2%`V3Eg6e#wi%dEJ?=y$J3)N{V5{S8fNPF$ zlgjd@9od|H<**jBFc2lACj+lc$HRb7#?5lzMY40I7so6T+L%#&k}@W_xQ(%2^J9#e z>>=+~dY!3F)?;ps`7|IA>k=2P!HtLpW8CmqCF0hVDYbWDtk^M*WOz24o&9ul=y2&$ zL|Ig7)V%!%_8%;bHipJMRz2rO{0q2n?=Kufz literal 0 HcmV?d00001 diff --git a/oauth/__pycache__/models.cpython-312.pyc b/oauth/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e00913129fa53073e81a1f8069ad2c6320bce9e9 GIT binary patch literal 4241 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 lsziAT%ZXKyit1dkr4pM{xS3dGnbKym*+ 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 LeUWvtikST`_#UDW literal 0 HcmV?d00001 diff --git a/oauth/admin.py b/oauth/admin.py new file mode 100644 index 00000000..6f52fd50 --- /dev/null +++ b/oauth/admin.py @@ -0,0 +1,93 @@ +import logging + +from django.contrib import admin +# 注册模型到admin站点 +from django.urls import reverse +from django.utils.html import format_html + +# 初始化日志记录器,用于记录当前模块的日志信息 +logger = logging.getLogger(__name__) + + +class OAuthUserAdmin(admin.ModelAdmin): + """ + OAuthUser模型的Admin管理类,用于在Django admin后台管理第三方登录用户信息 + """ + # 配置搜索字段,支持通过昵称和邮箱搜索 + search_fields = ('nickname', 'email') + # 配置每页显示20条记录 + list_per_page = 20 + # 配置列表页显示的字段,包括自定义字段 + list_display = ( + 'id', + 'nickname', # 昵称 + 'link_to_usermodel', # 关联的本地用户(自定义链接字段) + 'show_user_image', # 用户头像(自定义图片显示字段) + 'type', # 第三方平台类型 + 'email', # 邮箱 + ) + # 配置列表页中可点击跳转编辑页的字段 + list_display_links = ('id', 'nickname') + # 配置列表页的过滤条件 + list_filter = ('author', 'type',) + # 初始只读字段列表(后续会动态扩展) + readonly_fields = [] + + def get_readonly_fields(self, request, obj=None): + """ + 重写只读字段方法,当编辑对象时,将所有字段设为只读 + (新增时obj为None,不生效;编辑时obj存在,所有字段只读) + """ + if obj: # 编辑已有对象时 + # 合并初始只读字段 + 模型所有普通字段 + 所有多对多字段 + return list(self.readonly_fields) + \ + [field.name for field in obj._meta.fields] + \ + [field.name for field in obj._meta.many_to_many] + return self.readonly_fields # 新增时使用初始只读字段 + + def has_add_permission(self, request): + """ + 禁用在admin后台手动添加OAuthUser的权限(第三方用户信息应通过登录自动创建) + """ + return False + + def link_to_usermodel(self, obj): + """ + 自定义列表字段:生成关联本地用户的admin编辑页链接 + """ + if obj.author: # 如果存在关联的本地用户 + # 获取关联用户模型的app标签和模型名称 + info = (obj.author._meta.app_label, obj.author._meta.model_name) + # 反转生成用户编辑页的URL + link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + # 返回带链接的HTML(使用format_html确保安全渲染) + return format_html( + u'%s' % + (link, obj.author.nickname if obj.author.nickname else obj.author.email) + # 显示昵称,若昵称不存在则显示邮箱 + ) + return None # 无关联用户时返回空 + + def show_user_image(self, obj): + """ + 自定义列表字段:显示用户头像图片 + """ + img = obj.picture # 获取头像图片URL + if img: # 若头像存在 + # 返回图片HTML标签,限制宽高为50px + return format_html(u'' % (img)) + return None # 无头像时返回空 + + # 定义自定义字段在列表页的显示名称 + link_to_usermodel.short_description = '用户' + show_user_image.short_description = '用户头像' + + +class OAuthConfigAdmin(admin.ModelAdmin): + """ + OAuthConfig模型的Admin管理类,用于在Django admin后台管理第三方登录配置信息 + """ + # 配置列表页显示的字段 + list_display = ('type', 'appkey', 'appsecret', 'is_enable') + # 配置列表页的过滤条件(按第三方平台类型过滤) + list_filter = ('type',) \ No newline at end of file diff --git a/oauth/apps.py b/oauth/apps.py new file mode 100644 index 00000000..e6924786 --- /dev/null +++ b/oauth/apps.py @@ -0,0 +1,11 @@ +# 导入Django的AppConfig类,用于配置应用的基本信息 +from django.apps import AppConfig + + +class OauthConfig(AppConfig): + """ + oauth应用的配置类,用于定义应用的核心信息 + 继承自Django的AppConfig,是Django应用配置的标准方式 + """ + # 应用的名称,Django通过该名称识别和管理当前应用 + name = 'oauth' \ No newline at end of file diff --git a/oauth/forms.py b/oauth/forms.py new file mode 100644 index 00000000..dee74516 --- /dev/null +++ b/oauth/forms.py @@ -0,0 +1,26 @@ +# 导入Django表单基础类和控件模块 +from django.contrib.auth.forms import forms +from django.forms import widgets + + +class RequireEmailForm(forms.Form): + """ + 用于收集用户电子邮箱的表单类 + 通常在OAuth第三方登录时,若用户未提供邮箱信息,用于补充收集 + """ + # 电子邮箱字段:使用EmailField进行格式验证,标签为"电子邮箱",且为必填项 + email = forms.EmailField(label='电子邮箱', required=True) + # OAuth用户ID字段:隐藏控件(HiddenInput),用于关联第三方登录用户,非必填 + oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) + + def __init__(self, *args, **kwargs): + """ + 重写初始化方法,自定义表单字段的控件属性 + 主要用于设置邮箱输入框的占位符和CSS样式类 + """ + # 调用父类的初始化方法,确保表单正常初始化 + super(RequireEmailForm, self).__init__(*args, **kwargs) + # 为email字段设置自定义控件:EmailInput + # 添加placeholder提示文本和form-control的CSS类(通常用于Bootstrap样式) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) \ No newline at end of file diff --git a/oauth/migrations/0001_initial.py b/oauth/migrations/0001_initial.py new file mode 100644 index 00000000..ac105f03 --- /dev/null +++ b/oauth/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# Generated by Django 4.1.7 on 2023-03-07 09:53 +# 导入Django相关模块 +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + """迁移类,定义数据库表结构的变更操作""" + + # 标识为初始迁移(首次创建表结构) + initial = True + + # 依赖的其他迁移,此处依赖于用户模型的迁移 + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + # 具体的迁移操作列表 + operations = [ + # 创建OAuthConfig模型(存储OAuth第三方登录的配置信息) + migrations.CreateModel( + name='OAuthConfig', + fields=[ + # 自增主键ID + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # OAuth类型字段,限定可选值为常见第三方平台,默认值为'a'(可能需要后续调整) + ('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')), + # 应用AppKey字段 + ('appkey', models.CharField(max_length=200, verbose_name='AppKey')), + # 应用AppSecret字段 + ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), + # 回调地址字段,默认值为百度首页 + ('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={ + 'verbose_name': 'oauth配置', # 单数显示名称 + 'verbose_name_plural': 'oauth配置', # 复数显示名称 + 'ordering': ['-created_time'], # 排序方式:按创建时间倒序 + }, + ), + # 创建OAuthUser模型(存储通过OAuth登录的用户信息) + migrations.CreateModel( + name='OAuthUser', + fields=[ + # 自增主键ID + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 第三方平台的openid + ('openid', models.CharField(max_length=50)), + # 第三方平台的用户昵称 + ('nickname', models.CharField(max_length=50, verbose_name='昵称')), + # 访问令牌,可为空 + ('token', models.CharField(blank=True, max_length=150, null=True)), + # 头像图片地址,可为空 + ('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)), + # 额外元数据,文本类型,可为空 + ('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='修改时间')), + # 关联到本地用户模型的外键,可为空,级联删除 + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + # 模型元数据配置 + options={ + 'verbose_name': 'oauth用户', # 单数显示名称 + 'verbose_name_plural': 'oauth用户', # 复数显示名称 + 'ordering': ['-created_time'], # 排序方式:按创建时间倒序 + }, + ), + ] \ No newline at end of file diff --git a/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py new file mode 100644 index 00000000..a16d1eb1 --- /dev/null +++ b/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py @@ -0,0 +1,111 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 +# 导入Django配置、数据库迁移相关模块 +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + """迁移类,定义对OAuth相关模型的结构修改操作""" + + # 依赖的迁移:依赖于用户模型的迁移和oauth应用的初始迁移(0001_initial) + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth', '0001_initial'), + ] + + # 具体的迁移操作列表 + operations = [ + # 修改OAuthConfig模型的元选项 + migrations.AlterModelOptions( + name='oauthconfig', + # 排序方式改为按creation_time倒序;显示名称保持为"oauth配置" + options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'}, + ), + # 修改OAuthUser模型的元选项 + migrations.AlterModelOptions( + name='oauthuser', + # 排序方式改为按creation_time倒序;显示名称改为英文"oauth user" + options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'}, + ), + # 移除OAuthConfig模型中的created_time字段(旧时间字段) + migrations.RemoveField( + model_name='oauthconfig', + name='created_time', + ), + # 移除OAuthConfig模型中的last_mod_time字段(旧修改时间字段) + migrations.RemoveField( + model_name='oauthconfig', + name='last_mod_time', + ), + # 移除OAuthUser模型中的created_time字段(旧时间字段) + migrations.RemoveField( + model_name='oauthuser', + name='created_time', + ), + # 移除OAuthUser模型中的last_mod_time字段(旧修改时间字段) + migrations.RemoveField( + model_name='oauthuser', + name='last_mod_time', + ), + # 为OAuthConfig模型添加creation_time字段(新创建时间字段) + migrations.AddField( + model_name='oauthconfig', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + # 为OAuthConfig模型添加last_modify_time字段(新修改时间字段) + migrations.AddField( + model_name='oauthconfig', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + # 为OAuthUser模型添加creation_time字段(新创建时间字段) + migrations.AddField( + model_name='oauthuser', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + # 为OAuthUser模型添加last_modify_time字段(新修改时间字段) + migrations.AddField( + model_name='oauthuser', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + # 修改OAuthConfig模型的callback_url字段属性 + migrations.AlterField( + model_name='oauthconfig', + name='callback_url', + # 默认值从"http://www.baidu.com"改为空字符串;显示名称改为英文"callback url" + field=models.CharField(default='', max_length=200, verbose_name='callback url'), + ), + # 修改OAuthConfig模型的is_enable字段属性 + migrations.AlterField( + model_name='oauthconfig', + name='is_enable', + # 显示名称改为英文"is enable" + field=models.BooleanField(default=True, verbose_name='is enable'), + ), + # 修改OAuthConfig模型的type字段属性 + migrations.AlterField( + model_name='oauthconfig', + name='type', + # 选项中的显示文本部分改为英文(如"微博"改为"weibo");显示名称改为英文"type" + field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), + ), + # 修改OAuthUser模型的author字段属性 + migrations.AlterField( + model_name='oauthuser', + name='author', + # 显示名称改为英文"author" + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), + ), + # 修改OAuthUser模型的nickname字段属性 + migrations.AlterField( + model_name='oauthuser', + name='nickname', + # 显示名称改为英文"nickname" + field=models.CharField(max_length=50, verbose_name='nickname'), + ), + ] \ No newline at end of file diff --git a/oauth/migrations/0003_alter_oauthuser_nickname.py b/oauth/migrations/0003_alter_oauthuser_nickname.py new file mode 100644 index 00000000..29ef5b65 --- /dev/null +++ b/oauth/migrations/0003_alter_oauthuser_nickname.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-26 02:41 +# 导入Django数据库迁移相关模块 +from django.db import migrations, models + + +class Migration(migrations.Migration): + """迁移类,定义对OAuthUser模型的字段修改操作""" + + # 依赖的迁移:依赖于oauth应用的上一个迁移文件(0002_...) + dependencies = [ + ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), + ] + + # 具体的迁移操作列表 + operations = [ + # 修改OAuthUser模型的nickname字段属性 + migrations.AlterField( + model_name='oauthuser', # 目标模型为OAuthUser + name='nickname', # 目标字段为nickname + # 字段的verbose_name从'nickname'修改为'nick name',其他属性(如max_length=50)保持不变 + field=models.CharField(max_length=50, verbose_name='nick name'), + ), + ] \ No newline at end of file diff --git a/oauth/migrations/__init__.py b/oauth/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/oauth/migrations/__pycache__/0001_initial.cpython-312.pyc b/oauth/migrations/__pycache__/0001_initial.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..77ca92b714b1425cf893bda2046653de15267c59 GIT binary patch literal 3634 zcmds4U2NON9VaP?mSj1RT*sfX5<6~eIksY@Nw#H!Q^z0a*7_@U+DuTxQMwaNnIaX6 zvZEl|GPDB%Yz6XAEXLXu#V~9gtYsdstxk(Ieblgrk*EL)*M}C^Y}aq@l497Nb|kG> zj_eErwwH;3c>n+X?*D!NJN~Jz&Q0K-ck!dSPihI`F9uQmIkpE6B6x6z00bZfB1J|> z(il?$m9j-_DSO0j(l)^np)ubk)I?~CxIh5=s|0XBThdz=xbt4NS>uQ^XA9Ds1`#BY zGXoWc7r9K3LhcmL$*jUlVg}JE2|yt;5#JF&5RKc!gF$TP9Rd;&5)cszkP#b2Kzq{k zt8xKBKstF~H^-osNsGsk5vRGbz8V84U`TZ*%UUZSfW(g-IP&ze-C`#wP-CK-D`4A0 zjsVV|lE4Mrpf>NRSW&m%3UB}%%zKxqN3wlSK-TXw+Am&&D*=HfpGrh z@^%MLP+To|20vAG&QX!(+^OJc7^1#D60{=^n^hzx zE<;v<0M({tKE=v8W*+8{D+6Vo75M8Ac^04?lQNJI*%U-}C6|Ul2X5$&MaajbER7vl z`{3@{Yd^+3nv*0>fF|>^m3P;F^+S`%D|6WxqA&2ubJ-Z?I1_9f#w2MTImhtmJRV^# zb@eKurl%3fZW_Jb6teM~_t)O~0k*X!J}2>Um_aoFCfKZ?-~)xd#t2a4lsVZr1rW`q z)7bXSf=LXe)6c_PkdlqQW&vCnkb&4=1-bC_j5YMcSwV=g@p&dI3)dUw6eWH3#EHel z#ZZjpK{gbZQdtj{vG&GWpRK&I_U6jkPi_Wna+@KxLN=dapvcCs^9Fc*>DOzwf4;u- z!N$+t!#0>;(@4*DxEX~{S+ul-uj*2Z{GPWsuS3Z!rAE$;E+mb z>s)r3g>nh!9??=7iagjni4_J((^2FU`S`rC(oD_z((g8Yv4R|mG!Mni9~n3sa>N8y zoVVb1F)IkDCe6o{tPJ@p_*g~`m|}TsOA0D1U=%|vq|8$KESQgkY3Se6>+Gw~@|O2W7j9WhNfFGyU}tgz_Lg)KTTFc4<8 zV-`y1UkDNnWHmZTX z`NEc)a2+ZPNCTAX=wvnD9;8O1>7g?fB9ez3sTxc6=pMXfC#0 zRJ}7LpI;4xmJ)g(qy<8&M+?sv1D93rH%q?5YDf6(M!h4fb%fQkQ|im#Ql)PfJHDfO zziTcE8;inPAiUaDxKa#6RqxB@qSMBr(^|*r)x>?_o}drTXoEBQ;7i)zOKL(Xc4SrW zLdkbT?L71LF}?GQ)_G=i;r@+#H}rF}+PPW%+-2?DWtGboI~P>%;+D_VaA2#E=m?d% zPnFvHRDb{HHTFXX9=7;g?yas}Ve|jHuh5dcRuowW;m>OE2o}{aSnfYJH)n z*gm6rXG^|jwY7KYnBLl}wf3$we0a1lQf!@8y%$TqR@L9PG^YFeG=Ja9sSk$>py;>O zR3>KSVzK4Rs`o2Zk~#~gimg+scY3$(m627Y*gm3qNB{d_G_&7lWWP^q!#1yiLgLT- zA9;P^iZ*dYpWw6!PM?sp2}xCM{2d=M5s!I;Rb&^J`X%87fK6rSDn&)RVyECCV{66cq=q6wsjN~o#`i9&F(tE z5mJ9drCus=mz8!9;)u7Xb9#2hfj%TuU8r`?(^xazZQ#vA{Z2VjLDNWZSBqwPHLD=hdvK z*s7shIFL6W(yYtrZ2$n;YRZcl!t)_OAi+V9;CVnIiaQ-$8ToUOz19`ZwLvb+CxUFQ z2V2NPA7k$oxepdiYCwSrGhCIlK$i+pB3GUcr4~1>vh$t)9Fh zWQ|R^`d;sFLCv|&9coR?b$QsLdu_0}e4xJG^1=FPVEgIKqvc=Xap;YB1mXWrddFl5UI}BUlk@C@cZePi=H`+0VHt2!Y2HP8E${Xu=BW76uJHFvyCp-a< zm%CPcZnS;y3&L)OIpoQ&ja0;2dq;9dJ#42uY{PqP-aQL;m!m7S-n=i?J&kq(_QIZW z-`+aR9!#{A`eOq)G+l4Pq$B$crD)IL$m!Fk&&aB-+Nz@ACT2_se$B_kOrVIxGXhl=eNJt+W3>pRZ zJrH7GMGKirVJ1eYj3X>6f-ER|bkD2YHU42FYSVSCBEg9)bN<%YCM2@;L_SvNZiuq|K7Ll4VOxkOS7RVY# zRzqfYT~+P5VhRT?sP3N#8^-;!V8|8AW z9g8wciY()>Ow&=+5RoIYe5a^rbe1fev>b7(wWt&dbO%U;0@5MU(2X)xwj8iPWtA0V-i0 zrm)Dih+JcEaD42>_}D}ohm8WNU2PtL7la1*`}6U$$ysuitz=18&WxUUH+i*0Bjl%5 zD}B{8awu&lCm6Z&nvqSi)1TbFA|**tj>-yMyUfPiij-@WvvY)&%(h*JBoQVWE|kn5 zA;Nw_M6iA(cbxlRBLG4}Uxq8e26r z{nbNV)!xz7o_C&nygGl^X)SH~g;01y+~v|)b4jk|7M#|_EthVJ(&dO<)nf#xyMaCG zW$$g^Pwdig7_3Nxjx@Nu@ZG&f_bMY3&d5Y%WXc(tTKy=qCS^B+ zKEgf>Lc6E-y=JPqCgr@QQZ}!d+9)cS`bJT^=vq%W;t6*zt5PprYp)~qE{{I$dwO|I zy0yh{w{K@v8lb)djx?|gpLDEA(+xb2?Lhq#fKxO41^~Btg+6q?Kp&s*abXjH!;^v+ z23b^lRqCVB`W&fmx&Ql_Cm;TF^T(T&_^cD3t;BCT@!OSn#))TE=dHg0(6|Zy5BEX9 zZ}7Kx3ZCQ}fOqU5xSV>tv?g72J;(V;P6~W>(Y#PU^05$Za%+E>R*u_hLNyCav99EH zjP@<#FzI0AU&(O2z|FG+4IinEvko#`Zy;`QP10~I<`i`m-*bE5`{p!J=|2(cB>CA8 lIF5T609?;6VCXrBJ_ntffoYBlJP21>`kj{kmw;*v=pQ`RBys=% literal 0 HcmV?d00001 diff --git a/oauth/migrations/__pycache__/0003_alter_oauthuser_nickname.cpython-312.pyc b/oauth/migrations/__pycache__/0003_alter_oauthuser_nickname.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79319326f7b98b3a898e40dc6ba083b45d4337b8 GIT binary patch literal 833 zcmZWoJ#W)M7=F)oT-!=f8X@sngrW*5OO=KJ38C^)1QO7yOBc)1;rOnOgY64;0t6P2 zIxu$YUjVf~fd9Z0fs}A7Q@2PhLSo|X5-UY`I6u$(areB>*1*{YxBE}Mx%-SxJNUrA^}TxqY$&MQhqkm&t**!PYF&~ zM!BuJWR6>D6ue0NgdR7AhPSq)*CkhO_-}Ab(@>mQenqnu%PCelT{tD(QH&ch#!I^q zjmehKY+)70m^)Y;Ncu4qxQy}3z8}k4$q4Pzl+ZMYXr9T-;Ld$H(IdG_X2^iO(e|gD zJFH7*7xNiTq+5TUZ`}5F#0a@JNd2|u+AVK;ARCg+C=a$X)}cYqd)V>QkUfZ5=&6~# z`2~Bz#hd?mRoJoG9c1#eQHuomJ+bV;!~}Z>O?2(N`Tp6&0sGoVXJjo8t>y8B+IZ>8 zxLVu4F{;*v)!L`lmv`T*Pfr&N+nMNQ)%@@zyHsqsRX4azl)6>YQU<7@r-xpYWvceF qK-{`Bb#XnrDyaAqAeUgGBZPjNfa<4a8JYXesIoGwto#B&RQF%Wo8K+~ literal 0 HcmV?d00001 diff --git a/oauth/migrations/__pycache__/__init__.cpython-312.pyc b/oauth/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14920455e3d9dbb27237d7ea828d9c22487f7ca5 GIT binary patch literal 172 zcmX@j%ge<81OnxkGeGoX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!@^-e02`x@7Dvqhl zOEfexG>-|Wbk0aD%Jt1GP7Wx_&q_@$iE+tF%uCOA%E?cU$xkdT$%x6#OfO0-$;{6y tj){-Y%*!l^kJl@xyv1RYo1apelWJGQ3N)J$h>JmtkIamWj77{q766txEMNct literal 0 HcmV?d00001 diff --git a/oauth/models.py b/oauth/models.py new file mode 100644 index 00000000..afe3eef4 --- /dev/null +++ b/oauth/models.py @@ -0,0 +1,99 @@ +# Create your models here. +# 导入Django配置、模型相关模块及工具类 +from django.conf import settings +from django.core.exceptions import ValidationError # 用于数据验证抛出异常 +from django.db import models +from django.utils.timezone import now # 用于获取当前时间 +from django.utils.translation import gettext_lazy as _ # 用于国际化翻译 + + +class OAuthUser(models.Model): + """ + OAuthUser模型:存储通过第三方OAuth登录的用户信息 + 关联本地用户模型,记录第三方平台的用户标识、昵称、头像等核心信息 + """ + # 关联本地用户模型(AUTH_USER_MODEL),可为空,级联删除 + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('author'), # 国际化字段名:作者/用户 + blank=True, # 表单提交时可空 + null=True, # 数据库中可空 + on_delete=models.CASCADE) # 关联用户删除时,该记录同步删除 + # 第三方平台用户唯一标识(如微博、GitHub的openid) + openid = models.CharField(max_length=50) + # 用户昵称,支持国际化显示 + nickname = models.CharField(max_length=50, verbose_name=_('nick name')) + # 第三方平台访问令牌,可为空 + 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) + # 额外元数据(存储第三方返回的其他信息),文本类型,可为空 + 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): + """模型实例的字符串表示:返回用户昵称""" + return self.nickname + + class Meta: + verbose_name = _('oauth user') # 模型单数显示名(国际化) + verbose_name_plural = verbose_name # 模型复数显示名(与单数一致) + ordering = ['-creation_time'] # 排序规则:按创建时间倒序 + + +class OAuthConfig(models.Model): + """ + OAuthConfig模型:存储第三方OAuth登录的平台配置信息 + 记录各平台的AppKey、AppSecret、回调地址等核心配置 + """ + # 第三方平台类型选项(元组形式,用于choices参数) + TYPE = ( + ('weibo', _('weibo')), # 微博(支持国际化) + ('google', _('google')), # 谷歌(支持国际化) + ('github', 'GitHub'), # GitHub(固定显示) + ('facebook', 'FaceBook'), # FaceBook(固定显示) + ('qq', 'QQ'), # QQ(固定显示) + ) + # 平台类型,关联TYPE选项,默认值为'a',支持国际化 + type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') + # 应用AppKey(第三方平台分配的客户端ID) + appkey = models.CharField(max_length=200, verbose_name='AppKey') + # 应用AppSecret(第三方平台分配的客户端密钥) + appsecret = models.CharField(max_length=200, verbose_name='AppSecret') + # 回调地址(OAuth授权成功后的跳转地址),非空,默认空字符串,支持国际化 + callback_url = models.CharField( + max_length=200, + verbose_name=_('callback url'), + blank=False, # 表单提交时不可空 + default='') + # 是否启用该配置,默认启用,非空 + 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): + """数据验证方法:确保同一平台类型的配置不重复""" + # 排除当前记录(编辑时),查询是否已存在同类型的配置 + if OAuthConfig.objects.filter( + type=self.type).exclude(id=self.id).count(): + # 抛出验证异常,提示该平台配置已存在 + raise ValidationError(_(self.type + _('already exists'))) + + def __str__(self): + """模型实例的字符串表示:返回平台类型""" + return self.type + + class Meta: + verbose_name = 'oauth配置' # 模型单数显示名(中文) + verbose_name_plural = verbose_name # 模型复数显示名(与单数一致) + ordering = ['-creation_time'] # 排序规则:按创建时间倒序 \ No newline at end of file diff --git a/oauth/oauthmanager.py b/oauth/oauthmanager.py new file mode 100644 index 00000000..206ed0e5 --- /dev/null +++ b/oauth/oauthmanager.py @@ -0,0 +1,593 @@ +import json +import logging +import os +import urllib.parse +from abc import ABCMeta, abstractmethod # 用于定义抽象基类 + +import requests # 用于发送HTTP请求 + +from djangoblog.utils import cache_decorator # 导入缓存装饰器 +from oauth.models import OAuthUser, OAuthConfig # 导入OAuth相关模型 + +# 初始化日志记录器,用于记录当前模块的日志信息 +logger = logging.getLogger(__name__) + + +class OAuthAccessTokenException(Exception): + ''' + 自定义异常:OAuth授权过程中获取Access Token失败时抛出 + ''' + + +class BaseOauthManager(metaclass=ABCMeta): + """ + OAuth抽象基类:定义第三方登录的通用接口和基础方法 + 所有第三方平台的OAuth管理器都需继承此类并实现抽象方法 + """ + # 子类需重写:授权页面URL(用户跳转授权用) + AUTH_URL = None + # 子类需重写:获取Access Token的URL + TOKEN_URL = None + # 子类需重写:获取用户信息的API URL + API_URL = None + # 子类需重写:平台图标名称(对应OAuthConfig的type字段) + ICON_NAME = None + + def __init__(self, access_token=None, openid=None): + """ + 初始化OAuth管理器 + :param access_token: 第三方平台返回的访问令牌 + :param openid: 第三方平台用户唯一标识 + """ + self.access_token = access_token # 存储访问令牌 + self.openid = openid # 存储用户唯一标识 + + @property + def is_access_token_set(self): + """属性:判断Access Token是否已设置""" + return self.access_token is not None + + @property + def is_authorized(self): + """属性:判断是否已完成授权(Access Token和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 + :param nexturl: 授权成功后跳转的页面地址 + :return: 完整的授权URL字符串 + """ + pass + + @abstractmethod + def get_access_token_by_code(self, code): + """ + 抽象方法:通过授权码(code)获取Access Token + :param code: 第三方平台返回的授权码 + :return: 成功返回用户信息或Token,失败抛出异常 + """ + pass + + @abstractmethod + def get_oauth_userinfo(self): + """ + 抽象方法:通过Access Token获取第三方用户信息 + :return: 构造好的OAuthUser对象,失败返回None + """ + pass + + @abstractmethod + def get_picture(self, metadata): + """ + 抽象方法:从用户元数据中提取头像URL + :param metadata: 存储用户信息的元数据(JSON字符串) + :return: 头像URL字符串 + """ + pass + + def do_get(self, url, params, headers=None): + """ + 基础方法:发送GET请求(子类可重写) + :param url: 请求地址 + :param params: 请求参数 + :param headers: 请求头 + :return: 响应文本内容 + """ + rsp = requests.get(url=url, params=params, headers=headers) + logger.info(rsp.text) # 记录响应日志 + return rsp.text + + def do_post(self, url, params, headers=None): + """ + 基础方法:发送POST请求(子类可重写) + :param url: 请求地址 + :param params: 请求参数 + :param headers: 请求头 + :return: 响应文本内容 + """ + rsp = requests.post(url, params, headers=headers) + logger.info(rsp.text) # 记录响应日志 + return rsp.text + + def get_config(self): + """ + 获取当前平台的OAuth配置(从OAuthConfig模型中查询) + :return: OAuthConfig对象,不存在返回None + """ + value = OAuthConfig.objects.filter(type=self.ICON_NAME) + return value[0] if value else None + + +class WBOauthManager(BaseOauthManager): + """微博OAuth登录管理器:实现微博第三方登录的具体逻辑""" + AUTH_URL = 'https://api.weibo.com/oauth2/authorize' # 微博授权URL + TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' # 微博Token获取URL + API_URL = 'https://api.weibo.com/2/users/show.json' # 微博用户信息API + ICON_NAME = 'weibo' # 对应配置中的平台类型 + + def __init__(self, access_token=None, openid=None): + # 先获取微博的OAuth配置 + config = self.get_config() + self.client_id = config.appkey if config else '' # 应用ID + self.client_secret = config.appsecret if config else '' # 应用密钥 + self.callback_url = config.callback_url if config else '' # 回调地址 + # 调用父类初始化方法 + super(WBOauthManager, self).__init__(access_token=access_token, openid=openid) + + def get_authorization_url(self, nexturl='/'): + """生成微博授权URL,拼接跳转地址参数""" + params = { + 'client_id': self.client_id, + 'response_type': 'code', # 授权类型为code + 'redirect_uri': self.callback_url + '&next_url=' + nexturl # 回调地址+登录后跳转地址 + } + # 拼接参数生成完整URL + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + """通过授权码获取微博Access Token,并调用用户信息接口""" + params = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'grant_type': 'authorization_code', # 授权模式 + 'code': code, # 授权码 + 'redirect_uri': self.callback_url # 回调地址(需与授权时一致) + } + # 发送POST请求获取Token + rsp = self.do_post(self.TOKEN_URL, params) + obj = json.loads(rsp) + + # 成功获取Token则继续获取用户信息 + if 'access_token' in obj: + self.access_token = str(obj['access_token']) + self.openid = str(obj['uid']) # 微博用户唯一标识(uid) + return self.get_oauth_userinfo() + else: + # 失败则抛出异常 + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + """通过Access Token获取微博用户信息,构造OAuthUser对象""" + if not self.is_authorized: + return None # 未授权则返回None + + params = { + 'uid': self.openid, + 'access_token': self.access_token + } + # 发送GET请求获取用户信息 + rsp = self.do_get(self.API_URL, params) + 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 # 存储Access Token + # 若返回邮箱则赋值 + if 'email' in datas and datas['email']: + user.email = datas['email'] + return user + except Exception as e: + logger.error(e) + logger.error('weibo oauth error.rsp:' + rsp) + return None + + def get_picture(self, metadata): + """从元数据中提取微博用户头像URL""" + datas = json.loads(metadata) + return datas['avatar_large'] + + +class ProxyManagerMixin: + """ + 代理混入类:为HTTP请求添加代理支持 + 需与BaseOauthManager组合使用(适用于需要代理访问的平台,如谷歌、GitHub) + """ + def __init__(self, *args, **kwargs): + # 从环境变量读取代理配置 + if os.environ.get("HTTP_PROXY"): + self.proxies = { + "http": os.environ.get("HTTP_PROXY"), + "https": os.environ.get("HTTP_PROXY") + } + else: + self.proxies = None # 无代理则为None + # 调用父类初始化方法(注意:混入类需放在继承列表前面) + super().__init__(*args, **kwargs) + + 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登录管理器:集成代理支持,实现谷歌第三方登录逻辑""" + AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' # 谷歌授权URL + TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' # 谷歌Token获取URL + API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' # 谷歌用户信息API + 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 '' + self.callback_url = config.callback_url if config else '' + # 调用父类(ProxyManagerMixin)初始化方法 + super(GoogleOauthManager, self).__init__(access_token=access_token, openid=openid) + + def get_authorization_url(self, nexturl='/'): + """生成谷歌授权URL,请求openid和email权限""" + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': self.callback_url, + 'scope': 'openid email', # 授权范围:获取用户标识和邮箱 + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + """通过授权码获取谷歌Access Token""" + 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) + obj = json.loads(rsp) + + if 'access_token' in obj: + self.access_token = str(obj['access_token']) + self.openid = str(obj['id_token']) # 谷歌用户唯一标识(id_token) + logger.info(self.ICON_NAME + ' oauth ' + rsp) + return self.access_token + else: + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + """通过Access Token获取谷歌用户信息""" + if not self.is_authorized: + return None + + params = {'access_token': self.access_token} + rsp = self.do_get(self.API_URL, params) + try: + datas = json.loads(rsp) + user = OAuthUser() + user.metadata = rsp + user.picture = datas['picture'] # 头像URL + user.nickname = datas['name'] # 用户名 + user.openid = datas['sub'] # 用户唯一标识 + user.token = self.access_token + user.type = 'google' + if datas['email']: + user.email = datas['email'] # 邮箱(谷歌授权时已请求) + return user + except Exception as e: + logger.error(e) + logger.error('google oauth error.rsp:' + rsp) + return None + + def get_picture(self, metadata): + """从元数据中提取谷歌用户头像URL""" + datas = json.loads(metadata) + return datas['picture'] + + +class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): + """GitHub OAuth登录管理器:集成代理支持,实现GitHub第三方登录逻辑""" + AUTH_URL = 'https://github.com/login/oauth/authorize' # GitHub授权URL + TOKEN_URL = 'https://github.com/login/oauth/access_token' # GitHub Token获取URL + API_URL = 'https://api.github.com/user' # GitHub用户信息API + 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 '' + self.callback_url = config.callback_url if config else '' + # 调用父类初始化方法 + super(GitHubOauthManager, self).__init__(access_token=access_token, openid=openid) + + def get_authorization_url(self, next_url='/'): + """生成GitHub授权URL,请求user权限""" + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': f'{self.callback_url}&next_url={next_url}', # 回调+跳转地址 + 'scope': 'user' # 授权范围:获取用户基本信息 + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + """通过授权码获取GitHub Access Token(返回格式为query string)""" + 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) + + # GitHub返回的Token是query string格式,需解析 + from urllib import parse + r = parse.parse_qs(rsp) + if 'access_token' in r: + self.access_token = (r['access_token'][0]) # 取第一个值(列表格式) + return self.access_token + else: + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + """通过Access Token获取GitHub用户信息(需在请求头中携带Token)""" + # GitHub需在请求头中传递Token,而非URL参数 + 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'] # 头像URL + user.nickname = datas['name'] # 用户名(可能为None,优先显示login) + user.openid = datas['id'] # 用户唯一ID + user.type = 'github' + user.token = self.access_token + user.metadata = rsp + # 若返回邮箱则赋值(GitHub部分用户邮箱可能为None) + if 'email' in datas and datas['email']: + user.email = datas['email'] + return user + except Exception as e: + logger.error(e) + logger.error('github oauth error.rsp:' + rsp) + return None + + def get_picture(self, metadata): + """从元数据中提取GitHub用户头像URL""" + datas = json.loads(metadata) + return datas['avatar_url'] + + +class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): + """Facebook OAuth登录管理器:集成代理支持,实现Facebook第三方登录逻辑""" + AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth' # Facebook授权URL(v16.0版本) + TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token' # Facebook Token获取URL + API_URL = 'https://graph.facebook.com/me' # Facebook用户信息API + 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 '' + self.callback_url = config.callback_url if config else '' + # 调用父类初始化方法 + super(FaceBookOauthManager, self).__init__(access_token=access_token, openid=openid) + + def get_authorization_url(self, next_url='/'): + """生成Facebook授权URL,请求email和公开资料权限""" + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': self.callback_url, + 'scope': 'email,public_profile' # 授权范围 + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + """通过授权码获取Facebook Access Token""" + params = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.callback_url + } + rsp = self.do_post(self.TOKEN_URL, params) + obj = json.loads(rsp) + + if 'access_token' in obj: + token = str(obj['access_token']) + self.access_token = token + return self.access_token + else: + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + """通过Access Token获取Facebook用户信息(需指定返回字段)""" + params = { + 'access_token': self.access_token, + '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.type = 'facebook' + user.token = self.access_token + user.metadata = rsp + # 赋值邮箱(可能为None) + if 'email' in datas and datas['email']: + user.email = datas['email'] + # 处理头像(Facebook头像嵌套在picture.data.url中) + if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']: + user.picture = str(datas['picture']['data']['url']) + return user + except Exception as e: + logger.error(e) + return None + + def get_picture(self, metadata): + """从元数据中提取Facebook用户头像URL(处理嵌套结构)""" + datas = json.loads(metadata) + return str(datas['picture']['data']['url']) + + +class QQOauthManager(BaseOauthManager): + """QQ OAuth登录管理器:实现QQ第三方登录逻辑(需单独获取OpenID)""" + AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize' # QQ授权URL + TOKEN_URL = 'https://graph.qq.com/oauth2.0/token' # QQ Token获取URL + API_URL = 'https://graph.qq.com/user/get_user_info' # QQ用户信息API + OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me' # QQ OpenID获取URL(单独接口) + 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 '' + self.callback_url = config.callback_url if config else '' + # 调用父类初始化方法 + super(QQOauthManager, self).__init__(access_token=access_token, openid=openid) + + def get_authorization_url(self, next_url='/'): + """生成QQ授权URL,拼接跳转地址""" + params = { + 'response_type': 'code', + 'client_id': self.client_id, + 'redirect_uri': self.callback_url + '&next_url=' + next_url, + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + """通过授权码获取QQ Access Token(返回格式为query string)""" + params = { + 'grant_type': 'authorization_code', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.callback_url + } + # QQ获取Token使用GET请求 + rsp = self.do_get(self.TOKEN_URL, params) + if rsp: + # 解析query string格式的响应 + d = urllib.parse.parse_qs(rsp) + if 'access_token' in d: + token = d['access_token'] + self.access_token = token[0] # 取第一个值 + return token + else: + raise OAuthAccessTokenException(rsp) + + def get_open_id(self): + """单独获取QQ用户的OpenID(QQ OAuth特殊流程)""" + if self.is_access_token_set: + params = {'access_token': self.access_token} + rsp = self.do_get(self.OPEN_ID_URL, params) + if rsp: + # QQ返回的OpenID格式为callback包裹的JSON,需处理格式 + rsp = rsp.replace('callback(', '').replace(')', '').replace(';', '') + obj = json.loads(rsp) + openid = str(obj['openid']) + self.openid = openid + return openid + + def get_oauth_userinfo(self): + """获取QQ用户信息(需先获取OpenID)""" + openid = self.get_open_id() + if openid: + params = { + 'access_token': self.access_token, + 'oauth_consumer_key': self.client_id, # QQ需额外传递client_id + 'openid': self.openid + } + rsp = self.do_get(self.API_URL, params) + logger.info(rsp) + obj = json.loads(rsp) + user = OAuthUser() + user.nickname = obj['nickname'] # 昵称 + user.openid = openid # 唯一标识 + user.type = 'qq' + user.token = self.access_token + user.metadata = rsp + # 赋值邮箱(可能为None) + if 'email' in obj: + user.email = obj['email'] + # 赋值头像(figureurl为QQ头像URL) + if 'figureurl' in obj: + user.picture = str(obj['figureurl']) + return user + + def get_picture(self, metadata): + """从元数据中提取QQ用户头像URL""" + datas = json.loads(metadata) + return str(datas['figureurl']) + + +@cache_decorator(expiration=100 * 60) # 缓存100分钟,减少数据库查询 +def get_oauth_apps(): + """ + 获取所有启用的OAuth应用管理器实例 + :return: 启用的OAuthManager实例列表 + """ + # 查询所有已启用的OAuth配置 + configs = OAuthConfig.objects.filter(is_enable=True).all() + if not configs: + return [] # 无启用配置则返回空列表 + + # 提取已启用的平台类型 + configtypes = [x.type for x in configs] + # 获取BaseOauthManager的所有子类(各平台实现类) + 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管理器实例 + :param type: 平台类型(如weibo、github) + :return: 对应的OAuthManager实例,不存在返回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 \ No newline at end of file diff --git a/oauth/templatetags/__init__.py b/oauth/templatetags/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/oauth/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/oauth/templatetags/__pycache__/__init__.cpython-312.pyc b/oauth/templatetags/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a663cf8ff64dcadc7143e39b23629174776a16a8 GIT binary patch literal 174 zcmX@j%ge<81OnxkGnjz%V-N=&d}aZPOlPQM&}8&m$xsAR_Zg(-m#?!`OlWaxQE^OV zUZSCqp?OR|rE^ANQLb-hadJRWepYI7NsLQYVqSW_Q%-(*Onzc%Nk&XbYHmSJVo7RA vVtR2*e0*kJW=VX!UP0w84x8Nkl+v73yCPPg>5M>J3}Sp_W@Kb6Vg|AR0mdyd literal 0 HcmV?d00001 diff --git a/oauth/templatetags/__pycache__/oauth_tags.cpython-312.pyc b/oauth/templatetags/__pycache__/oauth_tags.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9809fa69671319de7bba39d36fdcd1f4985553a5 GIT binary patch literal 1308 zcmZ`&&2JM&6rY)0d!0CmUArZKg=$2Kw4!n2UINL@*(M}NJtF_Zbs(N_QadD*Y4*We*4?^-n{R5IX*s$ z;6#6H7w-^+ev?1`QTn9224o8v$iNI)co}2K2_u%Wtaw~usufrcU__9K%VWNS;di@F zmo-x>e>9Z)%uu&+j~juzXka~359-=msnD?-5~fvURG7duZvJ3$N79LcDRMTgiDHgc ztIql{kAE?cuu`6il3j69MPadgKa3YF2q=r}J_fj_+*7xZ80h^Z8h8`)S>ZPWn&7AU zK-om)famFN;NN_p3e8uD5ude`mbyv|ayPVzx1r_o_#PX?P&P>k-#`U4~-#%Y!n8s{8oAt?!ec$#&m`e!ToUK=A`pfj`?DhImk#g&5$;n^mcG=8}?8P#z z6zs2{BU!ICS${_aEjT?{-$vD#h zzcP&$XZ2&d=XA$(%K3m2dqxsG~( z%MIbGl#I(&U9B;ela;zED>=edELznAa-+Ymneaab*9IB!n7k~z!zTb7=_{E`o3cZ)>2=GCjKhr@?~fA zQ8#FKbK!E9Zn*c8g8guMeTaFpN_l9}3N4u2>yoQoDXnnIVOHEMKyys)j>D?6ngb75 z4$>>^Ir0MaS`b>WY@^1^MV^FU^7T54038Km{0b#rp-DS>zYj!W3*bOJz;bYBE fCy2=C9-iFAlRJk`xAE*dGKPa)1(7oz^Zb7RnUX?) literal 0 HcmV?d00001 diff --git a/oauth/templatetags/oauth_tags.py b/oauth/templatetags/oauth_tags.py new file mode 100644 index 00000000..9e2f3411 --- /dev/null +++ b/oauth/templatetags/oauth_tags.py @@ -0,0 +1,54 @@ +# 导入Django模板相关模块和URL反转函数 +from django import template +from django.urls import reverse + +# 导入获取OAuth应用配置的工具函数 +from oauth.oauthmanager import get_oauth_apps + +# 注册一个模板标签库,用于在模板中使用自定义标签 +register = template.Library() + + +@register.inclusion_tag('oauth/oauth_applications.html') +def load_oauth_applications(request): + """ + 自定义模板包含标签,用于加载可用的OAuth登录应用信息并传递给模板 + + 功能: + 1. 获取所有启用的OAuth应用配置 + 2. 为每个应用构建对应的登录URL(包含类型和跳转路径) + 3. 将处理后的应用列表传递给'oauth/oauth_applications.html'模板 + + 参数: + request: 当前请求对象,用于获取当前完整路径(登录后跳转使用) + + 返回: + 包含应用列表的字典,供模板渲染使用 + """ + # 获取所有可用的OAuth应用配置 + applications = get_oauth_apps() + + if applications: + # 生成OAuth登录的基础URL(通过URL名称反转得到) + baseurl = reverse('oauth:oauthlogin') + # 获取当前请求的完整路径(用于登录成功后跳转回原页面) + path = request.get_full_path() + + # 处理每个应用,生成包含图标名称和完整登录URL的元组列表 + apps = list(map(lambda x: ( + x.ICON_NAME, # 应用图标名称 + # 构建登录URL,包含应用类型和跳转路径参数 + '{baseurl}?type={type}&next_url={next}'.format( + baseurl=baseurl, + type=x.ICON_NAME, + next=path + ) + ), applications)) + else: + # 若没有可用应用,返回空列表 + apps = [] + + # 返回上下文数据,供模板使用 + return { + 'apps': apps + } \ No newline at end of file diff --git a/oauth/tests.py b/oauth/tests.py new file mode 100644 index 00000000..58883572 --- /dev/null +++ b/oauth/tests.py @@ -0,0 +1,319 @@ +import json +from unittest.mock import patch # 用于模拟外部依赖(如第三方API调用) + +from django.conf import settings +from django.contrib import auth # 用于用户认证相关操作 +from django.test import Client, RequestFactory, TestCase # Django测试工具 +from django.urls import reverse # 用于反向解析URL + +from djangoblog.utils import get_sha256 # 导入自定义加密工具函数 +from oauth.models import OAuthConfig # 导入OAuth配置模型 +from oauth.oauthmanager import BaseOauthManager # 导入OAuth基础管理器 + + +# Create your tests here. +class OAuthConfigTest(TestCase): + """测试OAuth配置模型及基础登录流程""" + + def setUp(self): + """测试前初始化:创建客户端和请求工厂""" + self.client = Client() # 用于模拟HTTP请求的客户端 + self.factory = RequestFactory() # 用于创建请求对象的工厂 + + def test_oauth_login_test(self): + """测试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) # 验证重定向到微博API + + # 测试授权回调接口是否正常重定向 + response = self.client.get('/oauth/authorize?type=weibo&code=code') + self.assertEqual(response.status_code, 302) # 验证重定向状态码 + self.assertEqual(response.url, '/') # 验证默认重定向到首页 + + +class OauthLoginTest(TestCase): + """测试各第三方平台的OAuth登录流程""" + + def setUp(self) -> None: + """测试前初始化:创建客户端、请求工厂,并初始化所有平台的OAuth配置""" + self.client = Client() + self.factory = RequestFactory() + self.apps = self.init_apps() # 初始化所有启用的OAuth应用 + + def init_apps(self): + """为所有BaseOauthManager的子类(各平台管理器)创建对应的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.save() + return applications + + def get_app_by_type(self, type): + """根据平台类型获取对应的OAuth管理器实例""" + for app in self.apps: + if app.ICON_NAME.lower() == type: + return app + + @patch("oauth.oauthmanager.WBOauthManager.do_post") + @patch("oauth.oauthmanager.WBOauthManager.do_get") + def test_weibo_login(self, mock_do_get, mock_do_post): + """测试微博登录流程:获取授权链接、Token及用户信息""" + # 获取微博OAuth管理器实例 + weibo_app = self.get_app_by_type('weibo') + assert weibo_app # 确保实例存在 + + # 验证授权链接生成(无需mock,直接调用方法) + url = weibo_app.get_authorization_url() + + # 模拟获取Token的响应 + 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", + }) + + # 测试通过code获取用户信息 + userinfo = weibo_app.get_access_token_by_code('code') + # 验证用户信息是否正确 + self.assertEqual(userinfo.token, 'access_token') + self.assertEqual(userinfo.openid, 'id') + + @patch("oauth.oauthmanager.GoogleOauthManager.do_post") + @patch("oauth.oauthmanager.GoogleOauthManager.do_get") + def test_google_login(self, mock_do_get, mock_do_post): + """测试谷歌登录流程:获取Token及用户信息""" + google_app = self.get_app_by_type('google') + assert google_app + + # 验证授权链接生成 + url = google_app.get_authorization_url() + + # 模拟获取Token的响应 + 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和用户信息 + 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') + + @patch("oauth.oauthmanager.GitHubOauthManager.do_post") + @patch("oauth.oauthmanager.GitHubOauthManager.do_get") + def test_github_login(self, mock_do_get, mock_do_post): + """测试GitHub登录流程:验证授权链接、Token及用户信息""" + github_app = self.get_app_by_type('github') + assert github_app + + # 验证授权链接包含GitHub域名和client_id参数 + url = github_app.get_authorization_url() + self.assertTrue("github.com" in url) + self.assertTrue("client_id" in url) + + # 模拟GitHub返回的Token(query string格式) + 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和用户信息 + token = github_app.get_access_token_by_code('code') + userinfo = github_app.get_oauth_userinfo() + # 验证Token和openid + self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') + self.assertEqual(userinfo.openid, '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登录流程:验证授权链接、Token及用户信息""" + facebook_app = self.get_app_by_type('facebook') + assert facebook_app + + # 验证授权链接包含Facebook域名 + url = facebook_app.get_authorization_url() + self.assertTrue("facebook.com" in url) + + # 模拟获取Token的响应 + mock_do_post.return_value = json.dumps({ + "access_token": "access_token", + }) + # 模拟用户信息响应(包含嵌套的头像结构) + mock_do_get.return_value = json.dumps({ + "name": "name", + "id": "id", + "email": "email", + "picture": { + "data": { + "url": "url" + } + } + }) + + # 测试获取Token和用户信息 + token = facebook_app.get_access_token_by_code('code') + userinfo = facebook_app.get_oauth_userinfo() + # 验证Token + self.assertEqual(userinfo.token, 'access_token') + + @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[ + # 模拟三次GET请求的响应:1.获取Token 2.获取OpenID 3.获取用户信息 + 'access_token=access_token&expires_in=3600', + 'callback({"client_id":"appid","openid":"openid"} );', + json.dumps({ + "nickname": "nickname", + "email": "email", + "figureurl": "figureurl", + "openid": "openid", + }) + ]) + def test_qq_login(self, mock_do_get): + """测试QQ登录流程(QQ需单独获取OpenID)""" + qq_app = self.get_app_by_type('qq') + assert qq_app + + # 验证授权链接包含QQ域名 + url = qq_app.get_authorization_url() + self.assertTrue("qq.com" in url) + + # 测试获取Token和用户信息 + token = qq_app.get_access_token_by_code('code') + userinfo = qq_app.get_oauth_userinfo() + # 验证Token + 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): + """测试微博登录(用户提供邮箱):验证自动注册登录流程""" + # 模拟获取Token的响应 + mock_do_post.return_value = json.dumps({ + "access_token": "access_token", + "uid": "uid" + }) + # 模拟包含邮箱的用户信息 + mock_user_info = { + "avatar_large": "avatar_large", + "screen_name": "screen_name1", + "id": "id", + "email": "email", + } + mock_do_get.return_value = json.dumps(mock_user_info) + + # 测试访问登录链接是否重定向到微博授权页 + 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) + self.assertEqual(response.url, '/') + + # 验证用户已登录,信息正确 + user = auth.get_user(self.client) + 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.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) + self.assertEqual(user.username, mock_user_info['screen_name']) + self.assertEqual(user.email, mock_user_info['email']) + + @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): + """测试微博登录(用户未提供邮箱):验证补充邮箱、绑定及确认流程""" + # 模拟获取Token的响应 + mock_do_post.return_value = json.dumps({ + "access_token": "access_token", + "uid": "uid" + }) + # 模拟不包含邮箱的用户信息 + mock_user_info = { + "avatar_large": "avatar_large", + "screen_name": "screen_name1", + "id": "id", + } + mock_do_get.return_value = json.dumps(mock_user_info) + + # 测试访问登录链接 + 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 + 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}) + response = self.client.get(path) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') + + # 验证用户信息(用户名、邮箱)及OAuth用户关联正确 + 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) \ No newline at end of file diff --git a/oauth/urls.py b/oauth/urls.py new file mode 100644 index 00000000..75a14d98 --- /dev/null +++ b/oauth/urls.py @@ -0,0 +1,40 @@ +# 导入Django的URL路径配置模块 +from django.urls import path + +# 导入当前应用(oauth)的视图模块 +from . import views + +# 定义应用命名空间,用于模板或反向解析时指定应用(如:{% url 'oauth:oauthlogin' %}) +app_name = "oauth" + +# URL路由配置列表:映射URL路径到对应的视图函数/类 +urlpatterns = [ + # 1. OAuth授权回调接口:接收第三方平台返回的授权码(code),处理后续登录逻辑 + path( + r'oauth/authorize', + views.authorize), # 对应视图函数:authorize + + # 2. 补充邮箱页面:第三方登录时用户未提供邮箱,跳转至此页面补充 + path( + r'oauth/requireemail/.html', # 路径参数:oauthid(OAuthUser的ID) + views.RequireEmailView.as_view(), # 对应基于类的视图:RequireEmailView + name='require_email'), # 路由名称:用于反向解析 + + # 3. 邮箱确认接口:验证用户补充邮箱的有效性(通过sign签名验证) + path( + r'oauth/emailconfirm//.html', # 路径参数:id(OAuthUser的ID)、sign(验证签名) + views.emailconfirm, # 对应视图函数:emailconfirm + name='email_confirm'), # 路由名称:用于反向解析 + + # 4. 绑定成功页面:邮箱补充或账号绑定完成后,展示成功提示 + path( + r'oauth/bindsuccess/.html', # 路径参数:oauthid(OAuthUser的ID) + views.bindsuccess, # 对应视图函数:bindsuccess + name='bindsuccess'), # 路由名称:用于反向解析 + + # 5. OAuth登录入口:生成第三方平台的授权链接,跳转至第三方授权页面 + path( + r'oauth/oauthlogin', + views.oauthlogin, # 对应视图函数:oauthlogin + name='oauthlogin') # 路由名称:用于反向解析 +] \ No newline at end of file diff --git a/oauth/views.py b/oauth/views.py new file mode 100644 index 00000000..835c61b8 --- /dev/null +++ b/oauth/views.py @@ -0,0 +1,313 @@ +import logging +# Create your views here. +from urllib.parse import urlparse # 用于解析URL,验证跳转地址合法性 + +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 # 用于返回403禁止访问响应 +from django.http import HttpResponseRedirect # 用于HTTP重定向 +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 # 导入oauth登录信号 +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,确保跳转地址安全(仅允许本站域名) + :param request: 请求对象 + :return: 验证后的合法跳转URL,默认返回'/' + """ + nexturl = request.GET.get('next_url', None) + # 过滤非法或默认的跳转地址 + if not nexturl or nexturl == '/login/' or nexturl == '/login': + nexturl = '/' + return nexturl + # 解析URL,验证域名是否为本站 + p = urlparse(nexturl) + if p.netloc: # 存在域名部分时验证 + site = get_current_site().domain + # 移除www.前缀后比较,确保域名一致 + if not p.netloc.replace('www.', '') == site.replace('www.', ''): + logger.info('非法url:' + nexturl) + return "/" + return nexturl + + +def oauthlogin(request): + """ + OAuth登录入口:根据平台类型生成第三方授权链接并跳转 + :param request: 请求对象,包含'type'参数(如weibo、github) + :return: 重定向到第三方平台授权页面 + """ + type = request.GET.get('type', None) + if not type: # 未指定平台类型,跳转到首页 + return HttpResponseRedirect('/') + # 获取对应平台的OAuth管理器 + manager = get_manager_by_type(type) + if not manager: # 管理器不存在,跳转到首页 + return HttpResponseRedirect('/') + # 获取合法的跳转地址(授权成功后返回的页面) + nexturl = get_redirecturl(request) + # 生成第三方平台的授权URL + authorizeurl = manager.get_authorization_url(nexturl) + # 重定向到授权页面 + return HttpResponseRedirect(authorizeurl) + + +def authorize(request): + """ + OAuth授权回调处理:接收第三方平台返回的code,获取用户信息并完成登录 + :param request: 请求对象,包含'type'(平台类型)和'code'(授权码) + :return: 重定向到目标页面或补充邮箱页面 + """ + 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: + # 通过code获取access token + 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 + # 获取授权成功后的跳转地址 + nexturl = get_redirecturl(request) + if not rsp: # 获取token失败,重新跳转授权 + return HttpResponseRedirect(manager.get_authorization_url(nexturl)) + + # 通过token获取第三方用户信息 + 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过长,不存储 + if type == 'facebook': + user.token = '' + + # 若用户提供了邮箱,直接关联或创建本地用户并登录 + if user.email: + 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' # 标记来源为oauth授权 + author.save() + # 关联本地用户并保存 + user.author = author + user.save() + # 发送oauth用户登录信号(用于后续处理,如日志、统计等) + 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}) + return HttpResponseRedirect(url) + else: + # 获取用户信息失败,跳转到目标页面 + return HttpResponseRedirect(nexturl) + + +def emailconfirm(request, id, sign): + """ + 邮箱确认处理:验证签名合法性,完成用户与邮箱的绑定并登录 + :param request: 请求对象 + :param id: OAuthUser的ID + :param sign: 验证签名(基于SECRET_KEY和id生成) + :return: 重定向到绑定成功页面 + """ + if not sign: # 签名为空,返回403 + 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(): # 数据库事务 + 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' # 标记来源为邮箱确认 + # 设置用户名为OAuth用户的昵称(或生成默认) + 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 + %(oauthuser_type)s to directly log in to this website without a password.

+ You are welcome to continue to follow this site, the address is + %(site)s + Thank you again! +
+ If the link above cannot be opened, please copy this link to your browser. + %(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}) + url = url + '?type=success' + return HttpResponseRedirect(url) + + +class RequireEmailView(FormView): + """ + 补充邮箱的类视图:显示表单收集用户邮箱,发送确认链接 + """ + form_class = RequireEmailForm # 使用的表单类 + template_name = 'oauth/require_email.html' # 渲染的模板 + + def get(self, request, *args, **kwargs): + """处理GET请求:获取OAuth用户,若已填写邮箱则跳转(注释中为跳转逻辑,实际未启用)""" + oauthid = self.kwargs['oauthid'] + oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + if oauthuser.email: + pass # 若已填写邮箱,可在此处添加跳转逻辑 + return super(RequireEmailView, self).get(request, *args, **kwargs) + + def get_initial(self): + """初始化表单数据:预设oauthid字段""" + oauthid = self.kwargs['oauthid'] + return {'email': '', 'oauthid': oauthid} + + def get_context_data(self, **kwargs): + """补充上下文数据:添加用户头像(若存在)""" + 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): + """处理表单验证通过:保存邮箱,生成确认链接并发送邮件""" + email = form.cleaned_data['email'] + oauthid = form.cleaned_data['oauthid'] + 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' + # 生成邮箱确认链接 + 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

+ + %(url)s + + Thank you again! +
+ If the link above cannot be opened, please copy this link to your browser. +
+ %(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' + return HttpResponseRedirect(url) + + +def bindsuccess(request, oauthid): + """ + 绑定成功页面:根据类型显示不同的成功信息 + :param request: 请求对象,包含'type'参数(email/success) + :param oauthid: OAuthUser的ID + :return: 渲染绑定成功模板 + """ + 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