From 57132f92eee401ef03ecc043ed19c440c438e863 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 2 Jun 2026 19:46:58 -0400 Subject: [PATCH] Feat : add Webauth --- .../backend/__pycache__/main.cpython-313.pyc | Bin 48688 -> 65572 bytes vps-monitor/backend/main.py | 364 +++++++++++++++++- vps-monitor/backend/requirements.txt | 1 + vps-monitor/frontend/src/App.jsx | 7 +- vps-monitor/frontend/src/api/client.js | 137 +++++++ .../frontend/src/components/AdminPage.jsx | 139 ++++++- .../frontend/src/components/LoginPage.jsx | 149 ++++--- .../frontend/src/components/ProfilePage.jsx | 149 ++++++- 8 files changed, 888 insertions(+), 58 deletions(-) diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index 9ac257c6bd56c89a925001695574f9f01f0937b0..af6dd0842e6ca23f07dc4410021f81331afe9a6a 100644 GIT binary patch delta 25269 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�~x4m~lz zP8MKhCW25brxjVpU`nSPj55j=GOU33^+qe(CZ@Kmwo&65mW6d&fu5vx5aJ@F4QaO{ zXu~XAWr%>F697bquQ0Mqw&W%t0m&F(8curMKuEmQ5}!c> z#W6I(h~XjFB7&KA6Wqc4cFNj%RK2VPk}Xt%LRQl3vE2s22T(I^c$93!wrL-Qx-8IJ z;%_=j^448HA1ZFcT-((0u0%G5N)enYWS-cwX+z^*K>|rX;C8{R+XI_qDqv`4p{Nk( zqv}h;UuXg))%KAcS4}GM_nT5QyP&;NZYpZ?eX0d7U{TFZ8(4lqFa!J{9m+q5=i?tk zZfJtPdsC7IElC$QHFMJ)Y^w{>l2I|3Hur9Vxl&wj-=M?h92x4xb1v*hH?@`WAp-5^ zD8Q0oDB-vjW)9g8)ok3<^F0z196MFr+G5TbVN2uP;b9$=H)hIJ6^k+<}ntcc1~7uODKIXt-&RZ?1SoT+bV7RuaaBUN}Y|G;G?9~2kl`0+aAfXgie7kQGr(?wg z`YDcb5&DiVdI%m@wu2(3uSYcwnV&7Q>_;zX;%o}G+^CIGK2i@UMRGYD|; zfmQVfjsg$97e>pnQmHYjN!ON8v-C_E??UKO??Ea-Of$*??`50 zr5W3~m9t`Bz;Z`fKMd)sME}!32K%^}v}-xLTWr~t>Cr+Vf(s_C(F9z<=wkOUmP7%`ahAzq+!{zy z@dEO}p#iRRPe8HboM_m6maP%b?_O6f4+j*6#VnYXl`a*^;;dr=BshqbJq_>AimL{# z&^5h-2RV@U-<<_UD(XSVBln7_E|&)>wMZJ-&`F$YccF=jN4|$;2;|bilgFA4y&G{4 zVXlbaJ$wz#ON8 zGsr>FO~c?L1h0s!xhYDGvMDxg|EOE`eVP%_B`*HB#h;B*cS z_1st4ZeUnSpiY~zzgG?#PDAAnIOq!;(lKV%%p?|g3;9ZK{7Nx*WV_+1%vzo5oX$|2 zI(2lUpW_Nx@lX49%V$V^*!nF9u(GVDL8lCsRWOwdp@puM&ynTGI1h^JE;LChSQP<) z4u?dgCu1~rgU%h6uvmMdjC(@BFZzJ(@y~s4n?T5&B^4^N`_XqMYkuZUjxh^r){?nc1oJu9CzSA~X138jj@gm$3oJqpGrp$r0J_n7E z_W-mgD!vKH-2(s+RPcAONErRqvi*yaMYV759dLpxa2`tiO?>A&n>8}YsSM8oKI46? zdlJB?xf2hjtpjk}<8;Ce5LC+pMVbGA1%86yLo8*ghqxyMqiEEVd?3DX;DMB9Ap_Pu ztsU;2!D%r;O@_-sw~KA}q0)44)!e$dp}x1Rp<_d9y+DKW*IKj5ohaRR5WI{c z8e}s;6<#xp9J=YAO^OufGN>~QD;|BuKa_JO1t-abfA^T-{5Ti`YT(i;59U1JV* z&Cm$h=^$PxnWUOZ`0f;2V=c7}4V2=VTpC#6OO=JBbUR+DK=ls(@Kyo!8?YlGfX<5l zxN>p798iF`7b-FRbpQZ-D z#Y@o%34*bLb85OY-8o8DM;?6QmC)ObI}7^jQY#E8Pr-!Vk(;x^e;Oc<8fk!{^>D~z zgZ-yy31@*AIKkVP z9_4mqp%y}dLlCi(wD7<{zNDq40Hdb~ij!;F2VnPW+bAQhpN$VQ&(2J#o39^?+*MNV8EMh_MGXx0;RwG!0fcg@k4h(_QI#@>A znrl1PwsveJ8T7TIp}lKEM?*bH$0E}RK0-iuj?ZHXR1deZxCnzm_aF_&ZQ#no0jR_c z?Utg#xpqrDTuF6PcCS7rOH$lnWwP|r$wK}eXg9zNasL~%1rDu&(It2j=J~bxllgw_ zq8W4gq{>^~QNprmpa%u9(TJxR|-dm$}9RhjiJ7Sp#D!x|D1(X~ycW zWH50#6M4^US9aqmxa(a4N3ec ztIooYX5YY$?4Wng`Z(wk8YRJ-b z#{@DNFc>@>6elY0U3^CpQo{WL{+ybKycEo$O&FSM=nCzyq3 zneT3kc&avy*eJBl!g|EStZRkop5o`aq=0N>8)FJX&2Nu%g42POx{> zWgGRxC}o0pGz#E!9)O1w2Q}fG1H8)M!M$V-;$)*39ej6@0ay^>6FY|4bpBN6db#!d zslI;K2r^J)eQIAr+Ysw^7wUFRp@PC#IRfI6dhZDd1zo$AGQNB2YZ`_ z^d%n2N+w*DltoEWfiZ^7BxKN)30lMTOLqLwXRsDhg@dC(C+|cb@#%! zfJru8-z4iceV&**QU84F$yR@2`B=**#?*kt?(mrfuTGfOCXVm&X|pGK&J_9PRr|Ho zf&8^T^E$6?-7K$HZ((PWm!7e|?t01PPhNY7pYbFwnNCihhQp-YzO2fb^un2p1rW1O zF&r{oiDRNICweYgmisKr{pJ;8>%fxZdktKE@S9hUt@~V?H?jGmw%DgF4m8#6GxvCP zJ)Z||z3EK!f+5F7E&Mbp^+b^`YTm@Y>8R9;QF*?oyb1gB1D=xuuf)I7`TEwEw@!8Y z%W7v;Y*Zb4MXL^+YQmN%DskNIHKu#{^sldEGp5AL42yEm)oNLrx4qNb*5xhVNfuw2te*PU&X;$@BJwe)xUA0;-5=iSYN=LkF7N}XjSj8C@?hes`nR1)z_%ruTew# z1FfMUMLTM_dd;Al0mbnYCsrlsa7(SM59(69A0@I+CW?dAdUGY%Xx#G9A4Pi_E8Hp1;>!`Ft;iU2NWa82k4+D1lncIXqpaaCQlMu3$@zG1fv9;doX zjXtU;hv`OdhyJ>Fg?HsC80Pqy#t~dB!u(9d{Zb|kSP!CDkQ>-foXW({f(Hb9s*BLS za2ki3>j-b{W583FjeG(rh;qG!wWyo63sZA^wIsx?MnK)K&#?A&eK0xrBYBLWX5Er5 zFoWIX*I4;C2<`@gG!i6Fc6qADD^p$GIm(XL*HC;MXZf2@1!lSCMzj3Ws+=fM4nDms13*)?Z9zN%J#aoYt` z`@bY*g8QXU0QW1|fbQ1=F%<4}RjoTAE7|l^! zf$&KXx-+!L&}mTI7ke&D#%O4t{=)Wue0QvZ6%4QyGYY1qyo|AiJ9Sau@=&J^95vj{ zVf+s~J7lL{nGxW1ggh@b_-WD9Pea|Kzi#yKNE&3SW2M*@3HtU#8qg(Tf;pU z{cTwWz7%+CSphc{m;;gIUzeer6_VleIbHWWvG!!TCn?x5;FkrvAky=U5>f&l+_!W~ zmrjUBj-Yc=g|x8F3GG3Z5;`FrOL~IM>$~(q1`wcV!BV24&Kmkg^asvb2{>!oZyOot z6omz&kkF+?XD*Bp)GPV}_b?HJ@__FxBssOU+8yi;CES-eg8T*?Bk=BmAC%0!^$5q# zH!w~oao+zYg7rX74m@YV>)LPP+vE>atOFi67jcc;it!PGk?PR|CClk@!VSwnOb0zZ z$q?qJd)w+)e1{|Mvk&W3tle*q@c8`J)H^Z4G$U8gpl-TdxNzUnsL!nQ|akHtRT{3m*5 zdLTNBeMFzdk~nCkll&F=dInp)A5-**Al%#ZgxpO!0FzksQv|tKz7WCJ_?nDhHG(<> zG!#Jn&|kx=WX9-u*r3ppho`Zi3Mjy#!~rCfj^KX~Tta{+3?A}3O#U9hEP@HFbR1L2 zI=!)`_>@*_AgY)U=+=m?BP`g6N$ji#q2kxyw%P=IO3(G2%1_O4M_q?q7tPr|bM{2u zBp3Y-1LEo1ec7^4GCZ}gd4ytU%?xioG(6tui_V&eg9|Ga7x<+!rj!#2;>7N3&(~Kh)V)+8pAkYWKT+w6 z&!6Nb8@x-`e;EIv+PkFXg03~tN}JEz?$x#bc~*-xzj33Xe=oke!N`1IG^|yrK1eKV zSf%=4l^W8caaS#y%9Z@hPz`>;welzMf-5D(0)Tr=^bVi0Sm1qNxIfDU@6XnPHHC<# ze0L9?@?|Yf{8@V@u1CCDe7q!EgLc2e2&+k2rJyNMK@<}-8K*D=3`3zq4(`@!N5DQa z&G?C)6@tBr^X9hN9f24j8XfscC+?`|cX@&R9X*)}f zxXlpM7=FUwlYH7GdiPr9EmTgtMixP1!pP7NfqVJzJwRyluZ-ttb@-8jVzdJnsUswz zH(hI6>LadK)2K!ZJ(B-|o|t}v!$^a|!4ow&3k>bWUj~M#*A5|%Pv~a_boBA;;94I6 z#mc7|K?Q&vw+@E?S2+CLP>1fH4Keh4D==GwhXb;tN#zcz(VH|(Uqy%C8BPuy$0cb;QA-RCU2fqVmHt@cjo|g@kFDrw{D(y5VcT zhckE_Me(z=44t4W!H+`L(d+Lvc__o8`+7R4wa5jm^f)~nR``SizVnZqvEeZtcgbk! zb#8NC?|ys>oyQ(Jz;f^eXR|!Vap)jTO+jd)U-gOI^`<BViHm=;}zI1&5$}&H)~`vvnR5Ama=J! zbt3JtUVJ1y)*YJpb!Sb!@`jHJe!lRh3w@T&7cDlQ#pbv4UC~1v;*trdgs#*EZjsmh zTb5$hU_>h)UFnq>YxjbTlKb~N{D?Qmah4DjRY z@dU-Hmy9m_|C#*qG$$HVgNSMFkx$Nv7INsch5~qGNP}b7n$>}m@xa^;3^y@Bbsn_B z-33BiYhqWPz1dsUOz-|HQ(A{pdPT&XzUGvU3ZK#mwQAKlwV^g%{NUhU=6;f05+2h%@pwB{QDQdO_(kB z>$##>z|f7wsldj9b_r6ts!NVMn8hvO#nTVwd16737FkJ?kN{xdvX$dgwaR&e7Nq%X4&FD{pD9B^)i;h9dEE z`vUbHMuf^)NM$^Y;-Omu;>p$=zY+w4L&eJh5!UeHZy&lH#M>$UO7(5x?R+$kyM-4Y zc(l+;ZB1|(zo>#X$nDspP6R(^rapdjlJn57XDLIH+6^}Z94+!Cm|L*WIs}af)+5+} zpb0@Uf^7(F2(|-&sN$SC^Acvgi~u|@`AT${GxH?k)DX|Vwr8u8J1G~{kako7q~N>Y z5%@TBST>HQubvQ4^R=g6Ql47IVecrT4oVc7SMo{0Xw+KOJYpX7tBjfPmO-B zkrSx0(hHe;kzL$F&}$wrfV6Fb2%JH1s_t-)3=X^JtiT2cek%7Mi=nN1`Kjcm&VH*h z{?F6dTOR_ph!w@W*6h1lg(!2`P=1cqD8j;B-ZIEN%1`Z_Sk8T$zP$a+yo{%Az=8O` z&*b;dnb51TH|QpQ6Lblf2CB}BQG_K3f_hDEf|6HV{xd^FXV^9Xf1paf#Z&=xPT<%O zan(!9rp`Tk)3&MeT5=sRLM8~O=e5O% z35gN-Q%BX%*7GRHC{&_hmn-7Bm&&FZo=@VxZ5;N$Ft7CO8%wkVnWoEVIi{M$e|jO4 zo{>;n{KXdRdc0*%@Kbz31vkwVdNVeu>k@X5ex z_@h6Vd@9&Y21qGWQJIV|;^0HUhhFmMdSnf-`Wkjr8Y|Ykv}j5?edJpW+krC$9^VZ+ zXw*~+msWNZ2O6OrpqbkNbmT+28~9rab2Jv3p)$y(Bv$@_!0ix=UNUIb0`EWL#mbj{ z)PcMG7IG`dU#R-Rjy(W*aM_I`wUEH)I{N%RTo&%NyZU;23AHirVA1CgEJm;#!2<|< z;?kG5YL8)uR4WPo zc(sP$3AcQXgOOzUMouQCeun@<=EREND+JjHmLSMM;K@bkK!)aF>K+6*>E*xLm5(WE z+c8=!f9mr$NJ$zvyMp`fl6uc>`v4)UFn1Ax)dK#JP#fYPMb4VR7KQtw*K z=s{qkTU^?){rK)gFoa+P!F~i|2o54RjNnlOk0aQM;AsTkM?ft+I&owQrJ?TC>-hQ> z07<=lKWqWzv+5h1vW5?tQs3PaFv6Eu2sLKB=B@?GsEH7HFr07+K`KJ&<+V-@Lz zY-kq#NQjAH;MAFAzvNXcf7!sW(Z6J(#OPNm(!b(0EcXWnz*lAi0Qgfh@Cx8khS>P( zYt8TncM_5wt$3)yYbidH?vJk?Gf&4S%81f4#s2u!UM41pDe*4e;E!({i-u~23%m>j zDNZbz;7?Q>bNQK~hhB59zG7Cfi4zr97*0_5u!(Ra&c$5f z3~b_M6{Kb{btSExWuq?ji$`A@_pogBxMLQoRZpm}cJ-`|XXlUi%`)&f;kbg2OXesx zX+k~Az~fB(ncDd@HYnX&);c*s-!Qa# zySNShP!X${O^X6tIkKVgmUmvk=WO0o*6Vk$iE37ZvRY<2NX^DDoci9ByTvnS7qEAW zGiP@Itb1cGd-v38*Rasf7SF<0Yn|+LT>6Rnlk>ermHwQS-i(#L zxT?!4%s{#&a+z6}Ae1uHEcUa2&1W;F4f(Swcz${f0&2ix{Zd+Twj)%%(Bo%|X1N4t zz?-@3GJ^>$xj@FvmINCr^0R;}VB1-5){0pM6Ynm+OrKa`p`16niPBEKE7_~J_}NSx zbj?J?WamrEy(^piOPamK&A!}?m$4(UT9${Fm`%fW((X?4nil!lvRTdq0>vlIs^ED| zfdG%y7t@lnnOK{)ndxV-@4Q-H`kKoOV6l#s=FjHHEiQ*9 z%ls@5G6tH2=cn43@q}ahPTc8FF80R4t&@_=YCvP{CA8qIRn)#!0V7!Z)+&HwZ>@wu aefzD4aZuZTECBq$j~fBb{DtJaC3CKfjUknx z1o9va^gtdBZE4d7Y?HK!x^0qg)23tSq|#f3GN$yPNMkkzFXA$viQuE@Eo7HrbfX zA;_X^ZccBqGds1@nlqX*SVmJO%WTSGSwyomXE)`r9BNyebDPGnG1Qit$FgxSUTV%` zdGKv(&Tkse#y1tPf~G=N*i^)dh%ddlxM>2LKy7>T#HLAX((I-ZRzie~=E+SC=4hJ2 zrqFX{^VFtkY??!Gncmk%KswfWzCZdSa~wFs8BAtZrps= zjJ6IwP zmlO-~WY@GxQ_RLHqB?*&C5gJ2s8fMDEs45>sMCQuBa%d}Ch|-mmnKnbh&l_X&Lrwm zqRs~DoFr;3QRf1+EQwl2)OkQHPoge!+2#4gk%X1Y<&$y+S---yB4ICirrzbm)dBx3 zAdZ#tLYGsXC`*gX@*<+F0{H{1v=ENM zKg{wfI<0P>L4z<_Hj15Qd1%B;J%*mu`{NQ4RwnO#J!lS$7#)Do6vRvS$AY-ZRh2Y* z>Ugt!$B6O0as59RpB+=)%{4H~kr5JtAVCkCkrF(vC8J2O$ipL~gbXk6zgq1JZ>7PZ z5o7y|v5lWMY@n!nkTUh*5mA@w4~u*!%1Vm5J%+UEBZjn5?2zQ6BXnWL=(@Y&qizKbvi2IXR(Udevl%i$WtUxG7SZF{k?<#7r zP{%@EKedSex~Qsa4fJXjk2mZO1sRTPJ!fUhamWgUdW4k-s}NQrtVKwfq!DQdC{Km+ zQ8+5RmYH*;Sy!Zy<~vw?Fw%sKRS1g_mLQ-NY$-wwf^G`7NFQg`E%c$`4L2B$x}<2E ztNWxCdk~uVi3zK8pR~q&Qp$@bj$bE3r)KSAp>D++))iqbI4Z^PO-Mt)0fymOxkp-b z!#9D@CH#96r`2tPe$CdeDlF*fRWw_lM^y(x%r}bcEhrmd8_%7TuWPv_CUES=%$87> zKiH=1=~vY76&Ld29IPFIJaCU2p;&T z*#NNKWAlG&&IkORIbWB#Gw#y!CF9q00+*Jy*AwVhM$yj;Tnqu(4TolAA8FD>dP!v6 z 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 diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index f6da13d..e6118dc 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -5,6 +5,7 @@ Agrège les données de tous les agents et expose une API REST pour le frontend. """ import asyncio +import base64 import json import os import secrets @@ -24,6 +25,25 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from pydantic import BaseModel +from webauthn import ( + generate_registration_options, + verify_registration_response, + generate_authentication_options, + verify_authentication_response, + options_to_json, +) +from webauthn.helpers.structs import ( + AuthenticatorAttachment, + AuthenticatorSelectionCriteria, + UserVerificationRequirement, + ResidentKeyRequirement, + PublicKeyCredentialDescriptor, + RegistrationCredential as WebAuthnRegistrationCredential, + AuthenticatorAttestationResponse, + AuthenticationCredential as WebAuthnAuthenticationCredential, + AuthenticatorAssertionResponse, +) + # ─── Config ─────────────────────────────────────────────────────────────────── DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db")) @@ -53,6 +73,14 @@ def _load_jwt_secret() -> str: JWT_SECRET = _load_jwt_secret() +# ─── WebAuthn / Passkeys config ─────────────────────────────────────────────── +WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost") +WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "VPS Monitor") +WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "http://localhost:3020") + +# In-memory challenge store: session_id → {challenge, username, expires_at} +_webauthn_challenges: dict[str, dict] = {} + bearer_scheme = HTTPBearer() # ─── Modèles ────────────────────────────────────────────────────────────────── @@ -110,6 +138,21 @@ class PurgeRequest(BaseModel): to_ts: int | None = None # epoch seconds, utilisé si period == 'custom' +class PasskeyRegisterFinishRequest(BaseModel): + session_id: str + name: str = "Ma passkey" + credential: dict + + +class PasskeyLoginBeginRequest(BaseModel): + username: str | None = None + + +class PasskeyLoginFinishRequest(BaseModel): + session_id: str + credential: dict + + # ─── SQLite ─────────────────────────────────────────────────────────────────── @contextmanager @@ -194,6 +237,20 @@ def init_db() -> None: conn.execute(""" INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false') """) + conn.execute(""" + INSERT OR IGNORE INTO settings (key, value) VALUES ('passkey_enabled', 'true') + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS passkeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + ) + """) init_db() @@ -258,6 +315,98 @@ def _get_setting(key: str) -> str: return row["value"] if row else "" +# ─── WebAuthn / Passkey helpers ─────────────────────────────────────────────── + +def _b64url_encode(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def _b64url_decode(s: str) -> bytes: + padding = 4 - len(s) % 4 + if padding != 4: + s += "=" * padding + return base64.urlsafe_b64decode(s) + + +def _store_challenge(challenge: bytes, username: str | None = None) -> str: + """Stocke un challenge WebAuthn en mémoire et retourne un session_id.""" + session_id = secrets.token_hex(16) + _webauthn_challenges[session_id] = { + "challenge": challenge, + "username": username, + "expires_at": time.time() + 300, + } + # Nettoyage des challenges expirés + now = time.time() + expired = [k for k, v in list(_webauthn_challenges.items()) if v["expires_at"] < now] + for k in expired: + _webauthn_challenges.pop(k, None) + return session_id + + +def _pop_challenge(session_id: str) -> dict | None: + entry = _webauthn_challenges.pop(session_id, None) + if entry is None: + return None + if time.time() > entry["expires_at"]: + return None + return entry + + +def _get_passkeys_for_user(username: str) -> list[dict]: + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM passkeys WHERE username = ? ORDER BY created_at DESC", + (username,), + ).fetchall() + return [dict(r) for r in rows] + + +def _get_passkey_by_credential_id(credential_id: str) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM passkeys WHERE credential_id = ?", (credential_id,) + ).fetchone() + return dict(row) if row else None + + +def _update_passkey_sign_count(credential_id: str, sign_count: int) -> None: + with get_db() as conn: + conn.execute( + "UPDATE passkeys SET sign_count = ? WHERE credential_id = ?", + (sign_count, credential_id), + ) + + +def _parse_registration_credential(data: dict) -> WebAuthnRegistrationCredential: + resp = data["response"] + return WebAuthnRegistrationCredential( + id=data["id"], + raw_id=_b64url_decode(data.get("rawId", data["id"])), + response=AuthenticatorAttestationResponse( + client_data_json=_b64url_decode(resp["clientDataJSON"]), + attestation_object=_b64url_decode(resp["attestationObject"]), + ), + type=data["type"], + ) + + +def _parse_authentication_credential(data: dict) -> WebAuthnAuthenticationCredential: + resp = data["response"] + user_handle = resp.get("userHandle") + return WebAuthnAuthenticationCredential( + id=data["id"], + raw_id=_b64url_decode(data.get("rawId", data["id"])), + response=AuthenticatorAssertionResponse( + client_data_json=_b64url_decode(resp["clientDataJSON"]), + authenticator_data=_b64url_decode(resp["authenticatorData"]), + signature=_b64url_decode(resp["signature"]), + user_handle=_b64url_decode(user_handle) if user_handle else None, + ), + type=data["type"], + ) + + # ─── Auth helpers ───────────────────────────────────────────────────────────── def create_token(username: str, role: str) -> str: @@ -477,7 +626,10 @@ async def _cleanup_old_stats() -> None: @app.get("/api/auth/status") def auth_status(): """Indique si des utilisateurs existent déjà (pour le frontend).""" - return {"has_users": len(load_users()) > 0} + return { + "has_users": len(load_users()) > 0, + "passkey_enabled": _get_setting("passkey_enabled") == "true", + } @app.post("/api/auth/register", status_code=201) @@ -561,7 +713,7 @@ def admin_update_setting( _: Annotated[dict, Depends(require_admin)], ): """Met à jour un paramètre d'administration.""" - allowed_keys = {"registration_open"} + allowed_keys = {"registration_open", "passkey_enabled"} if key not in allowed_keys: raise HTTPException(status_code=400, detail="Clé de paramètre inconnue") with get_db() as conn: @@ -680,6 +832,214 @@ def admin_db_purge(body: PurgeRequest, _: Annotated[dict, Depends(require_admin) return {"deleted": deleted, "status": "ok"} +# ─── Routes Passkeys ────────────────────────────────────────────────────────── + +@app.post("/api/auth/passkey/register/begin") +def passkey_register_begin( + current_user: Annotated[dict, Depends(get_current_user)], +): + """Démarre l'enregistrement d'une passkey pour l'utilisateur connecté.""" + if _get_setting("passkey_enabled") != "true": + raise HTTPException(status_code=403, detail="Les passkeys sont désactivées") + + username = current_user["username"] + existing = _get_passkeys_for_user(username) + exclude_creds = [ + PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"])) + for pk in existing + ] + + opts = generate_registration_options( + rp_id=WEBAUTHN_RP_ID, + rp_name=WEBAUTHN_RP_NAME, + user_id=username.encode(), + user_name=username, + user_display_name=username, + authenticator_selection=AuthenticatorSelectionCriteria( + authenticator_attachment=AuthenticatorAttachment.PLATFORM, + resident_key=ResidentKeyRequirement.REQUIRED, + user_verification=UserVerificationRequirement.REQUIRED, + ), + exclude_credentials=exclude_creds, + ) + + session_id = _store_challenge(opts.challenge, username=username) + return {"session_id": session_id, "options": json.loads(options_to_json(opts))} + + +@app.post("/api/auth/passkey/register/finish") +def passkey_register_finish( + body: PasskeyRegisterFinishRequest, + current_user: Annotated[dict, Depends(get_current_user)], +): + """Finalise l'enregistrement d'une passkey.""" + if _get_setting("passkey_enabled") != "true": + raise HTTPException(status_code=403, detail="Les passkeys sont désactivées") + + challenge_data = _pop_challenge(body.session_id) + if not challenge_data: + raise HTTPException(status_code=400, detail="Session expirée ou invalide") + if challenge_data["username"] != current_user["username"]: + raise HTTPException(status_code=403, detail="Session invalide") + + try: + cred = _parse_registration_credential(body.credential) + verification = verify_registration_response( + credential=cred, + expected_challenge=challenge_data["challenge"], + expected_rp_id=WEBAUTHN_RP_ID, + expected_origin=WEBAUTHN_ORIGIN, + ) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Vérification échouée : {exc}") + + cred_id = _b64url_encode(verification.credential_id) + pub_key = _b64url_encode(verification.credential_public_key) + + if _get_passkey_by_credential_id(cred_id): + raise HTTPException(status_code=409, detail="Cette passkey est déjà enregistrée") + + with get_db() as conn: + conn.execute( + """INSERT INTO passkeys (username, credential_id, public_key, sign_count, name, created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (current_user["username"], cred_id, pub_key, + verification.sign_count, body.name, int(time.time())), + ) + return {"status": "ok"} + + +@app.post("/api/auth/passkey/login/begin") +def passkey_login_begin(body: PasskeyLoginBeginRequest): + """Démarre l'authentification par passkey.""" + if _get_setting("passkey_enabled") != "true": + raise HTTPException(status_code=403, detail="Les passkeys sont désactivées") + + allow_creds: list[PublicKeyCredentialDescriptor] = [] + if body.username: + passkeys = _get_passkeys_for_user(body.username) + allow_creds = [ + PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"])) + for pk in passkeys + ] + + opts = generate_authentication_options( + rp_id=WEBAUTHN_RP_ID, + allow_credentials=allow_creds, + user_verification=UserVerificationRequirement.REQUIRED, + ) + + session_id = _store_challenge(opts.challenge, username=body.username) + return {"session_id": session_id, "options": json.loads(options_to_json(opts))} + + +@app.post("/api/auth/passkey/login/finish") +def passkey_login_finish(body: PasskeyLoginFinishRequest, request: Request): + """Vérifie la passkey et retourne un JWT.""" + if _get_setting("passkey_enabled") != "true": + raise HTTPException(status_code=403, detail="Les passkeys sont désactivées") + + challenge_data = _pop_challenge(body.session_id) + if not challenge_data: + raise HTTPException(status_code=400, detail="Session expirée ou invalide") + + cred_id = body.credential.get("id", "") + passkey = _get_passkey_by_credential_id(cred_id) + if not passkey: + ip = _get_client_ip(request) + _log_login("unknown", ip, False, "Passkey introuvable") + raise HTTPException(status_code=401, detail="Passkey non reconnue") + + try: + cred = _parse_authentication_credential(body.credential) + verification = verify_authentication_response( + credential=cred, + expected_challenge=challenge_data["challenge"], + expected_rp_id=WEBAUTHN_RP_ID, + expected_origin=WEBAUTHN_ORIGIN, + credential_public_key=_b64url_decode(passkey["public_key"]), + credential_current_sign_count=passkey["sign_count"], + ) + except Exception as exc: + ip = _get_client_ip(request) + _log_login(passkey["username"], ip, False, f"Passkey invalide : {exc}") + raise HTTPException(status_code=401, detail="Authentification échouée") + + _update_passkey_sign_count(cred_id, verification.new_sign_count) + + user = next((u for u in load_users() if u["username"] == passkey["username"]), None) + if not user: + raise HTTPException(status_code=401, detail="Utilisateur introuvable") + + ip = _get_client_ip(request) + _log_login(user["username"], ip, True, "passkey") + token = create_token(user["username"], user["role"]) + return {"access_token": token, "token_type": "bearer", "role": user["role"]} + + +@app.get("/api/auth/passkeys") +def list_my_passkeys(current_user: Annotated[dict, Depends(get_current_user)]): + """Liste les passkeys de l'utilisateur connecté.""" + passkeys = _get_passkeys_for_user(current_user["username"]) + return [ + { + "credential_id": pk["credential_id"], + "name": pk["name"], + "created_at": datetime.fromtimestamp(pk["created_at"], tz=timezone.utc).isoformat(), + } + for pk in passkeys + ] + + +@app.delete("/api/auth/passkeys/{credential_id}") +def delete_my_passkey( + credential_id: str, + current_user: Annotated[dict, Depends(get_current_user)], +): + """Supprime une passkey de l'utilisateur connecté.""" + with get_db() as conn: + cur = conn.execute( + "DELETE FROM passkeys WHERE credential_id = ? AND username = ?", + (credential_id, current_user["username"]), + ) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="Passkey introuvable") + return {"status": "ok"} + + +@app.get("/api/admin/passkeys") +def admin_list_passkeys(_: Annotated[dict, Depends(require_admin)]): + """Admin : liste toutes les passkeys enregistrées.""" + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM passkeys ORDER BY created_at DESC" + ).fetchall() + return [ + { + "credential_id": row["credential_id"], + "username": row["username"], + "name": row["name"], + "created_at": datetime.fromtimestamp(row["created_at"], tz=timezone.utc).isoformat(), + } + for row in rows + ] + + +@app.delete("/api/admin/passkeys/{credential_id}") +def admin_delete_passkey( + credential_id: str, + _: Annotated[dict, Depends(require_admin)], +): + """Admin : révoque n'importe quelle passkey.""" + with get_db() as conn: + cur = conn.execute( + "DELETE FROM passkeys WHERE credential_id = ?", (credential_id,) + ) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="Passkey introuvable") + return {"status": "ok"} + + # ─── Routes VPS ─────────────────────────────────────────────────────────────── @app.get("/api/vps") diff --git a/vps-monitor/backend/requirements.txt b/vps-monitor/backend/requirements.txt index a6c3ec3..869a466 100644 --- a/vps-monitor/backend/requirements.txt +++ b/vps-monitor/backend/requirements.txt @@ -4,3 +4,4 @@ aiohttp>=3.9.0 pydantic>=2.0.0 python-jose[cryptography]>=3.3.0 bcrypt>=4.0.0 +webauthn>=2.0.0 diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index 2851778..cde6a89 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -25,6 +25,7 @@ export default function App() { const [role, setRole] = useState(null) const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin' const [isFirstUser, setIsFirstUser] = useState(false) + const [passkeyEnabled, setPasskeyEnabled] = useState(false) const [authChecked, setAuthChecked] = useState(false) const [vpsList, setVpsList] = useState([]) @@ -56,7 +57,10 @@ export default function App() { // Vérifie si des utilisateurs existent (pour afficher login ou register) useEffect(() => { authStatus() - .then(({ has_users }) => setIsFirstUser(!has_users)) + .then(({ has_users, passkey_enabled }) => { + setIsFirstUser(!has_users) + setPasskeyEnabled(!!passkey_enabled) + }) .catch(() => setIsFirstUser(false)) .finally(() => setAuthChecked(true)) }, []) @@ -211,6 +215,7 @@ export default function App() { return ( ) diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index f094eff..224d78a 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -184,3 +184,140 @@ export async function purgeDb({ table, period, fromTs, toTs }) { }) return handleResponse(res) } + +// ─── Passkeys (WebAuthn) ────────────────────────────────────────────────────── + +function _b64url(buffer) { + const bytes = new Uint8Array(buffer) + let str = '' + for (const b of bytes) str += String.fromCharCode(b) + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +function _fromB64url(str) { + const padding = '='.repeat((4 - (str.length % 4)) % 4) + const base64 = (str + padding).replace(/-/g, '+').replace(/_/g, '/') + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes.buffer +} + +function _prepareRegistrationOptions(opts) { + return { + ...opts, + challenge: _fromB64url(opts.challenge), + user: { ...opts.user, id: _fromB64url(opts.user.id) }, + excludeCredentials: (opts.excludeCredentials || []).map(c => ({ ...c, id: _fromB64url(c.id) })), + } +} + +function _prepareAuthenticationOptions(opts) { + return { + ...opts, + challenge: _fromB64url(opts.challenge), + allowCredentials: (opts.allowCredentials || []).map(c => ({ ...c, id: _fromB64url(c.id) })), + } +} + +function _serializeRegistrationCredential(cred) { + return { + id: cred.id, + rawId: _b64url(cred.rawId), + type: cred.type, + response: { + clientDataJSON: _b64url(cred.response.clientDataJSON), + attestationObject: _b64url(cred.response.attestationObject), + }, + } +} + +function _serializeAuthenticationCredential(cred) { + const out = { + id: cred.id, + rawId: _b64url(cred.rawId), + type: cred.type, + response: { + clientDataJSON: _b64url(cred.response.clientDataJSON), + authenticatorData: _b64url(cred.response.authenticatorData), + signature: _b64url(cred.response.signature), + }, + } + if (cred.response.userHandle) { + out.response.userHandle = _b64url(cred.response.userHandle) + } + return out +} + +/** Lance l'enregistrement complet d'une passkey (begin → browser → finish). */ +export async function registerPasskey(name) { + // 1. Obtenir le challenge + const beginRes = await fetch(`${BASE}/auth/passkey/register/begin`, { + method: 'POST', + headers: authHeaders(), + }) + const { session_id, options } = await handleResponse(beginRes) + + // 2. Appel biométrique + const cred = await navigator.credentials.create({ + publicKey: _prepareRegistrationOptions(options), + }) + + // 3. Finaliser + const finishRes = await fetch(`${BASE}/auth/passkey/register/finish`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ session_id, name, credential: _serializeRegistrationCredential(cred) }), + }) + return handleResponse(finishRes) +} + +/** Authentification complète par passkey (begin → browser → finish). Retourne {access_token, role}. */ +export async function loginWithPasskey(username = null) { + // 1. Obtenir le challenge + const beginRes = await fetch(`${BASE}/auth/passkey/login/begin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }) + const { session_id, options } = await handleResponse(beginRes) + + // 2. Appel biométrique (TouchID / FaceID) + const cred = await navigator.credentials.get({ + publicKey: _prepareAuthenticationOptions(options), + }) + + // 3. Vérification côté serveur + const finishRes = await fetch(`${BASE}/auth/passkey/login/finish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id, credential: _serializeAuthenticationCredential(cred) }), + }) + return handleResponse(finishRes) +} + +export async function getMyPasskeys() { + const res = await fetch(`${BASE}/auth/passkeys`, { headers: authHeaders() }) + return handleResponse(res) +} + +export async function deleteMyPasskey(credentialId) { + const res = await fetch(`${BASE}/auth/passkeys/${encodeURIComponent(credentialId)}`, { + method: 'DELETE', + headers: authHeaders(), + }) + return handleResponse(res) +} + +export async function adminGetPasskeys() { + const res = await fetch(`${BASE}/admin/passkeys`, { headers: authHeaders() }) + return handleResponse(res) +} + +export async function adminDeletePasskey(credentialId) { + const res = await fetch(`${BASE}/admin/passkeys/${encodeURIComponent(credentialId)}`, { + method: 'DELETE', + headers: authHeaders(), + }) + return handleResponse(res) +} diff --git a/vps-monitor/frontend/src/components/AdminPage.jsx b/vps-monitor/frontend/src/components/AdminPage.jsx index 6a32765..53cd507 100644 --- a/vps-monitor/frontend/src/components/AdminPage.jsx +++ b/vps-monitor/frontend/src/components/AdminPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' -import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle } from 'lucide-react' -import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb } from '../api/client' +import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key } from 'lucide-react' +import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey } from '../api/client' const PAGE_SIZE = 50 @@ -27,7 +27,7 @@ function ToggleRow({ label, description, enabled, onChange, loading }) { } export default function AdminPage({ onBack }) { - const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'logs' | 'database' + const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'logs' | 'database' // ─── Settings ──────────────────────────────────────────────────────────── const [settings, setSettings] = useState(null) @@ -64,6 +64,55 @@ export default function AdminPage({ onBack }) { } } + const togglePasskeys = async () => { + if (!settings) return + const newValue = settings.passkey_enabled === 'true' ? 'false' : 'true' + setToggleLoading(true) + try { + await setAdminSetting('passkey_enabled', newValue) + setSettings(prev => ({ ...prev, passkey_enabled: newValue })) + } catch (err) { + setSettingsError(err.message) + } finally { + setToggleLoading(false) + } + } + + // ─── Passkeys admin ────────────────────────────────────────────────────── + const [adminPasskeys, setAdminPasskeys] = useState([]) + const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false) + const [adminPasskeysError, setAdminPasskeysError] = useState(null) + const [revokeLoading, setRevokeLoading] = useState(null) + + const loadAdminPasskeys = useCallback(async () => { + setAdminPasskeysLoading(true) + setAdminPasskeysError(null) + try { + const data = await adminGetPasskeys() + setAdminPasskeys(data) + } catch (err) { + setAdminPasskeysError(err.message) + } finally { + setAdminPasskeysLoading(false) + } + }, []) + + useEffect(() => { + if (activeTab === 'passkeys') loadAdminPasskeys() + }, [activeTab, loadAdminPasskeys]) + + const handleRevokePasskey = async (credentialId) => { + setRevokeLoading(credentialId) + try { + await adminDeletePasskey(credentialId) + setAdminPasskeys(prev => prev.filter(p => p.credential_id !== credentialId)) + } catch (err) { + setAdminPasskeysError(err.message) + } finally { + setRevokeLoading(null) + } + } + // ─── Login logs ────────────────────────────────────────────────────────── const [logs, setLogs] = useState([]) const [logsTotal, setLogsTotal] = useState(0) @@ -196,6 +245,7 @@ export default function AdminPage({ onBack }) {
{[ { key: 'settings', label: 'Paramètres' }, + { key: 'passkeys', label: 'Passkeys' }, { key: 'logs', label: 'Connexions' }, { key: 'database', label: 'Base de données' }, ].map(tab => ( @@ -236,12 +286,95 @@ export default function AdminPage({ onBack }) { onChange={toggleRegistration} loading={toggleLoading} /> +
) } )} + {/* ── Tab: Passkeys ── */} + {activeTab === 'passkeys' && ( +
+
+
+

Passkeys enregistrées

+

+ Gérez les passkeys (TouchID / FaceID) de tous les utilisateurs. +

+
+ +
+ + {adminPasskeysError && ( +
+ {adminPasskeysError} +
+ )} + + {adminPasskeysLoading && adminPasskeys.length === 0 + ?

Chargement…

+ : adminPasskeys.length === 0 + ? ( +
+ +

Aucune passkey enregistrée.

+
+ ) + : ( +
+ + + + + + + + + + + {adminPasskeys.map(pk => ( + + + + + + + ))} + +
UtilisateurAppareilEnregistrée le
{pk.username} + + {pk.name} + + {new Date(pk.created_at).toLocaleString('fr-FR')} + + +
+
+ ) + } +
+ )} + {/* ── Tab: Connexions ── */} {activeTab === 'logs' && (
diff --git a/vps-monitor/frontend/src/components/LoginPage.jsx b/vps-monitor/frontend/src/components/LoginPage.jsx index 63871b4..9c6b417 100644 --- a/vps-monitor/frontend/src/components/LoginPage.jsx +++ b/vps-monitor/frontend/src/components/LoginPage.jsx @@ -1,16 +1,26 @@ -import { useState } from 'react' -import { Monitor } from 'lucide-react' -import { login, register } from '../api/client' +import { useState, useEffect } from 'react' +import { Monitor, Fingerprint } from 'lucide-react' +import { login, register, loginWithPasskey } from '../api/client' -export default function LoginPage({ isFirstUser, onAuthenticated }) { +export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated }) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [password2, setPassword2] = useState('') const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const [passkeyLoading, setPasskeyLoading] = useState(false) + const [browserSupportsPasskey, setBrowserSupportsPasskey] = useState(false) const isRegister = isFirstUser + useEffect(() => { + if (window.PublicKeyCredential) { + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() + .then(available => setBrowserSupportsPasskey(available)) + .catch(() => setBrowserSupportsPasskey(false)) + } + }, []) + const handleSubmit = async (e) => { e.preventDefault() setError(null) @@ -39,6 +49,25 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) { } } + const handlePasskeyLogin = async () => { + setError(null) + setPasskeyLoading(true) + try { + const data = await loginWithPasskey(username.trim() || null) + onAuthenticated(data.access_token, data.role) + } catch (err) { + if (err.name === 'NotAllowedError' || err.message?.includes('NotAllowedError')) { + setError('Authentification annulée.') + } else { + setError(err.message) + } + } finally { + setPasskeyLoading(false) + } + } + + const showPasskeyButton = !isRegister && passkeyEnabled && browserSupportsPasskey + return (
@@ -53,7 +82,7 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {

-
+
{isRegister && (
Aucun utilisateur n'existe encore. Le premier compte créé sera admin. @@ -66,57 +95,79 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
)} -
- - setUsername(e.target.value)} - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" - /> -
- -
- - setPassword(e.target.value)} - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" - /> -
- - {isRegister && ( +
- + setPassword2(e.target.value)} + autoFocus + autoComplete="username" + value={username} + onChange={(e) => setUsername(e.target.value)} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" />
- )} - - +
+ + setPassword(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ + {isRegister && ( +
+ + setPassword2(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ )} + + + + + {showPasskeyButton && ( + <> +
+
+ ou +
+
+ + + + )} +
) diff --git a/vps-monitor/frontend/src/components/ProfilePage.jsx b/vps-monitor/frontend/src/components/ProfilePage.jsx index 0c2d745..dc25f93 100644 --- a/vps-monitor/frontend/src/components/ProfilePage.jsx +++ b/vps-monitor/frontend/src/components/ProfilePage.jsx @@ -1,6 +1,6 @@ -import { useState } from 'react' -import { KeyRound, ArrowLeft, Check } from 'lucide-react' -import { changePassword } from '../api/client' +import { useState, useEffect, useCallback } from 'react' +import { KeyRound, ArrowLeft, Check, Fingerprint, Plus, Trash2, Key } from 'lucide-react' +import { changePassword, getMyPasskeys, deleteMyPasskey, registerPasskey } from '../api/client' export default function ProfilePage({ username, onBack }) { const [oldPassword, setOldPassword] = useState('') @@ -10,6 +10,72 @@ export default function ProfilePage({ username, onBack }) { const [success, setSuccess] = useState(false) const [loading, setLoading] = useState(false) + // ─── Passkeys ───────────────────────────────────────────────────────────── + const [passkeys, setPasskeys] = useState([]) + const [passkeysLoading, setPasskeysLoading] = useState(true) + const [passkeysError, setPasskeysError] = useState(null) + const [addName, setAddName] = useState('') + const [adding, setAdding] = useState(false) + const [addError, setAddError] = useState(null) + const [deleteLoading, setDeleteLoading] = useState(null) + const [browserSupports, setBrowserSupports] = useState(false) + + useEffect(() => { + if (window.PublicKeyCredential) { + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() + .then(ok => setBrowserSupports(ok)) + .catch(() => setBrowserSupports(false)) + } + }, []) + + const loadPasskeys = useCallback(async () => { + setPasskeysLoading(true) + setPasskeysError(null) + try { + const data = await getMyPasskeys() + setPasskeys(data) + } catch (err) { + setPasskeysError(err.message) + } finally { + setPasskeysLoading(false) + } + }, []) + + useEffect(() => { loadPasskeys() }, [loadPasskeys]) + + const handleAddPasskey = async (e) => { + e.preventDefault() + setAddError(null) + setAdding(true) + try { + const name = addName.trim() || 'Ma passkey' + await registerPasskey(name) + setAddName('') + await loadPasskeys() + } catch (err) { + if (err.name === 'NotAllowedError' || err.message?.includes('NotAllowedError')) { + setAddError('Enregistrement annulé.') + } else { + setAddError(err.message) + } + } finally { + setAdding(false) + } + } + + const handleDeletePasskey = async (credentialId) => { + setDeleteLoading(credentialId) + try { + await deleteMyPasskey(credentialId) + setPasskeys(prev => prev.filter(p => p.credential_id !== credentialId)) + } catch (err) { + setPasskeysError(err.message) + } finally { + setDeleteLoading(null) + } + } + + // ─── Password change ────────────────────────────────────────────────────── const handleSubmit = async (e) => { e.preventDefault() setError(null) @@ -121,6 +187,83 @@ export default function ProfilePage({ username, onBack }) {
+ + {/* ── Passkeys ── */} + {browserSupports && ( +
+
+ +

Passkeys (TouchID / FaceID)

+
+

+ Connectez-vous sans mot de passe grâce à la biométrie de votre appareil. +

+ + {passkeysError && ( +
+ {passkeysError} +
+ )} + + {/* Liste des passkeys */} + {passkeysLoading + ?

Chargement…

+ : passkeys.length === 0 + ?

Aucune passkey enregistrée.

+ : ( +
    + {passkeys.map(pk => ( +
  • +
    + +
    +

    {pk.name}

    +

    + {new Date(pk.created_at).toLocaleDateString('fr-FR')} +

    +
    +
    + +
  • + ))} +
+ ) + } + + {/* Formulaire d'ajout */} + {addError && ( +
+ {addError} +
+ )} +
+ setAddName(e.target.value)} + disabled={adding} + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors disabled:opacity-50" + /> + +
+
+ )} + )