pax_global_header00006660000000000000000000000064142220745110014510gustar00rootroot0000000000000052 comment=27f7a321a027ceac3bcdb9c1356027753ff90eba flask-paranoid-0.3.0/000077500000000000000000000000001422207451100144035ustar00rootroot00000000000000flask-paranoid-0.3.0/.github/000077500000000000000000000000001422207451100157435ustar00rootroot00000000000000flask-paranoid-0.3.0/.github/workflows/000077500000000000000000000000001422207451100200005ustar00rootroot00000000000000flask-paranoid-0.3.0/.github/workflows/tests.yml000066400000000000000000000021761422207451100216730ustar00rootroot00000000000000name: build on: push: branches: - main pull_request: branches: - main jobs: lint: name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - run: python -m pip install --upgrade pip wheel - run: pip install tox tox-gh-actions - run: tox -eflake8 - run: tox -edocs tests: name: tests strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.8'] fail-fast: false runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - run: python -m pip install --upgrade pip wheel - run: pip install tox tox-gh-actions - run: tox coverage: name: coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - run: python -m pip install --upgrade pip wheel - run: pip install tox tox-gh-actions codecov - run: tox - run: codecov flask-paranoid-0.3.0/.gitignore000066400000000000000000000005361422207451100163770ustar00rootroot00000000000000*.py[cod] # C extensions *.so # Packages *.egg *.egg-info dist build eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 # Installer logs pip-log.txt # Unit test / coverage reports .coverage .tox nosetests.xml # Unit test temporary files tests/temp_folder # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject flask-paranoid-0.3.0/.travis.yml000066400000000000000000000006641422207451100165220ustar00rootroot00000000000000language: python matrix: include: - python: 3.6 env: TOXENV=flake8 - python: 2.7 env: TOXENV=py27 - python: 3.4 env: TOXENV=py34 - python: 3.5 env: TOXENV=py35 - python: 3.6 env: TOXENV=py36 - python: pypy env: TOXENV=pypy # - python: 3.6 # env: TOXENV=docs install: - pip install tox script: - tox flask-paranoid-0.3.0/CHANGES.md000066400000000000000000000045171422207451100160040ustar00rootroot00000000000000# flask-paranoid change log **Release 0.3.0** - 2022-04-02 - Project restructure and test fixes ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/a63fd06d56867969f971f66f1958611fc916e570)) - Refreshed requirements for the example app ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/d11124c875df3b299ff63669a3dde7f1b3d0b45b)) - Add change log ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/d48ff73b453178ed7deec327565b79dc619d4633)) - Update build badges ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/d984efe7ce0682da9dfea3c682670670dc59cc6e)) - Github actions build ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/ea84d08e5a3e8f5c76eb48829314822c6c3bcbad)) - Add LICENSE file to source distribution [#4](https://github.com/miguelgrinberg/flask-paranoid/issues/4) ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/c1c77443586b80263953fa5ed1d06dace876bce7)) (thanks **Nehal J Wani**!) **Release 0.2.0** - 2017-11-17 - Update requirements.txt ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/ce48283ac4043c83df913edf24fa826945b60922)) - Fixed documentation link ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/5a19267cb49b09024e1d3f42c9b8f0d40d1d071f)) - documentation fixes ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/c0f78688337286a6544cebf8ea54a4985a6a8803)) **Release 0.1** - 2017-07-02 - readme file ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/1dd96831e4b56d019577f590c2b3792dee1e7aaf)) - documentation ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/91dbad3d6e16b9f4b1e036669b50a706b07dc2fe)) - more unit tests ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/e5d515728b71df4f253d536b618149131bf44a32)) - first unit test ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/6678497c0a0b0b98b6353207d5ab7de122b9d7c8)) - tox and travis build setup ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/5788c24f0f79f3a8fd336b00603a066e00d8c084)) - fleshed out the extension, and added an example ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/11c4dc0dbf279d71afd477560ad734cb1f423edf)) - initial commit ([commit](https://github.com/miguelgrinberg/flask-paranoid/commit/7fd342a2ad2360a7ab312b5c0d2a2c5df1c2bdf1)) flask-paranoid-0.3.0/LICENSE000066400000000000000000000020721422207451100154110ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2017 Miguel Grinberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. flask-paranoid-0.3.0/MANIFEST.in000066400000000000000000000000441422207451100161370ustar00rootroot00000000000000include LICENSE exclude MANIFEST.in flask-paranoid-0.3.0/README.md000066400000000000000000000025761422207451100156740ustar00rootroot00000000000000flask-paranoid ============== [![Build status](https://github.com/miguelgrinberg/flask-paranoid/workflows/build/badge.svg)](https://github.com/miguelgrinberg/flask-paranoid/actions) [![codecov](https://codecov.io/gh/miguelgrinberg/flask-paranoid/branch/main/graph/badge.svg)](https://codecov.io/gh/miguelgrinberg/flask-paranoid) Simple user session protection. Quick Start ----------- Here is a simple application that uses Flask-Paranoid to protect the user session: ```python from flask import Flask from flask_paranoid import Paranoid app = Flask(__name__) app.config['SECRET_KEY'] = 'top-secret!' paranoid = Paranoid(app) paranoid.redirect_view = '/' @app.route('/') def index(): return render_template('index.html') ``` When a client connects to this application, a "paranoid" token will be generated according to the IP address and user agent. In all subsequent requests, the token will be recalculated and checked against the one computed for the first request. If the session cookie is stolen and the attacker tries to use it from another location, the generated token will be different, and in that case the extension will clear the session and block the request. Resources --------- - [Documentation](http://pythonhosted.org/Flask-Paranoid) - [PyPI](https://pypi.python.org/pypi/flask-paranoid) - [Change Log](https://github.com/miguelgrinberg/flask-paranoid/blob/main/CHANGES.md) flask-paranoid-0.3.0/docs/000077500000000000000000000000001422207451100153335ustar00rootroot00000000000000flask-paranoid-0.3.0/docs/Makefile000066400000000000000000000011461422207451100167750ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx SPHINXPROJ = flask-paranoid SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)flask-paranoid-0.3.0/docs/_static/000077500000000000000000000000001422207451100167615ustar00rootroot00000000000000flask-paranoid-0.3.0/docs/_static/logo.png000077500000000000000000002542031422207451100204400ustar00rootroot00000000000000PNG  IHDR4xbKGDC pHYs  tIME! dm IDATxyU>s[;@5Ȏ tFtFGFTeQPGdJBuwzᄋRuQo!y>6V}󮀆G-y[zACCCCCCCP乥hhhhhhhBs( `WGj<"Mj44444449 QVo@@@lB/ CPF2!IUjԩ*RboĤѳ-UqCgQ̤(` M@7Mv?ihhhhN* [/3Q2QVT"M^sgOBҾ?D|kw=>~]bkS3\2 ["R}."CIb#Y$dB&%$ DRBDPJv׃v}V =CnJHP㻄&6n4׭rJ;e΅.;<8<B&dppM|jhhhhh57`]@=jPW-\/_\TMBcIbfO dsqx C I΀{F DCCCCcdƳIVf qwPjMjK)yι%%(J)'Trxj&3{@f,ށ*Eh*vLZnNu/H(_} HH)˩5p^u5BRR&pihhhhh / {_w !%!!W^G_^#;nB @,D|T M  I乥aa*󄐌sΤT*D 3gOOYr[OUpˇdy-* LDIRgg!B)q(%DIޣM~;`uB2pSy )%pJ jsk@G=g (ӜwyIP;ڻ 1j3FH4o,+0)%%Dtww#6CKH u!n0l)hPE;9ƬcO>㧨1h| 2M;3X0i94zM)D,U8zhhhhh.qJ$RHʹ0xvmqxqx;B2)%u-7} )}dFg;>l.':SP(vBx+G(Dvttw1lN]8Ѕ44444vMfJ/HH ꑖpOrI=KJI%Є#4yW8k4X-3@/ -:fyn'2hhh|rP!DB_ipb76{(ylR(µ||['A@ł"@]4טYI(QGQd*~{d%-BCj d!o*݅W\ke[ ("өP^M, u]]C8@TrT.?ëeo{ .nPVÜ}Ϻc[^>RpIEz(&x?{.tЎ]Υ"Z!u`9:%FK>۶4 `kwLg *Z,&SLVV9ssHWVsO ͙{ޮs"KjQ9v%_8vì}YGO3f=zwR; y rUJSK/Vwr}^치>N9|RTTFC@7R8XU7~D*,S3L3#Ј\Ì"3;W GZ+9%Ce%"3[rc{hj# .ŷ1nûm?j I-߸i!p %C?`»r5?>2ៅ|%d};d$_]n8*{b0 }w^t^fa5f[2$1-1$JϽԆ/97?  `'Οظ~[<k4+15&LUaݖNemjQ$*۟{U (򇁭ZyIxޒnHN> 2cǎX%lذ;oCGG;POU2]n BX5`cI)!%O!J(c5S}"0X,cQBr@+쮥lg[>+ߺ4~U_ZK͆bI Z:܌-[iF46@KNleYs~5EDIVyKȁ;~̥ucoQ')9o:Uŧ՜w|1`( w~%HWWw6l܀BiSM7C,ĉq9sʾk.L]䇕j_w(ab_F/Buc%b>AwDy 4,gfx?V7BJIB@Kl߁$w݋D"Yk`l`cI|ۯį!, "`?E@EDYʐPX*bDk[2ݓ W사*g w^iu=OY; {uNW&WTM]F3)+&!o}:o3Lp1ŕ6Xr%fϞ]:(v``W.óTT*̌@КrcwQÏĆ.!O׿6pမ&f8Js{3Bu3W^y7fw`Y&lw0 p(nF'1e|+_Ŝb3eX6aҨu["3իWk+^BA3U3HFD2fGX,XI hmpFgظa[}wp|8"4~;h.y)ys}Ep$tEwQs:oRoGS@w,JcYZ`+PcYgr^&Rg}QJŸe=x_aM7~"._˒?uZr7]O_So=5?1a>;P4f8l9rp-s9uHYhek_Zm!Oʧ8"0Ϻp_ǟ3 8@(‘`0+_m݊~1zlB;Lbά9x'!St ' TBA@@[ᅥ/y' #fM0c P"iHsqsa ! )%9{*gc Z E]t}1HGH8E42d?s92ig<Z溗 j)P?o9$H47~M׿tBC15iqXi_B9xHb(:숉+҉x2'x" GBAƨRQj|AK||ZŤƂR(Qꍃn ! )%={m1߳1%adWdC%t(W6YWNg>R@DV q*kwzbpk^5>@w\b-;!,]j/ygy3DzcQ7jfOeJpVY_| @0( 8@ t |Yݝ> d:tMu&cI{)N9PUʛZ:zOՂ3KWצ0߽{XH%+K(Rذ" $ƍ}Έѵ>Oc۱wϵ9x FA 3:;TZaA !sN9BG͞ }f DzkUzn IqFsd]_|sxH} mݹrtŗ/8w}}[kQ)ƎGD^;/_w?XASJpy5,j(m!KϤ_|U|P'<^p$>jqD}4DÕx8 ÔR0Ƙ}`~HFsNMbbHm8X0΅nq!N<ܟkgWc(+U2B%]Wym^cOJ76-&%s%kbjC:eUVteLW&dEHb4r-dh! !ߺOSt+it-/BH ! i]]Y鏿Dc\o \p+KH-Vcc(b=r.0@ D ` 4-v.ظi]Y;}Ƅ^ϛ?u NAu\`$B"ĬH|ۗR}ΞlO(VaێYx3ź'sYH"!%p"c&cؾ}۠ -hk3>5^EcѢxqwcj0XJ)Tp¼y8˭"`BHsYBRVNɩT8Klۖғ^ 0)%BV blino{MFc aWW/ \H*!!Tm W_] NU' [k[2ĩ惨ᯮ< uƨ=㷪8>bSDIV˳8hY3ٱ]t" RF F%RRTJIgeHiϹ"66Y3'[5} TȐvl_|nuc-Nr(RӦ>1ixWqa03]SV2iF*0*qJ'XE:N+ ZrR#fNmc~cC7rWnz0 ]H$!K3 /<ƍοh<-^Z]qaNx?: >1dz696G IDATeWvwfDgGh;;֖NUf39R*FV[X1658[Ml&׫N]i8iV$JFbD he Ce:06l߾MMMhmmAGG{zE7 XmEo:EDc1̝{&R {m>f|=;숉,$HŌ_47c/<Ҕm;5C큔BA+xL$f27Hƌ8KUY*M$d++U{pZch]z03U曛!`AB$qe2pHBs%z{D8A$AGwX{( k++o/xA`û[*_|nu$CT\`'1ZI..!!R)%[OK~/x٧@Q P0B ad`b, ccu8SR+jӟ;`sDM!Dmxbq@>SuS]B'>;hOw7lڈ[Ѻs+юL&\Y%zs9ds9p("J!Qi7={ɤ܍E,K\JIԵ@]ΎmM䴅Ǧoe 57IQH8;ߏ0b$DY%<ˇg7gϩ+̹ Xl{L(wD>~ʷn^Ĵ[2L$hԦ#m}ؼqغI>0 vr__~RLM] ŽCɨ)4$$%[}Ӄ۶`GDWg'z{Q,Q(7ma& ;0@0@0P D,x"EGa֬و',;K,^HjĨډB1 AA ?.ɓ 6m–-[܄;ѡdB6ۃl6Ntvv ɂQf`,+` X'c+2.!$T3@ 4 < 1Lz}4O? 1XH8h4d2Z{0ʆH)zׄG=$bƌk`ޕ5^P.WĞZ64ʕqIF!--;܍j6$%A$ᅪ?$N>R@00;hf2 MSs#2 vBPgQYC> O9M?CaWr7Gfr{;9X\9_|wo[3nڸ S qI ᠐8|A7G|/@ԕDP p \lSD pWP3eSGYSؑ$ǿo.u#$!m߱@ue55u2LBSn{ ǗI4iA7PXi;}bV8 wsS'RPMjoJ G¬Qz%dE+B`hTdE0xD ,/0q;R}H A B܊#͔ufeU"\y m2$c0/j6~~!},5") u*gW畒 Š{:7pCً? pBJַKpYnW_%VZ7|! pcRKK bͻ\O@Muҩ4+9\|ŃGscW{(w`&/'$(ӝB0&1,B\Vg͙_OIOf6mؖ/lSUA=B"DLK^ qb]XhBf^JF ~(j2ILcO0Lkp, m嚂R<1EfQ3'/<66Vg_M2:an[Mߚj5ۏĦ46 \:䓏G%i" #"O"N#x񙨪em㭷@[k+黅D"I{<=(ՙ :؄GTx0Qh&u[7V y]8zd@u8LSN`UuŠnjl/?aspˆr!4Lr_EYiWqd [{}W+&M(gٲu+?`Ӵ`AeZd3hom}}p?2 ض|>H;sG֭Y/fL2u=ˎ===hmmASsZ[Zގtvvdz ϺESܕ@  GE'JJQUUz9 2J䣗]_Ē VW\K>F u7pQ_78șH]oaUZ ֋)!YACtnqSRtvu#ρP _(J;qw~83^zn7;|̩-?-4^q$Z8<4䙣)!\R"%33K*$RL0K,CoTތ1X&uw\w@3 dy*־ys^zguFrML H@(D4G<C<@EE 4*kP__Z#h!7nؖ@Y'D0hw|뿯eZ( p)PK/dw0 D5H;3$zeBaBL p.:^VdFHO xAyJC%SP(x.rhmkG0;pq0 A!]t X y7a w8gt-ݢg( mJ) V ܆===H&Dm,dʨ%qݒWCB;eVy(% "|B|Ţ-qD7? xvEs6NŖC3n `y N:mvr/!ku$R12~Ȳk=1t#^[~"e1UW(e09mI\J#%le a(5jl]&+jNR"̝XYGO7KU-")\$&L5y-^ q4_J+Y/Irt\tuIOgK͍S/ W_%\kꆾ ׋ ho=$~pˏ`IBtwexeu*_Yrb F]fx1Oމ)9;P][f̜Jh 7pKoH$h^sRD!\م4+ЇY&qG%B:(e2!%~> C7Ţ |Ţ6gq{P2aZ5, +ߟE a D9 `p Ezm6" H7ӶmpCf Q!H-͸;qd){<"( YSSH$^@pV?yQG=rg4us߿|-.\_6u&ںaM,{6eu/CP/l_ nb˜ҕ3pq0mt]*l߾m$A( sxu8OZڋ=j, BMh65t1k(YbMœ9gZ@D΅SˤrRqJTt!" " gk(=lM}FVcu#P;RJՕݝCzyLg>}ٙ\3&J&lSR{2bq;l;H8X,e1.C6 H6u#H!"wqHZ]/$E!@ A4 O#ז^+eYXp1%EtweĦ̍I)(%4pYg.˯حq}v)}֫z"3kuX3؉N}^y7|[%3,::\^GE]] rƪ^^=(w[Ԡ2d8H)uyu۝7DZ "ݮe2[`@00)@ 1PH0Q30A`OWG|N\vր:\tkSi`/ |__|?sN#A2a}(Jb⹘9gOۊy'ʅY J&q)sO>6cǟ<3Bp}2Iɶd*f֏f#bs[߻ C3[+&On= |r*ݐ 9uП8h}R (3 ap R(@2W awXeE)%n nW0:۸ |/|O?v̾?;8477GA{{?q){$+29tu:{҄bztu`}r\t>?cko;]N!F&?i (خ?q +`W B0؜qj.JA$9\N!$7~H.3O?ۗcf'~")2rR}`W[m'S1rĬߺtl7߈7_zݍ%8y^cƔ})M/<Mߕ`2u!>\OUriCZ搤 ܓR P7Gرe8ٌv)ZZv" nA3/ }ϯsp[-E嵚S?:;;q-7coI@(E "K`8m|6>c;?&M[rՠt֜i˖5 HB`V@eqpec;vۀ[X :;`0/|j{4 rGG5] p3qžըMLHrgmIp)u(!LR uk T[AUM :<凄$R'?9Gc+7^GBy\&Rr #>w.7zev:;2hiڊ|/aLq$>W 4=pA]}:tI*>HA2 \J#Eh;\*u ׎R]@.%6@Q23 V)3-P \bQmKܽwU{z (Ăh41DKb,DJUԡn;g h޼yys9{w}׺.^{ EؒCC¦yW5Uk8Yr9BH)cH6cӜ:y>@>r& xZz?y[r¢DSOjDTh])+]ݨDԑ^6^~%}.]F ݺ ~9K o䈱#ڼ܇7W6ի%oN)3g|FC &u\$eo.5HU AYMi3̈́gO>׬ 8fr?Ņ~85±ן$766'?L&Ã=ޅ2~H{|N;2a-;ms]>xkzmQIܹW_AzMPF˸2jZ BZ0 CthB"Q"ΐx8* BaڜmӒ+kslg+3ذq"QD5q." m}FktfQF6m+/SNqg0t=9}~Yh([7v-zt{W,_j{]P| G;d29MyǭS))\lC=sF|o$OL=LҴԳ*u@c##ۮ,²SFi c?HSt\飴Bp. |@Z( h[hӶ.A)Ɩ_G/_S~5@#)1ڀc!, ]:wE~]5m*ĘF[[u ~>7C*L#%ARӐJE)'Ns~j~^&P\qb|}[@!Ws. {  *ڰh"'3?h*+ʫ/k/.X:[1{n //6W2:(*,"P`h2֣]²C!*4dLHh0Ȧ)ЬF:} V~v37_f]M=dCRr$0@3Ƹ?_ok]~=_ ?3},%%%rI'_QRXspi9yx~K{W^yŹ*$XOZX#p=5kV1أ9SdNE :l\h"\g>z.(0v=aHMܝ?@A Of3*wx C.:hĶ+T*Ǟ`USZ)~,NAg=}Ю~>jT!L !UWCTg&=A}]-\梓Y_,0MvMd9Q)َÆ0⠽k/'>%EM@MeY18'2 bq|{izsJ&m+ܵ;ko@gP!<׉1` ,Y̜95pvL`6'_&ரJHkyOdӎ`)%={c8!9'}J6HphM XKf%0 }} `6TSQц#"swԫ?Ա-Ө/" a܃&#L^ A6䎇/$y&!a(ຒh ( JỞ5U h[5ֺZ~ax N:$N;I@q&.}J&r?Ǥ_Hđs5DED4Fk\Md%ӗJfW^+/%hc;~4V.EBIk.J,rwK/roN㜳;';o|JFsR$ 3yQ =M)\Z 5[Dܚ%r#Y@GHdQHi7GMG=WdLz9/\@$EYi gO6$9};ìB!OɐyujagJ>8fZvڈQ8sH+dZٷ6O/֞_Ւ&&#%;  AN]9ԟQUxN:8qᅗpƸs5ge04A*O>\u̙3wۆF4olAؖؑM/irͮ^~˧g-:}b:TtSMm+Wwޡ& z[ ׯ55QJ%BЖ,.Fp߯DQeO_/29u1W2%Bn`Zc=x)0V\I6k!$==3tp<(bϽ,_T2jնvlܰ{/x(z0\DG:ZtcQsf]HϮݭEfCmB̑RD$ !R<׆YvKy.ҿoso:ًJ6ԖE L4yѿh#޽Q[.DQM%Zaqf rph{sfOgÆ_mB˦ۤ jM gvh^:땴3s?FJZ1MQTc#& Ցq@zRdl@2ipVc$LG}뎖vcF).V*[-1cKXsTt^ce'C0{,$ Gڢpp\5kk={=>x' (**s]PW$ Qu 2D իW3q֥ )pPf|1-26hnnNӘ1k&e%%hmhlN &o%o؁ՋQGaڴi<̚5t:mKߺɠT"CUzn>'|*ߜGE˗U5&L[-7hED m)G:j[֭+N?-8+ݘ BZA׋D ۾cxi~b0xPṔˮ(/)GMORLV!E%s>ccpw܉ gN_*6maeܯ0qSZMc};vc}8etԙv;)o9h_/mKű'][:E (xBHȤaUhc9Y aQҵQ [kkyW0p 6dr;C,YBhc~6{oL])w7cG}rHJ)ӎ+d=)(+/;3J6Mjj{^Μu -f+V`E"l$A2 k|WEFv0x~:c}Ah7 ڠA eq-(--o;˜: y?%EqNk~s_}q0rau H%zi)}̘1ン .S߷qzK 0ZR߰+W"tJ!{>R(18CJٌ0i19fnBayNjYT+%RJΟnj3>lHИsEWV2`\tE -luȑ9wψ5a$kF6hRJ~v( O;ہڲ ફ Cbl& q=ڪ!$g7|3~ڵcټ9&뺞:t).(Cexk/݋I#5s f_p0m i01x,N 3̝;C a#9}g(֣cκ2eGr 8/_;PXPHO堑ci_/6>xuJiWYI2~ssRV'%@ 0#`Р=٭_^{ULh4 %3f`srBWMZvʒS+g#PfՆ=:]>⠽LW. .;$:vl['|w{Ǻ `08R F@zq\dž) a:6@t{@y.uK,ziSFaYv&R{;kKP<ܣٱh"n׭̙3~vSOToF& k0HD*"D3&1vXu~!<~W|$ ZѵKw.˕ptL̺uyjRFɴ U!x}aߜL]ɏLm5} 9cQI *'cҤ8/18T62"@[#HqG{S)|T1"\ -K*ќ1GKq,o*M6#RSbh|蝔lM+e&29:8ϛ?؅GF)vq4`|χ(1>PP0 r5vHD**;2el | , Z &Y}C-7ngϖN]q܉}vtJi~s9_tlڸ0Nh]čH#cғOqq'uq0?wY'NQA[!l;k  >{(ktcH7.;g'2|0nfzɽw)JmS%f2>l? 5xuZvA`(h|Q~v/CaG 8Y[X<3oBJ?9!u\jeB"ܤ7Э[W. ٶтa A6hGP!GyqvlA xIOb\DG6fcҵ돚Xt1׮'F v|Ri.9}~UMkQʠ3)$n܍I:>`_o +9z1-ջoWq!K.f괏¡e5)'u2Yy F]a-b,^ =Z/gNK߾(*,qgO{9ӯS_'%&&֯ξ:`1E7o7";uVjy]puٴHa=1t.ұ[TȢ߲e˖|Ҳ"w;յM^+t6t<u>sJIXуW_}vaÆ\ e@"d&L{~6mD&AlhңM;~uɜrjҵk&K?Ǟz t5Iele޼X'* gkߥk"4I)AܢB/Ln[+Vp#q\Og~I+m}yf:4sN\|zؗ>= IDAT6PUT#uVTU+Ű]d`~i6@R; Q\R.Yߵe86DL:j(aFF+:l,#}wCwSXXL~+Bb t GAA!Ll6C2R/"\|Yn?;3)\Hp {]z1eʧo߁:7 ^h(nz([PX\sU'SX #:>SB ҥVpK)cm~8S=k6B D$ }ߟ;/6l@"Q&>T!/|).)cJF9CIAD"aw ڠTHd @bb(fQlnbҗ] p"{\s_wz~ire44B(Rsf0ի;X֐ PV+13f_/@Jx:NdSw[ӇKt wϞtl߉bp< 1bȜ]| s#!ncѝ}Щc{Jh[QJ)+j*|35<4q"<( 8ׁ#Fb&)\p,ee<-=/eg:uC?gg`:v|UknHX q$l['#>+`Mzd #\R*E2(MQ"AQAx£x ? jng`7ӓT5s-Xw>p@Jk8s#Zf3L( -!1:w[6v=N]r;|7ojMlA#$)Q&m<1;1X<:\)px'Yz5>59lo4MCFm #@=5169W\ɦxp% 9!$+ %MV-Uٞ+үWy~ !e$b*9[pGٛ3839ڶDio|G_._۳9.UUUT4'~#pA#ۅ߱ i6mb8v|t8p$+VڿOx.߇rƝv=;t2 BX :uFiiig*JxJn/˿ASoPQ^ξX ٲe |ilh`sm-BgHa}fA{gD+jᡍh(,*b#C6I>(.)EYNkưz:"7K?T)mەf5TnKkC2t1D:Vi^ J8?soa#1:GPѦv)Ra̘J)߿osm+ڵ#*r1?ɟ#%\|9ڎ7V^ņQMnZ ÀP yNcSu5F#18ќd3u-7DQ4#yY v|Р{֮[GJ`m۶E riZ7zzmor;k׮zEzn4 "3h ۅ 1OP~ۮgww`ٲe[ JGܮm$::t(_z7On444p'Q__oj)t\2,aRp>fWsgfJT6J.D}M>fzn`:SѶYsU5u(~'{vA\9N~6eV7v,ǍK^~L˖-9ɈmsRTN?zm;w\gk6Z#-,5u .kp]ڣ[kkYqh")Ʊ\2EK1JO9[\G:,/lr-9mj 7ŘKt䘚k=ӵk/]liP;'l%{.h;Mb))VE2oJ, ]VyuKv~y/Z3ӾQA*jmv9|}k׮^IljK8?3Gb|,ZiӦ>`wn%[qw0 vk Ɇ_"c*T%c#7SEM@2 eb'48Q!z++{!;=zlZW^bE"EWQkl)Gyg?vk1CSƍDkw"`8Ƕk 4$3I ~٨4 c Df-@ I,QBBmsl!w?6bE8,Zt>f߽|ϯ_tؙ+WX)ymem??G?1c0qD",c+Z[B;~xƏςy7yT] 'L2Ɍ|})W]{XFI/J^ݛݺ:Zš]$& i9{Y9wMo؄"3QC<ǑuNQZ8<[τģ=/#Ç)(,dY1[trz._0FB+|%/^ґwp2*s 5+muƠB&#ŨR٣;;t$ /NG4:OfR[ru!|R*kz2TZZ7*h#lGݩGظeXh7J߉& "}FfWC|>{s_V!HǶcw(/֎aÆSUURʖYi d҅{=Ld2?,[f5"ƶ.lq8IkͿ%K!o#Lf$SIJh߾Ï~jO6Z#6 A&d߷MpƝxT*MCC򊖕S]]ͤAiE:&d;b1uY<ԓ8J)BCMeY9~ǰZwqH4ؐ˫k6t8K,s`(h`G&%"'OfIf46)HPZNzckZ‹/͠F+҂:goYn-XmEH/*LjE^fɖ}1Gͤ,\6 r1Q\9ӏ޻Xj%tN:3d!sӻo8lƄ+S_[\B9.is?om!{5Kɛwmɚ<6%\߷zޅ J!'HgRH)ɤ3 mgR{RR:`ҤxI46&Fu)))mJ P 2uLj/JI܏1Jr_Z~<)> $3'((h8hpf}1l:ґ Gi椒 m7z}SѶT%LcC̘6Ϝz]}r(^|e+% N>c"nޅ^CeVt:&&ҡSXTHuF:ɧάY_ef֬Yk0R\g͜Y_ R!7"؃6D&^,V o`#<\wɑ5u95 ,իV0[M NcpE&٘7]?57~/aEۓ9ymy˯1ىh)H d!Nag̬_[MNm+N6m E`Ʊ .(T"dЀ=;xl%JSPTBL* ^P@"QN H'G"jv TͶYر#Ql`;Z#AȦ7Q߷k׮cm823q*PlZ@ڔVp_{=UUPfBTVE Xbnݺ˛:qp!8^ ҃6xi;OtPhʋ4laْoxx9gr's±'ly)PHS0s4&A"VF wz{wiL64pp)۽?O>zٖu`Gxucun z*w][V8xN_:2o w!cnA?H'$pZX~}-˓H$H'S^ކcQipaOͰaܩ3+W' MPD%F 7'ċҵ &!q\T$񘡨l:C:Ȣab?2~9//jJn5CŎ:QXD]C ǵNSX$硇8iӱnjn Ʊ)p:jchE:e%[q`(؅Eh+4{M?pc s5uyvHt*0ޞM֑n܌vѥ+XcLZnkwH o[Huf (HX}yB~,HZ3`\ o%N֋!b6ȃb=X|X@楉jLc?{?XFu-"WM & 8h?/g}<,]CFUWK,{ff&>|rqib[``SRh޾#t'}˘5k"q}#dْ%^2M6q6/ǎǑcDppd,qq!Y*\-䩧b󖭌=zנ+xj(lǤkYv:ϭM "[4n%|{sDŽ 7rGҙڠ M7JpB ']'|i<)TuO=ׄo‹#X3@c@HPjLвHN6yHsiKJ mN+3=6EkSZRZ:lnUTz* l{܉-K܃(oOՆv(mSPV0SNڡT;Ϙ:OΘ:¢| " D[{d4K`7_19VvHa}Og\v?ߝ{~ʕ?j elݺպz1R/lpMniޞO0 @2lZ'fQLt39c9.Lc(iM6qu(8F qlW2kLV\'] @. ұ9Pvخ4>ȧ?a}mشiJfc;i:d!N ,/Tiawx7~q/W_W*++W' I&> C Ͷű"ah;w6k,{iJo7!Hcr*Kn=|s p>:ԤYRhVY͝ws/";{Gݻsfs((fDsR;Цγ|7hg7-)5a&vn,(>Z4@+tQ9fg0`w; GrYR5p:ᙧaú \e8 IDATs5-ʷdK/lFN<=׵ udbquij.lul^İЮd7gP^@zCvn6m.yݎ`{)єf>#mmSYTl3ִť&b0F\#K\aqֆC ^?h(BbGAA^e$D,߯q1` 9Pn>CdGPеK~cB;'< FG!hr,XÎ ,ؔ(Z{d2Yb1['Lxgh߾% oq°tzA{L5dd-q+%oY&7kp %ח^{tҖ1( M?ܽwUսZk}4f{oƆbKl H1M1vc4Kb45*V,X"fhLai{~}sޛ|AsfuVw1xZp"nFvD"Y+k](6$]&C*!ew /U7shm|zߏ!|DAP$Ner#9f~ú-MkVn:xXwL7 !bx`FZ[͘ ڼ>`j&Sh6ϢDi%KPRҖLh# uM,_#͗x'HSlܴ޽puW BND= T~<"юOXVY3ĉ0m LIhOev ha7nsqlڴt:ֆ*'FJabiD"D\t`t 92x ~l&M)YKٵŋ Y/ Ɖm=u&)N|@[)Gr9?+/z޿Xи xA;Px[cޏy4C qqQ Ob"7S<]p9'rO=#.]J8Ot,Nshy-PN ׍`$d,@:[k^} B5}9'u(+- hj6֑Ng3a(K*x \ סQ۫#^(Gha*kJTZkrnoԈlX_eh$ °@ؗkej`NTH%[`` 4."}V.f!;!6s Rdy?`}YD$il#R@&ݔ|{=h,cD1ycfaf"wĦM7I֖&8uR 9䗗uz.: =g %ݐdbZ:VﻏH=~w'} 0w{1!qaK 8}7w_ZYe6` [ ҠC$C`tXhyYyϯtX Cib } @6!/&+B&O))l=}vl;Y" -I2Y=ahiI#8;߭p/t"mH%D'JuU5,]`@ӽ{wn(1 dztR2$ | }wfW_{UWeK 7\cYSW^}ʝwC h@K瞘RNC |-@Kh_{3׷L.rӏ2{ *}4-C rM|p,]%ꄙBJfX7BInnw߲I^RCA}5У_ĬY$#I{>^:(E"-h |&ةs?'XWf)%eW7rС}0rXdjhCYl# 8BHVv-$qRe009rH>{%F!B_A1p`s 4=G|B\c nn?ow&L`" (R Gy5kWr=IS!E&+3aG_z;퀬L9z *X_PB9B"q%R02(&]eAA.e@ 3;3 *"|`fg?QaxHFB`Ĉ g?)MTV69b . i]B$8^I$ZFKhjgSj|)s?~#09(.R9NUv,^2A&\3iYɂ%>B)KXD`<}22 m!禫M`,h%pvv*,k&Wt}@$BhBhB@" dСPUhRm)Z |!6+2yڭ k֐L1>o|ŧϗ|t)eݞ 4zw>7N=d/_Nn u!C;w.%QVZŒ%K3fY*B_ ƌ 1#Mng_W^Li:x^z+D*L( (J"5C cGwﻗοUUk8Ul0%ԯPkMkt7$*d.90h6lgo?먩^K(TF[h7 ⸋UVfr"@UOq;h%Z,XA!x"F*9&Zld`m#ǐ V@ŲƯq$GqdJ^Ǎ7]ڪ5__Kq3qy̦Nj/=ϊիmIt\Ͻf![6辵C:vՎrJ XD Е?v3t ~8l^ze0uO0Qʊ Ie?D#.u'*_Ü.1FIqoܥr7ox 2 qGpAmwEݙh_|5-𐖞+ m2m4N9I,Ưe+ /pI N9d/ f>_)qFcT$c97[G#Yun欟]wOnF^ J = VaK?Y?y1)/+ƍ*]1a==mР'rJKD}ZZ^,[.t}ޝ(--#xi[pZJn# Fc=dudaczRJ[w;7m~>h 45mNؚ_x}GzS*< JZ:!–'O[mD9fQRP*ZxvT$[1͵HD# ZKqu{-3}L&[5|K W3$ǜug4ӎ?X4/d*E6HS-%uhkm-)-xI͘s:l)`A <;}D"38&M:d/̚v*Ŗ= >ofGZ\?t`օ _~ߤw9cw{M4n]JX{ p\T$5Z! `T1Hc8a7<!p-qw rW I%s?'EJyG̙l݊TٜTCU`PO$}ko.mO:}W_yC0avfQc8)nٽ&U+eSRRLeyM۶ꠐAg‰O|2,[N`$-YqH%khCh'Ku9\] +yٺf}>Mv WÏP}{__~9=ɓ{>e3Y{h;]0BJ|QF)BlV!0#F*|ճ73}=zub.R<Vm#ω'Q QcLH-A忢BӢi +4p!W-_״j}~+ٻk$w7~R]\E: 3VU.'^Pر/5W.6>J)e@mtQƚIJK;r I'V?7gY]W-:!1D`XxÐwes ,`W_,P_,!I؈kTT;YX/"lZjQٷ'_|fӣʴ~! Og! _*{xuyGJlWAk_{qDA*?I%/XUMCSݻvG⯗poDQX\jF[| [RBTvA䓏l;J8)UPôpsE\usKkSB!&Lss#af=֬YE~}֬Y I`oEsKShh*PkƐ2߮fH=3ΚExYLsŊR ٵ6yL,! sOjۿg0EkAՁ 7\Ӈ[o;~`7<64[^7j^ Qc; tؾCGv.ک__y-/;{,t:!2A@TmszѾP_WX˄RЭ[= $Q"zuf=< Dz{V8g?BQ&$5#RɌ48PDqE?X69F[~'~زހ85xLSYYчc:&VR8TPHxqc1lX_!g|/a7l#̤&ןomիOA2NyE rkX: ?2ԇY6lZ뺇՛G{7٨QxmYUc~܈cе$ "~F f F>3NVm F_͟1apg/wJ9&kVQ?v%)myB0$sZ$"V\aЁ[u\-;S-w+7O<zO~̤Ipy1bP΂?Ndv,^)b킍}x)GC555qUn݊d=T aD&mw}&"\V2?zw̚dױ Fkm)텕PXIm Rk`qno wa-#D& X|Yaҁ3)ì71c(=nH؛9N?Dn7LgfrygqI̙!6l-Ecs+Z(t,ʄkV {3aClJYl:4pIDc- 0nS+/**=´iGӴ^u2iR"|˲5G> .=vDk{xキ\TX>x/pƏ8LI)A`*r<x/ IDAT~+ | ||,[RM,Z5j4HeaBF EE/W֕ZN+VBk;kQþ{Oevdwߊ5#]!Lڀ/n\t]Hp*Ak[[B̎˯}ݏҢR\'B6F ʞ7}qK+ɸ {!\֓@$Be 9qX.d22J.ّ0rw6N4)23,q"(+AQdd#!'RT'*(2"%dLgd[u_s_g5*l>C} B_Kl7b Yb---B/.ǵO\h fm$5CM45kN}]iokn <7p3fl5sn:xa֭ZgR1 Cuaڴ]šᶵsa?[ )6F"LZ7g&ۨƦF4Aɤ=u44mk"+{ң J3iC,S'<؂-[SO!2cj2a̝c8R YZ\Ú]~x%D ӏlkts+Ad XDZ+N9,.O/Yvx8x駿ܹ˟ SϿs!%lKY{XK/K/ sG0x 6,Z3i+,xY/)FRv:Ͼ>~)ƍÀׁM( ^l'lחS9y0~<{b;>5r˺4a]^D:zŵocsWҋτʚn TT"$C+MFx)}5w 3Yͣܙ$0B O'׶5ۖ))٨tԨQ^66gRniϤ}ϛlظ5>O9O?h@khϊC*-C#hjj!dG6$%gy\|y`ScBH[_,^~yfHTٳߧf'͛v pYckh+d}"@6̓ 0UJ= +d04Φ9F__9~1bk+R@A8kYl&fQ\ۭw(eKH琓ZiGZr<.yÏVk{X0lg(8[B 9z9]9w 6Z̙ÂEټe (:8C& &$d?X4aБsd>E &֮Xp AV=1(HQ_]~)'8/Y/r1GQģVhj-ߴ gܘ<1O(8VnJѸDl`ت]cSդ iqYoCsE{Ͻ0-\ve$S$=ҩC (S^QV9{䨩v\ xw r5 J455j@8*b&^9k9C[Ǻu?ϩ$(B.[ZrEkFܝcpksJH-.IOB>=X 8(QCDC4ǍԄO6FPΑHaHZsCAhdsB3r8=b* NIee/1!#!^TDKњNԄFٸqczu̙5-Z[Z$bQ."U8]D]2O2'mH CNCC&9V\G\}yfZ3<*,L !@V- N;k֮s #Gcw^̛?mDc1JJhkkB^uZٕ'=D3)G9Asbu@&P圮hݼ.FŨRPmM`Xf( ˲T׈a# 0+Up5y>(Iߞy|U'+7n9]UPc/mlH0r27$<ٸA*iPHǪ$JX|Ŝ8|w3'ydA\0Э[7OihYoh<\PDEģ1t'3D4ZQ=ֶ$Jx\zL7>GAyeX~#Y !mp'A* p~_ᜏI)F:d=x,aiZ/<`=[y(8CŸ- Mg -cȳ4=o6R:מ"lyX,R _q.e]T+{%eq$nd,geOUj|5cF{#f6m e7Kl`'*TE3|Z>st֍{r/mDhVn@ +baajxߟѣFfȜLZ8wGO=9z%oIp]# R};7pE3Xb)J(|k'Q 6C(އfV{.%%h+iaއ(Ph.vpnϡ }r $&uyAq?(|aoيo4„ -?(*)jYd+"GB3|}mALFF#rC4GW~Xd>grBK5'MAHyu֍Oo'-Td/= ni'fy~e@J!uy8{G?{ϒ .ka!t +4=p e\N-]F$BXOkKaKVkaђEcqo:0 al#ѬY:/9'O=mBc6,Dh+8Sw 䐃'zƎØ1cxhnnqL;N)Y ) U;#A`Y@^H>#v=e0K:GBϾX]Jk#@YEO8qu[le-!0$ۥ6%G~8?84^mm Yi7weC {&Iӽ]ĸ,5)-+B.S_+8|!yHZ) 6 V58haMw]zqV_~㽯"{YֆIOĨ蒰M"UD# G .="90ņZk9$xz8Y/KSc EeLŅ(0-TLFz )eם^DayӖ>[RU)f*eg;zټ 0"<`f`Qb-4ի恿>ҥ_+/o۫m%cmM`8a#BRp^´WFS믻@1GXGnjfꫯRnlذ5!VmOօz!\{hm߯/pu3aN SN>|) ) 4cʴɢTj.۶DOk|cڱR 7| '|&eqpw՚~={k~u) B@γ8G9,h-#NXr8/G} %F&: qFpDL2CPZ6x^ze^@z}ih"ۆT6mo~Xt)A { S^Vr?H 17`21219|H>,EZWdl6V܃I#KREOssٔZk>|wn ͻ}@8`4]k W_n}7Sޥ_zb^zE}ʡwTWF R\\fgO4;|\@fл)l(#oBӎl6Z˯=༩y?N%5* $T=~}[]&g?/ 7if;x2xhljzz H?c_aAP[[o[aCӽ{ FqI{Z~ܞu*˘^(,%b\ǵQpȾp>SN9Ji qFѨUi$Q}`K&|9g^̜7UmaTk]q5UG pMo2jà&=ml2y\uoyXvC#B=;/?uA t0|_[IyXيe˩q?,X0SN9=8gQ/s:T:M|?xw,0&p <$Z[ (sS 7˗QZ=ٶEE'0AƦm$md5tj / dI-U.@ckEbҷov_'KTC0尃ԥaC`4%Ut!Z\qd@b?" Ϸ>iJ1*KcB9fԱ1z& ϧ+КJ imPxl!M9ſcƠl۶5g0uu[|sj_ h4ٸ;ah=zLjl%%ZX-mB=l)<0rƗ_|A6AjhiHFXy3~ȯ~ynw᫹l$e`R^CwȒR٣pSw`&P[@i]_ѡ eu3ZkZֵ;wqnݺqo~u]C[6%b$1Eq{8 ID;' C<zmHZQeacYh5md,dLUߌE#u!)~)6h ֫4z`)V,ÚU`-&!>ԲSfOM h{7nk1= xH YHlR!۷?wy_˯/?vf D6QcgzE C<R f&c^zyƙ f4uuo᧗tT7 [jʚ@# #175u5Wk/0iyGw68ϟ7|k͒<\2Dh UhbLt&n=̤3:R KWk46ƕ"|M$Z A~{6*|qu" !ݖ D K2–5ZW堬R7.'0zj:Ï3ӧMcH4motՓ}%<ϰ|- 3 S?(oA,z1^[V0bQJTs"28酂[\qywjgx CuXqQr}!b՚M 5|p/_ƻy^Wp%WÏߟfK hϵ뫷J;ط\Hm lOPYo֭|38C|1w PK&Q{ fN}vy#J`܍ݫW/̝?ߣr.dX:C&%I[PTMIi#w/xyGyG8'K2ϲ)(^.=ټbpI"#g3h!:" Ϭ߰ u DpYg`3<ˡ~{ߣ|dυ IDAT~gm-ʋqڙřօ ]Uie@k;e|e|F9]t2̜DH'LgMhh ۚygH&[ҷqf_!CiEqѮo?ʹ3o6֮_6ʳrU!ՆN"qV~h,KUk?G QԶC䓏 |)@ %_/ h|tRBQICط`ri|ita/e k];#PRCd۶tppر?p K}tqUD$CJE26\aeO>;GI'\ϗ>0IN#vڙVӎ hm4ϨpV'*JF3rhc.]BUu 9Z3|-'(/Jk[+T ʅw߆XڲhA{m̤8S(m+ӽwl̷z'Ldɒ֒FVew&JPJZ>uTT ׳~z PkL! '?̇|:{w;bJ>|ӫ`:x+a= @D$2H+m!0l+Ќ9rK&4Y?ʱLd&M 8Iכ5kVI|e:&D56R5wɂe@Ӗ$^J"Z $$uj/"ے$@eչs{+x.X:]h,R,R:1>cB?^l+ ރko-/y7Tm`MBo2`0M͍3dނlkl?ܝwUu*ܙ& M PE159YfQǀYPAETT@nx?6mpkZw-BUó;df̚ŧA8:UU}̥7\A hP.oy=(*~*Is9gڭ52 ?*[[{.ⶫf-|4ړn>3GԈY|)/tD5 *ZVƶͰfF jPPK$ҶzscI%Ҡ(! fu'Lx)<)CS&dKeOCYٹ{{înCw eg|@"i1/Bq^a ڵ;֗a %!/d>@"L}Kf?_t oMjjkx[73IR(~#(kF6}pӭ7HQQxݷk):55~#%c`tcfR2;3s_vA?Ӿ>ĕ>5PlYғ=>^e䈣Ok*;n4$>/^z˕%E ]_o.GuMMvKB @34<#''ql<ͧWt2cl*+ۭCp 7r P3L%1Pq駟(۴Cuff_~tK`۶JBńBa4Ep4o~YZ<U2c@p4e!Ņza-rE(fڗ?}Z95/0h(iZ8篿XT#dp 7fπߗt$ޔl-]>ANw1wbdΔܷdq1ٱcw|fiV,Q/ M"W- ~N:OuWȅzwԉp(H Gȍp3tP*V,_ʀ}Z,[CO>Ј!bPh8D,"'f{IG8gCi)#44οd2}Np,+meMg>vvEWr穮̮~08 :VLY ~+V*VZ {1 'ם1A``(zTTswZѡSQ! v2YgWɳ_nvdL)dZg2lFJ,uu%MT\w ?Vs\q!hRL$0xx/؃q dλ|/}fڿ3EнxCҗ$Pf_O}VV3xGW_~omPH{e+a9)++ `Eݻޞ7Wn,FjA#D8Oi]Q;n<2Ͻ[n&''D4ߧ~}pb,SqTTAHS1.}P AmMp-rp,*H.p.jCzqq]M6gZ۞{#G؞|** *"CI)Sǟ~lSu< ؿOkJMOP bYkJWQW_[;Ik6{ՕuLI)Jѭi~Cz.F  q=IM} YC^[1 TtM%hBEW B44F瞻yտflQҦ| MSճgً9`P_!8º_ f[of~˺RTU% \IONϘ}냼 볐-yNs0/NcSбLYjX|zJ(u ̚5NyT> [*1(ˆ#r>P(TWd7P x7due2Ot x.9\4(Bò}ܝG:edq P5HZq줍ЃR&i >.;CEcװ ݲ4ҥ9Z:f;,w7S~Rl43rpMq` &  PP>h^(:x.tp\#Dد?7]wO>xO->_:r(e1jqa4*c0wijjO 0S)AUU4Œ}j{_ZKe[PkL0o9X]Z{~i{m5tP?B~킌 ;ƆF91T1tH(³c_w>Z,[ USt_7aƙ5g'w<9n,7lO?EWUFyDyo@HLg/ųriƗЕ ٻ̔e:~(A\\v$iTTl˒=~Lth_Hmm= d !TnJaQ{_ >ho [3_o-J2Ĵl4C#/7MUX}++vƴ;bl  ڄrb(?[帨ҦJ((ghxGNy;mX2b~df~}J%yq= ?K.ڵټ&sLAJ~[;(߻̝ѡbtU8߽բrŌxBҏEƋT7%0` ݻwNEaETl-GӂTT x8l8t4|8ltЉp mأkjݚx衇ihh`   :#?-l(]C0:tൿiPTU0uZggJlfsox뚨Wֲӡt-U PaxՕӾ}>v@(Z;RPҸV%$55Xm[ܑ.{$'=o߁Uֱi' EbEBD5NUQRҳ׿*NNf6-]S6_ڰٙ sϔ= _rqEwoD}5 ³\M[pUzO=~oC}=_=KWBunQU2p@tu=b$d#$(:wJ,u&OnݺSkoh$lݲX8^Qb$ N<]=l06zjPN=4.bj(,,чDa!1H$,\ϕx.a:w"`Ͼl3hop gAMU`8kMyQ4Qv 8{L9y54Ld40n4kOKb?5ͤYv58iӨFU<.gލǛorl4]SĩAAQ.e9yu؆O_ѐޚy1BI/Q`٨/>"t\*fB(iӾ.W4SF4œyܳmGn=qy)N<$ w >m԰~n&7p3)..FСCF=˫h7'' ±Mjjjt t=e<³̙3;^~5uTĕ7:RUt pLpM4an= J7^ˋYSW\C^{2rkOgsϽ).nAuu5Lk^z.2?0f~:uٵ=mW>ryyy~XTl=N9VY*ݻwnd~u@{r 'o?Fn^!o&8Mmm-6lgo19ӘOt!x*գ'O~-m!C յY<_jꨪ(TU) /PW8SyQ۵HiDUUXIHlWܾW_(#F0x!xR)pQmIS61DCS#UfdAY%fNN&R3*ϲw)%+Ս lغG*D"9bFh5vOI'Z𳭪@W5tUE4!)֭D>UWae˾cocdzXNDҭC^&a 4094Ӷ/(>@A>e OI:5gsf.Cm܈eHOw-3Nosp8{o Oc_$Fs]m_ x>]W1]Muw]n?2ICX*S)%7pG>wJ]]-".Vf%hP%xoow3邂63mE4W1c nCk`|DPU_!/˜14--]e X ϓH/;X~ `Ͽ PA]~ڶd64d'Z < '80v~K8GOf)Q38%umW욡#F2%xyYۏT>/<1H9]̔q1qS Og7rfT0]Ӹ;0C~BɓO==J$<3|g ;u{m::ɉ8z.*n׹>xy蝽scRoBA.]hw/#7/dBmTW!ݺv]~!۶neŲ%:kRucwF]֭iiV6mFU5:v̶mp嵿a. DosqW|pLɴ(c%14F&hek[؞b9QXО#G0C  Ec4G~>.^@2W33J!Q2B+W. oӮ]1vx gfWcO8Q|ɇD8?a ;d"}J y򑧸+y~u455 ,{>'GhF3)s=$O3tr]_񾙉U ˶QT]QuDMC ]ץlCdZڈXG߂:(#خK2fu(Cѧ5֢U۵#RcGl 0}ޖM2dmll(G"uh4FaQ;:vBnI[6ʶrb/id˵~t M"%A=~WOy!xӭ\ʣ<sܟxrϽwlR4kfC*Ԯ:9+9frw}9s΋cTkbY^x |j:lMj*IBz>'}e߼zW,3Gx#v6M7CcIFT?lG:&46:b}{K=ؾ=P__C}Ccx.[Y|5eQz3NMLZ5oH̄3Q^PJQ4xX>Hh.E+($/w '@0B !l#/z5J^Ap-zfD =,B* Dk\i?%xB_ IDATx nwlZ*tTBI-q8/b$L# =,> 'vlT!2*-]•]e[~ݯ?W_uuݣﮪ_H5%ړw7φM*>`"ge…#j$M}8}۩=uL,[o۶cѳW / rbQ4]i*!LSXf|2C-+f뺞|we6dذaqخh:R[c:ڶ+V)ДLѵN]2|aqDٙ2x^4s߷=X~9t"cZѧWo:Xh53,G^{ޟX`:r٥W_s!5 EǎwߢLb.(hWLem-NHEE|aw~ivdr2k:4LɅh^N<߹hLHö-l|)cj'n6mē;oh;t]CU@S"ϗ; 7:[+Qߞj>wS tUES'w<]͆ ټʖв)ҚːzOO/%>x"ŋ3XGmlVAMQCc۶Uf8aDa_?K/ukױlwy7UUmKW7pޫꨊx+9m{&m'εd$k `P 8XfP @$uؼ6 w [:h;z$p #Ƒ*iGbs50n s6oބeYlڼ?w?v՗tʺ\!{}m6F[7W67B8W0/*Ӿi&IFA08?¿ěYyf:wvHmLŴ=<5p [orjkk0mI,UټiUUxkQ'r].1~*+ʨoBQ r ѽg_P4S^eݺq8uzt۶ UHjQT' TC$hr3֭S>_Mfh,=򘡁Y$BFr}av2 +]_7G94]G T]Gz@ J~~H.z nuB#Fj볷f3KJt POӴo0)]_ʃ< 9)ݰ2o͚n2SQ4g2{n*%_Du#{u]n6<upH&\zť̜9lf ZPahxRJWQpP0- eٶm[&Gm’%[_ZX~9CUY#<=wG]]}~i"B!P" }.O"U]WlFz]#t_L6 kg,+f_%x띷Yf-@ѳZ ̦t(*3Vl惿Oqo :w_`e=W\AMU%c!=EzH%SW[/hEbi&ϹvH ? %/?X?xҵk(,(dԩWZ?뺫w>TTVH$Sҋ3O9atr4Lp®#=_|]?}+N94J/Gr pUrԈbjjjx嗩EQU'q- ůKʩ9h~<)ò S|+?+6l)E a"tԌ귒Ռqo^)q5 C7n!EjQ6L8 ב! ƂE Yd)S|΀}g^b;3ɳ/q=@(PЀJN?\=D2߳=l: ӾkWB\S&h.h:\[E X{\ pȣp5Uئ㤱m c$㍘iqw{,EW;^#LD"'6cVsΛ?ab96fB(@EW'MX:PCb9E$GJ]70  xT^[ル'ˑGFFc 7+=oobfΞɣ=Ěk J1@m[> VU~@w. ŗ~_RBi6M ˖/熛oHX,'g  <9s碫isf BSQ /Ȃ9,})^<튊ƦF"{)>V ;7|ٳ /8]ӘTjj1Si"\i_8e_ں~7 9Eԣ hy{br~6[+ؼy+pWpa<}ͶmH|"Qt#g9/׬hiy9s~k.˵^ɪUI&UUH槊D2Μٳ[ŋsAjP  2D4Sbd"o\JB#G>x{*E@]c?e 04`pW^n=k6".tF0"mٔm݂0Ͻ*m(7z&LҲRj1TAܡ=kVFQDM h(2ݤ ңD*5;y.$ EI>K.dp8H$AGn8L:B.!]%'K$FUu$QƱLT`Y/z7ur{<3}#d W  *A| d{{&df9~۴MQQ //~={r9T]Cߥ @z=E)Tve+R ֬YӦCӣg~۶0 E8kHW~Vs8t=@,/P0L0 +xi2i 2N$~λl&47z7Ӳɦ9ZJႪ(}6K[.Ʉe$էEB)E.@hB\dgʁfmy<3F<,k QUUv_w->K[F-4QY]INA k*y ;pL͒=w>x-;H$l甕m'3 PDJ4a{i$pj+!Efď)]uOwph1,Fmg$`&2AkNՃ{twߥ>KLV/_,, 36]N;hꀦ 붶ء# q] xѧ8Ә={zCd`c_XF[ŋk"IH8Qҽ+yQݷ;s\ ˶hhlG??ߺ3x0< :tq5WsU[`:ӖXRbz'С}+B׈'ʘo!01H㤭4T&+EN?}K<} Ͻ<<~W^ynO'LgEg]/̥]9?2Hҩ4f&N#TtAn]ԥ#%ݻұC!.i~,mWF*YA|͗z2xir5׳icvѽۓ1-[Da*`C`Ĉ#Hd3ŅtԲT83-cW92[Sơi}T,'7u(f|7h0ʟN'MƛodM\r%xM\'mW'gI^J*$04Uh+С[˱+6of%xBgO.8 UӸkvz[2{LӾtahJ4ҥS! 4&3ضGȣ4M6mW\G"ܞq<]a_x\t^Yl[yҡSK=ؼuӻw_N:d8 +V[oq-R$i˦k{+#K0u/Ძn[P5O#rqQܡ+# rnE 1ɱ\|t؁-b*k֖zfےw<9s)sg֘I\:4; W5MysG3SO?~@mM5 u8Cbuk!OYUgFt׭Ԫc/!RI8JSUU〦6(d!CG0et^$ tZ!A2ia{ITU{Wu{2MьmYmz(15BP !@ $܄ $-w[r^st4ٲy1̜]~׻zUU@I&4o3(wٹgĄزsM=O‰O]0\ fnsoj@w>J6 @ӎ-9V;'6_w>b Ȋ q8v~7tK/A,"ɰ)*FIO¸q?\̟?=w߃<\)DQTWW?vFaQ?\sm1~X$7mE3 2t<xͅ8K0|X.0JQU&My!&M[-vA̛=7n=!w݉@9.+/A]c Btbe!T*=7ߘhiKhE#(.4`4u?]|i_qjrWX~_hjhcrirTT\Us>ynI$Sicq$b sSIhŮ?!&N0·q}uqoU5,=~gOG$!cú0 ͆,a/0 477O/o&vV.w;l\{xwc&4 o\#2Ct>Z*PFuq(2E8O77:) .-[Nu(Md} K l6SukʫOnW s/L;9C`Yy4 zq GWYyϦk6KΤvkyƶ όcrG 'pV/߬k&KP# vVmȔk03L-rhhl ;V؀x4"Q0a0$CQ%*;d>e"?]]ary.+ӄvWCOtȑ~ p͕W o}'s8Zc=zM}{.4M_?hoe߫QoKs^S|R TI3qi'ÓFk[;.]p=v%pyg'!3x2uV sf,ѣU_ t][3;k ]rÑQ.ܞSΚ|H͊%rD9/G̟]^} | i"|I$8r󟼿"#`=6 `=k 22ÕWT?yzh8ٙ< ] 2T -Au\;\uf nwOCچ|9Vـft4 y.'*+q)p"BXqVY믻[R& !w**Ko2ϸk׭_ist?eh^^|71dh)m9M~XVƱ<*M0 (*v((Fc࣏|*lٲ;w8HSHoM7۴y}YL275k47гssPf(ȈPV{L)6MgϛSF|p1E^출Nc"DC] ZQ9CT*'x;vnѣnϩ9?ڽȤho有eXiUo#.!#$VL-v籛~҈C?O|gˋ/ 8+هh4aĢq=IH\Db,KxJu8:@35&K"p,LbXxsQ>|_%}#?%)CU@FzD _z[ķX+^SY g`(YNRTy۟<ݫLgcn2ZŦ+ɄÎuK?ݼ-pULQJiz럶3rL_ lr< wlm/=P⓵?{>h[(nնN 1ūŋ? ?76lZYs?s”/Blhh_VpD"pF3e8Fq`L5NMv!OК$!$ow}sn"Ȅ}mT+(Tcɘ,I6j0-b{߱oO+_; `Ь'jo忰j]F3Ea?P;9vއ]5~66nuyg{XrK+RaJ&͊L#6@c#F&t#>AK5zF M337r uBxƫYn:cֽxׯ-ǝ8O? -N547u؇P-ذf;'0B3t%)=ߓ<KM,;_;PO:\v:l@nHE±:W@i{<aoeMk)+960 ƗD*$.[Uϣ djwyBf߿&EҢ dJJc#0,N0͏i\s[[J320À )2d~4{}KUǫUUNE!7jЂN>;pGW9/-᪰:(9۽>>vo=/^j*Q(5v?s x&pֳzg-uFpƹsi_ƛoF}cY-m1zԨ5&Ham!c-RrT"W,J.ZepOu]XxT7'mpE Xa# 9j_uTUm~ r~+ȼ-=yjl$sM~ǯulS6zxǰy=Ɛs P}6מwXjk^|w 2]L>1+a"Jז~[$0ᙞsL{OsM@%I,ENL9_z).{$e]vFxQq&Ug9bwxؗB2]ݯ_iI&RQ5pͦ:n?ykNsNA+ ,%he_lZLEqq0E4vA.T"Dl.PEoh4X$8#'p]7k:uk" FP0 p(7t6'cޅ;6'hw W##KQS>){ߦ:#F{H/}LCSa24C#E>}Eslӏ/}9kZc?\ծ S zE>O_sP0s麎d 똋`G[,==hy| [q_9㕃4@&gƍ1aq».~X+c֯8ffdL%K?[QO9sW[`gN݋5kWb޽ǒ ĉqnȑ#=mycq7 ;#mbO&q͚;y]\Wr8`{¶՘1}&͝LWB `,޵:vVfCgdhkM `s4Y|0Nt9f>>rp/ǚk3 1e)px7p47?Km2Z kw$>YBYBAH I|0M,1k1.qdƸ8z*2uy$"CQdb*Z=[!0B:vjĎX :!p8bфPYCw3 C7u]Eh8QE"'(a<Ҥd"eK4ƘEU6utH]x`z ΙkaiDk[^CkdL+Y>Ǭf &f0,{&*_Cc9N$Ly'*~v,CRL_'DV IyF-+юdъtcC[Z4E%T)Ԡݮv: Ofhm`lg7xƬ ^ι88R.U䗞_I4EnBnYX6-bO0Af_v{"7,4lP/]D9%uR<{ ܜbB JUPtEv_{4/h;c+T19gJcWv]UU1nxL 5ַv4IKhd2={P{7Mđ: !Kdk֬B-/G9@_텧h5 (m‘ Y>1xnj㧟~ Ӆ)'/b+,.@Aӱ*!|wf9`}f|3߇U`!1) Ff~=yz( eJ%4xx?v9Vp5t#q~m-5_0Q̋6[4pIqud0E6j6E2 2aضڢ۸rf@D&lvlj< vnW>FӄZ ynt٩a'M%]6G.X=<'E2VF (!!`Cxs*I;($j(HsBLb,l]mC]k8i1 Ƽ`PizQF6qG\6O8pVLfvT 4YX6Ky~5Uw(-DVmv Fq>*sgZ'%|qَ{uN="ae`5~;@c2xx*754%Gj d ̀-8sq ''eR&17OK4M`h, @SLYa5HC(ri'MǏ qA!tF"A$XpÖ˚['mabZĞ[lU,YLp/6-bWhӎ-5vs{8E[U6uD±eokf?;E|$Qc/AGڋFKFSJ?k`Z3614 ) EuAro" ΙxeMZp9.'J@8g-F.+m1胩IglbcrզDeY"{Kd4DB f0ŦD-(,E3@4^dh0k)َP(H0 t:d2T2]OCg(!U @VȲBvl ˇSKKPZZeY&x+q0 3 Pup!aDNι9OK%3ϛ7hwlk&Y-`"Ec,O[fa?[//f+Fv4K1(%$I)Y[7RUd?<4V!ax" 2L3PU 5pL=O'Țϔ 7<1 bI YX,ѺUo9W $L̜:8]ykWs"\޾lpO;d̗gIvoc0 "@F⨨ vXkش R8*$EJn-;n( ]A3T`ĨQ1X|։5֬آǷ;vLHm6nIn1w.׭ы 1?+@wB~a(;aؠK\NɿS{i||Eo-jLkIUDž>kR6ggC$Npǧ^ho *5}ι$۳o9l߆6:* Aii)yyp}K)i2,zA鉥r#1[;Kwĥ(rDH)I@)3ʨb8@N>}pؠz'S&LI,DP𐂂|-ȇ3G%GgGE1h` 4J)X4kMImhmkEggvf){I&2RBG41i;fjˠ8眲 Mk9.Cn<]"r +RY89CL]9']@K2 CIھVKkyЅ P 9rqgfw-bZ ʢO"1HMQtzMvbO6 #zM\V79.s /-͒b$!SF|F<F Fضnkv /_ Q xsc;{̯lsawLog~T.suͅuSdEC|s\6??RP0fmYUSs 65(%%"ų=y$4B|I/ƪ*lٴ6U|w>T $AKBEEC+aڴ8~ܯ`-ȗ}!ῗk6:4bm _YUt0c眛/7Tי]kntWZy~.KX[4($|MvauuMOkޒd M[ R]\qzcQ]Zl I0 ;SM0z\s>~F\8`&@{" g\svᓀ?E ݕSSSSBFQa)lȔzhBFMfs[m׺UZ'O]qnjT$Sp5@ +ȧo2ۦAK2~E>R d+'= m%Yk: tM7h鋡IRBtauÒ\ 0S" ߅yng9c2UUƘ#sRZ^읖DuA(IM2j*+[.!kW2&0ru]peݟX8jTZ:Jrhz i.wˏ-fvג#fs}\K6JF"O fxsjV9ᣇ|x}<xs~󓢖掂o|, *&t&=5υr0%7ۛm7<Eaqg4U[.h[So:~5454Jq>eCM{óx@{g$SqHy~0@Qd0CC`0 Ũ>ǏDZ3f`ڴQnkakVlI\RRU䜇ѭn\'G7:+6Ys'{ `-. P6rz _z~ahM]r"LiYҳQ|vmw-{˴W2md\  ;\a>n  -[ZN>@g-9mk}Bugmg_0p6KӍC[[ ΤHb nGwK T4]ngjZKEWYq׃[w{dKUu//1R %RbpNIa}:apg3f0#Khp,9E6`ΘX$h 4@R"%YJSJuBrm~s}C)? O=nh1g2#Sye⣈`@13fcL挙ґ.pGcg* 7dZ:d< ]Ogt HSRL8vY/fK!ȭI漙C&n8uo1aÃYJbqIĺ;~a -.}HtX‰%Ę tg:Zo~9TZVXtUge%h{nfSAAdM_e6E E_ ?ωߏҒBT .Ǩ#0y~7.=Ԉ|KUqԚ["\q/!FKHHGwEeB^ eWKRiyOyuAKS\Re:Нo=0Ⱥ: !wdL7ݮ̊F;J #p͆*4uv`XЮ.Ĺ/9,V, c&a,3&mR&jD<|,z5sb-FoY,r&!6GrOXXSY6[|)bMu@)EiiY, ѯy0릀<OhŢ ]%p 1Y=gkȊTT9N% 'p` RSJ4Y, e[vmי 5.<碓~f0 Mdːؾ} w8Ԉu8eKs2_U׉gEUlŗ7zIAw}3qfb$EA^^lgie*Ǎʡ>ݚ[kafƊszrzPJQRR9oӷǛ#ᘙH=sQ_WIPR碸OnH8[fcAbϦ+v m}ݼ{^?>Ɍ\s7{o<׹:spy|-x;ӷnIU߱uo1fU3M̬bH8!]~괲E;@-) C l*Wﻪ׿3]| @ހnLfaeݺ-΍ENբnK8s T+`D1T؉ # UfixQ4YTYi,г2囕 i34, XzCQtv0rL)xhߣ= ߑ]$e'*dw,c gy՗o{1[鸃4I 1sįsNf̘ E(, C8@ 2ܚL&QЀ]vaqS|O^u=."n]vRW^Zr9e֋12 t`fh< Fa%J5E5X #L0S`2c __zt J+QV6PVVAAQYtx<:ޅ&:;7_{/?,NeCdP1E(.*A 䂪R IX,sNL| *++s,2=]|o, 7U 9[iuϪpw|~5y'O/:ĩISGI-~Q0;շVդ6o߱uo0Xjj]e&x1gF-Y>`v`JOfԳx LF$-`R,9,R+4 ÐiWT T3a .I X"{9=lLb l1L@neU(~f4V!l:2 ~~;;WZ^(9G'ԽP>>F ߃3ϛgrHuA:RJq;O=kNho -CeMKys,;H'R(z1|\vN؈uكU'DcB`Ojb(1 7ҞU/ZZO4͟:f".幝{D"[,${ɐy'YpBP(!jm%л24&JX_ N}l]`PdN , T ΐ'OaFef3ƪU_#`w1yL\jP[[X,D2xs ~3wt-GxC*C@F8st.C#-6.maT&9XM6%ʐ<5s$9Iطި޹_^ڵ}_@mCf)-ej1zLqK0!c(d)ffn7~'v"lI`DnR+zJ"]םƤyCJ&V9 w~z} =BJD4D7}˞FJiJŠYbd_&F{G;D MDĮbRm;wa Gk_9db:*=?FUsUnU->h:Pf)ܼaW0edn)0tx*F,HwvMojhK7ַ[L+ c&ZհC%[P/+ D}aۻhjnFKs+l;y 93fOPf̞Pzf߇-m\#4K9a fO6CcX@ @!2tc뇀s-O5Ե*R=, h~4"_>FܓR4[|sG&DV$daK]Iϝ(,*Ht:$UUq6%E>B/"P;,v:`i8w'>vś z5eo2sCQ:aoMD@9h9DfK,Z3;^<)N粼LcV3maL c͇1_V%`ay-;;K㏱phhlw2sDy有]7<{=&Skm:{4WT0um!^VཥU5)By_{ ȊJ~C?,:z=Gw 6+rY5r2b9e陂`8$)0!PiECDAõeEuJ\7}iTWž{PWWfb1!A!DqNH2D:A$̟y6lxִ_2)ڮ^K?__7)Q<ax2 1^A5HKv&W }%Jb|p\\ꗊKO{-pB9)xwvC9ca3w DaC qp"+6Z9Y0Z"B͸\T۱uo{|2flB V6r]$R׿! 5k݅ض} 0,^q%5b0/oN(aϒYYO\EeՎLk`cds?P0c+Y~ffB B=>\|1.b_{N;T455㤓5!}}{%jjA6tA(Ԗ3)-`rH0w XTpX/ 9K8e)\t˜sY5A#'& 8" Zd@<>7)**#F""R9lQ6l*l6;\.׆n~.0#TW L (wp-Y,Y)x9/C1Q2+鰄{b}ΡeaeʐR|PUT s(:~Y|+LbVK K~,Yf7-nRiRb$Ԋ/7ɚdڤ͘鵶C@vkCB?'_c𰑃VRSZyoCi΁D0v 34 Ȁ@"C!Xf7y!8H,KqIiJ;?۸J&1vhcԩ8zɨoCK!`q4i*.saؑީl-{1jk'!+mضc+bNZ;m,)v]u8]˙;]n%PPY(Mt"6ծɊ44nsNz UK^shu0, 횦1D_@M0(߶K?_o}YEv hnG 2֤/+N]:S9|:LRY*WT<z zAhc]+`˖6,t6]^7\Qd fmF֬];05BZ;o,ޓH[[2;ܚ] hmdPR(-K7kQ'.v/>&U]-^l2V;\d0sSZ8}VgVo{^ s.̙lis}9~4y~Bhv஻Ɲwײϣip :;:@e<ܳD?pmV.'˯6"5'4t_ǩ8l&Veϟdjo+K YxYYxe1/¦^+ /yx8[c `(3gJd6ƴ}ʝ'r%a'D&9$MHZE})! ܰ};nǚ#~OP,U"Au ^QD<έq컩ȍ4rxPyiRl3io"C8]hHNF\n}cɖ?V{OVލ5>̍ OIށ+!ܵT$< 3\%jZ7v-Kl\ ʻt880ZV~ -ZeI 3/wd3}϶~w&{WG1:: 05=I&4MC>_ۂ@K`^`dB^( @r!ѱ1ү**ڍ/?0Ax g BKkm.{XQvͧ/?z⑛[)[ԫE^%^%(i*UeY-V+RVRuF[6 犳j Jp$iuFD;]J4$ZJjKUX(|I_t:''2ʼn b(@4y3yZC=Wk(WʘM$P. 8?UDco}SkT#֣F2H? yhԠKHv:RFA~ &] {E:t߇>b $Nz#g153T|f@7,+'=}hu-{{曱%i͟;an&sViu1[_EPs}~{h+i[1<|L~[(P{פtr'A~͏ZJ Nb$7BFd`oZlHgYvXB{=]h LMM… E.EX$UaJ%q[ZFյu}7Njˣ4{&1Ł> 9k03⃟~ty9Ŗ(# >Hb+I:ْ>"n ݡФħ'5X@3P G۹ex5=0i̧RH-f-<090thEaS,-[s݋{v7tͤr_ѳyE#PKD\q\8*M83aN z \0)!&3Kt Bgnp-.wl>> ˋhIRJJ/>ditqӃԼO!|3 '4V75mʹ":B)%7R г:R|uBpux¥eyQ|Dދ@(pk;:gg7ڣ7m۶禽}~L:gVϽ3QMhNrZQ 9I* ~7-Bnqt*f 5c΋ %1^ JV8ٹ֝Ļ@a`ɍPq蹫Qlwß44 Z Z`^ϳgqlXŋ2iK0mTh 6;r氐LvsWr6]^U u)P!p_/٧o [ '،>16\8?#?;Ufm%&aK3y|d oLVϝ ?+JJ[ɳAWd kj] ^߻z94|C,efrA&Yi|t, ):Tmo~0 iqgBC`L{%m@-Q 0s-t 6!] m/};wmn GB'rwx7/TAoJNըKܟI J(qu݋" ٌ>pY,+XHdlROL'2=9/MMUK QhBFE,w%^CII]Q ]QZ7&_)$D7P$ ]5Uqr0A`PM|>/ {;x7ţBguDp$Zi(\=;7 Ce4jL᱌&]4Q䢞+ ia%Eo~t-WHU c>fSLJM)3S rr![E-$(QÍWmY:yJϞnts]6c```Xa#UP^{RcrnBWʹvtxA,3K#)E'iF9l8 `[aqo?w=cm$)(+f>W43y#dNK'|"$fRr6'B稸2ElBBw㥓"!޳8a``x,EVиɁO}1+MP3lZ+Fh6ރNzm `Mh XgR4[v(/URu hҢ`i4n} n_Z#bz]&,sBDДl&+BZ3B1r+Ylډ9%4!Ue8U"#(-4aYMqn ׃jlW^T?"¹ڦ+l̇0,aɒ*4UY8I[ vF^rs RItᵙf\GP(8鎱i#.  ])O&!}Fh6IKRWj$ 1ǺKH #- MrȟIENDB`flask-paranoid-0.3.0/docs/_templates/000077500000000000000000000000001422207451100174705ustar00rootroot00000000000000flask-paranoid-0.3.0/docs/_templates/about.html000066400000000000000000000037451422207451100215010ustar00rootroot00000000000000{% if theme_logo %}

{{ project }}

{% endif %}

{% else %}

{{ project }}

{% endif %} {% if theme_description %}

{{ theme_description }}

{% endif %} {% if theme_github_user and theme_github_repo %} {% if theme_github_button|lower == 'true' %}

{% endif %} {% endif %} {% if theme_travis_button|lower != 'false' %} {% if theme_travis_button|lower == 'true' %} {% set path = theme_github_user + '/' + theme_github_repo %} {% else %} {% set path = theme_travis_button %} {% endif %}

https://secure.travis-ci.org/{{ path }}.svg?branch=master

{% endif %} {% if theme_codecov_button|lower != 'false' %} {% if theme_codecov_button|lower == 'true' %} {% set path = theme_github_user + '/' + theme_github_repo %} {% else %} {% set path = theme_codecov_button %} {% endif %}

https://codecov.io/github/{{ path }}/coverage.svg?branch=master

{% endif %} flask-paranoid-0.3.0/docs/_templates/links.html000066400000000000000000000004721422207451100215010ustar00rootroot00000000000000

Useful Links

flask-paranoid-0.3.0/docs/conf.py000066400000000000000000000120661422207451100166370ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # flask-paranoid documentation build configuration file, created by # sphinx-quickstart on Sat Jul 1 17:13:54 2017. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ['sphinx.ext.autodoc'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Flask-Paranoid' copyright = '2017, Miguel Grinberg' author = 'Miguel Grinberg' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '' # The full version, including alpha/beta/rc tags. release = '' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {'logo_name': 'blah', 'description': 'Simple session protection.', 'github_user': 'miguelgrinberg', 'github_repo': 'flask-paranoid', 'github_banner': True, 'github_button': True} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] html_sidebars = { '**': [ 'about.html', 'links.html' ] } # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = 'flask-paranoiddoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'flask-paranoid.tex', 'flask-paranoid Documentation', 'Miguel Grinberg', 'manual'), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'flask-paranoid', 'flask-paranoid Documentation', [author], 1) ] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'flask-paranoid', 'flask-paranoid Documentation', author, 'flask-paranoid', 'One line description of project.', 'Miscellaneous'), ] flask-paranoid-0.3.0/docs/index.rst000066400000000000000000000150241422207451100171760ustar00rootroot00000000000000.. flask-paranoid documentation master file, created by sphinx-quickstart on Sat Jul 1 17:13:54 2017. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. .. image:: _static/logo.png :alt: flask-paranoid :align: center Flask-Paranoid is a simple extension for the Flask microframework that protects the application against certain attacks in which the user session cookie is stolen and then used by the attacker. How Does It Work? ================= When a client connects to the application for the first time, a token that represents certain characteristics of this client is generated and stored. In succesive requests sent by this client, this token is regenerated and compared against the stored one. If the tokens are different, it is assumed that the client is sending requests from a different environment than the one in which the session was originally created, so in this case the session is destroyed and the request rejected as a preventive measure. By default, the token is generated from the IP address and the user agent of the client. This means that if a user session cookie is stolen and then used from a different location or from a different browser, the generated token will change, and that will block this "different" request. The idea is based on the strong session protection feature of Flask-Login, but generalized so that it can also be used outside of the Flask-Login context. The token generation and storage can be customized to fit different types of applications. Quick Start =========== Here is a simple application that uses Flask-Paranoid to protect the user session:: from flask import Flask from flask_paranoid import Paranoid app = Flask(__name__) app.config['SECRET_KEY'] = 'top-secret!' paranoid = Paranoid(app) paranoid.redirect_view = '/' @app.route('/') def index(): return render_template('index.html') In this example, the paranoid token computed on the initial request from a client will be stored in the user session. If a subsequent request generates a different token, the session will be cleared and then a redirect to the root URL will be made, forcing the potential attacker to log in again. Configuration ============= The only configuration is what determines the action that is taken when an invalid session is detected, after the session is cleared. The default is to return a 401 error back to the client. To redirect to a given URL, set ``paranoid.redirect_view`` to the desired location:: paranoid.redirect_view = '/login' Absolute URLs are also supported:: paranoid.redirect_view = 'https://www.google.com' To redirect to a registered Flask route, set ``paranoid.redirect_view`` to the desired endpoint name:: paranoid.redirect_view = 'index' To redirect to a route inside a blueprint, use the same syntax used by the ``url_for()`` function:: paranoid.redirect_view = 'auth.login' To have the extension invoke a callback function that generates the response, use the ``paranoid.on_invalid_session`` decorator. The function should return a valid Flask response:: @paranoid.on_invalid_session def invalid_session(): return 'please login', 401 Using with Flask-Login ====================== Since this extension overrides the similar feature in Flask-Login, it is recommended that when using both extensions in an application, session protection is disabled in Flask-Login:: login_manager.session_protection = None Flask-Paranoid detects if Flask-Login is being used, and in that case clears the "remember me" cookie in addition to the user session when an invalid session is detected. Customization ============= Flask-Paranoid allows applications to customize its inner workings through the use of subclasses. Changing the Token Generation Algorithm --------------------------------------- The default implementation creates a SHA256 hash of the client IP address and user agent. To use a different token generation algorithm, override the ``create_token`` method:: class MyParanoid(Paranoid): def create_token(self): pass This method is invoked in the context of a request, so it can access the ``request`` object to obtain information about the client. Changing Where the Token is Stored ---------------------------------- The default implementation writes the paranoid token to ``session['_paranoid_token']``. If a different storage mechanism is desired, override the ``write_token_to_session()`` and ``get_token_from_session()`` methods:: class MyParanoid(Paranoid): def write_token_to_session(self, token): pass def get_token_from_session(self): pass A tricky use case is when this extension is used with an API project that does not use the user session and instead provides authentication tokens to clients. In such a case, the application's token generation function can be enhanced to include the paranoid token. For example:: def get_token(username, password): if validate_user(username, password): return encode_jwt(claims={'username': username, 'paranoid_token': paranoid.create_token()}) And then the token storage methods can be overriden as follows:: class MyParanoid(Paranoid): def write_token_to_session(self, token): # nothing to do here, the paranoid token is inserted in the JWT # by the application pass def get_token_from_session(self): claims = decode_jwt(request.headers['X-API-TOKEN']) if 'paranoid_token' not in claims: abort(401) return claims['paranoid_token'] In this example, the ``encode_jwt`` and ``decode_jwt`` are application functions that work with JWT tokens. The client authenticates by passing the JWT token in the ``X-API-TOKEN`` header. Using a Different Session Cleanup Mechanism ------------------------------------------- By default, this extension empties the contents of the user session, and if Flask-Login is used, also deletes its "remember me" cookie. To change or replace the session cleanup algorithm, override the ``clear_session()`` method:: class MyParanoid(Paranoid): def clear_session(self, response): pass The ``response`` argument passed to this method is the response object that will be returned to the client after the session has been cleaned up. Any cookies that need to be deleted or modified must be included in this response object. .. toctree:: :maxdepth: 2 :caption: Contents: flask-paranoid-0.3.0/docs/make.bat000066400000000000000000000014541422207451100167440ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=python -msphinx ) set SOURCEDIR=. set BUILDDIR=_build set SPHINXPROJ=flask-paranoid if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The Sphinx module was not found. Make sure you have Sphinx installed, echo.then set the SPHINXBUILD environment variable to point to the full echo.path of the 'sphinx-build' executable. Alternatively you may add the echo.Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd flask-paranoid-0.3.0/example/000077500000000000000000000000001422207451100160365ustar00rootroot00000000000000flask-paranoid-0.3.0/example/app.py000066400000000000000000000017341422207451100171750ustar00rootroot00000000000000from flask import Flask, render_template, request, redirect from flask_paranoid import Paranoid from flask_login import LoginManager, UserMixin, login_user, logout_user, \ current_user app = Flask(__name__) app.config['SECRET_KEY'] = 'top-secret!' login_manager = LoginManager(app) paranoid = Paranoid(app) paranoid.redirect_view = '/' class User(UserMixin): def __init__(self, username): self.id = username @login_manager.user_loader def load_user(id): return User(id) @app.route('/', methods=['GET', 'POST']) def index(): if request.method == 'POST': if request.form.get('username'): remember = request.form.get('remember_me') is not None login_user(User(request.form.get('username')), remember=remember) else: logout_user() return redirect('/') else: return render_template( 'index.html', username=current_user.id if current_user.is_authenticated else None)flask-paranoid-0.3.0/example/requirements.txt000066400000000000000000000002001422207451100213120ustar00rootroot00000000000000click==8.0.3 Flask==2.0.2 Flask-Login==0.5.0 Flask_Paranoid itsdangerous==2.0.1 Jinja2==3.0.3 MarkupSafe==2.0.1 Werkzeug==2.0.2 flask-paranoid-0.3.0/example/templates/000077500000000000000000000000001422207451100200345ustar00rootroot00000000000000flask-paranoid-0.3.0/example/templates/index.html000066400000000000000000000035141422207451100220340ustar00rootroot00000000000000
{% if not username %}

Log In

{% else %}

Welcome, {{ username }}!

Try "stealing" the session and/or remember_token cookies to another computer (different IP address) or another browser (different user agent).

{% endif %}
flask-paranoid-0.3.0/pyproject.toml000066400000000000000000000001501422207451100173130ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=42", "wheel" ] build-backend = "setuptools.build_meta" flask-paranoid-0.3.0/setup.cfg000066400000000000000000000013561422207451100162310ustar00rootroot00000000000000[metadata] name = Flask-Paranoid version = 0.3.0 author = Miguel Grinberg author_email = miguel.grinberg@gmail.com description = Simple user session protection long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/miguelgrinberg/flask-paranoid project_urls = Bug Tracker = https://github.com/miguelgrinberg/flask-paranoid/issues classifiers = Intended Audience :: Developers Programming Language :: Python :: 3 License :: OSI Approved :: MIT License Operating System :: OS Independent [options] zip_safe = False include_package_data = True package_dir = = src packages = find: python_requires = >=3.6 install_requires = Flask>=0.10 [options.packages.find] where = src flask-paranoid-0.3.0/setup.py000066400000000000000000000000461422207451100161150ustar00rootroot00000000000000import setuptools setuptools.setup() flask-paranoid-0.3.0/src/000077500000000000000000000000001422207451100151725ustar00rootroot00000000000000flask-paranoid-0.3.0/src/flask_paranoid/000077500000000000000000000000001422207451100201475ustar00rootroot00000000000000flask-paranoid-0.3.0/src/flask_paranoid/__init__.py000066400000000000000000000000551422207451100222600ustar00rootroot00000000000000from .paranoid import Paranoid # noqa: F401 flask-paranoid-0.3.0/src/flask_paranoid/paranoid.py000066400000000000000000000103461422207451100223220ustar00rootroot00000000000000from hashlib import sha256 import sys from flask import session, request, make_response, url_for, current_app, \ redirect from werkzeug.exceptions import Unauthorized class Paranoid(object): def __init__(self, app=None): self.invalid_session_handler = self._default_invalid_session_handler if app: self.init_app(app) def init_app(self, app): @app.before_request def before_request(): token = self.create_token() existing_token = self.get_token_from_session() if existing_token is None: # this is a new session, so we write our id in it self.write_token_to_session(token) elif existing_token != token: # this session is invalid, so we get rid of it if callable(self.invalid_session_handler): response = make_response(self.invalid_session_handler()) else: if self.invalid_session_handler.startswith( ('http://', 'https://', '/')): url = self.invalid_session_handler else: url = url_for(self.invalid_session_handler) response = redirect(url) self.clear_session(response) return response def on_invalid_session(self, f): self.invalid_session_handler = f return f def _default_invalid_session_handler(self): try: raise Unauthorized() except Exception as e: response = current_app.handle_user_exception(e) return response @property def redirect_view(self): return self.invalid_session_handler @redirect_view.setter def redirect_view(self, view): self.invalid_session_handler = view def _get_remote_addr(self): address = request.headers.get('X-Forwarded-For', request.remote_addr) if address is None: # pragma: no cover address = 'x.x.x.x' address = address.encode('utf-8').split(b',')[0].strip() return address def create_token(self): """Create a session protection token for this client. This method generates a session protection token for the cilent, which consists in a hash of the user agent and the IP address. This method can be overriden by subclasses to implement different token generation algorithms. """ user_agent = request.headers.get('User-Agent') if user_agent is None: # pragma: no cover user_agent = 'no user agent' user_agent = user_agent.encode('utf-8') base = self._get_remote_addr() + b'|' + user_agent h = sha256() h.update(base) return h.hexdigest() def get_token_from_session(self): """Return the session protection token stored from the client session. This method retrieves the stored session protection token, or None if this is a brand new session that doesn't have a token in it. This default implementation finds the token in the user session. Subclasses can override this method and implement other storage methods. """ return session.get('_paranoid_token') def write_token_to_session(self, token): """Write a session protection token to the client session. This methods writes the session protection token. This default implementation writes the token to the user session. Subclasses can override this method to implement other storage methods. """ session['_paranoid_token'] = token def clear_session(self, response): """Clear the session. This method is invoked when the session is found to be invalid. Subclasses can override this method to implement a custom session reset. """ session.clear() # if flask-login is installed, we try to clear the # "remember me" cookie, just in case it is set if 'flask_login' in sys.modules: remember_cookie = current_app.config.get('REMEMBER_COOKIE', 'remember_token') response.set_cookie(remember_cookie, '', expires=0, max_age=0) flask-paranoid-0.3.0/tests/000077500000000000000000000000001422207451100155455ustar00rootroot00000000000000flask-paranoid-0.3.0/tests/__init__.py000066400000000000000000000000001422207451100176440ustar00rootroot00000000000000flask-paranoid-0.3.0/tests/test_paranoid.py000066400000000000000000000135161422207451100207610ustar00rootroot00000000000000import sys import unittest from flask import Flask from flask_paranoid import Paranoid class ParanoidTests(unittest.TestCase): def _delete_cookie(self, name, httponly=True): return (name + '=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; ' f'Max-Age=0; {"HttpOnly; " if httponly else ""}Path=/') def test_401(self): app = Flask(__name__) app.config['SECRET_KEY'] = 'foo' Paranoid(app) @app.route('/') def index(): return 'foobar' client = app.test_client(use_cookies=True) rv = client.get('/', headers={'User-Agent': 'foo'}) self.assertEqual(rv.status_code, 200) rv = client.get('/', headers={'User-Agent': 'foo'}) self.assertEqual(rv.status_code, 200) rv = client.get('/', headers={'User-Agent': 'bar'}) self.assertEqual(rv.status_code, 401) self.assertIn(self._delete_cookie('session'), rv.headers.getlist('Set-Cookie')) self.assertNotIn(self._delete_cookie('remember_token'), rv.headers.getlist('Set-Cookie')) def test_redirect_no_domain(self): app = Flask(__name__) app.config['SECRET_KEY'] = 'foo' paranoid = Paranoid(app) paranoid.redirect_view = '/foobarbaz' @app.route('/') def index(): return 'foobar' client = app.test_client(use_cookies=True) self.assertEqual(paranoid.redirect_view, '/foobarbaz') rv = client.get('/', headers={'User-Agent': 'foo'}) self.assertEqual(rv.status_code, 200) rv = client.get('/', headers={'User-Agent': 'bar'}) self.assertEqual(rv.status_code, 302) self.assertTrue(rv.headers['Location'].endswith('/foobarbaz')) self.assertIn(self._delete_cookie('session'), rv.headers.getlist('Set-Cookie')) self.assertNotIn(self._delete_cookie('remember_token'), rv.headers.getlist('Set-Cookie')) def test_redirect_domain(self): app = Flask(__name__) app.config['SECRET_KEY'] = 'foo' paranoid = Paranoid(app) paranoid.redirect_view = 'https://foo.com/foobarbaz' @app.route('/') def index(): return 'foobar' client = app.test_client(use_cookies=True) self.assertEqual(paranoid.redirect_view, 'https://foo.com/foobarbaz') rv = client.get('/', headers={'User-Agent': 'foo'}) self.assertEqual(rv.status_code, 200) rv = client.get('/', headers={'User-Agent': 'bar'}) self.assertEqual(rv.status_code, 302) self.assertEqual(rv.headers['Location'], 'https://foo.com/foobarbaz') self.assertIn(self._delete_cookie('session'), rv.headers.getlist('Set-Cookie')) self.assertNotIn(self._delete_cookie('remember_token'), rv.headers.getlist('Set-Cookie')) def test_redirect_view(self): app = Flask(__name__) app.config['SECRET_KEY'] = 'foo' paranoid = Paranoid(app) paranoid.redirect_view = 'custom_redirect' @app.route('/') def index(): return 'foobar' @app.route('/redirect') def custom_redirect(): return 'foo' client = app.test_client(use_cookies=True) self.assertEqual(paranoid.redirect_view, 'custom_redirect') rv = client.get('/', headers={'User-Agent': 'foo'}) self.assertEqual(rv.status_code, 200) rv = client.get('/', headers={'User-Agent': 'bar'}) self.assertEqual(rv.status_code, 302) self.assertTrue(rv.headers['Location'].endswith('/redirect')) self.assertIn(self._delete_cookie('session'), rv.headers.getlist('Set-Cookie')) self.assertNotIn(self._delete_cookie('remember_token'), rv.headers.getlist('Set-Cookie')) def test_callback(self): app = Flask(__name__) app.config['SECRET_KEY'] = 'foo' paranoid = Paranoid() paranoid.init_app(app) paranoid.redirect_view = 'custom_redirect' @app.route('/') def index(): return 'foobar' @paranoid.on_invalid_session def custom_callback(): return 'foo' client = app.test_client(use_cookies=True) self.assertEqual(paranoid.redirect_view, custom_callback) rv = client.get('/', headers={'User-Agent': 'foo'}) self.assertEqual(rv.status_code, 200) rv = client.get('/', headers={'User-Agent': 'bar'}) self.assertEqual(rv.status_code, 200) self.assertEqual(rv.get_data(as_text=True), 'foo') self.assertIn(self._delete_cookie('session'), rv.headers.getlist('Set-Cookie')) self.assertNotIn(self._delete_cookie('remember_token'), rv.headers.getlist('Set-Cookie')) def test_flask_login(self): app = Flask(__name__) app.config['SECRET_KEY'] = 'foo' paranoid = Paranoid(app) paranoid.redirect_view = 'https://foo.com/foobarbaz' @app.route('/') def index(): return 'foobar' client = app.test_client(use_cookies=True) sys.modules['flask_login'] = 'foo' self.assertEqual(paranoid.redirect_view, 'https://foo.com/foobarbaz') rv = client.get('/', headers={'User-Agent': 'foo'}) self.assertEqual(rv.status_code, 200) rv = client.get('/', headers={'User-Agent': 'bar'}) del sys.modules['flask_login'] self.assertEqual(rv.status_code, 302) self.assertEqual(rv.headers['Location'], 'https://foo.com/foobarbaz') self.assertIn(self._delete_cookie('session'), rv.headers.getlist('Set-Cookie')) self.assertIn(self._delete_cookie('remember_token', httponly=False), rv.headers.getlist('Set-Cookie')) flask-paranoid-0.3.0/tox.ini000066400000000000000000000010521422207451100157140ustar00rootroot00000000000000[tox] envlist=flake8,py37,py38,py39,py310,pypy3,docs skip_missing_interpreters=True [gh-actions] python = 3.7: py37 3.8: py38 3.9: py39 3.10: py310 pypy-3: pypy3 [testenv] commands= pip install -e . pytest -p no:logging --cov=flask_paranoid --cov-branch --cov-report=term-missing deps= pytest pytest-cov [testenv:flake8] commands= flake8 --exclude=".*" --ignore=E402 src/flask_paranoid tests deps= flake8 [testenv:docs] changedir=docs deps= sphinx whitelist_externals= make commands= make html