paskia 0.10.0__py3-none-any.whl → 0.10.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paskia/_version.py +2 -2
- paskia/bootstrap.py +8 -7
- paskia/db/__init__.py +2 -0
- paskia/db/background.py +5 -8
- paskia/db/operations.py +17 -0
- paskia/db/structs.py +3 -2
- paskia/fastapi/__main__.py +21 -13
- paskia/fastapi/authz.py +8 -1
- paskia/fastapi/mainapp.py +6 -4
- paskia/fastapi/user.py +22 -0
- paskia/frontend-build/auth/admin/index.html +7 -6
- paskia/frontend-build/auth/assets/AccessDenied-CVQZxSIL.css +1 -0
- paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-BOdNrlQB.css → RestrictedAuth-0MFeNWS2.css} +1 -1
- paskia/frontend-build/auth/assets/{RestrictedAuth-BSusdAfp.js → RestrictedAuth-DWKMTEV3.js} +1 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
- paskia/frontend-build/auth/assets/{admin-CmNtuH3s.css → admin-B1H4YqM_.css} +1 -1
- paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
- paskia/frontend-build/auth/assets/{auth-BKq4T2K2.css → auth-B4EpDxom.css} +1 -1
- paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
- paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
- paskia/frontend-build/auth/assets/{pow-2N9bxgAo.js → pow-DUr-T9XX.js} +1 -1
- paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
- paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
- paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
- paskia/frontend-build/auth/index.html +8 -7
- paskia/frontend-build/auth/restricted/index.html +7 -6
- paskia/frontend-build/int/forward/index.html +6 -6
- paskia/frontend-build/int/reset/index.html +3 -3
- paskia/frontend-build/paskia.webp +0 -0
- paskia/util/__init__.py +0 -0
- paskia/util/apistructs.py +110 -0
- paskia/util/frontend.py +75 -0
- paskia/util/hostutil.py +75 -0
- paskia/util/htmlutil.py +47 -0
- paskia/util/passphrase.py +20 -0
- paskia/util/permutil.py +43 -0
- paskia/util/pow.py +45 -0
- paskia/util/querysafe.py +11 -0
- paskia/util/sessionutil.py +38 -0
- paskia/util/startupbox.py +103 -0
- paskia/util/timeutil.py +47 -0
- paskia/util/useragent.py +10 -0
- paskia/util/userinfo.py +63 -0
- paskia/util/vitedev.py +71 -0
- paskia/util/wordlist.py +54 -0
- {paskia-0.10.0.dist-info → paskia-0.10.2.dist-info}/METADATA +14 -11
- paskia-0.10.2.dist-info/RECORD +78 -0
- paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +0 -1
- paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +0 -12
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +0 -49
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +0 -1
- paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +0 -1
- paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +0 -1
- paskia/frontend-build/auth/assets/forward-C86Jm_Uq.js +0 -1
- paskia/frontend-build/auth/assets/reset-D71FG0VL.js +0 -1
- paskia/frontend-build/auth/assets/restricted-CW0drE_k.js +0 -1
- paskia-0.10.0.dist-info/RECORD +0 -60
- {paskia-0.10.0.dist-info → paskia-0.10.2.dist-info}/WHEEL +0 -0
- {paskia-0.10.0.dist-info → paskia-0.10.2.dist-info}/entry_points.txt +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
function v(e){const a=e.replace(/-/g,"+").replace(/_/g,"/"),t=a+"=".repeat((4-a.length%4)%4);return Uint8Array.from(atob(t),i=>i.charCodeAt(0))}function k(e){return btoa(String.fromCharCode(...e)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}const h=["able","about","absent","abuse","access","acid","across","act","adapt","add","adjust","admit","adult","advice","affair","afraid","again","age","agree","ahead","aim","air","aisle","alarm","album","alert","alien","all","almost","alone","alpha","also","alter","always","amazed","among","amused","anchor","angle","animal","ankle","annual","answer","any","apart","appear","april","arch","are","argue","army","around","array","art","ascent","ash","ask","aspect","assume","asthma","atom","attack","audit","august","aunt","author","avoid","away","awful","axis","baby","back","bad","bag","ball","bamboo","bank","bar","base","battle","beach","become","beef","before","begin","behind","below","bench","best","better","beyond","bid","bike","bind","bio","birth","bitter","black","bleak","blind","blood","blue","board","body","boil","bomb","bone","book","border","boss","bottom","bounce","bowl","box","boy","brain","bread","bring","brown","brush","bubble","buck","budget","build","bulk","bundle","burden","bus","but","buyer","buzz","cable","cache","cage","cake","call","came","can","car","case","catch","cause","cave","celery","cement","census","cereal","change","check","child","choice","chunk","cigar","circle","city","civil","class","clean","client","close","club","coast","code","coffee","coil","cold","come","cool","copy","core","cost","cotton","couch","cover","coyote","craft","cream","crime","cross","cruel","cry","cube","cue","cult","cup","curve","custom","cute","cycle","dad","damage","danger","daring","dash","dawn","day","deal","debate","decide","deer","define","degree","deity","delay","demand","denial","depth","derive","design","detail","device","dial","dice","die","differ","dim","dinner","direct","dish","divert","dizzy","doctor","dog","dollar","domain","donate","door","dose","double","dove","draft","dream","drive","drop","drum","dry","duck","dumb","dune","during","dust","dutch","dwarf","eager","early","east","echo","eco","edge","edit","effort","egg","eight","either","elbow","elder","elite","else","embark","emerge","emily","employ","enable","end","enemy","engine","enjoy","enlist","enough","enrich","ensure","entire","envy","equal","era","erode","error","erupt","escape","essay","estate","ethics","evil","evoke","exact","excess","exist","exotic","expect","extent","eye","fabric","face","fade","faith","fall","family","fan","far","father","fault","feel","female","fence","fetch","fever","few","fiber","field","figure","file","find","first","fish","fit","fix","flat","flesh","flight","float","fluid","fly","foam","focus","fog","foil","follow","food","force","fossil","found","fox","frame","fresh","friend","frog","fruit","fuel","fun","fury","future","gadget","gain","galaxy","game","gap","garden","gas","gate","gauge","gaze","genius","ghost","giant","gift","giggle","ginger","girl","give","glass","glide","globe","glue","goal","god","gold","good","gospel","govern","gown","grant","great","grid","group","grunt","guard","guess","guide","gulf","gun","gym","habit","hair","half","hammer","hand","happy","hard","hat","have","hawk","hay","hazard","head","hedge","height","help","hen","hero","hidden","high","hill","hint","hip","hire","hobby","hockey","hold","home","honey","hood","hope","horse","host","hotel","hour","hover","how","hub","huge","human","hungry","hurt","hybrid","ice","icon","idea","idle","ignore","ill","image","immune","impact","income","index","infant","inhale","inject","inmate","inner","input","inside","into","invest","iron","island","issue","italy","item","ivory","jacket","jaguar","james","jar","jazz","jeans","jelly","jewel","job","joe","joke","joy","judge","juice","july","jump","june","just","kansas","kate","keep","kernel","key","kick","kid","kind","kiss","kit","kiwi","knee","knife","know","labor","lady","lag","lake","lamp","laptop","large","later","laugh","lava","law","layer","lazy","leader","left","legal","lemon","length","lesson","letter","level","liar","libya","lid","life","light","like","limit","line","lion","liquid","list","little","live","lizard","load","local","logic","long","loop","lost","loud","love","low","loyal","lucky","lumber","lunch","lust","luxury","lyrics","mad","magic","main","major","make","male","mammal","man","map","market","mass","matter","maze","mccoy","meadow","media","meet","melt","member","men","mercy","mesh","method","middle","milk","mimic","mind","mirror","miss","mix","mobile","model","mom","monkey","moon","more","mother","mouse","move","much","muffin","mule","must","mutual","myself","myth","naive","name","napkin","narrow","nasty","nation","near","neck","need","nephew","nerve","nest","net","never","news","next","nice","night","noble","noise","noodle","normal","nose","note","novel","now","number","nurse","nut","oak","obey","object","oblige","obtain","occur","ocean","odor","off","often","oil","okay","old","olive","omit","once","one","onion","online","open","opium","oppose","option","orange","orbit","order","organ","orient","orphan","other","outer","oval","oven","own","oxygen","oyster","ozone","pact","paddle","page","pair","palace","panel","paper","parade","past","path","pause","pave","paw","pay","peace","pen","people","pepper","permit","pet","philip","phone","phrase","piano","pick","piece","pig","pilot","pink","pipe","pistol","pitch","pizza","place","please","pluck","poem","point","polar","pond","pool","post","pot","pound","powder","praise","prefer","price","profit","public","pull","punch","pupil","purity","push","put","puzzle","qatar","quasi","queen","quite","quoted","rabbit","race","radio","rail","rally","ramp","range","rapid","rare","rather","raven","raw","razor","real","rebel","recall","red","reform","region","reject","relief","remain","rent","reopen","report","result","return","review","reward","rhythm","rib","rich","ride","rifle","right","ring","riot","ripple","risk","ritual","river","road","robot","rocket","room","rose","rotate","round","row","royal","rubber","rude","rug","rule","run","rural","sad","safe","sage","sail","salad","same","santa","sauce","save","say","scale","scene","school","scope","screen","scuba","sea","second","seed","self","semi","sense","series","settle","seven","shadow","she","ship","shock","shrimp","shy","sick","side","siege","sign","silver","simple","since","siren","sister","six","size","skate","sketch","ski","skull","slab","sleep","slight","slogan","slush","small","smile","smooth","snake","sniff","snow","soap","soccer","soda","soft","solid","son","soon","sort","south","space","speak","sphere","spirit","split","spoil","spring","spy","square","state","step","still","story","strong","stuff","style","submit","such","sudden","suffer","sugar","suit","summer","sun","supply","sure","swamp","sweet","switch","sword","symbol","syntax","syria","system","table","tackle","tag","tail","talk","tank","tape","target","task","tattoo","taxi","team","tell","ten","term","test","text","that","theme","this","three","thumb","tibet","ticket","tide","tight","tilt","time","tiny","tip","tired","tissue","title","toast","today","toe","toilet","token","tomato","tone","tool","top","torch","toss","total","toward","toy","trade","tree","trial","trophy","true","try","tube","tumble","tunnel","turn","twenty","twice","two","type","ugly","unable","uncle","under","unfair","unique","unlock","until","unveil","update","uphold","upon","upper","upset","urban","urge","usage","use","usual","vacuum","vague","valid","van","vapor","vast","vault","vein","velvet","vendor","very","vessel","viable","video","view","villa","violin","virus","visit","vital","vivid","vocal","voice","volume","vote","voyage","wage","wait","wall","want","war","wash","water","wave","way","wealth","web","weird","were","west","wet","what","when","whip","wide","wife","will","window","wire","wish","wolf","woman","wonder","wood","work","wrap","wreck","write","wrong","xander","xbox","xerox","xray","yang","yard","year","yellow","yes","yin","york","you","zane","zara","zebra","zen","zero","zippo","zone","zoo","zorro","zulu"],o=new Map;for(const e of h)for(let a=1;a<=Math.min(e.length,6);a++){const t=e.slice(0,a);o.has(t)||o.set(t,[]),o.get(t).push(e)}function w(e){if(!e)return[];const a=e.toLowerCase();return o.get(a)||[]}function x(e){const a=w(e);return a.length===1?a[0]:null}function z(e){if(!e)return!1;const a=e.toLowerCase();return h.includes(a)}function j(e){if(!e)return!0;const a=e.toLowerCase();return o.has(a)}async function A(e,a,t={}){const{signal:i}=t,m=performance.now(),n=e instanceof ArrayBuffer?new Uint8Array(e):e;if(!(n instanceof Uint8Array)||n.length!==8)throw new Error("Challenge must be exactly 8 bytes");const b=await crypto.subtle.importKey("raw",n,"PBKDF2",!1,["deriveBits"]),u=new Uint8Array(8*a);let l=0;const s=2047,r=new Uint32Array(2);for(let c=0;c<a;c++){if(i?.aborted)throw new DOMException("PoW operation aborted","AbortError");let p;do l++,++r[0]===4294967296&&++r[1],p=new Uint32Array(await crypto.subtle.deriveBits({name:"PBKDF2",salt:r,iterations:128,hash:"SHA-512"},b,32));while(p[0]&s);u.set(new Uint8Array(r.buffer),c*8)}const d=(performance.now()-m)/1e3,g=a*(s+1),f=(l/g).toFixed(1),y=l/((s+1)*d);return console.log(`PoW work=${a} solved in ${d.toFixed(2)}s (${f}x expected ${y.toFixed(1)} work/s)`),u}export{
|
|
1
|
+
function v(e){const a=e.replace(/-/g,"+").replace(/_/g,"/"),t=a+"=".repeat((4-a.length%4)%4);return Uint8Array.from(atob(t),i=>i.charCodeAt(0))}function k(e){return btoa(String.fromCharCode(...e)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}const h=["able","about","absent","abuse","access","acid","across","act","adapt","add","adjust","admit","adult","advice","affair","afraid","again","age","agree","ahead","aim","air","aisle","alarm","album","alert","alien","all","almost","alone","alpha","also","alter","always","amazed","among","amused","anchor","angle","animal","ankle","annual","answer","any","apart","appear","april","arch","are","argue","army","around","array","art","ascent","ash","ask","aspect","assume","asthma","atom","attack","audit","august","aunt","author","avoid","away","awful","axis","baby","back","bad","bag","ball","bamboo","bank","bar","base","battle","beach","become","beef","before","begin","behind","below","bench","best","better","beyond","bid","bike","bind","bio","birth","bitter","black","bleak","blind","blood","blue","board","body","boil","bomb","bone","book","border","boss","bottom","bounce","bowl","box","boy","brain","bread","bring","brown","brush","bubble","buck","budget","build","bulk","bundle","burden","bus","but","buyer","buzz","cable","cache","cage","cake","call","came","can","car","case","catch","cause","cave","celery","cement","census","cereal","change","check","child","choice","chunk","cigar","circle","city","civil","class","clean","client","close","club","coast","code","coffee","coil","cold","come","cool","copy","core","cost","cotton","couch","cover","coyote","craft","cream","crime","cross","cruel","cry","cube","cue","cult","cup","curve","custom","cute","cycle","dad","damage","danger","daring","dash","dawn","day","deal","debate","decide","deer","define","degree","deity","delay","demand","denial","depth","derive","design","detail","device","dial","dice","die","differ","dim","dinner","direct","dish","divert","dizzy","doctor","dog","dollar","domain","donate","door","dose","double","dove","draft","dream","drive","drop","drum","dry","duck","dumb","dune","during","dust","dutch","dwarf","eager","early","east","echo","eco","edge","edit","effort","egg","eight","either","elbow","elder","elite","else","embark","emerge","emily","employ","enable","end","enemy","engine","enjoy","enlist","enough","enrich","ensure","entire","envy","equal","era","erode","error","erupt","escape","essay","estate","ethics","evil","evoke","exact","excess","exist","exotic","expect","extent","eye","fabric","face","fade","faith","fall","family","fan","far","father","fault","feel","female","fence","fetch","fever","few","fiber","field","figure","file","find","first","fish","fit","fix","flat","flesh","flight","float","fluid","fly","foam","focus","fog","foil","follow","food","force","fossil","found","fox","frame","fresh","friend","frog","fruit","fuel","fun","fury","future","gadget","gain","galaxy","game","gap","garden","gas","gate","gauge","gaze","genius","ghost","giant","gift","giggle","ginger","girl","give","glass","glide","globe","glue","goal","god","gold","good","gospel","govern","gown","grant","great","grid","group","grunt","guard","guess","guide","gulf","gun","gym","habit","hair","half","hammer","hand","happy","hard","hat","have","hawk","hay","hazard","head","hedge","height","help","hen","hero","hidden","high","hill","hint","hip","hire","hobby","hockey","hold","home","honey","hood","hope","horse","host","hotel","hour","hover","how","hub","huge","human","hungry","hurt","hybrid","ice","icon","idea","idle","ignore","ill","image","immune","impact","income","index","infant","inhale","inject","inmate","inner","input","inside","into","invest","iron","island","issue","italy","item","ivory","jacket","jaguar","james","jar","jazz","jeans","jelly","jewel","job","joe","joke","joy","judge","juice","july","jump","june","just","kansas","kate","keep","kernel","key","kick","kid","kind","kiss","kit","kiwi","knee","knife","know","labor","lady","lag","lake","lamp","laptop","large","later","laugh","lava","law","layer","lazy","leader","left","legal","lemon","length","lesson","letter","level","liar","libya","lid","life","light","like","limit","line","lion","liquid","list","little","live","lizard","load","local","logic","long","loop","lost","loud","love","low","loyal","lucky","lumber","lunch","lust","luxury","lyrics","mad","magic","main","major","make","male","mammal","man","map","market","mass","matter","maze","mccoy","meadow","media","meet","melt","member","men","mercy","mesh","method","middle","milk","mimic","mind","mirror","miss","mix","mobile","model","mom","monkey","moon","more","mother","mouse","move","much","muffin","mule","must","mutual","myself","myth","naive","name","napkin","narrow","nasty","nation","near","neck","need","nephew","nerve","nest","net","never","news","next","nice","night","noble","noise","noodle","normal","nose","note","novel","now","number","nurse","nut","oak","obey","object","oblige","obtain","occur","ocean","odor","off","often","oil","okay","old","olive","omit","once","one","onion","online","open","opium","oppose","option","orange","orbit","order","organ","orient","orphan","other","outer","oval","oven","own","oxygen","oyster","ozone","pact","paddle","page","pair","palace","panel","paper","parade","past","path","pause","pave","paw","pay","peace","pen","people","pepper","permit","pet","philip","phone","phrase","piano","pick","piece","pig","pilot","pink","pipe","pistol","pitch","pizza","place","please","pluck","poem","point","polar","pond","pool","post","pot","pound","powder","praise","prefer","price","profit","public","pull","punch","pupil","purity","push","put","puzzle","qatar","quasi","queen","quite","quoted","rabbit","race","radio","rail","rally","ramp","range","rapid","rare","rather","raven","raw","razor","real","rebel","recall","red","reform","region","reject","relief","remain","rent","reopen","report","result","return","review","reward","rhythm","rib","rich","ride","rifle","right","ring","riot","ripple","risk","ritual","river","road","robot","rocket","room","rose","rotate","round","row","royal","rubber","rude","rug","rule","run","rural","sad","safe","sage","sail","salad","same","santa","sauce","save","say","scale","scene","school","scope","screen","scuba","sea","second","seed","self","semi","sense","series","settle","seven","shadow","she","ship","shock","shrimp","shy","sick","side","siege","sign","silver","simple","since","siren","sister","six","size","skate","sketch","ski","skull","slab","sleep","slight","slogan","slush","small","smile","smooth","snake","sniff","snow","soap","soccer","soda","soft","solid","son","soon","sort","south","space","speak","sphere","spirit","split","spoil","spring","spy","square","state","step","still","story","strong","stuff","style","submit","such","sudden","suffer","sugar","suit","summer","sun","supply","sure","swamp","sweet","switch","sword","symbol","syntax","syria","system","table","tackle","tag","tail","talk","tank","tape","target","task","tattoo","taxi","team","tell","ten","term","test","text","that","theme","this","three","thumb","tibet","ticket","tide","tight","tilt","time","tiny","tip","tired","tissue","title","toast","today","toe","toilet","token","tomato","tone","tool","top","torch","toss","total","toward","toy","trade","tree","trial","trophy","true","try","tube","tumble","tunnel","turn","twenty","twice","two","type","ugly","unable","uncle","under","unfair","unique","unlock","until","unveil","update","uphold","upon","upper","upset","urban","urge","usage","use","usual","vacuum","vague","valid","van","vapor","vast","vault","vein","velvet","vendor","very","vessel","viable","video","view","villa","violin","virus","visit","vital","vivid","vocal","voice","volume","vote","voyage","wage","wait","wall","want","war","wash","water","wave","way","wealth","web","weird","were","west","wet","what","when","whip","wide","wife","will","window","wire","wish","wolf","woman","wonder","wood","work","wrap","wreck","write","wrong","xander","xbox","xerox","xray","yang","yard","year","yellow","yes","yin","york","you","zane","zara","zebra","zen","zero","zippo","zone","zoo","zorro","zulu"],o=new Map;for(const e of h)for(let a=1;a<=Math.min(e.length,6);a++){const t=e.slice(0,a);o.has(t)||o.set(t,[]),o.get(t).push(e)}function w(e){if(!e)return[];const a=e.toLowerCase();return o.get(a)||[]}function x(e){const a=w(e);return a.length===1?a[0]:null}function z(e){if(!e)return!1;const a=e.toLowerCase();return h.includes(a)}function j(e){if(!e)return!0;const a=e.toLowerCase();return o.has(a)}async function A(e,a,t={}){const{signal:i}=t,m=performance.now(),n=e instanceof ArrayBuffer?new Uint8Array(e):e;if(!(n instanceof Uint8Array)||n.length!==8)throw new Error("Challenge must be exactly 8 bytes");const b=await crypto.subtle.importKey("raw",n,"PBKDF2",!1,["deriveBits"]),u=new Uint8Array(8*a);let l=0;const s=2047,r=new Uint32Array(2);for(let c=0;c<a;c++){if(i?.aborted)throw new DOMException("PoW operation aborted","AbortError");let p;do l++,++r[0]===4294967296&&++r[1],p=new Uint32Array(await crypto.subtle.deriveBits({name:"PBKDF2",salt:r,iterations:128,hash:"SHA-512"},b,32));while(p[0]&s);u.set(new Uint8Array(r.buffer),c*8)}const d=(performance.now()-m)/1e3,g=a*(s+1),f=(l/g).toFixed(1),y=l/((s+1)*d);return console.log(`PoW work=${a} solved in ${d.toFixed(2)}s (${f}x expected ${y.toFixed(1)} work/s)`),u}export{j as a,k as b,v as c,x as g,z as i,A as s,h as w};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as z,o as A,b as c,c as u,d as t,t as f,e as M,g as N,i as $,v as F,L as K,k as i,l as V,z as b,a6 as D,a7 as E,X as I,m as p,a8 as H,H as L,J as U,K as j}from"./_plugin-vue_export-helper-DJsHCwvl.js";const G={class:"app-shell"},J={key:0,class:"global-status",style:{display:"block"}},O={class:"view-root"},X={class:"surface surface--tight",style:{"max-width":"560px",margin:"0 auto",width:"100%"}},Y={class:"view-header",style:{"text-align":"center"}},q={class:"view-lede"},Q={key:0,class:"section-block"},W={key:1,class:"section-block"},Z={key:2,class:"section-block"},ee={class:"section-body"},se={class:"name-edit"},te=["disabled"],ae=["disabled"],ne={__name:"ResetApp",setup(ie){const o=I({show:!1,message:"",type:"info"}),d=i(!0),n=i(!1),l=i(""),x=i(null),g=i(null),m=i(""),y=i("");let v=null;const T=p(()=>g.value?.token_type||"your enrollment"),P=p(()=>d.value?"Preparing your secure enrollment…":h.value?`Finish up ${T.value}. You may edit the name below if needed, and it will be saved to your passkey.`:"This authentication link is no longer valid."),h=p(()=>!!(l.value&&g.value));function r(e,s="info",a=3e3){o.show=!0,o.message=e,o.type=s,v&&clearTimeout(v),a>0&&(v=setTimeout(()=>{o.show=!1},a))}async function R(){try{const e=await V();x.value=e,e?.rp_name&&(document.title=`${e.rp_name} · Passkey Setup`)}catch(e){console.warn("Unable to load settings",e)}}async function S(){if(l.value)try{g.value=await b("/auth/api/token-info",{method:"GET",headers:{Authorization:`Bearer ${l.value}`}}),m.value=g.value.display_name}catch(e){console.error("Failed to load token info",e);const s=e instanceof D?e.data?.detail||"The authentication link is invalid or expired.":E(e);y.value=s}}async function _(){if(!h.value||n.value)return;n.value=!0,r("Starting passkey registration…","info");let e;try{const s=m.value.trim()||null;e=await L.register(l.value,s)}catch(s){n.value=!1;const a=s?.message||"Passkey registration cancelled",w=a==="Passkey registration cancelled";r(w?a:`Registration failed: ${a}`,w?"info":"error",4e3);return}try{await B(e)}catch(s){n.value=!1;const a=s?.message||"Failed to establish session";r(a,"error",4e3);return}r("Passkey registered successfully!","success",800),setTimeout(()=>{n.value=!1,k()},800)}async function B(e){if(!e?.session_token)throw new Error("Registration response missing session_token");return await b("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})}function k(){const e=H.value||"/auth/";window.location.pathname!==e&&history.replaceState(null,"",e),window.location.reload()}function C(){const e=window.location.pathname.split("/").filter(Boolean);if(!e.length)return"";const s=e[e.length-1],a=e.slice(0,-1);return a.length>1||a.length===1&&a[0]!=="auth"||!s.includes(".")?"":s}return A(async()=>{if(l.value=C(),await R(),!l.value){const e="Reset link is missing or malformed.";y.value=e,r(e,"error",0),d.value=!1;return}await S(),d.value=!1}),(e,s)=>(c(),u("div",G,[o.show?(c(),u("div",J,[t("div",{class:M(["status",o.type])},f(o.message),3)])):N("",!0),t("main",O,[t("div",X,[t("header",Y,[s[1]||(s[1]=t("h1",null,"🔑 Registration",-1)),t("p",q,f(P.value),1)]),d.value?(c(),u("section",Q,[...s[2]||(s[2]=[t("div",{class:"section-body center"},[t("p",null,"Loading reset details…")],-1)])])):h.value?(c(),u("section",Z,[t("div",ee,[t("label",se,[s[3]||(s[3]=t("span",null,"👤 Name",-1)),$(t("input",{type:"text","onUpdate:modelValue":s[0]||(s[0]=a=>m.value=a),disabled:n.value,maxlength:"64",onKeyup:K(_,["enter"])},null,40,te),[[F,m.value]])]),t("button",{class:"btn-primary",disabled:n.value,onClick:_},f(n.value?"Registering…":"Register Passkey"),9,ae)])])):(c(),u("section",W,[t("div",{class:"section-body center"},[t("div",{class:"button-row center",style:{"justify-content":"center"}},[t("button",{class:"btn-secondary",onClick:k},"Return to sign-in")])])]))])])]))}},oe=z(ne,[["__scopeId","data-v-4830d223"]]);U(oe).mount("#app");j();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{o as f,b as w,x as k,u as g,k as y,J as _,K as T}from"./_plugin-vue_export-helper-DJsHCwvl.js";import{a as i,g as v}from"./theme-C2WysaSw.js";import{R as A}from"./RestrictedAuth-DWKMTEV3.js";import"./pow-DUr-T9XX.js";function h(){return new URLSearchParams(location.hash.slice(1)).get("theme")||v()||""}i(h(),".surface");addEventListener("hashchange",()=>i(h(),".surface"));const R={__name:"RestrictedApi",setup(u){const n=y(null);function d(){const a=window.location.pathname.match(/\/auth\/([^/]+)$/);if(a){const r=a[1],c=r.split(".");if(c.length===5&&c.every(l=>l.length>0))return r}return null}const o=new URLSearchParams(window.location.hash.slice(1)),p=["reauth","forbidden"].includes(o.get("mode"))?o.get("mode"):"login";function t(e){window.parent&&window.parent!==window&&window.parent.postMessage(e,"*")}function m(e){t({type:"auth-success",authenticated:!0,sessionToken:e.session_token})}function s(){t({type:"auth-back"})}return f(()=>{n.value=d(),t({type:"auth-ready"}),window.addEventListener("keydown",e=>{e.key==="Escape"&&s()})}),(e,a)=>(w(),k(A,{mode:g(p),"remote-auth-token":n.value,onAuthenticated:m,onBack:s},null,8,["mode","remote-auth-token"]))}};_(R).mount("#app");T();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const l={light:{"color-canvas":"#ffffff","color-surface":"#eff6ff","color-surface-subtle":"#dbeafe","color-border":"#2563eb","color-border-strong":"#1e40af","color-heading":"#1e3a8a","color-text":"#1e293b","color-text-muted":"#475569","color-link":"#1d4ed8","color-link-hover":"#1e40af","color-accent":"#2563eb","color-accent-strong":"#1e40af","color-accent-contrast":"#ffffff","color-success-text":"#166534","color-success-bg":"#dcfce7","color-error-text":"#b91c1c","color-error-bg":"#fee2e2","color-info-text":"#1e40af","color-info-bg":"#dbeafe","color-danger":"#dc2626","shadow-soft":"0 10px 30px rgba(30, 64, 175, 0.15)"},dark:{"color-canvas":"#0f172a","color-surface":"#141b2f","color-surface-subtle":"#1b243b","color-border":"#25304a","color-border-strong":"#3d4d6b","color-heading":"#fff","color-text":"#e2e8f0","color-text-muted":"#94a3b8","color-link":"#60a5fa","color-link-hover":"#93c5fd","color-accent":"#60a5fa","color-accent-strong":"#3b82f6","color-accent-contrast":"#0b1120","color-success-text":"#34d399","color-success-bg":"#1a4d2e","color-error-text":"#fca5a5","color-error-bg":"#4a1f1f","color-info-text":"#bae6fd","color-info-bg":"#1e3a5f","color-danger":"#f87171","shadow-soft":"0 0 0 #000000"}},s="theme-override",a="theme-transition",n="paskia-theme";function f(o,c=":root",t=!1){if(t){let e=document.getElementById(a);e||(e=document.createElement("style"),e.id=a,e.textContent="*, *::before, *::after { transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s !important; }",document.head.appendChild(e)),setTimeout(()=>document.getElementById(a)?.remove(),350)}if(document.getElementById(s)?.remove(),o&&l[o]){const e=`${c} { ${Object.entries(l[o]).map(([d,i])=>`--${d}: ${i}`).join("; ")}; }`,r=document.createElement("style");r.id=s,r.textContent=e,document.head.appendChild(r)}}function m(){return localStorage.getItem(n)||""}function b(o){o?localStorage.setItem(n,o):localStorage.removeItem(n)}function u(){f(m())}function g(o,c=!1){const t=o?.user?.theme||"";b(t),f(t,":root",c)}export{f as a,m as g,u as i,g as u};
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Auth Profile</title>
|
|
7
|
-
<script type="module" crossorigin src="/auth/assets/auth-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
7
|
+
<script type="module" crossorigin src="/auth/assets/auth-Pe-PKe8b.js"></script>
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js">
|
|
9
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/theme-C2WysaSw.js">
|
|
9
10
|
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-DzjFIx78.js">
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-
|
|
11
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/pow-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/auth-
|
|
11
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-Licr0tqA.js">
|
|
12
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/pow-DUr-T9XX.js">
|
|
13
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css">
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-CVQZxSIL.css">
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/auth-B4EpDxom.css">
|
|
15
16
|
</head>
|
|
16
17
|
<body>
|
|
17
18
|
<div id="app"></div>
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<script type="module" crossorigin src="/auth/assets/restricted-
|
|
7
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/
|
|
9
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/
|
|
10
|
-
<link rel="
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/
|
|
6
|
+
<script type="module" crossorigin src="/auth/assets/restricted-C9cJlHkd.js"></script>
|
|
7
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js">
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/theme-C2WysaSw.js">
|
|
9
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/pow-DUr-T9XX.js">
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-DWKMTEV3.js">
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css">
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-0MFeNWS2.css">
|
|
12
13
|
</head>
|
|
13
14
|
<body>
|
|
14
15
|
<div id="app"></div>
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Access Restricted</title>
|
|
7
|
-
<script type="module" crossorigin src="/auth/assets/forward-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
9
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/pow-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-
|
|
7
|
+
<script type="module" crossorigin src="/auth/assets/forward-BC0p23CH.js"></script>
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js">
|
|
9
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/pow-DUr-T9XX.js">
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-DWKMTEV3.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-DzjFIx78.js">
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css">
|
|
13
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-0MFeNWS2.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
|
16
16
|
<div id="app"></div>
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Complete Passkey Setup</title>
|
|
7
|
-
<script type="module" crossorigin src="/auth/assets/reset-
|
|
8
|
-
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-
|
|
7
|
+
<script type="module" crossorigin src="/auth/assets/reset-CkY9h28U.js"></script>
|
|
8
|
+
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js">
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css">
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/auth/assets/reset-B8PlNXuP.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
Binary file
|
paskia/util/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""API response utilities using msgspec for JSON serialization.
|
|
2
|
+
|
|
3
|
+
msgspec handles UUID and datetime conversion automatically.
|
|
4
|
+
API structs inherit from db structs with kw_only=True to add uuid/key fields.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
import msgspec
|
|
11
|
+
|
|
12
|
+
from paskia.db.structs import Org, Permission, Role, User
|
|
13
|
+
from paskia.util import useragent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _utc_datetime(dt: datetime | None) -> datetime | None:
|
|
17
|
+
"""Convert datetime to UTC, handling both aware and naive datetimes."""
|
|
18
|
+
if dt is None:
|
|
19
|
+
return None
|
|
20
|
+
if dt.tzinfo:
|
|
21
|
+
return dt.astimezone(UTC)
|
|
22
|
+
return dt.replace(tzinfo=UTC)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_datetime(dt: datetime | None) -> str | None:
|
|
26
|
+
"""Format a datetime to ISO 8601 string with Z suffix for UTC."""
|
|
27
|
+
if dt is None:
|
|
28
|
+
return None
|
|
29
|
+
utc_dt = _utc_datetime(dt)
|
|
30
|
+
return utc_dt.isoformat().replace("+00:00", "Z") if utc_dt else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# -------------------------------------------------------------------------
|
|
34
|
+
# API structs - inherit from db structs, add uuid for serialization
|
|
35
|
+
# -------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ApiUser(User, kw_only=True):
|
|
39
|
+
"""User with uuid serialized."""
|
|
40
|
+
|
|
41
|
+
uuid: UUID
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_db(cls, u: User) -> "ApiUser":
|
|
45
|
+
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ApiOrg(Org, kw_only=True):
|
|
49
|
+
"""Org with uuid serialized."""
|
|
50
|
+
|
|
51
|
+
uuid: UUID
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_db(cls, o: Org) -> "ApiOrg":
|
|
55
|
+
return cls(uuid=o.uuid, **msgspec.structs.asdict(o))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ApiRole(Role, kw_only=True):
|
|
59
|
+
"""Role with uuid serialized."""
|
|
60
|
+
|
|
61
|
+
uuid: UUID
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_db(cls, r: Role) -> "ApiRole":
|
|
65
|
+
return cls(uuid=r.uuid, **msgspec.structs.asdict(r))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ApiPermission(Permission, kw_only=True):
|
|
69
|
+
"""Permission with uuid serialized."""
|
|
70
|
+
|
|
71
|
+
uuid: UUID
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_db(cls, p: Permission) -> "ApiPermission":
|
|
75
|
+
return cls(uuid=p.uuid, **msgspec.structs.asdict(p))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ApiSession(msgspec.Struct):
|
|
79
|
+
"""Session for API responses with computed fields."""
|
|
80
|
+
|
|
81
|
+
id: str
|
|
82
|
+
credential_uuid: UUID = msgspec.field(name="credential")
|
|
83
|
+
host: str
|
|
84
|
+
ip: str
|
|
85
|
+
user_agent: str
|
|
86
|
+
last_renewed: datetime
|
|
87
|
+
is_current: bool = False
|
|
88
|
+
is_current_host: bool = False
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_db(
|
|
92
|
+
cls,
|
|
93
|
+
s, # Session
|
|
94
|
+
*,
|
|
95
|
+
current_key: str,
|
|
96
|
+
normalized_host: str | None,
|
|
97
|
+
expires_delta, # timedelta
|
|
98
|
+
) -> "ApiSession":
|
|
99
|
+
return cls(
|
|
100
|
+
id=s.key,
|
|
101
|
+
credential_uuid=s.credential_uuid,
|
|
102
|
+
host=s.host,
|
|
103
|
+
ip=s.ip,
|
|
104
|
+
user_agent=useragent.compact_user_agent(s.user_agent),
|
|
105
|
+
last_renewed=s.expiry - expires_delta,
|
|
106
|
+
is_current=s.key == current_key,
|
|
107
|
+
is_current_host=bool(
|
|
108
|
+
normalized_host and s.host and s.host == normalized_host
|
|
109
|
+
),
|
|
110
|
+
)
|
paskia/util/frontend.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import mimetypes
|
|
3
|
+
import os
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
__all__ = ["path", "file", "read", "is_dev_mode"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_dev_server() -> str | None:
|
|
13
|
+
"""Get the dev server URL from environment, or None if not in dev mode."""
|
|
14
|
+
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_static_dir() -> Path:
|
|
18
|
+
# Try packaged path via importlib.resources (works for wheel/installed).
|
|
19
|
+
try: # pragma: no cover - trivial path resolution
|
|
20
|
+
pkg_dir = resources.files("paskia") / "frontend-build"
|
|
21
|
+
fs_path = Path(str(pkg_dir))
|
|
22
|
+
if fs_path.is_dir():
|
|
23
|
+
return fs_path
|
|
24
|
+
except Exception: # pragma: no cover - defensive
|
|
25
|
+
pass
|
|
26
|
+
# Fallback for editable/development before build.
|
|
27
|
+
return Path(__file__).parent.parent / "frontend-build"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
path: Path = _resolve_static_dir()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def file(*parts: str) -> Path:
|
|
34
|
+
"""Return a child path under the static root."""
|
|
35
|
+
return path.joinpath(*parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_dev_mode() -> bool:
|
|
39
|
+
"""Check if we're running in dev mode (Vite frontend server)."""
|
|
40
|
+
return bool(_get_dev_server())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
|
44
|
+
"""Read file content and return response tuple.
|
|
45
|
+
|
|
46
|
+
In dev mode, fetches from the Vite dev server.
|
|
47
|
+
In production, reads from the static build directory.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tuple of (content, status_code, headers) suitable for
|
|
54
|
+
FastAPI Response(*args) or Sanic raw response.
|
|
55
|
+
"""
|
|
56
|
+
if is_dev_mode():
|
|
57
|
+
dev_server = _get_dev_server()
|
|
58
|
+
async with httpx.AsyncClient() as client:
|
|
59
|
+
resp = await client.get(f"{dev_server}{filepath}")
|
|
60
|
+
resp.raise_for_status()
|
|
61
|
+
mime = resp.headers.get("content-type", "application/octet-stream")
|
|
62
|
+
# Strip charset suffix if present
|
|
63
|
+
mime = mime.split(";")[0].strip()
|
|
64
|
+
return resp.content, resp.status_code, {"content-type": mime}
|
|
65
|
+
else:
|
|
66
|
+
# Production: read from static build
|
|
67
|
+
file_path = path / filepath.lstrip("/")
|
|
68
|
+
content = await _read_file_async(file_path)
|
|
69
|
+
mime, _ = mimetypes.guess_type(str(file_path))
|
|
70
|
+
return content, 200, {"content-type": mime or "application/octet-stream"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _read_file_async(file_path: Path) -> bytes:
|
|
74
|
+
"""Read file asynchronously using asyncio.to_thread."""
|
|
75
|
+
return await asyncio.to_thread(file_path.read_bytes)
|
paskia/util/hostutil.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Utilities for determining the auth UI host and base URLs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from urllib.parse import urlparse, urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@lru_cache(maxsize=1)
|
|
10
|
+
def _load_config() -> dict:
|
|
11
|
+
"""Load PASKIA_CONFIG JSON."""
|
|
12
|
+
config_json = os.getenv("PASKIA_CONFIG")
|
|
13
|
+
if not config_json:
|
|
14
|
+
return {}
|
|
15
|
+
return json.loads(config_json)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_root_mode() -> bool:
|
|
19
|
+
return _load_config().get("auth_host") is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dedicated_auth_host() -> str | None:
|
|
23
|
+
"""Return configured auth_host netloc, or None."""
|
|
24
|
+
auth_host = _load_config().get("auth_host")
|
|
25
|
+
if not auth_host:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
|
29
|
+
return parsed.netloc or parsed.path or None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ui_base_path() -> str:
|
|
33
|
+
return "/" if is_root_mode() else "/auth/"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def auth_site_url() -> str:
|
|
37
|
+
"""Return the base URL for the auth site UI (computed at startup)."""
|
|
38
|
+
cfg = _load_config()
|
|
39
|
+
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def reset_link_url(token: str) -> str:
|
|
43
|
+
"""Generate a reset link URL for the given token."""
|
|
44
|
+
return f"{auth_site_url()}{token}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_origin(origin: str) -> str:
|
|
48
|
+
"""Normalize an origin URL by adding https:// if no scheme is present."""
|
|
49
|
+
if "://" not in origin:
|
|
50
|
+
return f"https://{origin}"
|
|
51
|
+
return origin
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reload_config() -> None:
|
|
55
|
+
_load_config.cache_clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_host(raw_host: str | None) -> str | None:
|
|
59
|
+
"""Normalize a Host header preserving port (exact match required)."""
|
|
60
|
+
if not raw_host:
|
|
61
|
+
return None
|
|
62
|
+
candidate = raw_host.strip()
|
|
63
|
+
if not candidate:
|
|
64
|
+
return None
|
|
65
|
+
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
|
|
66
|
+
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
|
|
67
|
+
netloc = parsed.netloc or parsed.path or ""
|
|
68
|
+
# Strip IPv6 brackets around host part but retain port suffix.
|
|
69
|
+
if netloc.startswith("["):
|
|
70
|
+
# format: [ipv6]:port or [ipv6]
|
|
71
|
+
if "]" in netloc:
|
|
72
|
+
host_part, _, rest = netloc.partition("]")
|
|
73
|
+
port_part = rest.lstrip(":")
|
|
74
|
+
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
|
|
75
|
+
return netloc.lower() or None
|
paskia/util/htmlutil.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Utility functions for HTML manipulation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
|
7
|
+
"""Patch HTML by adding data attributes to the <html> tag.
|
|
8
|
+
|
|
9
|
+
If an <html> tag exists, adds data attributes to it.
|
|
10
|
+
If no <html> tag exists, prepends one with the data attributes.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
html: The HTML content as bytes
|
|
14
|
+
**data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Modified HTML as bytes
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
>>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
|
|
21
|
+
b'<html data-mode="reauth"><body>test</body></html>'
|
|
22
|
+
|
|
23
|
+
>>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
|
|
24
|
+
b'<html data-mode="reauth"><body>test</body>'
|
|
25
|
+
"""
|
|
26
|
+
if not data_attrs:
|
|
27
|
+
return html
|
|
28
|
+
|
|
29
|
+
html_str = html.decode("utf-8")
|
|
30
|
+
|
|
31
|
+
# Build the data attributes string
|
|
32
|
+
attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
|
|
33
|
+
|
|
34
|
+
# Check if there's an <html> tag (case-insensitive, may have existing attributes)
|
|
35
|
+
html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
|
|
36
|
+
match = html_tag_pattern.search(html_str)
|
|
37
|
+
|
|
38
|
+
if match:
|
|
39
|
+
# Insert data attributes into existing <html> tag
|
|
40
|
+
existing_attrs = match.group(1)
|
|
41
|
+
new_tag = f"<html{existing_attrs} {attrs_str}>"
|
|
42
|
+
html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
|
|
43
|
+
else:
|
|
44
|
+
# Prepend <html> tag with data attributes
|
|
45
|
+
html_str = f"<html {attrs_str}>" + html_str
|
|
46
|
+
|
|
47
|
+
return html_str.encode("utf-8")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
|
|
3
|
+
from paskia.util.wordlist import words
|
|
4
|
+
|
|
5
|
+
N_WORDS = 5
|
|
6
|
+
N_WORDS_SHORT = 3
|
|
7
|
+
|
|
8
|
+
wset = set(words)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate(n=N_WORDS, sep="."):
|
|
12
|
+
"""Generate a password of random words without repeating any word."""
|
|
13
|
+
wl = words.copy()
|
|
14
|
+
return sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_well_formed(passphrase: str, n=N_WORDS, sep=".") -> bool:
|
|
18
|
+
"""Check if the passphrase is well-formed according to the regex pattern."""
|
|
19
|
+
p = passphrase.split(sep)
|
|
20
|
+
return len(p) == n and all(w in wset for w in passphrase.split("."))
|
paskia/util/permutil.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Minimal permission helpers with '*' wildcard support (no DB expansion)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
5
|
+
|
|
6
|
+
from paskia import db
|
|
7
|
+
from paskia.util.hostutil import normalize_host
|
|
8
|
+
|
|
9
|
+
__all__ = ["has_any", "has_all", "session_context"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _match(perms: set[str], patterns: Sequence[str]):
|
|
13
|
+
return (
|
|
14
|
+
any(fnmatchcase(p, pat) for p in perms) if "*" in pat else pat in perms
|
|
15
|
+
for pat in patterns
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_effective_scopes(ctx) -> set[str]:
|
|
20
|
+
"""Get effective permission scopes from context.
|
|
21
|
+
|
|
22
|
+
Returns scopes from ctx.permissions (filtered by org) if available,
|
|
23
|
+
otherwise falls back to ctx.role.permissions for backwards compatibility.
|
|
24
|
+
"""
|
|
25
|
+
if ctx.permissions:
|
|
26
|
+
return {p.scope for p in ctx.permissions}
|
|
27
|
+
# Fallback for contexts without effective permissions computed
|
|
28
|
+
return set(ctx.role.permissions or [])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def has_any(ctx, patterns: Sequence[str]) -> bool:
|
|
32
|
+
return any(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def has_all(ctx, patterns: Sequence[str]) -> bool:
|
|
36
|
+
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def session_context(auth: str | None, host: str | None = None):
|
|
40
|
+
if not auth:
|
|
41
|
+
return None
|
|
42
|
+
normalized_host = normalize_host(host) if host else None
|
|
43
|
+
return db.data().session_ctx(auth, normalized_host)
|
paskia/util/pow.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proof of Work utility using PBKDF2-SHA512.
|
|
3
|
+
|
|
4
|
+
The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
|
5
|
+
output with a zero first byte. Each work unit requires finding one such nonce.
|
|
6
|
+
All valid nonces are concatenated into a solution for server verification.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
|
|
12
|
+
EASY = 2 # Around 0.25s
|
|
13
|
+
NORMAL = 8 # Around 1s
|
|
14
|
+
HARD = 32 # Around 4s
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_challenge() -> bytes:
|
|
18
|
+
"""Generate a random 8-byte challenge."""
|
|
19
|
+
return secrets.token_bytes(8)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None:
|
|
23
|
+
"""Verify a Proof of Work solution.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
challenge: 8-byte server-provided challenge
|
|
27
|
+
solution: Concatenated 8-byte nonces (8 * work bytes)
|
|
28
|
+
work: Number of work units expected
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If the solution is invalid
|
|
32
|
+
"""
|
|
33
|
+
if len(challenge) != 8:
|
|
34
|
+
raise ValueError("Invalid challenge length")
|
|
35
|
+
|
|
36
|
+
if len(solution) != 8 * work:
|
|
37
|
+
raise ValueError("Invalid solution length")
|
|
38
|
+
|
|
39
|
+
# Verify each work unit - check that PBKDF2 output starts with 0x00
|
|
40
|
+
for i in range(work):
|
|
41
|
+
nonce = solution[i * 8 : (i + 1) * 8]
|
|
42
|
+
# Require first byte of PBKDF2-SHA512 to be zero
|
|
43
|
+
result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
|
|
44
|
+
if result[0] or result[1] & 0x07:
|
|
45
|
+
raise ValueError("Invalid PoW solution")
|
paskia/util/querysafe.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def assert_safe(value: str, *, field: str = "value") -> None:
|
|
7
|
+
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
|
|
8
|
+
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["assert_safe"]
|