From f0d07ee8c3e61d70fe68f64126e8d41ef1fdcae2 Mon Sep 17 00:00:00 2001 From: migatu Date: Fri, 21 Aug 2026 00:18:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(bezpiecze=C5=84stwo):=20sesje=20logowania?= =?UTF-8?q?=20zamiast=20HTTP=20Basic=20(LOG-34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basic nie miał wylogowania i nie dało się tego obejść: przeglądarka zapamiętuje hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć. Poprzednia próba (LOG-32) opierała się na nakłonieniu przeglądarki, żeby porzuciła zapamiętane dane — zachowaniu powszechnym, ale nigdzie nie zapisanym. Teraz to serwer decyduje, czy dana przeglądarka jest w środku, i może to cofnąć. TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego: 1. wylogowanie = skasowanie ciasteczka. Natychmiastowe, bez magazynu. 2. zmiana hasła albo skasowanie konta = odcisk poświadczenia wpisany w ciasteczko przestaje pasować. Dzieje się SAMO, bez pamiętania o tym. Bez tego odebranie komuś dostępu nie odbierałoby dostępu aż do wygaśnięcia. 3. „zamknij sesje" z ekranu kont = licznik pokolenia. Jedyny wymagający zapisu, więc jedyny opcjonalny: gdy licznika nie ma, poziomy 1 i 2 nadal działają. KONTO ADMINISTRACYJNE ODSEPAROWANE. Sprawdzane pierwsze i BEZ DOTYKANIA pliku kont, co daje dwie rzeczy naraz: konto z pliku o tym samym loginie nie przesłoni administratora, a administrator zaloguje się także wtedy, gdy plik jest uszkodzony — czyli w jedynej sytuacji, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie jego stanu w tym samym pliku dawałoby zakleszczenie: nie da się naprawić, bo nie da się wejść. Jego odpowiednikiem „wyloguj zewsząd" jest zmiana APP_PASSWORD. KLUCZ WYMAGANY, FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie odróżniłaby ważnej sesji od podrobionej, więc nie wstaje — i lepiej przy starcie niż przy pierwszym logowaniu człowieka. Losowanie klucza byłoby wygodne, ale wylogowywałoby wszystkich przy każdym restarcie poda: wygląda jak awaria i uczy ludzi ignorować ekran logowania. Ciasteczko HttpOnly (jeden wstrzyknięty skrypt inaczej wynosi sesję) i SameSite=Strict (obca strona nie zadziała w imieniu zalogowanego). Wylogowanie POST-em, nie odsyłaczem: pod adresem GET wystarczyłby obrazek na obcej stronie. Adres powrotu po zalogowaniu przechodzi przez sito — bez tego `?dokad=https://obcy` zamieniłby nasz ekran logowania w narzędzie do wyłudzania haseł. Kres bezczynności 8 h i twardy 30 dni. Znacznik aktywności odświeżany z progiem, inaczej Set-Cookie leciałby przy każdym obrazku i arkuszu stylów. Zdarzenia logowania w dzienniku (PRE-17). Nieudane próby są tam ważniejsze od udanych: pojedyncza nic nie znaczy, ale seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Test pilnuje, że hasło tam nie trafia. Sprawdzone w przeglądarce: po zalogowaniu ciasteczko jest NIEWIDOCZNE dla JavaScriptu, a po wylogowaniu wejście na chronioną stronę ląduje na ekranie logowania — czyli dokładnie to, czego Basic nie potrafił. Testy: 21 na rdzeń podpisywania (w tym podrabianie ładunku, podpisu i klucza, ciasteczko z przyszłości, śmieci na wejściu), reszta przepisana z Basic na sesje. Prezentacja 338 zielonych. Co-Authored-By: Claude Opus 5 --- docs/astrololo_wymagania.xlsx | Bin 40264 -> 40993 bytes services/presentation/app/accounts.py | 26 +++ services/presentation/app/features.py | 5 +- services/presentation/app/main.py | 116 +++++++---- services/presentation/app/security.py | 181 +++++++++++++---- services/presentation/app/session.py | 183 ++++++++++++++++++ services/presentation/app/static/styles.css | 16 +- services/presentation/app/static/wyloguj.js | 44 ----- .../presentation/app/templates/accounts.html | 4 + services/presentation/app/templates/base.html | 9 +- .../presentation/app/templates/logowanie.html | 36 ++++ .../presentation/tests/test_accounts_audit.py | 101 +++++++--- .../tests/test_kontrola_dostepu.py | 182 +++++++++++------ services/presentation/tests/test_pliki.py | 16 +- services/presentation/tests/test_security.py | 104 +++++++--- services/presentation/tests/test_sesje.py | 143 ++++++++++++++ 16 files changed, 923 insertions(+), 243 deletions(-) create mode 100644 services/presentation/app/session.py delete mode 100644 services/presentation/app/static/wyloguj.js create mode 100644 services/presentation/app/templates/logowanie.html create mode 100644 services/presentation/tests/test_sesje.py diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index a847a599fcdc1de67faabd78da709c9a6e4710ba..656a3cf773d9f80047b34666cb78ec73f608c63a 100644 GIT binary patch delta 13712 zcmZvD1yCJLvo>&WcXxMp3l72E9fG_2!QI{6U4zTPU4pv>m*5ZxaPz)@-S5`D^-tCA zJhjuk)7`Vxwc9(-co#&`00fe<8~_p%3=9kwj2Z$f326ZKZ)X6!*4$58CIKoa4Y4AI ze@-s>UQnw|MRpmA=={PVHy=&IMfck=S(W$f6eo+CA$Kk6h!ke~ zuapbvPf4p>2hXc7u{o(HNGHMDu%ivJmZAor24GD;*ndI-`AN5Vu|TX2N}kJw~G_lGjqZst&_~$-97al1r;1= z-ym8RtD_WA6R$%o{y5nx@6#|sx*!?fOdtNdg<|1O9kC1?bNf0mK{UxbW>; zoG?{_XvOHzxDYVbKfy3BBFi@uZyhnWn0o}GfY3Rew7yFYzPt&~LJq#P^&3iA?m287 zXCeoXj#rxwkyg=(lJ|ZO#{d0DiBs>lS3DqEWqdukgIo$m*flZ&^7GC+%a}MSl&a=f zMGuUgPhvCti%3hDIp-7pG@_ZKdcuM_ZXAAWBE-0f10wK@`e1u}^4;XvVWq<;9873n zyd-IWRt0ml9~%(?4q1#;hID2j7c$fg#CyPNuJ89d*Y0(uDi4~$cR^4B4*qZeYt4|b zIFXMoEDSPek4Mes8yO7DPt>n0IN~&ls-!_zR1QTPqFz0eAMoQv<8fgGB!gx$aF(pO zq{57hmkU63j|lDh==mQqyWK`-`I={syR0*_uh?2uTZ`3#Gc;CV7O?{3idqnr3~R(X z(01<=<(lOt<#%)7%CU$KkL_P3XJoi?BwamY9bhxuM^AA9563f8>t}~8Bs+b}bPN4P zQGJ?GYS*JGvej;6;#PHlfgJAKr3x6ZzA zUquXvHbL)pFuLEBvT)u&)zMcXQ%`pEY{DbK(|8qlZ9U=Ur)mOJ_bdgg?KK{`2e1Oh z`q-S#bi<>_JRZ!RydUP0$mDa=QM;4K_w?&txGl6xd z&>$W2Budziaa0d`bc;@U+p}uxqSagj(4;z~W#d{u#Zf9kpBwa@?m$+XUwzX*&(w7kEb~Ro4 zWgFTxy6tc}dG`Y<(X^Bi8*S;3JIHsGjv1=`NX1Ey72UfzOrf#VKJftTGEWi{02pDE zT+w={isw>>-lIiHpn+(RebGswU_^14iF8t(tZKzg_gf&Oj)hvqRHuU_5aw69A_j2X zxmc^cHWOee!)Kd%Duz1}7>?g)q@~zSgZ|YAeqEW1;WueWwrFe+a*U2KTR&imqpd=# zn61r9UM-?rtMJSpCOhW9Tm`O5E11#|z$2TQLl5~5A!-z3?ATr%yZK4&(5{8yd84p_ zgg`m85gIeRJ?2!QKH)kTGJ6wbat@rW+E!tEmFdsYVz0ShPaTG@`08J%a8-Mgz2cJ= zQyYKQVa#8TCz~>J=<)-xJtc#KUeC9C^sL_Yv6Uc?iPx)kv$ky_@6A8O5O0vErZd86 zjJIA?Tm4JPHNJ*crb`5&t|f_XyJ`WU?lX`Pzl63_YCHc4qGGrK(lV9&p&R(N7b|P+ z;`FU#VHdE?+;IMP< zfDy=CrDg&_5b}OAV66df3y8zb!an#USD5noKS}%FoK1`TLG*9VPJK>?G-d?@W3Ku) zXDcw`0GDP;uc8h{(3zrtHl7~Ku*5Tx>d0dU){c&_vI=1k!Q1+HniY8Pn`$mLy?4y* z+$=i0O;CSE22A9;vV&umK{o4NTgA8wMD^tQ26*4K=#Sk0l#NXF$AMP4``uf^X<&x} z9pysGh#_E2f&M3 z%%sx6z3W)R3Yz@V&(ovj??i2>Md#1@a?k=*5EUiUZwLn7L$q}D!j7pzt}$%t{QGzB zt<#&;1zFO(QUHTwK4Y)NMA~G*Xo@(KCeRYvxdD&T!}hm3S2l}vp9z1tAet|IM`D1_ zQ;W^%|dg6RP2jcr?$S$+U{m!D;2cr)Iu4g&O5*37z zapl;Z3a>S(rf;<0|AL-%vD*PCZ)1FN-m3Ng*?eOJ6l*dUF&IGiG z>-$vTtxl~AHU_wh>EeDikLZ@*A1Js$2Uy{BwCaej_(QAcy7K+JTc37`gBUm40~`|F z$i*k5@f=uk4n^Cp^M;sY1-CNrK!mHl5D!(Kvds6p!>fy6ByG*R(6{;I-X?&wSw)k& zb5it@*jrf*MPXv|u-MCzsMj0t&?w-+(TD-ziUa0KEv;IgRXMn`$6M`yuz5#pxJW)) z5Z&M7%~C0Pv*0*4Zk6$hgn#2pa(-lw?PhVn5ve~EMc4aR$@8Z<9-#Ro(6TJj{z}lZ z)wLXA)&s`37KUDLc!@~+EEnd-$;}3LEyXH71Gaep-<-B1y{yL@MuH{%S`0Wb;Pvmp8;SA?WtkQIe==K= zLA7Dz%zime?mT~@y-AhufZZtcGWs;cyB0yw7VI;A#x1J9ljl}n8TaV?mnSh*pz$BAh=OlcZ;Y5KcpXe3L)jaOTe#UzsjDjh zt~J6nn=u;&9=A+1hFoeOVpNcC;tRSbO3`CYRL-6LN>@njKJj5^L1Zq2^+Pq9fw|l! z`A8vUC4uR8c#8P-ag!p&>H&V1(#uNAC~(Pk50Ov#;}C?5kj8Ql`Pdf^GiK_9UwhRk z_dD-qK{^4$*lHe{99*GyCPP);67P&|lOkUr zWrK|i+#&^)ofp_7=POj%wi<%9g-;-U%?~Tkx*DQ5^SMQ9iHjv4-W&CPP5l(aH`ljm zg5k%;x2d!8ahW}b^(K&xjo?&d89kEanRYcG_z^}trh{EAc<|TN{sBbk43~6J?k(xT z0|Beh>Cz7+s?K$|+EuQPSK)3%(tHyjNvS{r-Uq7PD-fas+3-#ja^>duiX6fcekWuM zFt~_I+nwzq@0^qElIUJV2UDe~#{Ia|OB&|^@%yg-_S?Y-gx>&o$usI+@Ux|cL_J-j zuK;rO=4Y(6ik(lXFKOHm;KH(Fpq#!-k57&2MGmbpOt@nsOa(Ug>)|0-e{e<~DC0bt zL;dj86PPXC0OdqL8=9GMIuZCohR7hu!P6(Y8=ZQk#{?-JIKC&uU)~8Z9RT45#*_rm^FlqbKx+if+K4t_S_g z36#7+rbv&=aDzGG!EY)H>J?A3YZ!>uo9-`v9Cb-=&93PhQT#oiv00=lU~~1APNY}4 z*IcoV$Q4E4A{s4(Yqcqdo-GYyzMJ)JNNjS=5EWmM+CK~GGpJ8_=+1E}qWTcUhz$>b z;ux$joLAKo8=3@~zE$+Oq>ykb04vj9q#4#eBeXRIu3u;n1f`cvT;aXrKXh695TH-?FKG0yB}?jj^01 z=05|liqi`zM9h!(Gy<=|+wr!3d}U4vC1R{+wWPqfD*Y*0Yy2CO0Jb2X0&eBUf-2BB zxTArld|=tquk-%vgoG9Lq!zypwx-Z~2}rv_0(z#?O*ab2ZE}fT2g$g)KV}Lqnz*Yr zpIu$#@CaMA#+bfNB{Z3^0%4cDI+27ek{ucWDTWYvUq7fBHz=T|urhYAoKAD4?0P(i zx~#FKH>m})hD?JGhapbKW(W|E)r{{$BW`{XRuE|gFpFOT5`v1@vQiI&{Za;k{Qw0f zh_Fn};*qPMUF_40D={33c<0`Mwm;Ml4~`#az0)8BMC@fuYKpKiAQhSSHQrEJME<#^ zF@KS7XU9(E3Fd|*b};KbKEJ;Qt1y4x?|g73+_0Q>L`#AX@t9y$_?9}q%rhPApqljv zhXX5$bJ`A+5&TR@tvj4gnNvv$)0;mbmf~XVd)ijg znhCK5nmo?u7w8%}Nu4BFrA_F>ta#gD+TuN`3)5T21J$Prb(M5qp0vOFKE!>yYc?;! znz?VuIAKYd;1&To+s6|(>;(!pXHmfH^XiDa*tf|fbjN)!f^?J`QPTsw#X}q3*kZNp z9Pce0?=8-NoFG4i)*;`}Ct>^7HV6g1c)bMi5!;E8!%>J4!%=QG8HgiJ?V5L-0%wol z1pT`=d`ENjp&ta9Y&XWkEsD5g5x8b3;1J*0F-BkPOe*hqG&x^l1%)*nW2=7M1NOH~0So3?v%D3+Hd zg)3fIfu-3LMJ#=5q6cKR4loWAg)sMaS9n)+NOB;U&JG}f=lCM#JSCsy_{O!B(q9$K z(}D=cl(3BbmeMh@^Y<|QMto!4C=_R@YjCHrNr;;AV5_gI@k|EKY$wAtg{f8Dox1`_Fd!CDw<5u zU+BOdC0o#&8c|3sRk!0k0F@4fXC*%h*N=|%`DKgf*jeIxqQP=L&R8aDe`|EU8TW?doah>r z53*{zf%|l+^S+KF&}CTp&~gvBV+n_kSOa9_=U*~aAyTi=Rv#0=j=sZbJyl?b>n$D6 z7WClsEP4)5W(1^R>f%3sM~!^v%rb+f!}++nup6b6sOfl?-1q7n4j{7+`_>@@r4L*4 z)8wgA&MG1YDcQ~y%J+8_mf~@tV3-c`vB&8`KAA&BTBPl_H!S24`u=z+;%4u}B6#4R zqc4bs-}b6b_0VV_z6679X#rG2iBV25VgpZ4ut>uncivY&+5K9hV&bw8GJS7$Y3LZ= zxqV@K9w4V*$q~F_)sR1Y5ltA1&C%w=lJugUJOROU$cj{NV~;aQ{=jjrtSi%B#amjn z5(_vU@xb$9$6Z<%`F@XbTgpxOCOn|&wqOmy)j8Dbf!s~g6}`z=em=2|xQ|vyx8ReU z{PfxUM?r0!peJHe)N&J0Bjeiqk6nq73ug_|GZ!tLQ?i^>cmrI7im4**(ESDHEbF^_ z-51#~Ppk<(i+GD9dS}=Q710IOWWN8 zfn(n-u$O(YK>|ip5WEuBfe+jYN>zd?k729YFDh@NhnKF<)d%3L)At40sxJZKn{Xi4 zMIm)1=Msr_&#mMy0wqTlN{wR+4~f|$IdDs~^*NM+>at1@Kp@9)w9oL(Y5WJ&$cyop z{Em>z8mEY(sGx~uj5s9oyy9JB%Ls-*%LvVz+L&g&2O?Z?4f?ot6JQnMhz}UKg~AQr zhPQ?{z;Yl;Fliz7u}|GOThf$OXI#5HBt)AkgbixhXmU+@84g~{!g&}F3#RG=wR?@A zBrzZ>YMNm^=62Z@-5PQeEb8^;x%TFF1@i%N)^&a-N}d0lj5i6C0O~~mV?S1sK_yYI z4TcSjP6cZNd_)*26;QfB{Ji%7gsWy1l&uri)vdy`s7o(NHG}FrXO>imOFoFwc_z+m z=VhPOzz?`L0oW4N2ig+pI@d8zI%K&QUd}>JMsK%xnowf#vdZ%e_>@eB(0$L6UBXn~ zNrPa^tAS$1j}%wOrn?@LjC7qwokO(|pEbQ@3SMIyUSosR1a8+FSP|VXm_!1h4wfW0 z$yyoA6wJzgxQ99w%_3;f7ITKIQ734;epJCzv^W@7X;=`MGvJwyRo+aCFFy;My4Ote z<9E{z_?afw)08=Q23MH%J#%u_K%cq{`r$$LB2Q3Mxil_@22E2{Ai1Dv%q+*kFDM}A z%Fx4P$3se>0R31S@HP2#%QPK&QcMkrevsicl}^cMzL9s@G=Wg)fvgB{EzP4;v?#u#&(!@;&0nG&0&f- zU_Y%|%gxfKKVAcuncH{FapbIcYTN|)mmm&YlH6t8fzL8K&-Hc9Tie47#6RFsyD|4* zeYJE&_&Ud}f<<`&jc8bwMXLt43ZUdL2mzt6HNzT6%IV)%N{}nTB<_=zA=8BQU^9fe zMOlB>05 zEs_oWQzP(hH}{>7@e-^T<>BD~)XwPaZ+21jJ+jG169*Y2&cM#O=CJ6gzpqn4cTPii z-WSLA6?WXXll$Nla_TphTYg^H&~O?n zAgRz@Uvve*CEKf-YKhF$u}m|~Liz%ls5+Sq8Ajt3VA`g5yp{5YFgou^@x>ByjmXL6 zMv*U4f|nr7R39z&09k5v;sVtH^#lHo9X2P$m_DZ&iI}%Oa`pIuIB=`_AvIsqUSF$J zh04Wu^al~*?H+|gcJx)vyn~-li1Pi%KsTi#-GMwO3Kf2xaOl5e(+5UA|avq74KUNh@Jh2`{t(aa0yAm4u z`0@RS=VHoHdmI3cESux{<9#b;-2g17W5tkgFjM?UQk&s0`>~#nxi|>Oouo?ps;)sV zC*N7whV&#$LJJlGac2N3JO&>I8b)=VWf?W1<`<`l^NLTG5@DQVAIEPMgft77^}{pI zGJhh+>R{=4ajsqpFDBY`cki7_=7?!S5uZ#xxucXQu<&XlqUg=9;CVw=zQNtpTHs2{ z$YNM)LWh&Jw+6tFe~AOeJPQwD?9hVIeqb5HnBG1hf6jKhivr3B>I2INber6mC;hLA zTT^W|YR2`%U-bb6$(%GJU;9Ly<7|-s5dWH_zl`UOIJD2p=*PID@%jUf$;6Rw4&E%d zwQWIdslA6R+fdrFTC}tt1bze!O>Wb&6W?jww$l%xb5eYjxT67NbN}^mrG$79kvDp- zcNVk8+)olw>6+v|S!V`jAADg7QZ1$$N0^4(!}GHrk>h7;wHxC~)&O=ZuV(tf0Itri zoNtWTsHieBD>-_FZBSiHuuxsa8BAv-&roMocx+CChb5WU?fzCKyH~clN~*Llp9%d= zq6TRVC!ESzRTPI2%{_nO$m+dWY7? zyqH?KOTu@@Bz_uk#*rcKA`Z7~4pN!nEjg9$F*KS8JoDPSD3El(?u3QVPr{UOiki-B zTyEUf1$f-nB&?9*Vz;OwKQx=gryeeny1v z;e85+i*FH^HY_B}uBB(+&xx+kPw9&tEL;hCS9H4JY=a3-GOt@FR(v}oQhnk-L&k2O zB$w$wxWupO>tmYLEVMv5>)$-Mda^Xcz91_dlk1H%xj%L(s1upvY;apB2(|LJg}Fn& zy1fP#6Ab|+{i$jYZ89%@uy&fCZ)QheiC#3bGmEYr?)o7*#{+ek zLF2ASh5?Mfo-N<3Oj*1Lctxi5$K z-(aV{&CZNbHAGYRD^k3HaT-JZF#}mL4=VmPtO6_1=p!<&o1~^eH{;Qc)(NkTfi6Sv zI6ha-x%`A*%QB<1C;wI5sEzFf3CC1zK|8aAke0d8rJfNQSYW)YzGm6}Wgl*pP$`5) zn+k{^JX`r%Jjg$qE6ZTxnhz$f`U7g=AkUIj8tK5U72?3oTr#(lmPV3SBWRmdAW=|5 zfn<1h8e@z56(bUx+cM2p+EyB!@GAI`IyWzPSIFn#9wkRf*d#x?J}W4;eu2V5w5cQ5 z>1liO*LrvR=j_h=+&`!G&p@xIEnekaVA@Vq%d3_F@7?uMGDo85}_M202hQQbP7Yjxmr5_6{X{O#qx`{Da#lf_gn?B!yw5`IZB%({FkJ-oC^O`pYfMZe& z17lK}FPSlSxnLif3s=zBjG%7!&S7lA<-U?UKyO$cVq<88eABi3WC&R43)=x?L{52t zUcojA4e^Gh!7S6LxfbXnKO%-`>v8mQX$K3%4@6O`5iCMK&4bc|`$6uqS`ktLm##T- zfmrWF-sn-p8dYD+XaOZLcc*6!Bz5lHr|re}i{?@SxjNE$oM-QOw7fIG!*y8+KmwOl zgCmDukEDUhF0Y%G8HCSzR@tTgpBu|H-w!5`n>}ug+Omz$?s4Z#bkjsVDey_^Z!KJM z6sVkxf|XnKt2bBOu3gLBG>W6yoDl%j2%&vJg-Y!uiq#wdCDQCq%wLb)I8&*(m z8{^T6t}6Ll(&Q78ZS)W@yjFq=Rt=$qo^DT^QVGq58GA*IAy(A4^_Y37CcW=5Y(x0+ zI;{i_AjURs*SaA>sux&2 zfB*b=i?PsPCxNMUO{z;JbV5{ERYpWr|%*{guOc38Vok4hzWsNGM zpl|KuaqRJlVNf5i%q@n2Y!mPJI&$1fqjg#wVR*c#qdq)zp?2TC8!{BU%B38%D7KpF zOA_U;#lkh-vj9KdlZ2IZQruJN)~-S3P7%{ri{eLspsX)#(~IvE|JqYgp4Qx~d9&du zcwozDJM9kj(pA(A{PyG`s^_HBpKWsx6C9wenvTUYasER9J&3uBv4F0GPR1dW^RizScG&lqK-x2vj@@2_QnY3 zy|YB|fds?>p6L8uz|9wnYRy!t<;5G)EqH5mH%6#Yj^l6P-igVH;N)XN%ApoZZF*!< zSL(V*U5>2~z%EB~$>+Zb#)k{0mhP+Rsu8ju<|NPky`6y)e0aW)JY)TsPH2y{c{hHC z29S?TJC2?;3^vD1$tJoDW+pl?c`(tPnjFn?^|9qwCFNaB^^_>IIxpG>F79ZcOMpOJ z^&yFRz}Vz<@;tF?Dogbm@+%z8qE=UY*T4-AH z`Q4YN7$;#dbVNcG(D_yxq(Qivk*(_+2K*#p--xYCwaq~4C0nxheYqMH7w^ylJnv8v zmgQ+Fa7im}-vDeXzFmWXFEFC<9CH(LiISHE5WvnNczQYwA)aJGQ<%Kktf|S@1qLqPHE3Jo{ajbzB@wB+Ehhv6u zMUOC_uj5d$aE-%q90LvD$%?_JC2uw-#n)Adlg;sqUNdPa2!-+Oha}Krb!x$`BG^>} zh&$ZEb9xJ7D%BUa;#xfNcW;c3t?t!n^|Vmh51BsKK_g)3}t zL?*`_kXiqWC<70B-Yjag;MAdnBO<4?bzRobAX6n1u#h=P6*56RAR9@rMSr;zi*)YN z3UTgYE@{w3+jiGPF9x%5#fNnvS9eAi&(Cw6Ye)L6ko z8@vi^z$kk)jH7n`As5CxYagsA%nGKwQ?cj;-}LBT{)=~%o^H`}BCUE+qhnQjEk#qn zE0KDrZmL_j9h^{7r;Lf5#aY$#ti=Q3_aWPEp; zTl0MNzy~zlye9;%1{An4JT{4!Y|%=wtuko351d1;OOAn|;AwN5YzRXB6`HHL(Z?4> zH;M6HR_WMwQ=q^u5YAdLN%?vAi2W zW@i8}=rLu}kKFS{by=_j)!o3bOY5uh;lFD3?CPS={EgHt;M~hkSeP6DF)cDq%}q+U zMT(6p7(Q0iIV7D>GHz>Qbd5A?(VlFNWL^P%2TPWLh~KmoFPgDS*(;1( zd)7QZ$hBSWcS7}*J-2?y8sA>mgUrwSHJY)uVG4F_e{qB8pZK06Y~O6Tv)<&tq;ThY z`>R4X1Q6CWz2Dw~`+B%|n6P^XxV%1c*EsOcR0Tp*yoL&gvU%}L^>n^p&8Fpfy&D)A zdCjj<8H2Ry5KeeQS&-ZIP(nMpW;iJ) zhgye+Lu|ey9%Ctjid;aVC*wFGVx`%+zJR{xW$uM6*#g})jaH;SJ`hWXi~py6IyD|wIQw-UeLx%;T^E>%-+o3JCqp*7I&;f zeUN6+PeFK%O5Qqjths>)*BWk-&d7>-==I^{!oE5(#G)I!@$FH1aXJE~{PO-4V`lH^ zLWF3tUI_fK__7CmNfdz@fMPW$ol_!xk&|E&ChO3P`I04f#bw-{tOV%MNSl{uoe*ek zSIsD_!vx=svjXOu7or$(WPe%;j+16zTGaotC@*?CfSED{o{907v?W&=o8|rpC ztv?E(5+~7%cY|(-j+=p5!D*l~tj!efkPWXBjl-^jg9==WxrUZ@Jya3XlN@z(%je&< z8MxPD2s~-8-Seg?Tg-79$WU^ttyOXwgKvjFto@$BJC&Ba>`A#VlsC1}a20GmX66}} z&K5zynlsoZY~G8`$+o=xEvnK$`+wZIxP;xazcr?m@;dAM)~XfTL#YHjec%*hdT z?sK`^AQ&uxts@KR2emt2Oi_X^lk;k8^YjcZ#2<8;rQ@s`!Xd5T@?j_wb8m(q=AQ5WWsd zBrn|rz6nu?&Q|zM<%Tx6i&VbLH5TUPlOi@bK8e`dA#LyA+m|yRkI1Lf6(8;zqMgx< z#(Y+1_r1p1UXYosIe|ql7jm_<(%A&wy&mM`C$EXGSE)Ju&88i49p!#G09DAu=xMDa(_!0_N(rg6Zdg*>VF z*WckgAqy48L28kX`_>Ol+}~fyhJi6;po?yRY*BaFz~@j*ZS@o`LKpLWT|>iK<*x*~ z8m58j5hM@gy1TQ9pFNWp^Yp$p;NmZqd0DJ9o523SG?f6O2rX*o=-VFQsftAm_v|90 z^&nyFFdWdAKL{G%f1gB0y>1L*j?$eU_F_2)MXH>Wodx&e8_63IouW&>2I$_&JYuFn*2);4ow(3h_ou>$_q);82W7b3 z$>9*hh+?IY7#@GK%N5TRu*(f;UL57K`lKc}N&IFUA6Wu3!BAzE@_wRucyyZ9C-2<@ zwvKjDf|S$pkyaC@h;WSiH3tagn#d$=lMQ@v@he~3!BQ&7&%dCxW!dvWh_Rbx|3O5q z;D3@A9d|pc=?~(sVln&4ngg+sVJ5i^zZ2w=k*F6geW9%9{lkuGPZ-?Aqhi}ek=Vuem+nu)tQ;>4}_&(F)F#i7I7h1(!uJ%_t|XwTk( z?oC%D+rIKzI=*;LY1`Kx`m^=2yQx-#_;Kn#ghm%`aQRks?3%0&JrNL#)nzEQ3Y$l9 z3J12exuRPNC_{7)r#(O-4+H>7D2Avle_%DWw>ADyturyc;PaPy6zTamE@Od>?V(Dk z&862dxr``|MGy5W&jE3aU<~Eo>r|8wW!K4wcD*nl;%U^i3b*evP0-w?i5{yH?+XdVRLjzfZ3cea0gZ1FkN2BLdlFbHnb zjO&*{mVnMHN>1>46n7^N(je#@*C=!oii*HdGz?ng{5YpH)~iq?Zrb_10I8j^BQ#3T z4w4>;4LHW3+bLxeHY5=;?>%Hm3DcwncTB0*ToMd$+FET#f;a19K#12RCamPLXXvDh zDA}LF%YkJXE#>gdSCAx(Ql~`fahH4-@8?)TZqX8Y4Sp|Z9&|m;$exsy6{rUKS0Ugh=SG~bT z!~+OO@0@{K#Md{^Rp=3`(&lQxcvw9m&~ir7nb{yLHWvS$q`?YAwwG+HJ*m;Oq}0Q{ z6+?Yl_b;?Qx66ov9&SKf5Bm5Y&9M|!4$r0)hb^};;)^YP&NSkgs^CDhfPE^!>A}iM zS5xGLi@_Y^F6=v(+Dh5*X<$iFd3HI!R$FU0d_)xoZC_Uk$VBOzPjln0-4Xg*JnfF? zx(@B4WGsddLPvt(E`&bX9@F3r_fFdK3cs7Uy=}&P7d|(*K^gE>o*TJ+!$A5oN0|iQ z+u3Ic|3P5GnrAq^=sTV%%sNMGpNbd~55*QrTzwWLyhaq)$#qGf=ZGi9RDmj7F0<=ek!{rwU4@PSO2;&e3wav8aISW!G^=)Ke zI0`7fgzW979~HPx_!|sCOet%kOwy|bK7U8&>ne#9kVv3rpX0hOp<_XUa43t!n8g&oSkwG=In zjsq$vHbHs8hr^#Qe?sG)jR=WJ6ldo~eOda0Vc*PwE@0tLKTZ7*n`{hph|^&hqA!pd zw?nk3T_v*V2L^3JLJ@I>{F!Y)b??@o;X!cJHR2nO$*b@@7V(5>v`zrjbsO| z7Jx}YKc>;3X9pRRd|1F+a((8j*pG0iTt3#u&gz&M^=P;8)Gpz&W*|=p>Fd~YDUQ(UDGOEQpHZL=0q8xs^G$3xj2!c->@_$1~hY?Ttl}G zmcv=Y_%H6)a&gA)=twT9@hwXfM%tin?SYVa3%&#kS@cytUfwi&7~gY_^V_&xY|eY5 z%KQ}lGliEfz)}ppEusVVDwUt5Ho4t-;#2|kExMheE`Q6TZF2oDv6fY@s?ex02kr#g zK0Y~!uX{0b*nZT8H(t~wkfkT+K7F0HxZ$W%>l*LW8VIcK&ui*sRq$s=X?uIW4@gk< z-sH-DydrV(iHpQjPqE8E{C7s*90G`dfE17~{@)1vuY06`{tj_Q|7*;B4w~LS#$-U` zs}uvkzz*=iz>xmilD~Z*ICTmbn3o-ktDCpoH`o84MeWiT(F1=)u_3^~5dN3yeR_&f zF5dsMCYJzWDEpQm072px@VPIg8VQ$@#5-_@u{}zVcJ1iy%bCjTN z<=^b+VaAq6A|9F3wbF)cRLWG;swll5<-e#25O#cGy?eo_mNNYqOV`82p{ z7Naa*L0I^7bILM_S}h$+4v^c%XJrB9^KDLq3Fr<|bED1O`>$Ga@i`{-tjuhWvJ*l^I8EgFe^A-p@|xA%ZHOcz%O;*7N7rz^&emG zMM5G1w((|!Vj3SLRtLx@gRv8D5A@ptDCERvSs4TB(}_yn7>3@HwIkMU%dv~yhxLkL z)T%sxgQyIdPnKM=yJCUNHz3;krh61tGQ9A`Kb2~_=w-9sMU)*T2HAe;`SK|+sWB@* zNgdBL3DI?z4S>WP8nA^R8^ne6ZF0f*^&g3~urKZAQ zzp|lYm&b?PRsr&ZE+vq^B5UwufCHRah-HD{G0|+f;ITG=h8Yt}wv2EF2~%Jb+)A_* zJ|}(Q#i}^X!&r!}?qD~pz{ly4KK4j3zMP3)r2c^-HU(Q`C!8yTtNCVsxHE}b4@n4K zZ}>j4A9wN$bt zs}HIzNw!TZd3J-rr-k=_IgPtwSNm#@}55bTF(_qlfUe0(?w|K;Q%e{-%~ zP^Gz*)asIy$VC3AM{Z@eTVvqOfO~UpY{Qk9ipd&B002qsUWDRfQ1Bx1vPJ+~3^r=% z@+xCbq2C(eKdcmwM!%S7_Ic)6`tc?c=bB0E#mvO$t#fkTlw>FVNu4-e(G*g42_a|S zX+%>dF-BP&-T4?O4IC;{!r&y|HeKHnyp0Lh>zBR?cJmZ4t+OKdlb`Y|U?DT`87huU zm@>Hk@C=#@EvPf8*$?38J_f-_dnLv_xcj4h2n@Z$%?Ks3$vv2QVHTRc8g;MO;+=N& z3sEtP*GqqF2Ys>dt{tukFj`Op+&K;jM4sQ!x=esL+s~ z;!)QA{P>wCkG&|zMPTLjQ8YWX+I zH`Hh~IO3HG?gv8fRT(fF>_4EalJ7{bvnGEd`$&7N;?>Egk4&O;14LqxKNsymgyF|c z?Wsml^_oo;y0(G@7)k^>=G zX`y132ZQ!F*+DB8svVy~?hJp73uhB0#G^uw4fgu?znOm9E@ZefizwQgX^}q8|1h>C z)(5*3np!ByB!}hj99o_)0@_a$;@n2}xg+9bD+BF{c|wweg^Vruf>yF z2mApBMy}v*Jv#{hU3&eRDh*>Kp|q=xyIVPtEMYv$do*#aZ8PGuoJh=2o;o286(Mqt zvctu1tJY2rwqDN`V9>`-iwS{rm?$+k-49;%eJ$A^jmAYfh5zVQZuz*r`#9Wz7R>PH zd0jJu)e|wew-uLKg?Co+LD&Od( zb6DD~hLVSK`@qT{v_MXXE>$FSp7wt{cruE)H`vM5b0S0t4CKVxAD`E^{YHsWh0&(D zlEsLuFn^Uur~pu2MqFRERn9rs6!L_^h;zt><=%ETG~GHsvXeG^jzRA`@Q*s>jrt%} zb1T{h86%8B3ft;_!vAfG*4LQ7_!_YF7f4B6Ucg5oeZY@H2lYYj87b<&KiA}YKX;!r zifpS(T^=IgJ8Tm>YAH1R5gpTFR%$ zr?==gk5f>S&jT7R%o_Gr7FrD~Fn3thyZW?mE=_MsRH5|H)mfq2$%OUm#vvJn@#c%+gXlzXd=?s|U#2AZ*4}>hSSD^~z-$ZerY5avPPF zhU#v|@|A*sA;qOEZT4g$sn6|+Z7eLpv7vGT60$8puM`G z>fjSibi87k6;ffhco`;wp`Q~6=P61_t^2;5Y+y=uG}t#VGdm@cn8RK8`RfB%mE9Be z3_Qrrz|wkxa}Ae6H@)4>ik8f_6>-}Ug*0I1Oj>?gtMDiLVoEj+&%|aDkp@<&eFO~T zU?qs-1HHnMZcniUP~P`P@_9E8tiN1?jXBtH69zMX{SqTsCoP8pzxIibExLj8IcTXB zWZj6Q;nW6w>u&~8;m7EyZd>A7vA@2z940wrT%k4M2k&z5z?y=|IQc;esdv7g?W>TO zn|OG`zHU*SOd4Sc^1PuzvP?DDN$%d?wPr3N?Fm0QrYc?Z?R@dleX6q;)jo03(PQZf z&0!?8Ue|0yyM7?@1zPL=yo%_2+J1xM0vZnMBD(blN6a{N-Q=k&h$Zh>(N`NSJRCor za0YmXy*R|5iJCn~RUd-Tpn~2*NH{vbGk--RXEAzXQD7mfnCG3?4RiE4*bPg}7Zw43 z=qWADs^153FQe>?y!EF>5}{qdBE_sr1)#0xYT`5(b)B8^|8#ugDw3`_OgIbV#>6Vm z-Gs|5J#gGrOop;a=Mg?a?TBQko!@_tgMUUf@i2A zFHgIhC!)Z9e}G4w+pa4&$%K8PczkcTY|=W9xWfFviS=cb#~3z5>{qSRtZ+Li-8oBb)4*1{b_JYb=X9+`&xT zN}%eLhj8Y_H;2q#A>?~geqJBYq60%r({BqCLP#pSbzox1)P4dg0{<&7;qtlT4L3CB zrVhctY&VeE4(eY<>d z=nQc~mjZzTjj-nBR%t}w`9R=6G3OYrD`+%KzdDMa<^a@*s;V%FL&fQK!6beku!8P* zNSFGl2a`}ctu7(bQ|*dItNIx#8(>p31jF4y6k(`idR~gma9OvsQT}>=jYS;druAJb zL~~O3$SL|Z{K7vfWjB@5x;)FwlYQF2P^4(WT_-!~i|^Cp-ghk0(}J|uEAubJ=pZ;A zqqBgx^dDp=T|ZLAtrI&@qVM9nUHb~X06@WF9I`6IaL0_ksYVo)z3Mb5e+|r|%Qa%~ zAN)n(P3mW%0o#G(+!5@jE^`wb#rL~SR2y{FR2yB^#PE{QC)isa7ry|HmMVGCI0H>a zP`z(>zK?vMH={l1JH%)^RB%GiHYhP(_&#QHT1v%(nLT^5-N3+U)(!QP22CQ(2P0cl zh?tUm8_5W)|Mlj}Sa zfj5ar-AR6KH}Fgyj|k**xHFQv2&nMHs7Ta%4<_h@CTf~Z7YvevONX!OWu{{;a$hpX zT;e^JfJnBjV~2zX!{tDw@ouaP#H;)Cki%8!SBW>2CYJC%yd;}+%K6QQgr&vUzD|>>v^*!k39&~1`+Zo|aWF6rNcNn>I zaTzGQNFhv$5vIQqycvuXyvh5`uWuVWTWOvQbFx`Du=rfVZz2sm5|A?xI@E&k?bS5( zex9zWRqHiuWM?_q2esARVsb@K{F)A>P96JeusQv$(hQ-3LKX7~UX4Q})c&QT7kP zIHwu9*m>3{AA=p}M{`^jgcZI{ANVot%Y^Z${MUm+`T}eHb)~idOGSy~fQswMBY9_c z6tv)TcZ2dzp8)UDG`lBop;wGL1FQ|#O6+805TOS7@T)l#RzXByqf^AhLc;b`o5 z^BOu{LdER#5>#-Y_+60;Z}X0hpQ49<%rM1d6{hI{&eroT)cvYVcq&(6X_+eJ1*d7z ze_xQ`Ry=pqkxWyah2&O3Tl|(Y@*30W>@SYIzbSkhmY8cHTUa|RT;s3n=QZe2^`aG& zQR-IZF)N%=-YLvzA^>NLIL98BBnXlur~E;bQW5D!_f3U@{Pob*i4l{g(4JxmbMU_ykD5`(dkkusbHn8p(1+CMW2yp0U?8C@=ST@x&gQ4h z5#6b9ZgCT9mK3rUXiJ)>J}xmRmkMPE0#*>i8(@fG!9JD8ispbKDN)Yt9lb0%&o{)^ zegBla?&@Js0YvHp;haQ6l(YxBMLq*aB6^(;JgE>>K4kuPDfB%@>f`+UDZfzF2`z9@ z1uS3{@4C6sF>a+OSs&$Rj31k-a6&Y#r4zQI-W6IcPhl0;65`pkL^Z^#U7eb%w`qYn zte&=O<`_dJ)8_2Q(JW97Wr2rtj*yCo zL}_s%8OL|a8yLt%{QSu$=kt8BcQ4cn7LC#E(XPfdxYEXp01%7iam zeO3`5kLz;Q`ZA(}XtwSF&(aWbKTlr^j6*{V(ajoTGc)DzJCKvZZm4;LQhDr4S&KXU zwl&#l0z}uLsY>EgAlM*?>#Guf46a)bagK4ZOW0wR{fjD7TbbNAhZ}daMr0wIFBxqNBg(o# zoIjSfJ&(S z$bx6o!TiA-QGGnu_=_0=E!e>4FyN(A(^$C#R$xPqh9{gm%{-|3tj}speVhqi4S7O{vmPa*OPqHYOlpP!bU2k6Cgb zg=DUn53sW$I69MQP_!?>Pv(~D6^WA$GK)YK+bP0ouHJ00>^MRn4aX?=Xo9Qsu<2{~ z>xJcfV66}y1GDGEdT4?Mw2OTW=J-1kR?(sA8piL_e#Ph8!Ajy-5*V8MAYLP zQo>R{F$ALfej%F;V4E*c>3D$ZJo7z9Wy7go2_n>q9Kt6nV|czN%P-*cgbK20PrN!BJolAmiLSniW2W&|i2d&vD-y@mQW9Kqxv<5bU(sf<`%&ZlE|t=3gvupvwS zTKV+p!DqCH5$Soxxit{2&qizGu&WndmA4$T_=#Eo?TMi+?CuT?1O;&uy-P+TE7ZuV zj}M?ghipG9o3>lA+LVHp><6@JZji9z93Kh*5CysqYP@mT- zq%*;ZXfrOvyyZaf^qMI^vGt}UFS=p2pWW7^U(c(`z~9C#9|Sm=p7VL4jnjfk;ov<< z>@eA-)VyoCV)~RqA8q`t_hnafgpi5VY5f6V_&0|2PDSRg?Aj$0hH&NphHx$H!sx4{ zfyju8pu>O*Y!I~q`T+**%TVGM@jNGj@iUnTjZejXI5|l6IC8p0*x9r{tZl91!KfA)&2(c z`i?vT{`5_{o{6Bju=liVl}Pj+F2@oma-my-id$F$9(0&+uPMA{5;vKfNGh$zE1M`1 z*%%XxT!5;ImLA|hX`+QEUGy&DE}=_+$gtQ`2Owy@fVpPx-McNIb0=K`gPE@p8pDdi zm%k8y7aQoNcN1#=UT^iDAm0_i5nbGkJZBm2cuflOMKb#=H4o|PV9$XxHKD2T2%r9! zwaFt-F~|>f4(=?3EOL5?Q!#q&RqUWqW;kmfRdYNk6)MwT`eVb@vjV5m2*pUCma1?U zAAXO;8p_DR&u*Fkv7Rh(CQVv(2BfV{qJrI2JsE&V(@3kx*c$+f)Bbuh`Py)|5EyG$ z&nrPkTgE?7@1BH@8U&+TnREnh^L|51#1nR1pbZ5QG+!5<0x`Q96FlfonGe{#(v;g& z6@fn$qJsqBn+BfZ@@YV6jKd= zfFurwqw=|g<7z%5kIqkSMnN9m?~c3LARFL~si$d3O2a1R8iI}f;5iKZmMS7`SSdj! zU0rqtK$j;`4gPaE$Ps4BG%1QW$lX5;S5u-^oQIvg>YS}p*ZA9SNeX1=&&yfhvwfq{ zGq}axhPy57ejpQDj~>ZFx!0Hr4n{pFGD&i(xKvHoY*@O&p<4S<9F`bA{u~vN2aD?U zT7Fb%;yEbv0>1*-Lt^&+G>w0O+iU0b?{Y=*r0MM9`Nzv-v~;lt(rk5~9%iw!29RZ2 z#_wUe;MZ=f@*{Te?d~@7UJPm+ALJ5WQwgx<`OPn>mLXTi$&TJ!cq@ec$JPMUc2AOX zApfU~?Cc|kvN`D}9H&yFTk z1-!?nX_;EbFU;{Ctjm~(h6Et>`?K7)-s*3Qw&fkk9TBHjPc9LHM7qVRf{2svn^*~1 z-suIkbt%H+bT4SN{lJqN@id^-z5Rve@x9rLeKKdA0tt&yUClKzQgG%Ng2=(A2g|`% zPqSx`gt-ayfP}Hi8SEsEg(3Ug^k(RLao=1j}m(U{_T2~au$utNQ?N#9kY4hpzj!VzK^NfA{C6^7RPTAUQn~VJQyY6rrOdw8Mx|` zAH))anu2dJwr4ZOFBbzERzohSm_D#^aeX*>xH#Nge|X-BSv9i3AcX7gzF*2_^QmRs ze4A?V0w5*~XMIGivC`&LLN*cx#vtm$Bc~#DlE<9A%{ww0a?PF{wJ-=-dJ?py8(L}q z&eVN(vwdN~TB`}v*RERmI49wm?_fSZD^;ehuVbC3QjEp0%wYhUwXXd`Lnut;?pO2o zJ3M2HjuV?@uf-9CoT1$xV|A9X1jFJ>J)FfCi>hA-d0F1b%?%PRcQTk=#04ij9kN@O z=q@NmZ4K`uA=D9?L%+f!(fC^m4{+}b`oa=KSUIGe)D{6x7$Jk5Q*X~!4!=raHK$b# zLat^~LY4Uk4YUP7!xG>C)$q_|%fO64Nm$x*qUIZ}O4YDxgT2+S23Mv0)BD`DSmN+9 zU1Ee^_h?R%&XZBu^>$Za=S#o1(r|bN7p7Sbw%B}bF|n@1Y#4g23dk0anSOs&i5hJg zC>uXnU>7#6*#0@Pq(Z^t6Y>L!mQtv{=M!Fz^VlV~caUZ+2)Z3p%EnY~K3Y;=z`Kva zPWR3b`@^~qqT4LhxD4~(Q>!;U+Epz?onR584t_{|?ndHyOt{+tA{J<4>ZaA~RqsjH zJ;kcy)U(9+um%Yid#nEQfVWq2kU=tdKq_KJh#$?2Y`xL`lGE<@bbhk-_RM#8X8Z=f zwdA%f?w<;(t)e+(G(I_6tJ59z?_T$RBWk_)#*I8Cy~Nl~i6QAWX7QmT0xuamFMX&B zf>i-?B&7{n4)G#*PSz3j^fyFr?qgwGy9xYVy+ ze0~>K-&x}+))VuF(37(Zmx8meg^LjjE`Cck0uShz@#x_13Ci-e$@LKUMGQ6sJn2)g zdb%u-)v5bw3ts@@sZ2gO$8Jh+~ZWr7m1-mr(8qVRi}L0 z#Jd-J?W*M{dZEGPKG2+9P~n8RaJa1|;@WqcuTA}#L*%^4UH&MNQ?4bB#xuC>8# zO#Qtd(9>A39}$C=`H<8AnFG@DPe8?@Xl2l))1)ngVA7VBS#V6+XC4?yM>JQ8420%g za_6_>x~@(w%@B(W`x*v?_h1&J_qM9JtA*X(vGt?dvqugZQF?9M_Rdgcj$Ob=&5c&d zkgav3pd6VfI>)5-GHv|SuO2Nk%OSKJ)P60B`8G;KTx&2RO{YKrpq7b5W6_%1wntYW+A0XN z22Z}OtefWE7TJeotdV$x5FcgoiCU@FJmd*rDA}TzmC$=)?C6wXV%#uO`LgO^Qe?3B z_!C82n3oB#+SHN*=b1CI=WwubjRq%$pkoVAgwb$AD%Vgs?24!DCjI};JCHvubIXLZz*L!l ziP9(B)>l_3aFP z5`I`Y7fvL9Yh|v#?7IYGO+!r1yq?I5fi8B$r0W$24f40VDj z^p5G!HMAnWVkSxjGmzC3(eaEK`CGwAqnC}gaiylcws9vM%|XF!sCJUh!B^XKhtaFm zhQtceFGHNV4atyTLI=xPjf%qZj6>}%3`41gpLFF(nyGI!#v#`@i6+npRIU9=x&zLv zN4?CI+|Qr)ENFTGPH57 zS9n$`Z2l>{#R|F(R1(*W85Gls zW=ed<5a`B5)~BeeTOLWDn2^+Ysy!-zR75< z;#9TVT6mN~&OTfD+rG{D8_t(y>H%-f z!F|seoW!d?REEZd>PAJX-fY3XgrI}s609GZolzrU7^@|27+d~Rfv8YRR7?6mytO^g zEL$g=$O;69AHc{jd#bAIKbHN#Ge6eIBr`y(oKi3>NGsVye&JD|u~9Zfr|erY+1IQz zIfR4=GyLREszTk^F>ymap!3dBkEL17qXqSz6wPnijtFboqRGQ&VV^eu!hm*bi7J`e ztWtWdd|@7;EgiCEUf4B?&{xi{;>HyhqW11ySqZek=3~yiDqHZ;&^?=UT%xrFeqYC) zyoV1=&3>;eOE1mC==pNza-mAJBp=B>5MM;PjXUM?D^ZzHk6_W58t|wL11Y6~QS9D= zS7DshbH`vxTjDdZ`s8J6xjXZXOu#}X;L}E)4560Oxdd~frP1M#0qa2VnwOsg&(E2P zF#=E$n84%chp;b7>?EU+kO`ZU0na3_#{|0p#C+M^3wDUiTYS+`_jDo9t25+frq$%$ zW0%a5ZMgH?_vD*$>_{Pc$hZo5+{A#ag?Io7@=`BiNHAG19Q;uB`=c*RZsI=!8Oy;$ z)<|G*bXCQUjv8ui$1IEyy0)VEP0J0A7dlAzsT?ETh}>}9#CW~!Lv(X`33?iw`RCQV zce1LqzdYG_<@Vh5kspxU!69Cu)AHf5#13IJ(X9AJ`N%0;>DJIyk^E=bxjRg^%+fKy`mf${ymjOw0AvSI_-n<+j(#DmLMRW87+J**w6#+r(sP^_#y| zac{7QAXit}(d{S0_6{c|lDbEE$~MKIv&J9_r;3{Bq3Fx9%gfEQb~BZJu&go<$nehO zJ2{2Q_(X?q&HXNH>%-&7%G;_C@SJ-ObpUg$E2Ql2KgdBj9w~L{REgiK351`xC82!} zcb#`w9U*|}{c-W{@cV_m{9Xey*9SW&?>f;B!p(U+b!d_Xi%tM@LH+ee{9T2Tv;o`6 zd#S_$7q(&fX{(ki_7^Te4VMzTAc+~F{>Lk1Lso3|SlN;gm7+fxU#k_;rjg?;jh)!1 zaf{SDCKbbO4=)#lG;ol{JU^J+AIBA?V__*S?cdU8bpIWH!6ZXJxPIv+pLail2@Mb_ z{stIcS*!&<%5K!;RbR#nx720(F2{Gdh~8yP`ROK!Ubaq!tP=W+a4lq0pl>`sAuAuW z)}-ex#|$C%5>risrEAPPv7^Ab%F2N}MYMTEa8K5#BG*{K!7QSyO#Ua!LE08}VyUDI zf+;!{67=@g1`C+MsHv(8es{%U#ZhXor0n%xX+b^m;beMq44{BC(w7VRu8oeJl~LKY zr8c5Q54grnP>auIQNT(CswP^)h~4XlgfvFQ93BXJbj$_p|I&h)aMJC*R+ca3+YDgK zJ+uszZ7^N|48;zJa_IE~9lO4uJ{`s1%1`#XgP3EE z?k0K{y@!VJ6VR>BcWzEh{2yR$?2SwWrEwh0u_Tm5hL=nLcsxWP_k1!x2~8Lt4I;g< zN&NV}yUj70fEttP*)omwE99l2r3cLl^eF?|?KWXHkDaiE!EQEpRd@@b8)v|Je+3&D zp-#cGw${^C+D#TwSxKbszT7J6E|?aj&ReO*J8a1tAZ1ie3BHxv(`{31HD9=j{Y1V~ zaS^I&*uJG2D`Acq1xv<#4TqjRT0?*B(RA0+`ainoPkvczQ^GL_M~c5q-gPI^FO+0Y z2m(N=PqQj}VxBdnCLh1khxAc#ce*l=efoFv7q_9Mf7xo^fS}S|K_;m5VtP;-F~4t& z3>Vp*a9<~-T0`d7WS|9p5R+Jc{U=Fx?^t9f#d(V&Zqrj1GtT#03p{77;;kQDW#cYkkiCGxc^iPeg$UKw4?6o04|FRRvL1+?Ibe{) zXSiAW^OqGN-3Xhn$ft3T8`Sf+AID47%MktJrE*ViGUFcKp5D2@--7puaTN&;X}9xf zCa8!}cAW1XOvmD zmwz_egqOtld&pdCRt3m%Db_DtCGNI@sbWKm}s!e}m#0vX%s9^xlcDR$u%1ao`#3a@>IynUgDa-PQ(8r9&@m2z|?d*!jAP+U;{y|e- zKt0<)R0}~H{4Oi?FZ8(i{Kl&vOY&X*a62c;t#etf%X*thGby)8U~uh{yqM;arI3;D zh))|`cfI0=5?dAzNM{SN7@0O;y6Y5!krtLh zbOBtUUXZ5$MFVT%(BjX~(!vC0+<@!=(&LxrAwSa9Tex$_37+yl8MQ?_{L{dS!|T!? z)~Um6GoAu8nHO$fD`wt#7c>m(bT?g6pemN7h?es>r1J4bR@Jy7y6}mh7f(X2L7s~r z(7$3UBUBv1lxFM{+M?I>``BEpf_{ktCHUq8PHnufAfyiJ$CPY_@5dEaR2w#6v6PUN z#dob!bFrA%_CmuUJ(7bDVXN|vFuaEWP>G1wzJ+_0QI=CtLAXhrolqg%Jec4iV7|6Uwm|^$wDE6^eioQ$1Ehyy z>nhB<1Y;KyVwgKc?}{0AFG*(n%{@*V`HHli_X0(?qGPOh_6mA|n3A-KL06V&4>!tb ztB^G%MLeQ&dct}4W$SK#j)epuhqMGc`qpPf7zECRREdhIs6ddEspa(#kX2?cyRZzu z6^{Mgs>ul!$OL?S>0;?gHM(R<^khx{>&?UUFNeo*#YWk28Zk*N$2`)-pQjkHEWr8x zyZH})svFqh5=CY&+j!&<2@{2t%*@OetOdm4$tri0c|D$28Ubt)_b_?UtvRf@NCYj1 z9UASx>03GqBYq!%^31~sT`+mJpwFQ1zAT=Jq3=Di(<&k*0oP-?@egls|AV)G{h057 z3OHVi0k#U!qfi2)8tth>{O65`#~-JI{Ta>u!zrsNZ~G*tlTZZ2g|b2)pV_631tB-%1+u}^w_E{t<8c?W&kTo;Q| zAk)PPe)$p3*dHxx(b=&R{cKoD6OaAb=%(_3XX%H~RP?-Pef{Hpn#vIm5xlRoHw%io zskjjM*su({j3a}&X;LO&(Rkj&kuqYq2>5WQ=UZK}!ii9UHOiKlNb#ZhLXp2iwN$?pd+p&{#83K@)svbf191%Lsk39bDQEdY@x7a zs|a$7364h@6rR->XEE*;tHkPFPz4q=oY5#n9ZksP$GGe`!4@sH8D!fVXX}ugz0Jle z^#;u{(_KlWf;=Ny2w6gJa#O18l8jDU@`ujcY4n75N@+1C4DSxs)A%0%f6Eg3pX>&3cNX7&cfn6tqW_!RFPqIz^&j8={}D=IV2J;wNc`U;Adn0b z?2pQfOHtCZrwFF9oYG{|zvgh-QMV2oWYzqqln{&)SX01Jl? z|3A4MhHtb0PZ&5Dn32CJ3|Rlym%n8IEPOK9C71kPI|;dDaO|nz%3Owj;?iG{Hw}E3 M`wlTB{Xd)k57+Qypa1{> diff --git a/services/presentation/app/accounts.py b/services/presentation/app/accounts.py index 6eed557..685bfc6 100644 --- a/services/presentation/app/accounts.py +++ b/services/presentation/app/accounts.py @@ -121,6 +121,7 @@ def all_users() -> dict[str, dict]: for login, row in _read()["users"].items(): out[login] = { "features": sorted(features.normalise(row.get("features"))), + "session_version": int(row.get("session_version") or 0), "note": str(row.get("note") or ""), "created": str(row.get("created") or ""), } @@ -131,6 +132,31 @@ def secret_of(login: str) -> str | None: return (_read()["users"].get(login) or {}).get("secret") +def session_version(login: str) -> int: + """Numer pokolenia sesji konta (LOG-34). + + Podbicie unieważnia WSZYSTKIE otwarte sesje tego konta, także na innych + urządzeniach — bez zmiany hasła. Zmiana hasła robi to samo niejako przy + okazji (zmienia się odcisk w ciasteczku), więc licznik jest potrzebny tylko + do „wyrzuć zewsząd, ale hasło zostaw".""" + try: + return int((_read()["users"].get(login) or {}).get("session_version") or 0) + except (TypeError, ValueError): + return 0 + + +def bump_session(login: str) -> int: + """Unieważnia wszystkie sesje konta. Podnosi ValueError, gdy konta nie ma.""" + with _lock: + data = _read() + row = data["users"].get(login) + if row is None: + raise ValueError(f"Nie ma konta „{login}”.") + row["session_version"] = session_version(login) + 1 + _write(data) + return row["session_version"] + + def permissions_of(login: str) -> frozenset[str]: return features.normalise((_read()["users"].get(login) or {}).get("features")) diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py index 7fc1700..192097e 100644 --- a/services/presentation/app/features.py +++ b/services/presentation/app/features.py @@ -111,7 +111,10 @@ ROUTES: dict[tuple[str, str], str | None] = { ("GET", "/health"): None, # Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego, # co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść. - ("GET", "/wyloguj"): None, + ("GET", "/logowanie"): None, + ("POST", "/logowanie"): None, + ("POST", "/wyloguj"): None, + ("POST", "/accounts/sesje"): ADMIN, } diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 4ef4c07..d3860ae 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -27,6 +27,7 @@ import base64 from app import accounts as accounts_store from app import chartwheel as chartwheel_mod from app import features +from app import session from app import files_state from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label @@ -873,6 +874,20 @@ def accounts_update(request: Request, login: str = Form(...), password: str = Fo return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.") +@app.post("/accounts/sesje") +def accounts_sesje(request: Request, login: str = Form(...)): + """Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu. + + Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek + „wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany + komputer i nie chce wpisywać nowego hasła wszędzie.""" + try: + accounts_store.bump_session(login) + except (ValueError, accounts_store.AccountsUnavailable) as e: + return _accounts_redirect(error=str(e)) + return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.") + + @app.post("/accounts/delete") def accounts_delete(request: Request, login: str = Form(...)): try: @@ -882,46 +897,69 @@ def accounts_delete(request: Request, login: str = Form(...)): return _accounts_redirect(done=f"Skasowano konto „{login}”.") -# ---------------- Wylogowanie (LOG-32) ---------------- -# HTTP Basic NIE MA prawdziwego wylogowania i trzeba to powiedzieć wprost: -# przeglądarka zapamiętuje dane logowania i dosyła je sama przy każdym żądaniu, -# więc „zapomnienie" po stronie serwera nic nie daje — kolejne kliknięcie weszłoby -# z powrotem. Nie ma czego wyczyścić w sesji, bo sesji nie ma. -# -# Działa natomiast jedno: doprowadzić do tego, żeby PRZEGLĄDARKA porzuciła to, co -# zapamiętała. Robimy to dwutorowo: -# -# 1. ta trasa odpowiada ZAWSZE 401 z nagłówkiem WWW-Authenticate, co zmusza -# przeglądarkę do ponownego zapytania o hasło — działa też bez JavaScriptu, -# 2. wyloguj.js dokłada to, co faktycznie CZYŚCI pamięć przeglądarki: żądanie -# z celowo błędnymi danymi, którym nadpisuje ona zapamiętany wpis. -# -# Sam punkt 1 zostawiłby stare dane w pamięci przeglądarki: po anulowaniu okienka -# wystarczyłoby wejść na dowolny adres, żeby wrócić do środka. Dlatego oba naraz. +# ---------------- Logowanie i wylogowanie (LOG-34) ---------------- +# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała +# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer +# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć. -@app.get("/wyloguj", response_class=HTMLResponse) -def wyloguj() -> HTMLResponse: - """Kończy sesję przeglądarki. Zawsze 401 — to jest tu WYNIK, nie awaria.""" - strona = ( - '' - 'astrololo · wylogowano' - f'' - '

astrololo

' - '

Wylogowano.

' - '

Aby wrócić, zaloguj się ponownie.

' - '

Jeśli komputer jest współdzielony, zamknij okno ' - 'przeglądarki — to jedyny sposób pewny w każdej przeglądarce.

' - '
' - ) - return HTMLResponse( - strona, status_code=401, - headers={"WWW-Authenticate": 'Basic realm="astrololo"', - # Bez tego przeglądarka mogłaby oddać tę stronę z pamięci - # podręcznej przy kolejnym wylogowaniu, nie pytając serwera — - # a wtedy okienko logowania w ogóle by się nie pojawiło. - "Cache-Control": "no-store, no-cache, must-revalidate", - "Pragma": "no-cache"}, - ) +def _bezpieczne_dokad(dokad: str) -> str: + """Adres powrotu po zalogowaniu, przepuszczony przez sito. + + Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie + do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła, + bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki + wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby + zacząć adres obcego serwera.""" + dokad = (dokad or "").strip() + if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad: + return "/" + return dokad.split("?", 1)[0].split("#", 1)[0] or "/" + + +@app.get("/logowanie", response_class=HTMLResponse) +def logowanie_form(request: Request, dokad: str = "/", blad: str = ""): + # Zalogowanego nie ma po co pytać o hasło jeszcze raz. + if security.auth_enabled() and security.principal(request) is not None: + return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303) + return templates.TemplateResponse(request, "logowanie.html", + {"dokad": _bezpieczne_dokad(dokad), "blad": blad}) + + +@app.post("/logowanie") +def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""), + dokad: str = Form("/")): + cel = _bezpieczne_dokad(dokad) + who = security.verify(login.strip(), haslo) + if who is None: + # JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby, + # które konta istnieją — a to informacja warta czegoś dla zgadującego. + security.audit_login(request, login.strip(), udane=False) + return templates.TemplateResponse( + request, "logowanie.html", + {"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login}, + status_code=401) + + security.audit_login(request, who.login, udane=True) + odpowiedz = RedirectResponse(cel, status_code=303) + odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login), + max_age=session.max_age(), + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + +@app.post("/wyloguj") +def wyloguj(request: Request): + """Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe + i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać. + + POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby + wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa + do uniknięcia.""" + security.audit_logout(request) + odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303) + odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params( + secure=security.cookies_secure())) + return odpowiedz @app.get("/health") diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py index 4d9e166..a56b3a8 100644 --- a/services/presentation/app/security.py +++ b/services/presentation/app/security.py @@ -45,7 +45,7 @@ from collections import deque from dataclasses import dataclass from fastapi import Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse log = logging.getLogger("astrololo.security") @@ -114,6 +114,15 @@ def rate_limit_per_min() -> int: return int(os.getenv("RATE_LIMIT_PER_MIN", "120")) +def cookies_secure() -> bool: + """Czy ciasteczko sesji ma być wyłącznie dla https. + + Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej + przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa" + bez żadnego komunikatu, co jest wyjątkowo mylącą awarią.""" + return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"} + + def trust_proxy() -> bool: return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} @@ -122,7 +131,9 @@ def trust_proxy() -> bool: # odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego # wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym # żądaniu, więc handler i tak widziałby zalogowaną osobę. -PUBLIC_PATHS = frozenset({"/health", "/wyloguj"}) +# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być +# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła. +PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"}) PUBLIC_PREFIXES = ("/static/",) _hits: dict[str, deque[float]] = {} @@ -146,53 +157,99 @@ class Principal: def _admin_permissions() -> frozenset[str]: - """Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie. + """Administrator widzi wszystko — z definicji, bez wypisywania listy ręcznie. - Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania - o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja - byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać.""" + Liczone z katalogu, więc dopisanie funkcji NIE wymaga pamiętania o koncie + administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja byłaby + niewidoczna dla jedynej osoby, która ma ją komu nadać.""" from app import features return frozenset(features.GRANTABLE) | {features.ADMIN} -def principal(header: str | None) -> Principal | None: - """Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują. +def _admin_spec(login: str) -> str | None: + """Sekret konta administracyjnego z KONFIGURACJI ŚRODOWISKA albo None. - Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne - uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej - założenie w pliku konta o loginie administratora przesłoniłoby konto - administracyjne i odebrało mu uprawnienia. + Sprawdzane ZAWSZE PIERWSZE i bez dotykania pliku kont — to jest cała + separacja konta administracyjnego (LOG-34): - Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto - istnieje.""" - if not header or not header.lower().startswith("basic "): - return None - try: - raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") - user, _, password = raw.partition(":") - except (binascii.Error, UnicodeDecodeError, IndexError): - return None + * konto z pliku o tym samym loginie nie przesłoni administratora, bo do + pliku w ogóle nie zaglądamy, gdy login pasuje do konfiguracji, + * administrator zaloguje się nawet wtedy, gdy plik kont jest uszkodzony + albo nieczytelny — czyli w jedynej sytuacji, w której ktoś musi wejść, + żeby to naprawić. Trzymanie jego stanu w tym samym pliku dawałoby + zakleszczenie: nie da się naprawić, bo nie da się wejść. + """ + return accounts().get(login) - spec = accounts().get(user) + +def verify(login: str, password: str) -> Principal | None: + """Sprawdza login i hasło. None = odmowa, bez podawania powodu. + + Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które + konta istnieją, a to informacja warta czegoś dla zgadującego.""" + spec = _admin_spec(login) if spec is not None: - return (Principal(user, _admin_permissions(), True) - if _secret_matches(spec, password) else None) + return Principal(login, _admin_permissions(), True) if _secret_matches(spec, password) else None from app import accounts as store - managed = store.secret_of(user) - if managed is None: + try: + zapisany = store.secret_of(login) + except store.AccountsUnavailable: + # Magazyn kont niedostępny — konta zarządzane nie mogą się zalogować, + # ale administrator (wyżej) już przeszedł, więc jest kto to naprawi. return None - if not _secret_matches(managed, password): + if zapisany is None or not _secret_matches(zapisany, password): return None - return Principal(user, store.permissions_of(user), False) + return Principal(login, store.permissions_of(login), False) -def authenticate(header: str | None) -> str | None: - """Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz.""" - who = principal(header) - return who.login if who else None +def issue_session(login: str) -> str: + """Zawartość ciasteczka dla właśnie zalogowanego konta. + + W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo + skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym + i bez żadnego magazynu sesji.""" + from app import accounts as store + from app import session + + spec = _admin_spec(login) + if spec is not None: + return session.issue(login, session.fingerprint(spec), 0) + return session.issue(login, session.fingerprint(store.secret_of(login) or ""), + store.session_version(login)) + + +def principal(request: Request) -> Principal | None: + """Ciasteczko → kto to jest i co mu wolno. None = nie wpuszczać.""" + from app import session + + dane = session.read(request.cookies.get(session.COOKIE)) + if dane is None: + return None + login = dane["login"] + + spec = _admin_spec(login) + if spec is not None: + # Zmiana APP_PASSWORD ubija sesje administratora — to jest jego + # odpowiednik „wyloguj mnie ze wszystkich urządzeń". + if session.fingerprint(spec) != dane["odcisk"]: + return None + return Principal(login, _admin_permissions(), True) + + from app import accounts as store + + try: + zapisany = store.secret_of(login) + wersja = store.session_version(login) + except store.AccountsUnavailable: + return None + if zapisany is None or session.fingerprint(zapisany) != dane["odcisk"]: + return None + if wersja != dane["wersja"]: + return None + return Principal(login, store.permissions_of(login), False) def client_ip(request: Request) -> str: @@ -270,6 +327,22 @@ def audit_records(request: Request, count: int) -> None: pass +def audit_login(request: Request, login: str, udane: bool) -> None: + """Zdarzenie logowania w dzienniku (PRE-17). + + Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale + seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy + LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku.""" + audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s", + "logowanie-ok" if udane else "logowanie-odmowa", + login or "-", client_ip(request)) + + +def audit_logout(request: Request) -> None: + audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s", + getattr(request.state, "user", "") or "-", client_ip(request)) + + def _audit(request: Request, user: str, status: int, ms: float) -> None: records = getattr(request.state, "audit_records", None) audit_log.info( @@ -280,8 +353,20 @@ def _audit(request: Request, user: str, status: int, ms: float) -> None: def install(app) -> None: - """Podpina ochronę pod wszystkie ścieżki poza /health i /static.""" + """Podpina ochronę pod wszystkie ścieżki poza publicznymi.""" + from app import session + _setup_audit_logging() + + # FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić + # ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała — + # i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka. + if auth_enabled() and not session.available(): + raise session.SessionError( + "Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza " + "podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. " + "Wygeneruj: openssl rand -hex 32") + if not auth_enabled(): log.warning( "UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA " @@ -311,16 +396,27 @@ def install(app) -> None: from app import features - user, who = "", None + user, who, odswiez = "", None, None if auth_enabled(): - who = principal(request.headers.get("authorization")) + who = principal(request) if who is None: - _audit(request, "-", 401, (time.monotonic() - started) * 1000) - return HTMLResponse( - "

401 — wymagane logowanie

", status_code=401, - headers={"WWW-Authenticate": 'Basic realm="astrololo"'}, - ) + _audit(request, "-", 303, (time.monotonic() - started) * 1000) + # Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało + # okienko systemowe, którego nie da się ani opisać, ani z niego wyjść. + # Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce. + from urllib.parse import quote + + cel = request.url.path + dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else "" + return RedirectResponse(f"/logowanie{dokad}", status_code=303) user = who.login + # Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy + # znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy + # każdym obrazku i arkuszu stylów. + from app import session as _s + + dane = _s.read(request.cookies.get(_s.COOKIE)) + odswiez = _s.refresh(dane) if dane else None else: # Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej # deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma. @@ -336,5 +432,10 @@ def install(app) -> None: _audit(request, user, 404, (time.monotonic() - started) * 1000) return HTMLResponse("

404 — nie znaleziono

", status_code=404) response = await call_next(request) + if odswiez: + from app import session as _s + + response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(), + **_s.cookie_params(secure=cookies_secure())) _audit(request, user, response.status_code, (time.monotonic() - started) * 1000) return response diff --git a/services/presentation/app/session.py b/services/presentation/app/session.py new file mode 100644 index 0000000..7204858 --- /dev/null +++ b/services/presentation/app/session.py @@ -0,0 +1,183 @@ +"""Sesje logowania — podpisane ciasteczko (LOG-34). + +CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje +hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć. +Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest +w środku, i może tę decyzję cofnąć. + +TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego: + + 1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu, + działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj". + 2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować. + Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania + o tym — i też nie wymaga magazynu. + 3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji, + który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja + nie poda licznika, poziomy 1 i 2 nadal działają. + +Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę +hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie +dostępu nie odbierałoby dostępu. + +KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie +byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie +poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania. + +Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt +w PRE-27 i AES w PRE-16. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time + +COOKIE = "astrololo_sesja" + +# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji. +DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować +DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec + +# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde +# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów. +REFRESH_AFTER = 300 + + +class SessionError(RuntimeError): + """Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia.""" + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, "") or default) + except ValueError: + return default + + +def max_age() -> int: + return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE) + + +def idle_timeout() -> int: + return _env_int("SESSION_IDLE", DEFAULT_IDLE) + + +def secret() -> bytes: + """Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz. + + Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji + od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała.""" + raw = os.getenv("SESSION_SECRET", "").strip() + if not raw: + raise SessionError( + "Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. " + "Wygeneruj: openssl rand -hex 32") + if len(raw) < 32: + raise SessionError( + f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; " + f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.") + return raw.encode("utf-8") + + +def available() -> bool: + """Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie).""" + try: + secret() + except SessionError: + return False + return True + + +def fingerprint(secret_spec: str) -> str: + """Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza). + + NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót + zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt. + Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta + unieważniły jego sesje same z siebie.""" + return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16] + + +def _sign(payload: bytes) -> bytes: + return hmac.new(secret(), payload, hashlib.sha256).digest() + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _unb64(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str: + """Nowe ciasteczko sesji dla konta.""" + teraz = int(now if now is not None else time.time()) + payload = json.dumps( + {"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def read(token: str | None, *, now: float | None = None) -> dict | None: + """Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza. + + None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości + dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga + zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania.""" + if not token or "." not in token: + return None + surowy, _, podpis = token.rpartition(".") + try: + payload = _unb64(surowy) + oczekiwany = _sign(payload) + podany = _unb64(podpis) + except (ValueError, SessionError): + return None + # Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile + # pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia. + if not hmac.compare_digest(oczekiwany, podany): + return None + try: + dane = json.loads(payload.decode("utf-8")) + login, odcisk = str(dane["u"]), str(dane["f"]) + wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"]) + except (ValueError, KeyError, TypeError): + return None + + teraz = int(now if now is not None else time.time()) + # Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie — + # w obu wypadkach nie ma powodu mu ufać. + if wyd > teraz + 60: + return None + if teraz - wyd > max_age() or teraz - akt > idle_timeout(): + return None + return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt} + + +def refresh(dane: dict, *, now: float | None = None) -> str | None: + """Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć. + + None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby + Set-Cookie — także po arkusze stylów i obrazki.""" + teraz = int(now if now is not None else time.time()) + if teraz - dane["aktywnosc"] < REFRESH_AFTER: + return None + payload = json.dumps( + {"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"], + "wyd": dane["wydano"], "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def cookie_params(secure: bool = True) -> dict: + """Ustawienia ciasteczka wspólne dla ustawiania i kasowania. + + httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty + skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających + na tym, że obca strona wysyła żądanie w imieniu zalogowanego.""" + return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"} diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index ce121dc..e42efc9 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -271,5 +271,17 @@ tr.row-held td { background: rgba(184, 134, 11, .07); } rozpychać. */ .whoami { color: var(--muted); font-size: .85rem; align-self: center; margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); } -nav a.wyloguj { color: var(--muted); } -nav a.wyloguj:hover { color: var(--fg, inherit); } +/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak + reszta paska, nie jak przycisk akcji w formularzu. */ +.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; } +button.wyloguj { + background: none; border: 1px solid transparent; color: var(--muted); + font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer; +} +button.wyloguj:hover { color: var(--ink); } + +/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */ +.login-main { max-width: 26rem; } +.login-card label { display: block; margin-top: .75rem; } +.login-card input { width: 100%; } +.login-note { margin-top: 1.25rem; } diff --git a/services/presentation/app/static/wyloguj.js b/services/presentation/app/static/wyloguj.js deleted file mode 100644 index 6c560d1..0000000 --- a/services/presentation/app/static/wyloguj.js +++ /dev/null @@ -1,44 +0,0 @@ -// Wylogowanie z HTTP Basic (LOG-32). -// -// PROBLEM, KTÓREGO NIE DA SIĘ ROZWIĄZAĆ PO STRONIE SERWERA. Basic nie ma sesji: -// przeglądarka zapamiętuje login i hasło, po czym dosyła je SAMA przy każdym -// żądaniu. Serwer nie ma czego „zapomnieć" — następne kliknięcie przyszłoby -// z kompletem poświadczeń i weszłoby z powrotem. -// -// Działa natomiast doprowadzenie do tego, żeby PRZEGLĄDARKA porzuciła to, co -// zapamiętała: żądanie z celowo błędnymi danymi, którym nadpisuje ona swój wpis -// dla tej witryny. To jest zachowanie powszechne w przeglądarkach, ale NIE -// ZAPISANE w żadnym standardzie — dlatego jest tu jako uzupełnienie, a nie -// jedyny mechanizm: sama trasa /wyloguj odpowiada 401 i wymusza okienko -// logowania nawet przy wyłączonym JavaScripcie. -(function () { - 'use strict'; - - function ready(fn) { - if (document.readyState !== 'loading') fn(); - else document.addEventListener('DOMContentLoaded', fn); - } - - ready(function () { - var link = document.querySelector('a[data-wyloguj]'); - if (!link) return; - - link.addEventListener('click', function (e) { - e.preventDefault(); - // Znacznik czasu w loginie: gdyby przeglądarka zapamiętała nawet TE dane, - // przy kolejnym wylogowaniu byłyby już inne, więc nie da się przypadkiem - // trafić w cokolwiek działającego. - var bezsensowne = 'Basic ' + btoa('wyloguj:' + Date.now()); - fetch(link.href, { - headers: {Authorization: bezsensowne}, - cache: 'no-store', - credentials: 'omit' - }).catch(function () { - // Brak sieci nie może zablokować wyjścia — i tak przechodzimy dalej, - // bo sama trasa /wyloguj odpowie 401 i wywoła okienko logowania. - }).then(function () { - window.location.href = link.href; - }); - }); - }); -})(); diff --git a/services/presentation/app/templates/accounts.html b/services/presentation/app/templates/accounts.html index afba6b8..a8e1b39 100644 --- a/services/presentation/app/templates/accounts.html +++ b/services/presentation/app/templates/accounts.html @@ -53,6 +53,10 @@
+ + {% endif %} diff --git a/services/presentation/app/templates/logowanie.html b/services/presentation/app/templates/logowanie.html new file mode 100644 index 0000000..3862bc3 --- /dev/null +++ b/services/presentation/app/templates/logowanie.html @@ -0,0 +1,36 @@ + + + + + + astrololo · logowanie + + + +
+

astrololo

+ + {% if request.query_params.get('wylogowano') %} +

Wylogowano.

+ {% endif %} + {% if blad %}

{{ blad }}

{% endif %} + + + + +
+ + diff --git a/services/presentation/tests/test_accounts_audit.py b/services/presentation/tests/test_accounts_audit.py index c0cb70f..1843b9e 100644 --- a/services/presentation/tests/test_accounts_audit.py +++ b/services/presentation/tests/test_accounts_audit.py @@ -5,7 +5,6 @@ krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać treści baz (byłby wtedy kolejnym nośnikiem wycieku). """ -import base64 import logging import pytest @@ -13,9 +12,13 @@ import pytest from app import security -def _basic(user: str, password: str) -> dict: - raw = base64.b64encode(f"{user}:{password}".encode()).decode() - return {"Authorization": f"Basic {raw}"} +def _kto(user: str, password: str) -> str | None: + """Login, gdy poświadczenia pasują; None przy odmowie. + + Po przejściu na sesje (LOG-34) sprawdzanie hasła robi `verify`, a nie + parsowanie nagłówka Basic — samego nagłówka nie ma już w ogóle.""" + who = security.verify(user, password) + return who.login if who else None @pytest.fixture(autouse=True) @@ -57,29 +60,29 @@ def test_named_accounts_are_parsed(monkeypatch): def test_login_with_hashed_password(monkeypatch): monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}") - assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja" - assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None + assert _kto("alicja", "tajne") == "alicja" + assert _kto("alicja", "złe") is None def test_unknown_user_is_denied(monkeypatch): monkeypatch.setenv("APP_USERS", "alicja:tajne") - assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None + assert _kto("obcy", "tajne") is None def test_revoking_one_account_leaves_others_untouched(monkeypatch): """Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym.""" monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne") - assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek" + assert _kto("bartek", "inne") == "bartek" monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka - assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None - assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja" + assert _kto("bartek", "inne") is None + assert _kto("alicja", "tajne") == "alicja" def test_shared_password_still_works_alone(monkeypatch): """Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji.""" monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_PASSWORD", "wspolne") - assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo" + assert _kto("astrololo", "wspolne") == "astrololo" def test_named_accounts_disable_the_shared_password(monkeypatch): @@ -88,7 +91,7 @@ def test_named_accounts_disable_the_shared_password(monkeypatch): monkeypatch.setenv("APP_USERS", "alicja:tajne") monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_PASSWORD", "wspolne") - assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None + assert _kto("astrololo", "wspolne") is None def test_no_accounts_means_auth_disabled(monkeypatch): @@ -97,28 +100,82 @@ def test_no_accounts_means_auth_disabled(monkeypatch): # ── dziennik audytowy ── -def _client(): +def _client(monkeypatch): + # Klucz MUSI być w środowisku ZANIM zaimportujemy app.main: security.install + # biegnie przy imporcie i bez klucza celowo nie pozwala usłudze wstać. + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") + from starlette.testclient import TestClient from app.main import app - return TestClient(app) + return TestClient(app, follow_redirects=False) + + +class _dziennik: + """Przechwytuje wpisy audytowe. + + `caplog` podpina się pod korzeń, a dziennik audytowy ma celowo + `propagate=False` — żeby wpisy nie dublowały się w logach usługi. Trzeba więc + podpiąć uchwyt WPROST pod niego, inaczej test widzi pustkę i wygląda to na + brak wpisów zamiast na brak podsłuchu.""" + + def __init__(self, caplog): + self.caplog = caplog + + def __enter__(self): + security._setup_audit_logging() + security.audit_log.setLevel(logging.INFO) + security.audit_log.addHandler(self.caplog.handler) + return self + + def __exit__(self, *a): + security.audit_log.removeHandler(self.caplog.handler) + + @property + def tekst(self) -> str: + return "\n".join(r.getMessage() for r in self.caplog.records) + + +def _sesja(user: str) -> dict: + from app import session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} def test_audit_logs_who_and_how_many(monkeypatch, caplog): monkeypatch.setenv("APP_USERS", "alicja:tajne") - with caplog.at_level(logging.INFO, logger="astrololo.audit"): - _client().get("/synastry", headers=_basic("alicja", "tajne")) - entry = "\n".join(r.getMessage() for r in caplog.records) + c = _client(monkeypatch) + with _dziennik(caplog) as d: + c.get("/synastry", headers=_sesja("alicja")) + entry = d.tekst assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry -def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog): +def test_audit_marks_a_request_without_a_session(monkeypatch, caplog): + """Odesłanie na ekran logowania też zostawia ślad — bez tego seria prób + dobicia się do chronionej ścieżki byłaby w dzienniku niewidoczna.""" monkeypatch.setenv("APP_USERS", "alicja:tajne") - with caplog.at_level(logging.INFO, logger="astrololo.audit"): - _client().get("/synastry", headers=_basic("alicja", "złe")) - entry = "\n".join(r.getMessage() for r in caplog.records) - assert "status=401" in entry and "user=-" in entry + c = _client(monkeypatch) + with _dziennik(caplog) as d: + c.get("/synastry") + entry = d.tekst + assert "status=303" in entry and "user=-" in entry + + +def test_failed_login_is_logged_without_saying_which_account_exists(monkeypatch, caplog): + """Nieudane próby są w dzienniku ważniejsze od udanych: pojedyncza nic nie + znaczy, ale seria pod jednym adresem to jedyny widoczny ślad zgadywania + haseł. Hasło ani jego fragment NIE mogą tam trafić.""" + monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}") + c = _client(monkeypatch) + with _dziennik(caplog) as d: + r = c.post("/logowanie", data={"login": "alicja", "haslo": "zgaduje-haslo"}) + assert r.status_code == 401 + entry = d.tekst + assert "logowanie-odmowa" in entry + assert "zgaduje-haslo" not in entry, "hasło trafiło do dziennika" def test_audit_logger_actually_emits(monkeypatch): diff --git a/services/presentation/tests/test_kontrola_dostepu.py b/services/presentation/tests/test_kontrola_dostepu.py index 5d2514f..d548301 100644 --- a/services/presentation/tests/test_kontrola_dostepu.py +++ b/services/presentation/tests/test_kontrola_dostepu.py @@ -28,7 +28,10 @@ def env(tmp_path, monkeypatch): monkeypatch.setenv("APP_USER", "szef") monkeypatch.setenv("APP_PASSWORD", "tajne-szefa") monkeypatch.delenv("APP_USERS", raising=False) - monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + # Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34). + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http # limit nie jest tu przedmiotem badania return tmp_path @@ -41,11 +44,16 @@ def _client(monkeypatch, chart=None): return TestClient(app) -def _auth(user, password): - import base64 +def _auth(user, password=""): + """Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym. - raw = base64.b64encode(f"{user}:{password}".encode()).decode() - return {"Authorization": f"Basic {raw}"} + Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma + nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne + i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań — + sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie.""" + from app import security, session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} def _chart(): @@ -130,7 +138,7 @@ def test_empty_password_on_update_keeps_the_old_one(env): # ── logowanie i rozpoznanie konta ──────────────────────────────────────── def test_environment_account_is_the_administrator(env): - who = security.principal(_auth("szef", "tajne-szefa")["Authorization"]) + who = security.verify("szef", "tajne-szefa") assert who and who.is_admin assert features.ADMIN in who.permissions assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko" @@ -138,7 +146,7 @@ def test_environment_account_is_the_administrator(env): def test_managed_account_gets_exactly_its_own_permissions(env): store.create("ala", "hasło-ali", ["chart", "ai"]) - who = security.principal(_auth("ala", "hasło-ali")["Authorization"]) + who = security.verify("ala", "hasło-ali") assert who and not who.is_admin assert who.permissions == frozenset({"chart", "ai"}) @@ -147,14 +155,14 @@ def test_managed_account_cannot_shadow_the_administrator(env): """Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić — inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać.""" store.create("szef", "podszywam-się", []) - who = security.principal(_auth("szef", "tajne-szefa")["Authorization"]) + who = security.verify("szef", "tajne-szefa") assert who and who.is_admin and features.ADMIN in who.permissions def test_wrong_password_is_refused_the_same_way_as_unknown_login(env): store.create("ala", "hasło-ali", ["chart"]) - assert security.principal(_auth("ala", "złe")["Authorization"]) is None - assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None + assert security.verify("ala", "złe") is None + assert security.verify("nie-ma-takiego", "cokolwiek") is None # ── niewidzialność funkcji ─────────────────────────────────────────────── @@ -294,9 +302,10 @@ def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypa data={"login": "nowa", "granted": ["chart"]}) assert c.get("/significators", headers=her).status_code == 404 - # skasowanie konta odcina logowanie + # skasowanie konta ubija też OTWARTĄ sesję — bez tego odebranie dostępu + # działałoby dopiero po jej wygaśnięciu c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"}) - assert c.get("/", headers=her).status_code == 401 + assert c.get("/", headers=her, follow_redirects=False).status_code == 303 def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch): @@ -411,50 +420,59 @@ def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch "uszkodzony plik został nadpisany — konta by zniknęły" -# ── wylogowanie (LOG-32) ───────────────────────────────────────────────── -# HTTP Basic nie ma sesji: przeglądarka dosyła zapamiętane dane przy KAŻDYM -# żądaniu, więc serwer nie ma czego zapomnieć. Jedyne, co działa, to doprowadzić -# do porzucenia tych danych przez przeglądarkę — stąd 401 z tej trasy jest -# WYNIKIEM, nie awarią. +# ── logowanie i wylogowanie (LOG-34) ───────────────────────────────────── +# Sesja zastąpiła HTTP Basic. Basic nie miał wylogowania, bo przeglądarka +# dosyłała hasło sama przy każdym żądaniu; teraz to serwer decyduje, czy dana +# przeglądarka jest w środku, i może to cofnąć. -def test_logout_always_answers_401_even_with_valid_credentials(env, monkeypatch): - """Sedno: 200 dla zalogowanej osoby oznaczałoby, że nie da się wyjść.""" +def test_logging_in_hands_out_a_session_and_takes_you_where_you_were_going(env, monkeypatch): + store.create("ala", "hasło-ali", ["chart"]) c = _client(monkeypatch) - r = c.get("/wyloguj", headers=_auth("szef", "tajne-szefa")) - assert r.status_code == 401 - assert r.headers.get("WWW-Authenticate", "").lower().startswith("basic"), \ - "bez tego nagłówka przeglądarka nie zapyta ponownie o hasło" + r = c.post("/logowanie", follow_redirects=False, + data={"login": "ala", "haslo": "hasło-ali", "dokad": "/"}) + assert r.status_code == 303 and r.headers["location"] == "/" + from app import session + + assert session.COOKIE in r.cookies, "nie wydano ciasteczka sesji" -def test_logout_works_without_being_logged_in(env, monkeypatch): - """Trasa stoi poza bramką, więc wyjście działa też z nieważnymi danymi — - inaczej wylogowanie wymagałoby bycia zalogowanym, co jest błędnym kołem.""" +def test_a_bad_password_says_the_same_thing_as_an_unknown_login(env, monkeypatch): + """Rozróżnienie mówiłoby, które konta istnieją.""" + store.create("ala", "hasło-ali", ["chart"]) c = _client(monkeypatch) - assert c.get("/wyloguj").status_code == 401 + zle = c.post("/logowanie", data={"login": "ala", "haslo": "złe"}) + obcy = c.post("/logowanie", data={"login": "nie-ma-takiego", "haslo": "cokolwiek"}) + assert zle.status_code == obcy.status_code == 401 + # Odpowiedzi różnią się WYŁĄCZNIE odbitym polem loginu — czyli tym, co sam + # wpisał pytający. Wszystko poza tym musi być identyczne, inaczej dałoby się + # wyliczyć, które konta istnieją. + bez_loginu = lambda t, login: t.replace(f'value="{login}"', 'value="X"') + assert "Nieprawidłowy login lub hasło" in zle.text + assert bez_loginu(zle.text, "ala") == bez_loginu(obcy.text, "nie-ma-takiego") -def test_logout_page_is_never_cached(env, monkeypatch): - """Oddana z pamięci podręcznej nie dotarłaby do serwera, więc okienko - logowania w ogóle by się nie pojawiło.""" +def test_logging_out_drops_the_session(env, monkeypatch): + """Sedno całej zmiany: po wylogowaniu przeglądarka nie ma już czego dosyłać.""" + store.create("ala", "x", ["chart"]) c = _client(monkeypatch) - cc = c.get("/wyloguj").headers.get("Cache-Control", "") - assert "no-store" in cc and "no-cache" in cc + assert c.get("/", headers=_auth("ala"), follow_redirects=False).status_code == 200 + + r = c.post("/wyloguj", headers=_auth("ala"), follow_redirects=False) + assert r.status_code == 303 and "/logowanie" in r.headers["location"] + # Ciasteczko skasowane: przeglądarka dostaje puste z natychmiastowym kresem. + from app import session + + ustawione = r.headers.get("set-cookie", "") + assert session.COOKIE in ustawione + assert "Max-Age=0" in ustawione or 'expires=Thu, 01 Jan 1970' in ustawione.lower() -def test_logout_page_explains_what_actually_ends_the_session(env, monkeypatch): - """Nie wolno obiecywać więcej, niż Basic potrafi: pewne wyjście na wspólnym - komputerze to zamknięcie przeglądarki i strona ma to mówić wprost.""" - html = _client(monkeypatch).get("/wyloguj").text - assert "Wylogowano" in html - assert "zamknij okno" in html.lower() - assert 'href="/"' in html, "brak drogi powrotnej" - - -def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch): - """Konto bez ani jednego ekranu też ma prawo wyjść.""" - store.create("bez", "x", []) +def test_logging_out_is_a_post_not_a_link(env, monkeypatch): + """Pod adresem GET wystarczyłby obrazek na obcej stronie, żeby wylogować + kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa do uniknięcia.""" c = _client(monkeypatch) - assert c.get("/wyloguj", headers=_auth("bez", "x")).status_code == 401 + assert c.get("/wyloguj", headers=_auth("szef"), + follow_redirects=False).status_code == 405 def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monkeypatch): @@ -462,9 +480,9 @@ def test_the_logout_button_is_on_every_page_with_the_login_next_to_it(env, monke bezużyteczny — na wspólnym komputerze nie wiadomo, kogo się wylogowuje.""" store.create("ala", "x", ["chart", "significators"]) c = _client(monkeypatch) - for kto, haslo in (("szef", "tajne-szefa"), ("ala", "x")): - html = c.get("/", headers=_auth(kto, haslo)).text - assert 'href="/wyloguj"' in html, f"{kto}: brak wyjścia" + for kto in ("szef", "ala"): + html = c.get("/", headers=_auth(kto)).text + assert 'action="/wyloguj"' in html, f"{kto}: brak wyjścia" assert kto in html, f"{kto}: nie widać, kto jest zalogowany" @@ -474,20 +492,62 @@ def test_no_logout_button_when_authentication_is_off(env, monkeypatch): monkeypatch.delenv("APP_PASSWORD", raising=False) monkeypatch.delenv("APP_USERS", raising=False) c = _client(monkeypatch) - assert 'href="/wyloguj"' not in c.get("/").text + assert 'action="/wyloguj"' not in c.get("/").text -def test_the_browser_side_step_is_wired_in(env, monkeypatch): - """Sam 401 zostawiłby stare dane w pamięci przeglądarki: po anulowaniu - okienka wystarczyłoby wejść na dowolny adres, żeby wrócić do środka. - Skrypt dokłada żądanie z celowo błędnymi danymi, które ten wpis nadpisuje.""" - import pathlib +def test_every_account_can_leave_regardless_of_permissions(env, monkeypatch): + """Konto bez ani jednego ekranu też ma prawo wyjść.""" + store.create("bez", "x", []) + c = _client(monkeypatch) + assert c.post("/wyloguj", headers=_auth("bez"), + follow_redirects=False).status_code == 303 - html = _client(monkeypatch).get("/", headers=_auth("szef", "tajne-szefa")).text - assert "wyloguj.js" in html - assert "data-wyloguj" in html - js = (pathlib.Path(__file__).resolve().parents[1] / "app" / "static" - / "wyloguj.js").read_text(encoding="utf-8") - assert "Authorization" in js and "btoa" in js - assert "Date.now()" in js, "bez zmiennej części dane mogłyby się powtórzyć" +def test_the_login_screen_cannot_be_turned_into_a_redirector(env, monkeypatch): + """`?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie do + odsyłania ludzi gdzie indziej — droga do wyłudzenia hasła, bo odsyłacz + prowadzi z zaufanego adresu.""" + c = _client(monkeypatch) + for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x", "\\\\obcy.pl"): + r = c.post("/logowanie", follow_redirects=False, + data={"login": "szef", "haslo": "tajne-szefa", "dokad": zly}) + assert r.status_code == 303 + assert r.headers["location"] == "/", f"przepuszczono: {zly}" + + +def test_changing_the_password_kills_open_sessions(env, monkeypatch): + """Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej + odebranie dostępu nie odbiera dostępu aż do wygaśnięcia.""" + store.create("ala", "stare", ["chart"]) + c = _client(monkeypatch) + ciasteczko = _auth("ala") + assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200 + + store.update("ala", password="nowe") + assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303 + + +def test_the_administrator_can_close_all_sessions_of_an_account(env, monkeypatch): + """„Wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił + zalogowany komputer.""" + store.create("ala", "x", ["chart"]) + c = _client(monkeypatch) + ciasteczko = _auth("ala") + assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200 + + c.post("/accounts/sesje", headers=_auth("szef"), follow_redirects=False, + data={"login": "ala"}) + assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303 + # ...a hasło dalej działa + assert security.verify("ala", "x") is not None + + +def test_the_administrator_gets_in_even_when_the_accounts_file_is_broken(env, monkeypatch): + """Jedyna sytuacja, w której ktoś MUSI wejść, żeby to naprawić. Trzymanie + stanu administratora w tym samym pliku dawałoby zakleszczenie.""" + import pathlib as _p + + _p.Path(store.store_path()).write_text("{to nie jest json", encoding="utf-8") + c = _client(monkeypatch) + assert c.get("/accounts", headers=_auth("szef")).status_code == 200 + assert security.verify("szef", "tajne-szefa") is not None diff --git a/services/presentation/tests/test_pliki.py b/services/presentation/tests/test_pliki.py index 0bd3923..56b7416 100644 --- a/services/presentation/tests/test_pliki.py +++ b/services/presentation/tests/test_pliki.py @@ -21,14 +21,22 @@ def env(tmp_path, monkeypatch): monkeypatch.setenv("APP_PASSWORD", "tajne-szefa") monkeypatch.delenv("APP_USERS", raising=False) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + # Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34). + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http return tmp_path -def _auth(user, password): - import base64 +def _auth(user, password=""): + """Nagłówek z WAŻNĄ SESJĄ dla konta — odpowiednik bycia zalogowanym. - return {"Authorization": "Basic " + base64.b64encode( - f"{user}:{password}".encode()).decode()} + Po przejściu z Basic na sesje (LOG-34) „zalogowany" nie znaczy już „ma + nagłówek z hasłem", tylko „ma podpisane ciasteczko". Hasło jest tu nieistotne + i przyjmowane wyłącznie po to, żeby nie przepisywać wszystkich wywołań — + sprawdzanie poświadczeń ma własne testy, które idą przez /logowanie.""" + from app import security, session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} REGISTRY = [ diff --git a/services/presentation/tests/test_security.py b/services/presentation/tests/test_security.py index 92ec096..63590a9 100644 --- a/services/presentation/tests/test_security.py +++ b/services/presentation/tests/test_security.py @@ -1,11 +1,13 @@ -"""Logowanie do aplikacji i limit żądań (LOG-32). +"""Logowanie do aplikacji i limit żądań (LOG-32/LOG-34). To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują, że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą. -""" -import base64 +Od LOG-34 bramą jest SESJA, nie HTTP Basic. Zmiana nie jest kosmetyczna: Basic +nie miał wylogowania, bo przeglądarka dosyłała hasło sama przy każdym żądaniu. +Teraz to serwer decyduje, czy dana przeglądarka jest w środku — i może to cofnąć. +""" import pytest from fastapi import FastAPI from fastapi.responses import HTMLResponse @@ -16,9 +18,11 @@ from app import security USER, PASSWORD = "astrololo", "haslo-testowe" -def _basic(user: str, password: str) -> dict[str, str]: - raw = base64.b64encode(f"{user}:{password}".encode()).decode() - return {"Authorization": f"Basic {raw}"} +def _sesja(user: str = USER) -> dict[str, str]: + """Nagłówek z ważną sesją — odpowiednik bycia zalogowanym.""" + from app import session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} def _app() -> FastAPI: @@ -47,48 +51,93 @@ def _reset_rate_limit(): def guarded(monkeypatch): monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120") - return TestClient(_app()) + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http + return TestClient(_app(), follow_redirects=False) # ------------------------------------------------------------------ logowanie def test_blocks_anonymous_access(guarded): r = guarded.get("/significators") - assert r.status_code == 401 + assert r.status_code == 303 assert "treść z bazy" not in r.text -def test_challenges_with_basic_realm(guarded): - """Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania.""" - assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "") +def test_sends_people_to_the_login_screen_not_a_system_dialog(guarded): + """Basic odpowiadał 401 z WWW-Authenticate, co otwierało okienko systemowe: + nie dało się go opisać, ostylować ani z niego wyjść inaczej niż anulowaniem. + Ekran logowania jest zwykłą stroną, więc może wyjaśnić, co się stało.""" + r = guarded.get("/significators") + assert r.headers["location"].startswith("/logowanie") + assert "WWW-Authenticate" not in r.headers, "okienko systemowe wróciło" -def test_rejects_wrong_password(guarded): - assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401 +def test_remembers_where_someone_was_heading(guarded): + """Po zalogowaniu ma wrócić tam, gdzie szedł — nie na stronę główną.""" + assert "dokad=/significators" in guarded.get("/significators").headers["location"] -def test_rejects_wrong_user(guarded): - assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401 +def test_no_session_no_content(guarded): + """Popsute albo podrobione ciasteczko jest tym samym, co jego brak.""" + from app import session + + # Same znaki ASCII — nagłówek Cookie nie przenosi innych. + for zle in ("", "smiec", "a.b", "...", session.issue(USER, "zly-odcisk")): + r = guarded.get("/significators", headers={"Cookie": f"{session.COOKIE}={zle}"}) + assert r.status_code == 303, f"wpuszczono z ciasteczkiem: {zle!r}" -def test_rejects_malformed_header(guarded): - for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"): - assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401 - - -def test_allows_correct_credentials(guarded): - r = guarded.get("/significators", headers=_basic(USER, PASSWORD)) +def test_allows_a_valid_session(guarded): + r = guarded.get("/significators", headers=_sesja()) assert r.status_code == 200 assert "treść z bazy" in r.text +def test_credentials_are_checked_by_verify(monkeypatch): + """Samo sprawdzanie hasła — bez HTTP, bo to czysta funkcja.""" + monkeypatch.setenv("APP_PASSWORD", PASSWORD) + monkeypatch.delenv("APP_USERS", raising=False) + assert security.verify(USER, PASSWORD) is not None + assert security.verify(USER, "zle") is None + assert security.verify("obcy", PASSWORD) is None + + +def test_a_session_dies_when_the_password_changes(monkeypatch): + """Odebranie dostępu przez zmianę hasła MUSI ubić otwarte sesje — inaczej + odebranie dostępu nie odbiera dostępu aż do wygaśnięcia sesji.""" + monkeypatch.setenv("APP_PASSWORD", PASSWORD) + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + client = TestClient(_app(), follow_redirects=False) + ciasteczko = _sesja() + assert client.get("/significators", headers=ciasteczko).status_code == 200 + + monkeypatch.setenv("APP_PASSWORD", "zupełnie-nowe-hasło") + assert client.get("/significators", headers=ciasteczko).status_code == 303 + + def test_health_stays_public(guarded): assert guarded.get("/health").status_code == 200 +def test_no_key_no_service(monkeypatch): + """Fail-closed: usługa z kontami, ale bez klucza podpisu, nie umiałaby + odróżnić ważnej sesji od podrobionej — więc nie wstaje.""" + from app import session + + monkeypatch.setenv("APP_PASSWORD", PASSWORD) + monkeypatch.delenv("SESSION_SECRET", raising=False) + with pytest.raises(session.SessionError): + _app() + + def test_open_when_password_unset(monkeypatch): - """Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie).""" + """Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie). + Klucz sesji nie jest wtedy potrzebny — nie ma czego podpisywać.""" monkeypatch.delenv("APP_PASSWORD", raising=False) + monkeypatch.delenv("SESSION_SECRET", raising=False) assert not security.auth_enabled() assert TestClient(_app()).get("/significators").status_code == 200 @@ -99,8 +148,9 @@ def test_rate_limit_blocks_flood(monkeypatch): """Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu.""" monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5") + monkeypatch.setenv("SESSION_SECRET", "t" * 64) client = TestClient(_app()) - auth = _basic(USER, PASSWORD) + auth = _sesja() codes = [client.get("/significators", headers=auth).status_code for _ in range(8)] assert codes[:5] == [200] * 5 assert 429 in codes[5:] @@ -109,8 +159,9 @@ def test_rate_limit_blocks_flood(monkeypatch): def test_rate_limited_response_has_retry_after(monkeypatch): monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1") + monkeypatch.setenv("SESSION_SECRET", "t" * 64) client = TestClient(_app()) - auth = _basic(USER, PASSWORD) + auth = _sesja() client.get("/significators", headers=auth) r = client.get("/significators", headers=auth) assert r.status_code == 429 and r.headers.get("Retry-After") == "60" @@ -121,7 +172,8 @@ def test_rate_limit_precedes_auth(monkeypatch): i sondowanie API jest darmowe.""" monkeypatch.setenv("APP_PASSWORD", PASSWORD) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3") - client = TestClient(_app()) + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + client = TestClient(_app(), follow_redirects=False) codes = [client.get("/significators").status_code for _ in range(6)] assert 429 in codes diff --git a/services/presentation/tests/test_sesje.py b/services/presentation/tests/test_sesje.py new file mode 100644 index 0000000..5cfc3f1 --- /dev/null +++ b/services/presentation/tests/test_sesje.py @@ -0,0 +1,143 @@ +"""Sesje logowania — rdzeń podpisywania (LOG-34). + +Testy dzielą się na dwie grupy i ta druga jest ważniejsza: „czy działa" oraz +„czy da się oszukać". Kod, który tylko przechodzi tę pierwszą, wpuszcza każdego, +kto spróbuje czegoś innego niż zamierzony przebieg. +""" +import json +import time + +import pytest + +from app import session + +KLUCZ = "x" * 64 +ODCISK = session.fingerprint("scrypt$sól$hash") + + +@pytest.fixture() +def klucz(monkeypatch): + monkeypatch.setenv("SESSION_SECRET", KLUCZ) + monkeypatch.delenv("SESSION_MAX_AGE", raising=False) + monkeypatch.delenv("SESSION_IDLE", raising=False) + + +# ── klucz: fail-closed ─────────────────────────────────────────────────── + +def test_no_key_means_no_sessions_not_a_random_one(monkeypatch): + """Losowy klucz przy starcie wylogowywałby wszystkich przy każdym restarcie + poda — wygląda jak awaria i uczy ludzi ignorować ekran logowania.""" + monkeypatch.delenv("SESSION_SECRET", raising=False) + with pytest.raises(session.SessionError): + session.secret() + assert session.available() is False + + +def test_a_short_key_is_refused_with_a_reason(monkeypatch): + monkeypatch.setenv("SESSION_SECRET", "krótki") + with pytest.raises(session.SessionError) as e: + session.secret() + assert "32" in str(e.value) + + +# ── zwykły przebieg ────────────────────────────────────────────────────── + +def test_issue_and_read_round_trip(klucz): + dane = session.read(session.issue("ala", ODCISK, 3)) + assert dane["login"] == "ala" and dane["odcisk"] == ODCISK and dane["wersja"] == 3 + + +def test_fingerprint_never_carries_the_password(klucz): + """Odcisk jedzie do przeglądarki, więc nie może dać się cofnąć do hasła.""" + odcisk = session.fingerprint("tajne-hasło-użytkownika") + assert "tajne" not in odcisk and "hasło" not in odcisk + assert len(odcisk) == 16 + assert session.fingerprint("a") != session.fingerprint("b") + + +# ── próby oszukania ────────────────────────────────────────────────────── + +def test_a_tampered_payload_is_refused(klucz): + token = session.issue("ala", ODCISK) + surowy, _, podpis = token.rpartition(".") + dane = json.loads(session._unb64(surowy)) + dane["u"] = "szef" # podmiana konta + podrobiony = session._b64(json.dumps(dane, separators=(",", ":"), + sort_keys=True).encode()) + "." + podpis + assert session.read(podrobiony) is None + + +def test_a_tampered_signature_is_refused(klucz): + token = session.issue("ala", ODCISK) + surowy, _, podpis = token.rpartition(".") + assert session.read(f"{surowy}.{podpis[:-2]}xx") is None + + +def test_a_token_signed_with_another_key_is_refused(klucz, monkeypatch): + token = session.issue("ala", ODCISK) + monkeypatch.setenv("SESSION_SECRET", "y" * 64) + assert session.read(token) is None + + +@pytest.mark.parametrize("smiec", ["", None, "bez-kropki", ".", "a.b", + "!!!.???", "a" * 500]) +def test_garbage_never_raises_it_just_refuses(klucz, smiec): + """Wejście przychodzi z przeglądarki, więc wyjątek tutaj to 500 na każdym + żądaniu z popsutym ciasteczkiem — czyli aplikacja nie do odratowania + inaczej niż przez wyczyszczenie przeglądarki.""" + assert session.read(smiec) is None + + +def test_a_token_from_the_future_is_refused(klucz): + """Przestawiony zegar albo podrobienie — w obu razach nie ma powodu ufać.""" + przyszly = session.issue("ala", ODCISK, now=time.time() + 3600) + assert session.read(przyszly) is None + + +def test_reading_without_a_key_refuses_instead_of_exploding(klucz, monkeypatch): + token = session.issue("ala", ODCISK) + monkeypatch.delenv("SESSION_SECRET", raising=False) + assert session.read(token) is None + + +# ── wygasanie ──────────────────────────────────────────────────────────── + +def test_a_session_dies_of_old_age(klucz, monkeypatch): + monkeypatch.setenv("SESSION_MAX_AGE", "100") + token = session.issue("ala", ODCISK, now=time.time() - 200) + assert session.read(token) is None + + +def test_a_session_dies_of_inactivity(klucz, monkeypatch): + """Twardy kres jeszcze daleko, ale nikt nie ruszał — koniec.""" + monkeypatch.setenv("SESSION_MAX_AGE", "100000") + monkeypatch.setenv("SESSION_IDLE", "60") + stary = session.issue("ala", ODCISK, now=time.time() - 600) + assert session.read(stary) is None + + +def test_activity_extends_the_session_but_not_past_the_hard_limit(klucz, monkeypatch): + monkeypatch.setenv("SESSION_MAX_AGE", "1000") + monkeypatch.setenv("SESSION_IDLE", "100") + teraz = time.time() + token = session.issue("ala", ODCISK, now=teraz - 900) + odswiezony = session.refresh(session.read(token, now=teraz - 900), now=teraz - 10) + assert session.read(odswiezony, now=teraz) is not None, "ruch nie przedłużył sesji" + # ...ale twardego kresu nie przeskoczy + assert session.read(odswiezony, now=teraz + 200) is None + + +def test_refresh_stays_quiet_until_it_is_worth_it(klucz): + """Bez progu każde żądanie odsyłałoby Set-Cookie — także po arkusze stylów.""" + dane = session.read(session.issue("ala", ODCISK)) + assert session.refresh(dane) is None + assert session.refresh(dane, now=time.time() + session.REFRESH_AFTER + 1) is not None + + +# ── ustawienia ciasteczka ──────────────────────────────────────────────── + +def test_the_cookie_cannot_be_read_by_scripts_or_sent_cross_site(klucz): + p = session.cookie_params() + assert p["httponly"] is True, "jeden wstrzyknięty skrypt wyniósłby sesję" + assert p["samesite"] == "strict", "obca strona mogłaby działać w imieniu zalogowanego" + assert p["secure"] is True