From b87f52500cab89447b88b66c3b11e6119be27e24 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 25 Oct 2025 19:05:25 +0800 Subject: [PATCH] =?UTF-8?q?ly=5F=E7=AC=AC=E5=85=AD=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/oauth/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 146 bytes src/oauth/__pycache__/admin.cpython-310.pyc | Bin 0 -> 2263 bytes src/oauth/__pycache__/apps.cpython-310.pyc | Bin 0 -> 366 bytes src/oauth/__pycache__/forms.cpython-310.pyc | Bin 0 -> 882 bytes src/oauth/__pycache__/models.cpython-310.pyc | Bin 0 -> 2760 bytes .../__pycache__/oauthmanager.cpython-310.pyc | Bin 0 -> 13559 bytes src/oauth/__pycache__/tests.cpython-310.pyc | Bin 0 -> 7498 bytes src/oauth/__pycache__/urls.cpython-310.pyc | Bin 0 -> 655 bytes src/oauth/__pycache__/views.cpython-310.pyc | Bin 0 -> 7486 bytes src/oauth/admin.py | 89 +++ src/oauth/apps.py | 12 + src/oauth/forms.py | 30 + src/oauth/migrations/0001_initial.py | 99 ++++ ...ptions_alter_oauthuser_options_and_more.py | 97 ++++ .../0003_alter_oauthuser_nickname.py | 20 + src/oauth/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-310.pyc | Bin 0 -> 1993 bytes ...oauthuser_options_and_more.cpython-310.pyc | Bin 0 -> 1892 bytes ...3_alter_oauthuser_nickname.cpython-310.pyc | Bin 0 -> 674 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 157 bytes src/oauth/models.py | 115 ++++ src/oauth/oauthmanager.py | 538 ++++++++++++++++++ src/oauth/templatetags/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 159 bytes .../__pycache__/oauth_tags.cpython-310.pyc | Bin 0 -> 911 bytes src/oauth/templatetags/oauth_tags.py | 54 ++ src/oauth/tests.py | 323 +++++++++++ src/oauth/urls.py | 46 ++ src/oauth/views.py | 313 ++++++++++ 30 files changed, 1737 insertions(+) create mode 100644 src/oauth/__init__.py create mode 100644 src/oauth/__pycache__/__init__.cpython-310.pyc create mode 100644 src/oauth/__pycache__/admin.cpython-310.pyc create mode 100644 src/oauth/__pycache__/apps.cpython-310.pyc create mode 100644 src/oauth/__pycache__/forms.cpython-310.pyc create mode 100644 src/oauth/__pycache__/models.cpython-310.pyc create mode 100644 src/oauth/__pycache__/oauthmanager.cpython-310.pyc create mode 100644 src/oauth/__pycache__/tests.cpython-310.pyc create mode 100644 src/oauth/__pycache__/urls.cpython-310.pyc create mode 100644 src/oauth/__pycache__/views.cpython-310.pyc create mode 100644 src/oauth/admin.py create mode 100644 src/oauth/apps.py create mode 100644 src/oauth/forms.py create mode 100644 src/oauth/migrations/0001_initial.py create mode 100644 src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py create mode 100644 src/oauth/migrations/0003_alter_oauthuser_nickname.py create mode 100644 src/oauth/migrations/__init__.py create mode 100644 src/oauth/migrations/__pycache__/0001_initial.cpython-310.pyc create mode 100644 src/oauth/migrations/__pycache__/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.cpython-310.pyc create mode 100644 src/oauth/migrations/__pycache__/0003_alter_oauthuser_nickname.cpython-310.pyc create mode 100644 src/oauth/migrations/__pycache__/__init__.cpython-310.pyc create mode 100644 src/oauth/models.py create mode 100644 src/oauth/oauthmanager.py create mode 100644 src/oauth/templatetags/__init__.py create mode 100644 src/oauth/templatetags/__pycache__/__init__.cpython-310.pyc create mode 100644 src/oauth/templatetags/__pycache__/oauth_tags.cpython-310.pyc create mode 100644 src/oauth/templatetags/oauth_tags.py create mode 100644 src/oauth/tests.py create mode 100644 src/oauth/urls.py create mode 100644 src/oauth/views.py diff --git a/src/oauth/__init__.py b/src/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oauth/__pycache__/__init__.cpython-310.pyc b/src/oauth/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e99526b9a34cc532a0cec251a0cf6df42318fef7 GIT binary patch literal 146 zcmd1j<>g`kf~$)zWq|0%AOaaM0yz#qT+9L_QW%06G#UL?G8BP?5yUT37ps_@%J_n! z{H)aEl9=M6ZiW7y$Avjh-BDlaJvEnjX7|l+%?eWfQyT=4u zo73jN1tBCp01yXqN8&$_xbzhTlruLZ2vzNM*mZ*FaaC8htGnLSMZ4V~Fn;;&=h1;n z$gj9KIc!+m08pO*;)K(b1UR#lW-MS7X)CodJFpFHr%vVuE+v9|Qxo2|G^oEzxWnCh zgu8-`8%I{qgq<4qV8=V&X>qzk{Q5(XLAHIWorq^i4n$3gPlZ$h$mXDsSyY9iDobNz zJUJ{_+yGD?0um&koCJ*1z~U^hxfM9v4qWa8wIK;SzHY>WYP`-H_ejv-P2PgJ$=iGd z<`!QS&9KeaL<_JBs&(|r2RA3x=p!ZMO`{vrUY^AJd6Wt5h%8Fdhi5^y?`oEC39cl1 zJx%icuqwg{oXQF=QoX81#T~N_lPnqvZCBG$$QqtxvJR6u>ND*|Ag++I1$)~u+P4@; zBisT|T|gB%Bx8C+IpypgIb>rtwr-OndJaPKc*maafGzfROySgGFd*={9e_VNfQE40 z$Ie}PNXO(WcEEPXeR7{_8%@2>H0xf`&L9yfm#e^S(4SXtU)xQmVJVBTh^t*CP5bIS4Rv zRkxQGag?e)5Q{7R(wE#rWdvTiRA49bn2HOavUaioNlYAbsX3);td~6;`1~*C>0m#_YdmTmSEb82z;v zNMleOvmhawg&7jt87jW*@od5I|InwFW6nHa=CSA8NQl)c|H$->j)Zz zS$T^Uhm3_>o^?z@UAw44_{AegI_ws9B!_BtrWb9Fq!?V^x~R7L7gg_a)c5PM4T?#0 zyRJuN8K%)5lxD+JXoy)Zu^#5th{l+boY$y5cO-g9+etnsFjeyXC8tV^<`#f@7LZHN z(hbVs>p*zzM;=3L|25lO^6eAt;*@JqCj{eKOk#8gxDm=8-G~nI+4k(UUXl$rp#Y7| zo2r_o;`-K|gjb_$Z(J!qdut?;;ixk7R=;-{xcks{}t?y?HGP_azP3JT+d8%*mny~6=sxl|Q z9_^)21M{cDccc$@OAMEM6%jre@(QBI{d-OsbFRW4uLCHI6q@5viFS<_}JQ0G)>Bbq_A|I>zu{?jK{!;05*ZRj+G GGyV&1MN^*u literal 0 HcmV?d00001 diff --git a/src/oauth/__pycache__/forms.cpython-310.pyc b/src/oauth/__pycache__/forms.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1c692162d48ee0a822cbfb81bfe5b638b5243e9 GIT binary patch literal 882 zcmZ8fy>8S%5Z<-DKIh_th@VJ#1Q)piQAG$L6b{m)04nRGmE+mF_*mPQU0YG2t4O3& zK@^l!h$aP*D0mShnr#t^@(MICyADyrNHdQnNZfDgejfk4n%aO9^9*ejBRm-r7!s9}>@l^UJiegRX_ z=-d`=iXCG`Z=ew*r9e_o__F{gc;KIUDZ?m4?LhzxD4p~S)9qX+Wf7Q&^P(ym&Xoma`i|vRHE$ae zP6{DGs~#2--vnC7i3u__)h2wiZ@*{MEOGAobACpCKiJ$D%L6`F^-Sc=SgU-zIm@bP z{Z!V|^)l0qP~$plo87Uyz~THr4Y2zXs*cdC(tt+v&Y#1z|Cc`ehcj8Wn5(Q5oSQ!9 zWeu&ge$4q^o5`-CJnVMGW@Kq7+up=mRU@WCxs+LcQ2>alW7~7M3UGkT;EGl*q{Q9a z$DccM*Lthg*!3|~2RtIT=nB2(x$Jflnl*65hwf5}ouMT?bfg`$G`^~LDYyGzgtzD# a+?eMk+j%B>)KPtGUy`q_5T8r>gp!| literal 0 HcmV?d00001 diff --git a/src/oauth/__pycache__/models.cpython-310.pyc b/src/oauth/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c6ff615fa0c3509ab935aa435d5d1fded772135 GIT binary patch literal 2760 zcmaJ@NpBoQ6z=MM#^bR)-adFE6%q<|o_i83%lK|c7*KetMUG?g#uR5QctXjB!`S$1i z&nlMnD;ei67sd*nf*NZ%(yVCClQDAgWhTTe3W!9S( z_j&Mz#RK6TOdLB=4W5u!;Hiu}lf1fZH78E-C9BDFSctMrvrYkh>cd2)JSo#W+fXW3 zaQWSw3rRsI%WvUT>!7{3UB)sQ9JMLQ`7402f~WWiLRb;wR%8q3z{SUX5pZ_QxXsBE za_7X0D$uIj<=zR4CWbm%`DhiK*ip@RLh8XQCr&hJ?5eRRVAqXZ<27WMggrIPFm68$ zd&XFGWNW~hMfRyRL_tu#YB#wZOObWT z{Xr#5+lNck>yEAJh$~>jI{k`TIzz_O|yN7FU|G|2 zQkmi-Ob1--C4E`mot8;aE@5rcy(1HBBmMXyot`y3zxxoMS_js)b%$+X1|f~w44P@T z5OVL1r7oh)-K*=@cI8prQ~7~tm%D{(@2(#tStox(=AA2Dd{C&}oa(ka1l4lyNC$CT zlq!xX!|yhP&jvH&GB1y!58;ejuHY#igE+$(;VW{58+qLBu*l~Q*3BK%&k|l2WiqIm zQ7rYbkaz1>?iVShB5gGT9mZxU<5*YY7(?w#0lgN-H~WdCnZshSSu8z!e(~d;?5jlT zFjrhCY${WjbK(+4t6dtyWg2`45n3U0S(T}0U~DxXQIF%TmPkB7f~Y}PH*+P@PWHAq zQZ#ivwR&Uct@y_F#s~5Bch@)GQBRULSX{$g?>1O!Eu;5wOe*zsN7) z-2mG>$}fX$E`x1m`C~xs<9foNb}i5L(oW>v5@{=s{7#;Cq-fgE(sIAwiu^az@~wUg zo^UT|i&mZ=M&Ty3H=x05-@F;w@4v5}LwQb#D>O?+?4pDAex9~Pp=K%g14QJn_IiY} zDKaWBwgvV_IjFT0DO(Bns;}gx#;!VJtRV$ajzJ|YmPD4cq}ahn$aRjNX2@)in1gtT zaR^q#IK@j4rFFm#Z14`&@q`_?(6FaZ*w_9ypqnx%LyDC+MUL5`b&R$C9A^#f_N_b2 zOcqpSr+iRPBuJ1SEs5J{Q5MaLx{CBV$Xmoxh4%MS31Tu`bRcfGWgmO@8nyA-c|R+a z1KsQ|b%8X4;W3cWNQxw*Ar~|?9{_gmLEdwjeQ&{b{~FQRa2=OT>xZU(pKR4UePMyb z%Ow8ggJxg7NJG*D$3wV#?|B>4>NLbj%pKJpJK|HFyK0CERQ1O~P-k__!yb{B|EKHKn18Qf6J>=SNQ-)JHS2nUo3n7ajr~02@P)BQ ziDdv!^ytT{FnH6IQINNWC&2v3?w6@7mg%G#kKu-z7NnlufH+HR>dDasZoTA)A-H0WD_JQwK8ed$AMH$hSKL47FHSnl_o z*?%r63iT5A+?ly^=gyotbG~!V4C}+g83Vt6{_#JZz5JwM{3|ovKNp$9xB|;G4A1b) zs!`B)vtUZzDp+#23l8p9)tyfi67$JI($w`*g%rx|YI;6X$Viz}9h%P;vh%}*Vbk!< zm7Krjnpqfm&hT6>amDZw{?=u?Fp7NAOCg`~6JGkNT^K`2#v4M(kUxeR+mO$C!^jUW zTZQe&<-9G(ZSl9`xlaq`H~r;ht!DbAH4#p;k+N7t$TSh z-cl}=&-z8rFV|HmtSi(`pE$A@o_!_oRXilgr|PvcmDw^|>;8eun%^Tx_y&0l{+b2b zv+#90o{*Q1l*@h)oT{JmYtLLN`wL;EUR%5iZ)Eo1S1;jHpT7UzrynhU@{_;)<=_4E zlb?V0laD`^XVZBnP8Ex_(!5_R#+hPqzV0nnna&oA=NC)W=96Tx=+(=`;;NytXjjOM zuRs0R>FVX;f~wE?6E7fM}N2~SOgY%^z^wnwkx~B7`T4~l-lM9zs5{+!Z z6*wR%BV|g-n&I>5VOIMQNbm3qP8#A>aD^s>CNW92=Ug!hDL<`+2NRY-S_B8_AwTO) zh|rkG4I?K4gLDpQ5f`MlAT5G|^a!S#B4QS{B1O?K3!@YXh`^Xk!tQwFXesbdkjuyQ z2p31u-Cw@{vroSFqksS5Uwr!B&ouX^%QnheKc+2QPvHtUy$!Qr6pe-jifMk?ST(0t ztvFkf$t{MQ-8fNS@M{$hGw%d`^^Dqz_Y}X@nHDvSEGM;6tAxekmyr`LC(Mi)P4stl zvQ@84QEUB}ZugBEp<&En`7T1_6{jt4%9mG7HHKXOx5p|$vAfsBzz_GLUce?ZR|cGVl^RGDLG{g@`pIF;8avz$I*lzcL837g_%&A${bs%n2fw$J)uyCWqo-Xdx} zz{c3JV@3Pd^k?8J>vl>_pq;fHa>{kj@9)h{X}U}3iZhpstT@Fs?f{AMgS#`}oi(%A zVpCs_)S&qpWluzZ6c?c>E44HAN7{YZJFpMEwQrXnDX8p{R|-r0JQ-q77Aj@Tp8vIW z7w#C?g=V!f<)HN=Z1NHTxf_WPl8g0jHEhqkYA-pVwPH7%Ip>;b8oWcfThmzOHUuF~ zERoARx^tM>Dg&O#l4cps7~PjWvA zrMtf$Nwm+phxu$0B-%Ns3#`&3S6n1EE^;^6148c3L66RnyXF!&t#S?1vnH@Cxis_k z^r{IqU0(1N1v2j_jyq1)XNY7$oH$dd0=(k%v8PT<7pIRLf2OsD-3z7a;+i#+p?s7D z!k)2+KWVP1a?fyHSrLxlH+yeu#{?G1Z5+%w)5Zv^+DcMZoVWHHBM zaqiWl-KZSxKO2S%!DCZXrG?7mMZYprpDfqsr$lEwJk>^mD8F&NE;Se3JGkn@Q&d>N zRB*O_adIxG*J4+`*J}ZA{Jc0z!h?pfVq7zp?1rt<4SUY4nI3HdXbH6ydApf+ke@)_ zZRXuD5hfePvNe~YPVua(u+GJTu%}}~(YDB^WuoImX<^}qNk?30&iC23lP=p%)EYgC z7UoIY`}pnwzH39R_j8;2(8CpQQXHGQjoK~ozqw?_N|`zQrOcES-8+c)T^gWe9Q6Pa zSx*Hn?EeK4WPjjtNpcco0?ZFB08?uMOiX|kJtR{A`NU)-myIP%Ov5RrU?c##kb+X+ zbRd;M3R8iU>A9doY!mmahbhF=9mVs+MEi~>!wu(EeBUv`++}oj$u*5=EZnj((r{^X zFy$p1SU1eQYP=5aEV*wgCV(j|!rAjp#7X_%D(LK;3bu|f7?LoOZN7AQNi1xpaOWcwSx*F{o(6J^{)o-wcuY;@(_uC>= zak3PZ%uUl6&jLgx8Xr%o16zI5WX*AeJ(XduL{ zmTZ0hC%{rm#mqwGG_K> z#*KEZ8C@GUr^_5y>(>no*Y~58BhrqD)=F0$I3xCo11CiE0`DSvu#xwoe#ezimyyjU z#KxDQNWmuTd|BatikHRN*ruJ0ooh$1jp6F6^plx)P%k{-t7);JeC2h^N?AZ(2q>b)x)xLP~VzVOb-=N#{z3`POQJ>Ml zP1JovY+G?O6Z*UBTea#aMxmbKP_zN6o?*eWB+rr1EYMI-U7+3~5r)3NlxT|%8~NaG zSk6@%?Ow~7w$KkY7AgGf%k={w5O;*HL18SyOmsG5CPrBue%m9E2D45E@*-LjO9m!# znJ(DNEI38-8c9bYL>Or2{XMShbg*dW(e_CEdOQJ3@4f+Jx=m}3QpK^!$Zp5ldvqgf zFU+ja#K?Ls3~O5(3CO2l{|z_ozr?h99p6?3654cyRK?R_%P85z{d{g+3$KG|s@Q5l zM>eVHb>u)mhlZZ|Es{RQ(xm%GT)`b6#A-3=ti+963MQRJWZp)!wxS2tWAkkpc0b2w z$Z2iZX<4c;+F-0m>;dRaqwZkXbUI+vCE7+^vTfASywgS<%{y(>WjK2@>%_RrHjTMM z`rc>Ez48Ar=Cl#*V<53ZTMCx-;*9!jJMY+Rio5fg8gDOzmu z0dpIKNgG6_(^UXCjmQuZKiMA6JK{fc7xCYrqnbdaJMy#<3?5Kp# zFV38xSJt$AqWfAJezp>xU7QgVK=80q)3DJM1$p3BHMJUc|!SO%M)}8V3W_&gp>Y00+&@ z+$Dz*>J{iO_$Ds&lM8qAfd*0&2)Do`MWg_25gDM*Br?#03>XQwkA4$3(LVXi`jGGn zE3syrl9)>Ie4sTJF#x6~4Q9>feM}u7k?N-EU!!;8*%-Mf{(cWQWMM_q<8MO4Ivnki z_JKaXix1E$7pDFL5-p}yAU}{C_~$!}is{)1_nYwaX=96f7rFoi8%`cozgp3nr{BEe3E-mX=?`Ci6X1XegQuTc+0u~h z1*W_-qhnp3zVjA#>*>iXAtj!EW;z}|(%kWqAXAhO8*vZpZQ=Gk7-O7i1^^K2Qddx* zsFc*ZBpwI=MAXb$02%3m4MCAUXTJ|N1Th3V_O#mW!w&2=h&YH4h$k$|Zl2Y%Z6fNG zh_auua6c0Cp;DhSj@|9hCkDCn_dGXPHf|S9uChBDqRAoju7xIpzj+&ILT&j!xCW!i z*vPnIja#D0wr5La|7g8_t_M%rC_(Sy3~J1vhM>xYhaR1LaLTKc052L`qDNXNfjHp8 z*+JEXl~diD$I+KIcnKCus`b$?4qo2p{A>s>&vAYPUcNMlE1Luu^)^1#3PDHP#Hl#2 zusBn#lzFHEdr*EKY^e9xwLWpCL-7(jw2VY_|G*Ay4iu=vM1RqFY3Z*fB$yiU)(IFR z_6S|HUt9>pDwhEb4R-((*f?;~rSAnJU&6<32?ZVX(D4p7K!Fn56n)XhEaJdvFe~6D zgg6P7({d3c5+NvXaD67;3I_Ps#{#wZlqCrB z_r{5_IVy4DY$r~<8j_!EkrsdFy&~U)$t_Fhx<~UN?y-2-n z-Hh&CS4cJq2myt$6Xy+^kzNM-DF(|tVCFQPkMDD^S{g0?)xhEQ#5qu0gu?l#%%<#DYv zws^PeqgvSHm1A1i-4Hhpto*La+J@x>m1YVXi$%sGF)EARz; zYVBD0(cWI1>942*WP@9ymor5b%LL9{ovBBAHf{~TWt~6&#X;5|fUz4x)(A^<(1ED*YXu3PCjnTZ`7A3}smZr;tC^;?Yw$p>1Wro9GmVO(z zbumw$KUw1g9ppQYleB#x^}Ec9UR)peV$)j}_Wqolq{<;D86DHxo#ZOEI-&7U`dP}2 z?j6M2TfrVphqkCtJrl6hly;rhM#UPIdZ=NoIE=ks;~lJ(JN9cBH4C{H-P{P(AP18I zn-XGD;B=IBR(Qn7AbR=$ZQH1Kdnwj)xw&!`~ES zZ1<*C;GJ`?Kg_`Jz!tJ>F}v7EHXANBvusc~Ux_;+yr4M`{D*1r1nSaNb&FeQnxN!Z zID#@btuP?m;?~wu$R}`uP1s%-)rg4mA;BVcY>PO8qPa*(Q9mT{K;kscK;wuVyvh8i z;^_3OJ^Zby6mhl^8tXziZ=HW{O_5K3o>a#+|v-x-!T}B=W-D{1KRrvj!f|hx7ndi64R%6 zW(@~}{aR_J>i4WY3*X@f30eV#XBN%A;d1TG8Q-_gqX9kahwuzXx2?$sWAjoMC&+|2 zbEsOu0ml0L!V|v&FXV{ts1e;w2-C}i2eKv)RqIe`!4pWds;}t`=XVTOa5so_W|*B> zhU#50uNm(dSe%GiE_Zhhrw2HNv0JTQ#JPw+L3$MjQl%KL4 zVM?xd^#8_hGTu2VD~)GyMzN;UU!kan3!DzFdXa+%kq8Z_Gb+RJ&aT}OJ;`P^}+#MEEoQT`6Wy*SAO#Fg5t;(?bqt532`O3+m-5KQz; zSy2k~nbQ5NHsZ;Dd(7b7$;Gfz4HN~kkQFJO#D9|bC=#7asfp9*!3Gw5i@n4Kmtz%VqE!#eXeEudXM}^I zeF;?Fct};=f~!QH!avD#s`4^Vsmhc80%elx_i!af9E@=kAgzM zQSkZK-~M^$t)q(aAF7Og3{)=S3jYEj6rt9YGW}|GwV{xb_bf=`p)@t4N zno(T*!rN+lVe~qeaqOO$;Y2t2dRrxigMU~9$tI}$1 zdiUZienYP+z3A!w6a2D?#1i<;_oI6)?*b3_ZUuYrv`}gJD_mcx292jj(h;;qf%H4x z$bh5OKzJ8OJXKGWa*_-zHfTb*greNd^{@h{6{)BLYH_X-hMtVBZnZ1*I2T5hs2#dA zN9-hbZpgM5>up&t>2enhQ>(Epy|5KDF_5-35%Y9!k+#j{mvZ@iTxuZrgrtI{CSPo# zR#azM&Z(UvliN9UmbH=@+pgQJG(6Xh9oKCHqFtwQ-gQa+ZB(Z0y-OK9IM_N46&+V_fm;w3NF%ZvTuWpO|p z6o(!Gt{b{|<)J2)#jDh}TtLsECWpn5bcW(s`%&>)+CC#Rz_b(>nO8m~5Z=I^#TEeC zmd&*>s~Tg416Y_zR)DR*n!pn#pht# z)z+{{vdK8->pr+QXtdt@B~3)L&MBSa6K^?Kt(EijpjxSi@1f8iIJEClRuXXIqU(n3 z^=h4LICR~F3$h=tUR!h5E`4@2HtWGRo|G@6?{KiB9R;D2nV0j}l)7K7L_Q21G%>8n z!!&Bbu8yPa7q~*&#yRYi1)?TvcT_{|?D?s;snYBAKeU*GeIpA7Cu6^E6~M?^P3>tw zmb$6Q*{-HxSFS5t?1d7P>GE}qkf%tThDhm(UoqC9{)EQy40&3#yX*H{BFQ%~C?w8w zfHGUvo*BMxA3j>WT+cX$krtVyO(S zRa{{m0tN;+!}S2BVfGW{k19Y4SZ^DV*3$)?nlK(|kJYZ`CN1D80ge(`;tDuC0J~=j z2Q!$M%j%l7jL=dyM>Shy2F@$DCKvI{IA5t&y)bm6VAE^D8TjIfCePxIJeSPLrf4@n7Wssi+`E-J9Mc{iF`^yqCQT_1(S&BAu@$turZ2!m?7?!4pt|W|V=y?* z_baqZS0?A_ygmge9|pljeJt`D;Oh8}yH8?^a5cbIgv$RFTYAp|DlN>J=~_Tl zA7fO@j$tgeHu&`zQL*id1ZSlbVxK~@IMed0a7SKjke82jkQ?I%Z1oxL%QX_$ND!*z z`w%6EK_xFz!zB`zNsuMUD-iJHat&#=Cf9)9#EAdXaD$B0-43$Z&o%j8Tk`ivvmyO+(4(WYb~dM<2D+~3C*Lm-Ruc!x#5I6VYzXqCiW$Kr_a3cM&~rb<@$Q1x_Rsm z%L95?erFj@-WSWqmy_TC4QXNfQ0d*+Zu-?t@-<1Al0^ar?uQJX9fKi%gEvo{&DT`o zRwQgfUwE!}`!@0$W3jD;P6jTk1}&D*0#1f*47pyi$KEg!mdv+$rS2H!y@)_WlXUHJQNrY=*mR#5RVHfrQqZ! z^eH0d=N;caZO8kAo(pqLOu(yiQKfTb54jo&XVYL-69YSZWNhnnzl&9&JPu9?WVcyf zBc*3{4Xllm`i+dpp>Fp*68Fqc?MdToQJ`^Aw&&2S93-2yT=)qG+7!y5mgIo&9J7}1 z>Y}(;NuNb61+--PEqGcn%`K6JKmWOZ`d^;Qyfd>l4A3RTrW}38`H|rKKiyCm(L+R3=iof zVj=Ewo@r_HWqqHwhDoU+Qd15Nuygq07s;w&%aYnTJq_6D$ss+xJonr&yyxlpxESw$ ze<9u<=~K~ek6}1FK)drC+JRSaN8c{EXOyL0J%>fqe871&2-KC>TZR~yJR|^NodVWdFNP zZLwx_v*P84>UIv#&UCZ2rGXV^YrA2y2l_UPchA=Lh`E7H=W2VqoCjvRIylv?daUJTU}9dqSQ)Tnog1ni3&bc`l`$H|D# z&Y6Hvj!<_O7v5R-n<8xUVOVZpQv73JZGMA8dDzJ%d19WnGoN(l3J%Nob3xLa6onvP z?{73a^Qkr?GoI+ou`0|JOEGKWjggMgV60=JPW8cprj9B;aFNql<=@i8V9mZvr z7F8#$oa2$eRNq(hg`#>XKH}D3gYalxiWqq9|)YH#7>wogec?QC$L6<%yy|SA2Xt zbB#9Q(|$5{G|4c97zYXg1eAkZE6vK+@GFmzD3Rc#`f)08JfTtP6B3`3xIyA85|>C^ zCUKL5AVGoR$P)26d7j!RUw9Z-NCRx--E7l#ER!>byI9V%oFi#0(fbaMAJ+HQ4~}DH zxyMlp|0eK!rA5}ETxcV`O#1*(R5v9l9Onf8*#r}6-ez2ti^G;k`s<0)>UX2jN$Q%Y zT2iWHFa-j^kU<#FCR41#eXX=3AI%KgEgC}E#PVE%O_phnkaM*Bw*%`uZE<4zeDeRs adqfZ65`?W_m$kgQK)a3KygF+vEdCeh5-@`R literal 0 HcmV?d00001 diff --git a/src/oauth/__pycache__/urls.cpython-310.pyc b/src/oauth/__pycache__/urls.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8cda9c8d86d2aaff61d4751dac157d1dd79a04c6 GIT binary patch literal 655 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' % + (link, obj.author.nickname if obj.author.nickname else obj.author.email)) + + def show_user_image(self, obj): + """ + 自定义字段:显示用户头像 + 在admin列表中显示50x50像素的头像图片 + """ + img = obj.picture + return format_html( + u'' % + (img)) + + # 设置自定义字段在admin中的显示名称 + link_to_usermodel.short_description = '用户' + show_user_image.short_description = '用户头像' + + +class OAuthConfigAdmin(admin.ModelAdmin): + """ + OAuth配置模型的管理界面配置 + 用于管理第三方登录的配置信息 + """ + + # 列表页显示的字段 + list_display = ('type', 'appkey', 'appsecret', 'is_enable') + # 右侧筛选器 + list_filter = ('type',) \ No newline at end of file diff --git a/src/oauth/apps.py b/src/oauth/apps.py new file mode 100644 index 0000000..75bc3a6 --- /dev/null +++ b/src/oauth/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class OauthConfig(AppConfig): + """ + OAuth应用配置类 + 用于配置Django应用中OAuth模块的元数据和行为 + """ + + # 指定Django应用的完整Python路径 + # 这应该与应用目录名和settings.INSTALLED_APPS中的名称一致 + name = 'oauth' \ No newline at end of file diff --git a/src/oauth/forms.py b/src/oauth/forms.py new file mode 100644 index 0000000..daf69bd --- /dev/null +++ b/src/oauth/forms.py @@ -0,0 +1,30 @@ +# 导入Django的表单基础模块和组件模块 +from django.contrib.auth.forms import forms +from django.forms import widgets + + +# 定义一个用于获取用户邮箱的表单类,继承自Django的基础表单类forms.Form +class RequireEmailForm(forms.Form): + # 定义邮箱字段: + # - 类型为EmailField,自动验证邮箱格式 + # - label设置表单显示的标签为"电子邮箱" + # - required=True表示该字段为必填项 + email = forms.EmailField(label='电子邮箱', required=True) + + # 定义oauthid字段: + # - 类型为IntegerField,用于存储第三方登录的关联ID + # - widget=forms.HiddenInput设置为隐藏输入框,不在页面显式展示 + # - required=False表示该字段为非必填项 + oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) + + # 重写表单的初始化方法 + def __init__(self, *args, **kwargs): + # 调用父类的初始化方法,确保表单基础功能正常 + super(RequireEmailForm, self).__init__(*args, **kwargs) + # 自定义email字段的渲染组件: + # - 使用widgets.EmailInput作为输入组件(语义化邮箱输入框) + # - attrs设置HTML属性: + # - placeholder="email":输入框默认提示文本 + # - "class": "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/src/oauth/migrations/0001_initial.py b/src/oauth/migrations/0001_initial.py new file mode 100644 index 0000000..7b7a487 --- /dev/null +++ b/src/oauth/migrations/0001_initial.py @@ -0,0 +1,99 @@ +# Generated by Django 4.1.7 on 2023-03-07 09:53 +# 备注:此迁移文件由Django 4.1.7版本自动生成,生成时间为2023年3月7日9:53 + +from django.conf import settings +# 备注:导入Django的配置模块,用于获取项目设置(如用户模型) +from django.db import migrations, models +# 备注:导入Django的数据库迁移和模型字段模块,用于定义迁移操作和模型结构 +import django.db.models.deletion +# 备注:导入Django的外键删除行为模块,用于定义外键关联的删除策略 +import django.utils.timezone +# 备注:导入Django的时区工具,用于处理时间字段的默认值 + + +class Migration(migrations.Migration): +# 备注:定义迁移类,所有数据库迁移操作都通过此类实现 + + initial = True + # 备注:标记为初始迁移(首次创建模型时的迁移) + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + # 备注:迁移依赖配置,依赖项目中配置的用户模型(确保用户表先于当前表创建) + + operations = [ + # 备注:定义迁移操作列表,包含需要执行的数据库操作 + migrations.CreateModel( + # 备注:创建第一个模型(数据库表)的操作 + name='OAuthConfig', + # 备注:模型名称,对应数据库表名为"oauth_oauthconfig"(应用名_模型名) + fields=[ + # 备注:模型字段定义列表 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 备注:主键字段,自增BigInteger类型,自动创建,不可序列化,显示名为"ID" + ('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')), + # 备注:第三方平台类型字段,字符类型,最大长度10,可选值为微博/谷歌等,默认值为'a'(可能需要修正),显示名为"类型" + ('appkey', models.CharField(max_length=200, verbose_name='AppKey')), + # 备注:第三方应用的AppKey字段,字符类型,最大长度200,显示名为"AppKey" + ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), + # 备注:第三方应用的AppSecret字段,字符类型,最大长度200,显示名为"AppSecret" + ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')), + # 备注:授权回调地址字段,字符类型,默认值为百度地址(需替换为实际地址),最大长度200,显示名为"回调地址" + ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), + # 备注:是否启用该平台的字段,布尔类型,默认值为True(启用),显示名为"是否显示" + ('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'], + # 备注:默认排序方式,按创建时间倒序(最新的在前) + }, + ), + migrations.CreateModel( + # 备注:创建第二个模型(数据库表)的操作 + name='OAuthUser', + # 备注:模型名称,对应数据库表名为"oauth_oauthuser" + fields=[ + # 备注:模型字段定义列表 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 备注:主键字段,同OAuthConfig的id字段 + ('openid', models.CharField(max_length=50)), + # 备注:第三方平台返回的用户唯一标识,字符类型,最大长度50 + ('nickname', models.CharField(max_length=50, verbose_name='昵称')), + # 备注:第三方用户的昵称,字符类型,最大长度50,显示名为"昵称" + ('token', models.CharField(blank=True, max_length=150, null=True)), + # 备注:第三方授权令牌,字符类型,允许为空,最大长度150,可设为null + ('picture', models.CharField(blank=True, max_length=350, null=True)), + # 备注:第三方用户的头像地址,字符类型,允许为空,最大长度350,可设为null + ('type', models.CharField(max_length=50)), + # 备注:关联的第三方平台类型(如"weibo"),字符类型,最大长度50 + ('email', models.CharField(blank=True, max_length=50, null=True)), + # 备注:第三方用户的邮箱,字符类型,允许为空,最大长度50,可设为null + ('metadata', models.TextField(blank=True, null=True)), + # 备注:存储额外的第三方用户信息,文本类型,允许为空,可设为null + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 备注:创建时间字段,同OAuthConfig + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 备注:最后修改时间字段,同OAuthConfig + ('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/src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py new file mode 100644 index 0000000..98111ea --- /dev/null +++ b/src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth', '0001_initial'), + ] + + operations = [ + # 修改模型选项:设置排序方式和中文显示名称 + migrations.AlterModelOptions( + name='oauthconfig', + options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'}, + ), + migrations.AlterModelOptions( + name='oauthuser', + options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'}, + ), + + # 移除旧的时间字段(从 created_time 和 last_mod_time 改为新的字段命名) + migrations.RemoveField( + model_name='oauthconfig', + name='created_time', + ), + migrations.RemoveField( + model_name='oauthconfig', + name='last_mod_time', + ), + migrations.RemoveField( + model_name='oauthuser', + name='created_time', + ), + migrations.RemoveField( + model_name='oauthuser', + name='last_mod_time', + ), + + # 添加新的时间字段(使用新的字段命名 convention) + migrations.AddField( + model_name='oauthconfig', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='oauthconfig', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + migrations.AddField( + model_name='oauthuser', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='oauthuser', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + + # 修改字段定义和选项 + migrations.AlterField( + model_name='oauthconfig', + name='callback_url', + field=models.CharField(default='', max_length=200, verbose_name='callback url'), + ), + migrations.AlterField( + model_name='oauthconfig', + name='is_enable', + field=models.BooleanField(default=True, verbose_name='is enable'), + ), + # 修改OAuth类型选择项,定义支持的第三方登录平台 + 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'), + ), + # 修改外键关系,关联到用户模型 + 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'), + ), + migrations.AlterField( + model_name='oauthuser', + name='nickname', + field=models.CharField(max_length=50, verbose_name='nickname'), + ), + ] \ No newline at end of file diff --git a/src/oauth/migrations/0003_alter_oauthuser_nickname.py b/src/oauth/migrations/0003_alter_oauthuser_nickname.py new file mode 100644 index 0000000..7e38bbe --- /dev/null +++ b/src/oauth/migrations/0003_alter_oauthuser_nickname.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.7 on 2024-01-26 02:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), + ] + + operations = [ + # 修改OAuth用户昵称字段的显示名称 + # 将verbose_name从'nickname'改为'nick name'(添加空格) + migrations.AlterField( + model_name='oauthuser', + name='nickname', + field=models.CharField(max_length=50, verbose_name='nick name'), + ), + ] \ No newline at end of file diff --git a/src/oauth/migrations/__init__.py b/src/oauth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oauth/migrations/__pycache__/0001_initial.cpython-310.pyc b/src/oauth/migrations/__pycache__/0001_initial.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23c471da63879448cce547d00c7806ab8e9f5310 GIT binary patch literal 1993 zcmb7FOKjXk81{SZeWYn#ZOW@ac^JqhEpVw2YLabQ1ZgQrFRKX4>zVa>ah@}r?tTb~U@-|;pvDDO%fJB^c77z~uyg*7vstYIw zSkdN?dW}aK%A*1*GQ5=FXb9~>!yN&5yEAztlSi3628?ku(Gh{M2apoj-is#LihUpt z;a$z)=I-VQx+g>L{TF&)irOl=AI1YVkM>^?(SsfEA3T2OKRte!?R)?|(&1s(M>FZM z%&*6p%p)Bw>NYD(d9ZS|iDHSFa{#Eh3a1P40OS>R9oCC`7$k>oQzEc_!$FON*zn92rc%`Qu`WYX$!plDkywG2 z>E5}~?Ysw=ViRJ!n1OdUZuUNVn}JT$h-*|i=0wNi8X#)jv~Z1(RjMw)Yz}6C3TMtx z;nXS0oBia>Fe95^-0og|3w+I44dPfhq&bA^X6#1L)aF^k#hx8Cf+Fy#V){P#-v6En zv%dc#ZtFs@55$z90U^W?Z$vdvFQwC>Wx8(7v{sEcaM#BgQRE+3RU^3}T==ek!my6?ZPi@{`KcEGk18rU;y5PU-JUHZKH&h_4?%uxDyZCkg;+M4Knqg$X5#7=M{N0V-g>O=Q3|9z8plpr)o1M*XZ>*Q5 z2JL}cY?IN!k{P}m2c}C^5+Eq5XX_%BSS^*hgIxqEC4PE0Xsl6AL+ze+V|z zc@`*DJ;z#2R#>yWAS9==u5P?&2XR@3-j#oEGAcKkX+@oIOo`K+Q>qZTB^-A%pjc>q03!t zdZ|>Zz?oZy7Dz<}HBHYZRE{IZ4XK(m$2##a6+N;>wPk!ZN{RUe0;#k;Xl*K>8dX_B z;9{q_`PrrU*~(FxhstG_{ij^82u`ujpOwnt6}N5p0cm0@S_uPdrNVZdbBR6Gg1*DS z3hS+vo!`aE%*@O|Bg)5F#Iz&)&^Bj3FZ?bHWvpM-Uzl}WqlGjbaP+)-S_dsdP>q!=&GqCqSWbUx v<*jT4^WYsF&-5&*<*l5uO?L8<&Q2tJ^|`cNo`+85!sHGr3d6h#V?z7~6%b$X literal 0 HcmV?d00001 diff --git a/src/oauth/migrations/__pycache__/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.cpython-310.pyc b/src/oauth/migrations/__pycache__/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d06bd524b88cc87708991c0a58ed85e9d0bdf5a GIT binary patch literal 1892 zcmaJ?Id264MAYAnWZSIu$JJ?o9~@7-Xt0^qfi8C#N$L3`Wz@ro_Cbp?6Z!OC-_gkH4TFr zO%sf!MeQFP>h=mhnqY=j(^I*?0y{J@JLdo1hQb)F5cM?bgFk~7(Y6RBZK34yRQ6_Y zZ;F@Drb5fT3a$3$dh@*nsKVUXg88wHIh1zchIa9W*1Of$yTx}ABQIg(C0K?PScSE* zJ;|vw>!?|V4cMH?rtmw;BQ)UdTNAe6J-7q!kDaNX2lr6Ff_@vgn$=#px7J%%dkOcT zHg;$1?(57Szag;^@YnCp%0vkhC-cZrz2__h7CX0Li)Gz}gIt&&SFxCz_XCxC$8kfIgkkJ&Z zA&8NeRCT3(@Fih14%4pAD2q`-XdJW|JsF`~%Vp`L!vU?kiiryvwYj*f%Clr4)LL=(BV%Tg+iR0F)2bWQmc?ejPEP*dmc0%%F7yH8ddK}t`M_FP+lUJ!>|x>*{r zMEa^BMm(m{j`@Ww>DaktaladgT&&=RG8)ABtaQi)jl%eII+PZ)rLVULFF)z+?;h{( zHV$No59s7V{K*jeSciW7bJ6&u#fD@cc#n3{Rw6pBhOTFi@$gX}dzgxr?)%pDmD%vP%QYSYQXuH|U?^NHLG^m_9lm^{WLu{5pHr~RiW;5kQR6s& z_qb#_|2nR+xLul?+F_1~$I4XNno3IR|E4Rf7iBr;XIrf?Z-?Rpfwu0PoYl=KxQ$(g x-<$0#62!;2Lb*E8YnHdKNY+WN>ByyqviCpDoAopNHW`UJEfs6Q^zpZ5{|~^xBclKS literal 0 HcmV?d00001 diff --git a/src/oauth/migrations/__pycache__/0003_alter_oauthuser_nickname.cpython-310.pyc b/src/oauth/migrations/__pycache__/0003_alter_oauthuser_nickname.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01eb1b99c804b70d8e276443a78d3f5f95c6e0e2 GIT binary patch literal 674 zcmZWnO^eh(5bcj-CJD13f_wHP%)wz;Zz8(tq9^wvUP7SBrfM^ZNji4VEUs6*?w|1H zFYzvT>8q#x3xcfGJq|lyLlyn%Rq9n$S|1!_jLy%mKUUv4W4}DuKM{drGW&oWFyI~Q zdCq&03(j6M5Fo!WkjPv4S>_U=w`?9?Q!bkem-S6;%5|d$>n6Ph>}=(!{WB$SOlBA4 zkmVd$E`ZlOml(CN7r;q7g$QDpTnU(7N#YVnNGNsbo1Fs5#k$vMMT0aULIx-@*{IhPDg^?oROygs7#KBXSs_c%;fepDR}^?K!I z7iiA3MHM(nlly4{a*>a@_`JamPO0Zag}72KQzQtR4(UEq>cgh&e6B};BMyK=)u1(k zv|aYxPxQ)HG13Rpm#cD%U8YC;x9D%o+#@!)-|tQy7u}^Ajc&187uHn8Nm~xJe$nar zVP9IRScGYd+h0}C{vQ3erQISHg`kf~$)zWq|0%AOaaM0yz#qT+9L_QW%06G#UL?G8BP?5yUSC7ps_@%J_n! z{H)aEl9=M62CKnE3e2yv&mL ac)fzkTO2mI`6;D2sdgZfig`kf~$)zWiSHi#~=wzk|f$m5IN=5^VPi1|0dxkVVI60qF{B?4cz7W#ga(OssiX z3#z&04avW7sT^I)xy~v&(Zh_^wVG~t{^HgmiaymvHCFARE=3<)wCm-hhIYGu_0EDu z3p9rr$PEn)7I21U=p0|5Gthx1C+GwQm|+$i$1_;q1+PFLQ)`P9w2uRp#u(V&tg)wXJIaEYbM-H@8<;U)UL zySv#FhgmJF5%24slKtN9h>r7W$LfDxQl+`<`FwgejnFh#SEkI-c715#EkVnDMmy^+ zHJ4+IVu+X1L{yaB_35rFTcYYyp*rmwPeWWElTZ@tEnRO+%k_9L5h8P94H1RXCMap` zvX=bwge#pkt=nx_dEYrV9k_9oEf?nTe>AMvkG>5NCScXVI>rz!Nl2FVhsjbtvNyWP zs)?8{iL`@{%)P5a+crUYo#_K#pSH)6j%m)N&%!i56#J4&TOY}DE1pZ!vNItj%1)*$ io`2y=uNUpXi;lO-icJLXxS>(b-b77k;uy!l8vFq+M)_0# literal 0 HcmV?d00001 diff --git a/src/oauth/templatetags/oauth_tags.py b/src/oauth/templatetags/oauth_tags.py new file mode 100644 index 0000000..f5b1969 --- /dev/null +++ b/src/oauth/templatetags/oauth_tags.py @@ -0,0 +1,54 @@ +from django import template +from django.urls import reverse + +from oauth.oauthmanager import get_oauth_apps + +# 注册Django模板标签库 +register = template.Library() + + +@register.inclusion_tag('oauth/oauth_applications.html') +def load_oauth_applications(request): + """ + 自定义模板标签:加载OAuth认证应用程序列表 + + 功能: + - 获取所有可用的OAuth应用配置 + - 生成每个OAuth应用的登录URL + - 通过包含模板的方式渲染OAuth登录按钮 + + 参数: + request: HttpRequest对象,用于获取当前请求路径 + + 返回: + 包含apps列表的字典,用于渲染模板 + """ + + # 获取所有配置的OAuth应用 + applications = get_oauth_apps() + + if applications: + # 获取OAuth登录的基础URL + baseurl = reverse('oauth:oauthlogin') + # 获取当前请求的完整路径(用于登录成功后跳转回原页面) + path = request.get_full_path() + + # 为每个OAuth应用生成登录URL + # 格式:/oauth/login/?type=github&next_url=/current/path/ + apps = list(map(lambda x: ( + x.ICON_NAME, # OAuth应用类型标识(如:github、weibo等) + '{baseurl}?type={type}&next_url={next}'.format( + baseurl=baseurl, + type=x.ICON_NAME, + next=path + )), + applications + )) + else: + # 如果没有配置任何OAuth应用,返回空列表 + apps = [] + + # 返回模板上下文数据 + return { + 'apps': apps # 包含(OAuth类型, 登录URL)元组的列表 + } \ No newline at end of file diff --git a/src/oauth/tests.py b/src/oauth/tests.py new file mode 100644 index 0000000..0c3a525 --- /dev/null +++ b/src/oauth/tests.py @@ -0,0 +1,323 @@ +# 导入必要的模块:JSON处理、单元测试Mock工具、Django配置/认证/测试/URL工具、项目工具类及OAuth相关模型和管理器 +import json +from unittest.mock import patch # 用于Mock测试中替换真实函数/方法,模拟第三方接口返回 + +from django.conf import settings # 导入Django项目配置 +from django.contrib import auth # 导入Django认证模块,用于获取/验证用户 +from django.test import Client, RequestFactory, TestCase # Django测试基础类:Client模拟HTTP请求,RequestFactory构建请求对象,TestCase测试基类 +from django.urls import reverse # 用于通过URL名称反向解析URL + +from djangoblog.utils import get_sha256 # 导入项目自定义工具类,用于生成SHA256加密字符串 +from oauth.models import OAuthConfig # 导入OAuth配置模型,用于测试中创建配置数据 +from oauth.oauthmanager import BaseOauthManager # 导入OAuth管理器基类,用于获取所有第三方登录管理器子类 + + +# Create your tests here. +# 定义OAuth配置相关的测试类,继承Django测试基类TestCase +class OAuthConfigTest(TestCase): + # 测试前置方法:在每个测试方法执行前初始化测试环境 + def setUp(self): + self.client = Client() # 创建HTTP客户端对象,用于发送测试请求 + self.factory = RequestFactory() # 创建请求工厂对象,用于构建自定义请求 + + # 测试OAuth登录流程的基础功能(以微博为例) + def test_oauth_login_test(self): + # 1. 创建测试用的微博OAuth配置数据并保存到数据库 + c = OAuthConfig() + c.type = 'weibo' # 平台类型为微博 + c.appkey = 'appkey' # 模拟AppKey + c.appsecret = 'appsecret' # 模拟AppSecret + c.save() # 保存到数据库 + + # 2. 发送GET请求到微博OAuth登录地址,测试跳转是否正常 + response = self.client.get('/oauth/oauthlogin?type=weibo') + self.assertEqual(response.status_code, 302) # 断言响应状态码为302(重定向,符合OAuth授权流程) + self.assertTrue("api.weibo.com" in response.url) # 断言重定向URL包含微博API域名,确认跳转到微博授权页 + + # 3. 模拟授权成功后回调,发送带code参数的请求到授权处理地址 + response = self.client.get('/oauth/authorize?type=weibo&code=code') + self.assertEqual(response.status_code, 302) # 断言响应状态码为302(处理后重定向) + self.assertEqual(response.url, '/') # 断言重定向到首页,确认授权后跳转正常 + + +# 定义第三方登录流程的测试类,继承TestCase +class OauthLoginTest(TestCase): + # 测试前置方法:初始化客户端、请求工厂,并创建所有支持的第三方平台配置 + def setUp(self) -> None: + self.client = Client() # 初始化HTTP客户端 + self.factory = RequestFactory() # 初始化请求工厂 + self.apps = self.init_apps() # 调用自定义方法,创建所有第三方平台的测试配置 + + # 初始化所有第三方登录平台的配置(基于BaseOauthManager的子类) + def init_apps(self): + # 1. 获取BaseOauthManager的所有子类(即各第三方平台的具体管理器,如微博、谷歌等) + applications = [p() for p in BaseOauthManager.__subclasses__()] + # 2. 为每个平台创建对应的OAuthConfig数据并保存 + for application in applications: + c = OAuthConfig() + c.type = application.ICON_NAME.lower() # 平台类型与管理器的ICON_NAME一致(小写) + c.appkey = 'appkey' # 模拟AppKey + c.appsecret = 'appsecret' # 模拟AppSecret + c.save() # 保存到数据库 + return applications # 返回所有平台管理器实例,供后续测试使用 + + # 根据平台类型获取对应的管理器实例 + def get_app_by_type(self, type): + for app in self.apps: + if app.ICON_NAME.lower() == type: # 匹配平台类型(小写) + return app + return None # 未找到时返回None + + # 测试微博登录流程(使用@patch模拟管理器的do_post/do_get方法,避免真实调用第三方接口) + @patch("oauth.oauthmanager.WBOauthManager.do_post") # Mock微博管理器的POST请求方法 + @patch("oauth.oauthmanager.WBOauthManager.do_get") # Mock微博管理器的GET请求方法 + def test_weibo_login(self, mock_do_get, mock_do_post): + # 1. 获取微博管理器实例,断言实例存在(确认初始化成功) + weibo_app = self.get_app_by_type('weibo') + assert weibo_app + + # 2. 调用获取授权URL的方法(仅验证方法可执行,未断言URL内容) + url = weibo_app.get_authorization_url() + + # 3. 模拟do_post返回(第三方平台返回的access_token和uid) + mock_do_post.return_value = json.dumps({"access_token": "access_token", + "uid": "uid" + }) + # 4. 模拟do_get返回(第三方平台返回的用户信息,如头像、昵称等) + mock_do_get.return_value = json.dumps({ + "avatar_large": "avatar_large", + "screen_name": "screen_name", + "id": "id", + "email": "email", + }) + + # 5. 调用获取access_token的方法,获取用户信息对象 + userinfo = weibo_app.get_access_token_by_code('code') + # 6. 断言用户信息中的token和openid与模拟返回一致(验证流程正确性) + self.assertEqual(userinfo.token, 'access_token') + self.assertEqual(userinfo.openid, 'id') + + # 测试谷歌登录流程(逻辑与微博类似,Mock谷歌管理器的请求方法) + @patch("oauth.oauthmanager.GoogleOauthManager.do_post") + @patch("oauth.oauthmanager.GoogleOauthManager.do_get") + def test_google_login(self, mock_do_get, mock_do_post): + # 1. 获取谷歌管理器实例,断言存在 + google_app = self.get_app_by_type('google') + assert google_app + + # 2. 调用获取授权URL的方法 + url = google_app.get_authorization_url() + + # 3. 模拟do_post返回(谷歌返回的access_token和id_token) + mock_do_post.return_value = json.dumps({ + "access_token": "access_token", + "id_token": "id_token", + }) + # 4. 模拟do_get返回(谷歌返回的用户信息) + mock_do_get.return_value = json.dumps({ + "picture": "picture", + "name": "name", + "sub": "sub", # 谷歌用户唯一标识(对应openid) + "email": "email", + }) + + # 5. 调用获取token和用户信息的方法 + token = google_app.get_access_token_by_code('code') + userinfo = google_app.get_oauth_userinfo() + # 6. 断言token和openid与模拟返回一致 + self.assertEqual(userinfo.token, 'access_token') + self.assertEqual(userinfo.openid, 'sub') + + # 测试GitHub登录流程 + @patch("oauth.oauthmanager.GitHubOauthManager.do_post") + @patch("oauth.oauthmanager.GitHubOauthManager.do_get") + def test_github_login(self, mock_do_get, mock_do_post): + # 1. 获取GitHub管理器实例,断言存在 + github_app = self.get_app_by_type('github') + assert github_app + + # 2. 调用获取授权URL,断言URL包含GitHub域名和client_id(验证授权URL正确性) + url = github_app.get_authorization_url() + self.assertTrue("github.com" in url) # 确认跳转到GitHub授权页 + self.assertTrue("client_id" in url) # 确认URL包含client_id参数 + + # 3. 模拟do_post返回(GitHub返回的token字符串,非JSON格式) + mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer" + # 4. 模拟do_get返回(GitHub返回的用户信息) + mock_do_get.return_value = json.dumps({ + "avatar_url": "avatar_url", + "name": "name", + "id": "id", # GitHub用户唯一标识 + "email": "email", + }) + + # 5. 调用获取token和用户信息的方法 + token = github_app.get_access_token_by_code('code') + userinfo = github_app.get_oauth_userinfo() + # 6. 断言token与模拟返回一致 + self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') + self.assertEqual(userinfo.openid, 'id') + + # 测试Facebook登录流程 + @patch("oauth.oauthmanager.FaceBookOauthManager.do_post") + @patch("oauth.oauthmanager.FaceBookOauthManager.do_get") + def test_facebook_login(self, mock_do_get, mock_do_post): + # 1. 获取Facebook管理器实例,断言存在 + facebook_app = self.get_app_by_type('facebook') + assert facebook_app + + # 2. 调用获取授权URL,断言包含Facebook域名 + url = facebook_app.get_authorization_url() + self.assertTrue("facebook.com" in url) + + # 3. 模拟do_post返回(Facebook返回的access_token) + mock_do_post.return_value = json.dumps({ + "access_token": "access_token", + }) + # 4. 模拟do_get返回(Facebook返回的用户信息,头像嵌套在picture.data.url中) + mock_do_get.return_value = json.dumps({ + "name": "name", + "id": "id", # Facebook用户唯一标识 + "email": "email", + "picture": { + "data": { + "url": "url" + } + } + }) + + # 5. 调用获取token和用户信息的方法 + token = facebook_app.get_access_token_by_code('code') + userinfo = facebook_app.get_oauth_userinfo() + # 6. 断言token与模拟返回一致 + self.assertEqual(userinfo.token, 'access_token') + + # 测试QQ登录流程(Mock do_get方法,模拟多步返回:token、openid、用户信息) + @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[ + # 模拟三次do_get调用的返回值(QQ授权流程需多步请求) + 'access_token=access_token&expires_in=3600', # 第一步:获取token + 'callback({"client_id":"appid","openid":"openid"} );', # 第二步:获取openid(带callback包裹) + json.dumps({ # 第三步:获取用户信息 + "nickname": "nickname", + "email": "email", + "figureurl": "figureurl", + "openid": "openid", + }) + ]) + def test_qq_login(self, mock_do_get): + # 1. 获取QQ管理器实例,断言存在 + qq_app = self.get_app_by_type('qq') + assert qq_app + + # 2. 调用获取授权URL,断言包含QQ域名 + url = qq_app.get_authorization_url() + self.assertTrue("qq.com" in url) + + # 3. 调用获取token和用户信息的方法 + token = qq_app.get_access_token_by_code('code') + userinfo = qq_app.get_oauth_userinfo() + # 4. 断言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): + # 1. 模拟do_post返回(access_token和uid) + mock_do_post.return_value = json.dumps({"access_token": "access_token", + "uid": "uid" + }) + # 2. 模拟do_get返回(包含邮箱的用户信息) + mock_user_info = { + "avatar_large": "avatar_large", + "screen_name": "screen_name1", # 昵称(将作为Django用户名) + "id": "id", + "email": "email", # 包含邮箱 + } + mock_do_get.return_value = json.dumps(mock_user_info) + + # 3. 发送请求到微博登录地址,断言重定向到微博API + response = self.client.get('/oauth/oauthlogin?type=weibo') + self.assertEqual(response.status_code, 302) + self.assertTrue("api.weibo.com" in response.url) + + # 4. 模拟授权回调,发送带code的请求到授权处理地址 + response = self.client.get('/oauth/authorize?type=weibo&code=code') + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, '/') # 断言授权后跳转到首页 + + # 5. 验证用户已登录,且用户信息与模拟数据一致 + 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']) # 邮箱=微博返回的邮箱 + + # 6. 测试登出后再次登录(验证重复登录逻辑) + self.client.logout() # 登出当前用户 + response = self.client.get('/oauth/authorize?type=weibo&code=code') # 再次授权 + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, '/') + + # 7. 再次验证用户登录状态和信息 + 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): + # 1. 模拟do_post返回(access_token和uid) + mock_do_post.return_value = json.dumps({"access_token": "access_token", + "uid": "uid" + }) + # 2. 模拟do_get返回(不含邮箱的用户信息) + mock_user_info = { + "avatar_large": "avatar_large", + "screen_name": "screen_name1", + "id": "id", + } + mock_do_get.return_value = json.dumps(mock_user_info) + + # 3. 发送请求到微博登录地址,断言重定向到微博API + response = self.client.get('/oauth/oauthlogin?type=weibo') + self.assertEqual(response.status_code, 302) + self.assertTrue("api.weibo.com" in response.url) + + # 4. 模拟授权回调:因无邮箱,应跳转到邮箱填写页 + response = self.client.get('/oauth/authorize?type=weibo&code=code') + self.assertEqual(response.status_code, 302) # 重定向到邮箱填写页 + + # 5. 解析邮箱填写页URL中的oauth_user_id(第三方用户记录ID) + oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) + self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html') # 断言跳转地址正确 + + # 6. 模拟提交邮箱:发送POST请求到邮箱填写页,传入邮箱和oauth_user_id + response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id}) + self.assertEqual(response.status_code, 302) # 提交后重定向到绑定成功页 + + # 7. 生成邮箱验证的签名(使用项目工具类,基于SECRET_KEY和oauth_user_id) + sign = get_sha256(settings.SECRET_KEY + str(oauth_user_id) + settings.SECRET_KEY) + + # 8. 反向解析绑定成功页URL,断言提交邮箱后跳转地址正确 + url = reverse('oauth:bindsuccess', kwargs={'oauthid': oauth_user_id}) + self.assertEqual(response.url, f'{url}?type=email') # 跳转带type=email参数的绑定成功页 + + # 9. 模拟访问邮箱验证链接(确认邮箱绑定) + path = reverse('oauth:email_confirm', kwargs={'id': oauth_user_id, 'sign': sign}) + response = self.client.get(path) + self.assertEqual(response.status_code, 302) + # 断言验证后跳转到带type=success的绑定成功页 + self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') + + # 10. 验证用户已登录,且信息与填写的邮箱一致 + user = auth.get_user(self.client) + from oauth.models import OAuthUser # 导入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) # 关联的第三方用户ID正确 \ No newline at end of file diff --git a/src/oauth/urls.py b/src/oauth/urls.py new file mode 100644 index 0000000..d237931 --- /dev/null +++ b/src/oauth/urls.py @@ -0,0 +1,46 @@ +from django.urls import path + +from . import views + +# 定义应用命名空间,用于URL反向解析时区分不同应用的URL +app_name = "oauth" + +# OAuth认证模块的URL配置 +urlpatterns = [ + # OAuth授权回调端点 + # 第三方平台授权成功后回调此URL + path( + r'oauth/authorize', + views.authorize), + + # 邮箱补充页面 + # 当OAuth用户没有邮箱时需要补充邮箱信息 + # oauthid: OAuth用户ID + path( + r'oauth/requireemail/.html', + views.RequireEmailView.as_view(), + name='require_email'), + + # 邮箱确认端点 + # 验证用户邮箱的确认链接 + # id: 用户ID, sign: 安全签名 + path( + r'oauth/emailconfirm//.html', + views.emailconfirm, + name='email_confirm'), + + # 绑定成功页面 + # OAuth账号绑定成功后的展示页面 + # oauthid: OAuth用户ID + path( + r'oauth/bindsuccess/.html', + views.bindsuccess, + name='bindsuccess'), + + # OAuth登录入口 + # 发起第三方登录请求的端点 + path( + r'oauth/oauthlogin', + views.oauthlogin, + name='oauthlogin') +] \ No newline at end of file diff --git a/src/oauth/views.py b/src/oauth/views.py new file mode 100644 index 0000000..6dd92a9 --- /dev/null +++ b/src/oauth/views.py @@ -0,0 +1,313 @@ +import logging # 导入日志模块,用于记录系统运行日志 +# Create your views here. +from urllib.parse import urlparse # 导入URL解析工具,用于验证跳转URL的合法性 + +from django.conf import settings # 导入Django项目配置 +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 # 导入OAuth用户登录信号 +from djangoblog.utils import get_current_site # 导入获取当前站点信息的工具 +from djangoblog.utils import send_email, get_sha256 # 导入发送邮件和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: HTTP请求对象 + :return: 清洗后的合法跳转URL + """ + nexturl = request.GET.get('next_url', None) # 从请求参数中获取跳转URL + # 处理默认情况:无next_url或指向登录页时,默认跳转到首页 + if not nexturl or nexturl == '/login/' or nexturl == '/login': + nexturl = '/' + return nexturl + # 解析URL,验证域名合法性(防止跳转到外部恶意网站) + p = urlparse(nexturl) + if p.netloc: # 如果URL包含域名(非相对路径) + site = get_current_site().domain # 获取当前网站域名 + # 比较跳转URL的域名与当前网站域名(忽略www.前缀) + if not p.netloc.replace('www.', '') == site.replace('www.', ''): + logger.info('非法url:' + nexturl) # 记录非法URL日志 + return "/" # 非法URL时跳转到首页 + return nexturl # 返回合法的跳转URL + + +def oauthlogin(request): + """ + 处理第三方登录请求,生成并跳转到第三方平台的授权页面 + :param request: HTTP请求对象 + :return: 重定向到第三方授权页面的响应 + """ + type = request.GET.get('type', None) # 获取第三方平台类型(如weibo、github等) + if not type: # 无平台类型时跳转到首页 + return HttpResponseRedirect('/') + # 根据平台类型获取对应的OAuth管理器(如微博管理器、GitHub管理器) + manager = get_manager_by_type(type) + if not manager: # 管理器不存在时跳转到首页 + return HttpResponseRedirect('/') + nexturl = get_redirecturl(request) # 获取并验证跳转URL + # 通过管理器生成第三方平台的授权URL(包含回调地址、state等参数) + authorizeurl = manager.get_authorization_url(nexturl) + return HttpResponseRedirect(authorizeurl) # 重定向到第三方授权页面 + + +def authorize(request): + """ + 处理第三方平台的授权回调,验证授权码并完成用户登录/绑定流程 + :param request: HTTP请求对象 + :return: 重定向到目标页面或邮箱填写页的响应 + """ + type = request.GET.get('type', None) # 获取第三方平台类型 + if not type: # 无平台类型时跳转到首页 + return HttpResponseRedirect('/') + manager = get_manager_by_type(type) # 获取对应的OAuth管理器 + if not manager: # 管理器不存在时跳转到首页 + return HttpResponseRedirect('/') + code = request.GET.get('code', None) # 获取第三方平台返回的授权码 + try: + # 使用授权码获取访问令牌(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) # 获取验证后的跳转URL + if not rsp: # 令牌获取失败时,重新生成授权URL并跳转 + return HttpResponseRedirect(manager.get_authorization_url(nexturl)) + + # 通过令牌获取第三方用户信息(如昵称、头像、邮箱等) + 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: + # 查找是否已存在该第三方用户的关联记录 + 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: + # 尝试通过关联ID获取本地用户 + 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] + # 若为新创建的用户(result[1]为True) + 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 + url = reverse('oauth:require_email', kwargs={'oauthid': user.id}) + return HttpResponseRedirect(url) # 重定向到邮箱填写页 + else: # 未获取到用户信息时,跳转到目标页面 + return HttpResponseRedirect(nexturl) + + +def emailconfirm(request, id, sign): + """ + 处理邮箱验证请求,完成第三方用户与本地用户的绑定 + :param request: HTTP请求对象 + :param id: OAuthUser记录ID + :param sign: 验证签名(防止恶意请求) + :return: 重定向到绑定成功页或403禁止访问 + """ + if not sign: # 无签名时返回403 + return HttpResponseForbidden() + # 验证签名合法性(使用SECRET_KEY和ID生成SHA256比对) + if not get_sha256(settings.SECRET_KEY + str(id) + settings.SECRET_KEY).upper() == sign.upper(): + return HttpResponseForbidden() # 签名不匹配时返回403 + + # 获取对应的第三方用户记录(不存在则返回404) + 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' # 标记用户来源为邮箱验证 + # 设置用户名为第三方昵称(为空时生成默认值) + 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用户登录信号 + 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 + url = reverse('oauth:bindsuccess', kwargs={'oauthid': id}) + url = url + '?type=success' # 添加成功标识参数 + return HttpResponseRedirect(url) # 重定向到绑定成功页 + + +class RequireEmailView(FormView): + """处理邮箱填写的表单视图,继承自Django的FormView""" + form_class = RequireEmailForm # 指定使用的表单类 + template_name = 'oauth/require_email.html' # 指定渲染的模板 + + def get(self, request, *args, **kwargs): + """处理GET请求:获取第三方用户记录,若已填写邮箱则跳转(注释中逻辑)""" + oauthid = self.kwargs['oauthid'] # 从URL参数中获取第三方用户ID + oauthuser = get_object_or_404(OAuthUser, pk=oauthid) # 获取第三方用户记录 + if oauthuser.email: + pass # 若已填写邮箱,此处可添加跳转逻辑(当前注释) + # 调用父类的GET方法,渲染表单页面 + return super(RequireEmailView, self).get(request, *args, **kwargs) + + def get_initial(self): + """设置表单初始值:预填第三方用户ID""" + oauthid = self.kwargs['oauthid'] + return {'email': '', 'oauthid': oauthid} # 邮箱为空,ID为URL参数中的值 + + 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'] # 获取第三方用户ID + 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) + # 获取当前站点域名(开发环境使用127.0.0.1:8000) + 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(带邮件发送标识) + url = reverse('oauth:bindsuccess', kwargs={'oauthid': oauthid}) + url = url + '?type=email' + return HttpResponseRedirect(url) # 重定向到绑定成功页 + + +def bindsuccess(request, oauthid): + """ + 显示绑定成功页面(根据类型显示不同内容) + :param request: HTTP请求对象 + :param oauthid: 第三方用户ID + :return: 渲染绑定成功页面的响应 + """ + type = request.GET.get('type', None) # 获取类型参数(email或success) + 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 -- 2.34.1