Compare commits

...

11 Commits

Author SHA1 Message Date
6d721886d0 Add docs.rs badge 2023-09-01 12:16:03 +02:00
96ddd2c51b Returns new pair after refresh session 2023-09-01 11:37:24 +02:00
0666c74068 Fix refresh 2023-09-01 09:01:00 +02:00
e10c903b52 Fix refresh 2023-09-01 08:58:06 +02:00
88fecb793c Documentation and tests 2023-09-01 06:52:11 +02:00
12c11c9a27 Documentation and tests 2023-09-01 06:51:06 +02:00
e32046ffec Documentation and tests 2023-09-01 06:46:48 +02:00
095bd30f62 Documentation and tests 2023-09-01 06:42:30 +02:00
f21040d507 Fix refresh 2023-08-31 12:58:12 +02:00
5f2bc7b5fa Fix refresh 2023-08-31 12:58:09 +02:00
aa10241d57 Test sign out, fix refresh token and access token 2023-08-30 22:53:12 +02:00
17 changed files with 2139 additions and 734 deletions

72
Cargo.lock generated
View File

@ -171,18 +171,20 @@ dependencies = [
"futures-util",
"jsonwebtoken",
"serde",
"time 0.3.25",
"time 0.3.28",
"tokio 1.30.0",
"tracing",
]
[[package]]
name = "actix-jwt-session"
version = "0.1.1"
version = "1.0.1"
dependencies = [
"actix-web",
"argon2",
"async-trait",
"bincode",
"cookie 0.17.0",
"futures",
"futures-lite",
"futures-util",
@ -193,7 +195,9 @@ dependencies = [
"redis-async-pool",
"ring",
"serde",
"serde_json",
"thiserror",
"time 0.3.28",
"tokio 1.30.0",
"tracing",
"uuid",
@ -315,7 +319,7 @@ dependencies = [
"derive_more",
"serde",
"serde_json",
"time 0.3.25",
"time 0.3.28",
"tracing",
]
@ -365,7 +369,7 @@ dependencies = [
"bytes 1.1.0",
"bytestring",
"cfg-if 1.0.0",
"cookie",
"cookie 0.16.0",
"derive_more",
"encoding_rs",
"futures-core",
@ -383,7 +387,7 @@ dependencies = [
"serde_urlencoded",
"smallvec",
"socket2 0.4.9",
"time 0.3.25",
"time 0.3.28",
"url",
]
@ -895,6 +899,9 @@ name = "bitflags"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42"
dependencies = [
"serde",
]
[[package]]
name = "bitvec"
@ -1282,7 +1289,17 @@ dependencies = [
"rand 0.8.5",
"sha2",
"subtle",
"time 0.3.25",
"time 0.3.28",
"version_check",
]
[[package]]
name = "cookie"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24"
dependencies = [
"time 0.3.28",
"version_check",
]
@ -2534,7 +2551,7 @@ dependencies = [
"oswilno-view",
"redis",
"redis-async-pool",
"ron 0.8.0",
"ron 0.8.1",
"sea-orm",
"serde",
"serde_json",
@ -2630,7 +2647,7 @@ dependencies = [
"ring",
"sea-orm",
"serde",
"time 0.3.25",
"time 0.3.28",
"tokio 1.30.0",
"tracing",
"uuid",
@ -3278,13 +3295,14 @@ dependencies = [
[[package]]
name = "ron"
version = "0.8.0"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300a51053b1cb55c80b7a9fde4120726ddf25ca241a1cbb926626f62fb136bff"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [
"base64 0.13.0",
"bitflags 1.3.2",
"base64 0.21.2",
"bitflags 2.3.3",
"serde",
"serde_derive",
]
[[package]]
@ -3412,7 +3430,7 @@ dependencies = [
"serde_json",
"sqlx",
"thiserror",
"time 0.3.25",
"time 0.3.28",
"tracing",
"url",
"uuid",
@ -3475,7 +3493,7 @@ dependencies = [
"rust_decimal",
"sea-query-derive",
"serde_json",
"time 0.3.25",
"time 0.3.28",
"uuid",
]
@ -3491,7 +3509,7 @@ dependencies = [
"sea-query",
"serde_json",
"sqlx",
"time 0.3.25",
"time 0.3.28",
"uuid",
]
@ -3567,18 +3585,18 @@ checksum = "a2333e6df6d6598f2b1974829f853c2b4c5f4a6e503c10af918081aa6f8564e1"
[[package]]
name = "serde"
version = "1.0.183"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32ac8da02677876d532745a130fc9d8e6edfa81a269b107c5b00829b91d8eb3c"
checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.183"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aafe972d60b0b9bee71a91b92fee2d4fb3c9d7e8f6b179aa99f27203d99a4816"
checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
dependencies = [
"proc-macro2",
"quote",
@ -3587,9 +3605,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.104"
version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "076066c5f1078eac5b722a31827a8832fe108bed65dfa75e233c89f8206e976c"
checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360"
dependencies = [
"itoa 1.0.2",
"ryu",
@ -3702,7 +3720,7 @@ dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time 0.3.25",
"time 0.3.28",
]
[[package]]
@ -3840,7 +3858,7 @@ dependencies = [
"sqlx-rt",
"stringprep",
"thiserror",
"time 0.3.25",
"time 0.3.28",
"tokio-stream",
"url",
"uuid",
@ -4018,9 +4036,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.25"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fdd63d58b18d663fbdf70e049f00a22c8e42be082203be7f26589213cd75ea"
checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48"
dependencies = [
"deranged",
"itoa 1.0.2",
@ -4037,9 +4055,9 @@ checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb"
[[package]]
name = "time-macros"
version = "0.2.11"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb71511c991639bb078fd5bf97757e03914361c48100d52878b8e52b46fb92cd"
checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572"
dependencies = [
"time-core",
]

View File

@ -8,3 +8,4 @@ members = [
'./crates/oswilno-actix-admin',
'./crates/actix-jwt-session',
]
resolver = "2"

View File

@ -1,7 +1,7 @@
(()=>{var a=Object.create;var n=Object.defineProperty;var d=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var u=Object.getPrototypeOf,m=Object.prototype.hasOwnProperty;var p=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,s)=>(typeof require<"u"?require:t)[s]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var f=(e,t,s,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of h(t))!m.call(e,o)&&o!==s&&n(e,o,{get:()=>t[o],enumerable:!(r=d(t,o))||r.enumerable});return e};var y=(e,t,s)=>(s=e!=null?a(u(e)):{},f(t||!e||!e.__esModule?n(s,"default",{value:e,enumerable:!0}):s,e));customElements.define("oswilno-price",class extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"})}connectedCallback(){let e=this.shadowRoot,t=parseInt(this.getAttribute("price"));isNaN(t)&&(t=0);let s=parseInt(this.getAttribute("multiplier")),r=t,o=0;isNaN(s)||(r=Math.floor(t/s),o=t%s);let l=this.getAttribute("currency")||"PLN";e.innerHTML=`<style>:host{display:block;}</style><div>${r}.${o>=10?o:o+"0"} ${l}</div>`}});var i=e=>{let t="";for(let r=0;r<document.styleSheets.length;r++){let o=document.styleSheets[r];for(let l=0;l<o.rules.length;l++)t+=o.rules[l].cssText}let s=new CSSStyleSheet;s.replaceSync(t),e.adoptedStyleSheets=[s]};customElements.define("oswilno-error",class extends HTMLElement{constructor(){super();let e=this.attachShadow({mode:"open"});e.innerHTML=`
(()=>{var h=Object.create;var c=Object.defineProperty;var m=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var p=Object.getPrototypeOf,f=Object.prototype.hasOwnProperty;var g=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,s)=>(typeof require<"u"?require:t)[s]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var S=(e,t,s,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of u(t))!f.call(e,r)&&r!==s&&c(e,r,{get:()=>t[r],enumerable:!(o=m(t,r))||o.enumerable});return e};var y=(e,t,s)=>(s=e!=null?h(p(e)):{},S(t||!e||!e.__esModule?c(s,"default",{value:e,enumerable:!0}):s,e));customElements.define("oswilno-price",class extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"})}connectedCallback(){let e=this.shadowRoot,t=parseInt(this.getAttribute("price"));isNaN(t)&&(t=0);let s=parseInt(this.getAttribute("multiplier")),o=t,r=0;isNaN(s)||(o=Math.floor(t/s),r=t%s);let l=this.getAttribute("currency")||"PLN";e.innerHTML=`<style>:host{display:block;}</style><div>${o}.${r>=10?r:r+"0"} ${l}</div>`}});var a=e=>{let t="";for(let o=0;o<document.styleSheets.length;o++){let r=document.styleSheets[o];for(let l=0;l<r.rules.length;l++)t+=r.rules[l].cssText}let s=new CSSStyleSheet;s.replaceSync(t),e.adoptedStyleSheets=[s]};customElements.define("oswilno-error",class extends HTMLElement{constructor(){super();let e=this.attachShadow({mode:"open"});e.innerHTML=`
<style>:host{display:block;}</style>
<div class="flex bg-red-100 rounded-lg p-4 mb-4 text-sm text-red-700">
<slot></slot>
<div>
`,i(e)}});import("https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js");var c=document.body;c.addEventListener("htmx:beforeOnLoad",function(e){let t=e.detail,s=t.xhr,r=s.status,o=t.successful;if(console.log(o,s.getResponseHeader("Authorization")),r===200){let l=s.getResponseHeader("Authorization");l&&localStorage.setItem("jwt",l.replace(/^Bearer /i,""))}else r===401&&localStorage.removeItem("jwt");(r===422||r===400)&&(t.shouldSwap=!0,t.isError=!1)});c.addEventListener("htmx:configRequest",function(e){localStorage.getItem("jwt")&&(e.detail.headers.Authorization="Bearer "+(localStorage.getItem("jwt")||""))});})();
`,a(e)}});import("https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js");var n="Authorization",E="ACX-Authorization",i="ACX-Refresh",d=document.body;d.addEventListener("htmx:beforeOnLoad",function(e){let t=e.detail,s=t.xhr,o=s.status,r=t.successful;if(o===200){let l=s.getResponseHeader(n);l&&(console.log(s),localStorage.setItem("jwt",l.replace(/^Bearer /i,""))),s.getResponseHeader(i)&&localStorage.setItem("refresh",l.replace(/^Bearer /i,""))}else o===401&&localStorage.removeItem("jwt");(o===422||o===400)&&(t.shouldSwap=!0,t.isError=!1)});d.addEventListener("htmx:configRequest",function(e){localStorage.getItem("jwt")&&(e.detail.headers[E]="Bearer "+(localStorage.getItem("jwt")||""),e.detail.headers[n]="Bearer "+(localStorage.getItem("jwt")||""),e.detail.headers[i]=localStorage.getItem("refresh")||"")});})();
//# sourceMappingURL=build.js.map

View File

@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../crates/web-assets/assets/elements/oswilno-price.js", "../crates/web-assets/assets/css.js", "../crates/web-assets/assets/elements/oswilno-error.js", "../crates/web-assets/assets/app.js"],
"sourcesContent": ["customElements.define('oswilno-price', class extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tthis.attachShadow({ mode: 'open' });\n\t}\n\tconnectedCallback() {\n\t\tlet shadow = this.shadowRoot;\n\t\tlet price = parseInt(this.getAttribute('price'));\n\t\tif (isNaN(price)) price = 0;\n\t\tconst multiplier = parseInt(this.getAttribute('multiplier'));\n\t\tlet major = price;\n\t\tlet minor = 0;\n\t\tif (!isNaN(multiplier)) {\n\t\t\tmajor = Math.floor(price / multiplier);\n\t\t\tminor = price % multiplier;\n\t\t}\n\t\tconst currency = this.getAttribute('currency') || 'PLN';\n\t\tshadow.innerHTML = `<style>:host{display:block;}</style><div>${major}.${minor >= 10 ? minor : minor + '0'} ${ currency }</div>`;\n\t}\n});\n", "export const copyCss = (shadow) => {\n\tlet css = '';\n\tfor (let i = 0; i < document.styleSheets.length; i++) {\n\t\tconst styleSheet = document.styleSheets[i];\n\t\tfor (let j = 0; j < styleSheet.rules.length; j++) {\n\t\t\tcss += styleSheet.rules[j].cssText;\t\n\t\t}\n\t}\n\tconst sheet = new CSSStyleSheet();\n\tsheet.replaceSync(css);\n\tshadow.adoptedStyleSheets = [sheet];\n};\n", "import { copyCss } from \"../css.js\";\n\ncustomElements.define('oswilno-error', class extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tconst shadow = this.attachShadow({ mode: 'open' });\n\t\tshadow.innerHTML = `\n\t\t\t<style>:host{display:block;}</style>\n\t\t\t<div class=\"flex bg-red-100 rounded-lg p-4 mb-4 text-sm text-red-700\">\n\t\t\t\t<slot></slot>\n\t\t\t<div>\n\t\t`;\n\t\tcopyCss(shadow);\n\t}\n});\n", "import './elements/oswilno-price.js';\nimport './elements/oswilno-error.js';\nimport(\"https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js\");\n\nconst body = document.body;\nbody.addEventListener('htmx:beforeOnLoad', function (evt) {\n\tconst detail = evt.detail;\n\tconst xhr = detail.xhr;\n\tconst status = xhr.status;\n\tconst successful = detail.successful;\n\n\tconsole.log(successful, xhr.getResponseHeader('Authorization'));\n\tif (status === 200) {\n\t\tconst bearer = xhr.getResponseHeader('Authorization');\n\t\tif (bearer) {\n\t\t\tlocalStorage.setItem('jwt', bearer.replace(/^Bearer /i, ''));\n\t\t}\n\t} else if (status === 401) {\n\t\tlocalStorage.removeItem('jwt');\n\t}\n\tif (status === 422 || status === 400) {\n\t\tdetail.shouldSwap = true;\n\t\tdetail.isError = false;\n\t}\n});\nbody.addEventListener('htmx:configRequest', function (evt) {\n\tif (localStorage.getItem('jwt')) {\n\t\tevt.detail.headers.Authorization = 'Bearer ' + (localStorage.getItem('jwt') || '');\n\t}\n});\n\n"],
"mappings": "0sBAAA,eAAe,OAAO,gBAAiB,cAAc,WAAY,CAChE,aAAc,CACb,MAAM,EACN,KAAK,aAAa,CAAE,KAAM,MAAO,CAAC,CACnC,CACA,mBAAoB,CACnB,IAAIA,EAAS,KAAK,WACdC,EAAQ,SAAS,KAAK,aAAa,OAAO,CAAC,EAC3C,MAAMA,CAAK,IAAGA,EAAQ,GAC1B,IAAMC,EAAa,SAAS,KAAK,aAAa,YAAY,CAAC,EACvDC,EAAQF,EACRG,EAAQ,EACP,MAAMF,CAAU,IACpBC,EAAQ,KAAK,MAAMF,EAAQC,CAAU,EACrCE,EAAQH,EAAQC,GAEjB,IAAMG,EAAW,KAAK,aAAa,UAAU,GAAK,MAClDL,EAAO,UAAY,4CAA4CG,CAAK,IAAIC,GAAS,GAAKA,EAAQA,EAAQ,GAAG,IAAKC,CAAS,QACxH,CACD,CAAC,ECnBM,IAAMC,EAAWC,GAAW,CAClC,IAAIC,EAAM,GACV,QAASC,EAAI,EAAGA,EAAI,SAAS,YAAY,OAAQA,IAAK,CACrD,IAAMC,EAAa,SAAS,YAAYD,CAAC,EACzC,QAASE,EAAI,EAAGA,EAAID,EAAW,MAAM,OAAQC,IAC5CH,GAAOE,EAAW,MAAMC,CAAC,EAAE,OAE7B,CACA,IAAMC,EAAQ,IAAI,cAClBA,EAAM,YAAYJ,CAAG,EACrBD,EAAO,mBAAqB,CAACK,CAAK,CACnC,ECTA,eAAe,OAAO,gBAAiB,cAAc,WAAY,CAChE,aAAc,CACb,MAAM,EACN,IAAMC,EAAS,KAAK,aAAa,CAAE,KAAM,MAAO,CAAC,EACjDA,EAAO,UAAY;AAAA;AAAA;AAAA;AAAA;AAAA,IAMnBC,EAAQD,CAAM,CACf,CACD,CAAC,ECZD,OAAO,mDAAmD,EAE1D,IAAME,EAAO,SAAS,KACtBA,EAAK,iBAAiB,oBAAqB,SAAUC,EAAK,CACzD,IAAMC,EAASD,EAAI,OACbE,EAAMD,EAAO,IACbE,EAASD,EAAI,OACbE,EAAaH,EAAO,WAG1B,GADA,QAAQ,IAAIG,EAAYF,EAAI,kBAAkB,eAAe,CAAC,EAC1DC,IAAW,IAAK,CACnB,IAAME,EAASH,EAAI,kBAAkB,eAAe,EAChDG,GACH,aAAa,QAAQ,MAAOA,EAAO,QAAQ,YAAa,EAAE,CAAC,CAE7D,MAAWF,IAAW,KACrB,aAAa,WAAW,KAAK,GAE1BA,IAAW,KAAOA,IAAW,OAChCF,EAAO,WAAa,GACpBA,EAAO,QAAU,GAEnB,CAAC,EACDF,EAAK,iBAAiB,qBAAsB,SAAUC,EAAK,CACtD,aAAa,QAAQ,KAAK,IAC7BA,EAAI,OAAO,QAAQ,cAAgB,WAAa,aAAa,QAAQ,KAAK,GAAK,IAEjF,CAAC",
"names": ["shadow", "price", "multiplier", "major", "minor", "currency", "copyCss", "shadow", "css", "i", "styleSheet", "j", "sheet", "shadow", "copyCss", "body", "evt", "detail", "xhr", "status", "successful", "bearer"]
"sourcesContent": ["customElements.define('oswilno-price', class extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tthis.attachShadow({ mode: 'open' });\n\t}\n\tconnectedCallback() {\n\t\tlet shadow = this.shadowRoot;\n\t\tlet price = parseInt(this.getAttribute('price'));\n\t\tif (isNaN(price)) price = 0;\n\t\tconst multiplier = parseInt(this.getAttribute('multiplier'));\n\t\tlet major = price;\n\t\tlet minor = 0;\n\t\tif (!isNaN(multiplier)) {\n\t\t\tmajor = Math.floor(price / multiplier);\n\t\t\tminor = price % multiplier;\n\t\t}\n\t\tconst currency = this.getAttribute('currency') || 'PLN';\n\t\tshadow.innerHTML = `<style>:host{display:block;}</style><div>${major}.${minor >= 10 ? minor : minor + '0'} ${ currency }</div>`;\n\t}\n});\n", "export const copyCss = (shadow) => {\n\tlet css = '';\n\tfor (let i = 0; i < document.styleSheets.length; i++) {\n\t\tconst styleSheet = document.styleSheets[i];\n\t\tfor (let j = 0; j < styleSheet.rules.length; j++) {\n\t\t\tcss += styleSheet.rules[j].cssText;\t\n\t\t}\n\t}\n\tconst sheet = new CSSStyleSheet();\n\tsheet.replaceSync(css);\n\tshadow.adoptedStyleSheets = [sheet];\n};\n", "import { copyCss } from \"../css.js\";\n\ncustomElements.define('oswilno-error', class extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tconst shadow = this.attachShadow({ mode: 'open' });\n\t\tshadow.innerHTML = `\n\t\t\t<style>:host{display:block;}</style>\n\t\t\t<div class=\"flex bg-red-100 rounded-lg p-4 mb-4 text-sm text-red-700\">\n\t\t\t\t<slot></slot>\n\t\t\t<div>\n\t\t`;\n\t\tcopyCss(shadow);\n\t}\n});\n", "import './elements/oswilno-price.js';\nimport './elements/oswilno-error.js';\nimport(\"https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js\");\n\nconst READ_AUTH_HEADER = 'Authorization';\nconst AUTH_HEADER = 'ACX-Authorization';\nconst REFRESH_HEADER = 'ACX-Refresh';\nconst body = document.body;\nbody.addEventListener('htmx:beforeOnLoad', function (evt) {\n\tconst detail = evt.detail;\n\tconst xhr = detail.xhr;\n\tconst status = xhr.status;\n\tconst successful = detail.successful;\n\n\tif (status === 200) {\n\t\tconst bearer = xhr.getResponseHeader(READ_AUTH_HEADER);\n\t\tif (bearer) {\n\t\t\tconsole.log(xhr);\n\t\t\tlocalStorage.setItem('jwt', bearer.replace(/^Bearer /i, ''));\n\t\t}\n\t\tconst refresh = xhr.getResponseHeader(REFRESH_HEADER);\n\t\tif (refresh) {\n\t\t\tlocalStorage.setItem('refresh', bearer.replace(/^Bearer /i, ''));\n\t\t}\n\t} else if (status === 401) {\n\t\tlocalStorage.removeItem('jwt');\n\t}\n\tif (status === 422 || status === 400) {\n\t\tdetail.shouldSwap = true;\n\t\tdetail.isError = false;\n\t}\n});\nbody.addEventListener('htmx:configRequest', function (evt) {\n\tif (localStorage.getItem('jwt')) {\n\t\tevt.detail.headers[AUTH_HEADER] = 'Bearer ' + (localStorage.getItem('jwt') || '');\n\t\tevt.detail.headers[READ_AUTH_HEADER] = 'Bearer ' + (localStorage.getItem('jwt') || '');\n\t\tevt.detail.headers[REFRESH_HEADER] = (localStorage.getItem('refresh') || '');\n\t}\n});\n\n"],
"mappings": "0sBAAA,eAAe,OAAO,gBAAiB,cAAc,WAAY,CAChE,aAAc,CACb,MAAM,EACN,KAAK,aAAa,CAAE,KAAM,MAAO,CAAC,CACnC,CACA,mBAAoB,CACnB,IAAIA,EAAS,KAAK,WACdC,EAAQ,SAAS,KAAK,aAAa,OAAO,CAAC,EAC3C,MAAMA,CAAK,IAAGA,EAAQ,GAC1B,IAAMC,EAAa,SAAS,KAAK,aAAa,YAAY,CAAC,EACvDC,EAAQF,EACRG,EAAQ,EACP,MAAMF,CAAU,IACpBC,EAAQ,KAAK,MAAMF,EAAQC,CAAU,EACrCE,EAAQH,EAAQC,GAEjB,IAAMG,EAAW,KAAK,aAAa,UAAU,GAAK,MAClDL,EAAO,UAAY,4CAA4CG,CAAK,IAAIC,GAAS,GAAKA,EAAQA,EAAQ,GAAG,IAAKC,CAAS,QACxH,CACD,CAAC,ECnBM,IAAMC,EAAWC,GAAW,CAClC,IAAIC,EAAM,GACV,QAASC,EAAI,EAAGA,EAAI,SAAS,YAAY,OAAQA,IAAK,CACrD,IAAMC,EAAa,SAAS,YAAYD,CAAC,EACzC,QAASE,EAAI,EAAGA,EAAID,EAAW,MAAM,OAAQC,IAC5CH,GAAOE,EAAW,MAAMC,CAAC,EAAE,OAE7B,CACA,IAAMC,EAAQ,IAAI,cAClBA,EAAM,YAAYJ,CAAG,EACrBD,EAAO,mBAAqB,CAACK,CAAK,CACnC,ECTA,eAAe,OAAO,gBAAiB,cAAc,WAAY,CAChE,aAAc,CACb,MAAM,EACN,IAAMC,EAAS,KAAK,aAAa,CAAE,KAAM,MAAO,CAAC,EACjDA,EAAO,UAAY;AAAA;AAAA;AAAA;AAAA;AAAA,IAMnBC,EAAQD,CAAM,CACf,CACD,CAAC,ECZD,OAAO,mDAAmD,EAE1D,IAAME,EAAmB,gBACnBC,EAAc,oBACdC,EAAiB,cACjBC,EAAO,SAAS,KACtBA,EAAK,iBAAiB,oBAAqB,SAAUC,EAAK,CACzD,IAAMC,EAASD,EAAI,OACbE,EAAMD,EAAO,IACbE,EAASD,EAAI,OACbE,EAAaH,EAAO,WAE1B,GAAIE,IAAW,IAAK,CACnB,IAAME,EAASH,EAAI,kBAAkBN,CAAgB,EACjDS,IACH,QAAQ,IAAIH,CAAG,EACf,aAAa,QAAQ,MAAOG,EAAO,QAAQ,YAAa,EAAE,CAAC,GAE5CH,EAAI,kBAAkBJ,CAAc,GAEnD,aAAa,QAAQ,UAAWO,EAAO,QAAQ,YAAa,EAAE,CAAC,CAEjE,MAAWF,IAAW,KACrB,aAAa,WAAW,KAAK,GAE1BA,IAAW,KAAOA,IAAW,OAChCF,EAAO,WAAa,GACpBA,EAAO,QAAU,GAEnB,CAAC,EACDF,EAAK,iBAAiB,qBAAsB,SAAUC,EAAK,CACtD,aAAa,QAAQ,KAAK,IAC7BA,EAAI,OAAO,QAAQH,CAAW,EAAI,WAAa,aAAa,QAAQ,KAAK,GAAK,IAC9EG,EAAI,OAAO,QAAQJ,CAAgB,EAAI,WAAa,aAAa,QAAQ,KAAK,GAAK,IACnFI,EAAI,OAAO,QAAQF,CAAc,EAAK,aAAa,QAAQ,SAAS,GAAK,GAE3E,CAAC",
"names": ["shadow", "price", "multiplier", "major", "minor", "currency", "copyCss", "shadow", "css", "i", "styleSheet", "j", "sheet", "shadow", "copyCss", "READ_AUTH_HEADER", "AUTH_HEADER", "REFRESH_HEADER", "body", "evt", "detail", "xhr", "status", "successful", "bearer"]
}

View File

@ -1,14 +1,17 @@
[package]
name = "actix-jwt-session"
version = "0.1.1"
version = "1.0.1"
edition = "2021"
description = "Full featured JWT session managment for actix"
license = "MIT"
[features]
default = ['use-redis', 'use-tracing']
default = ['use-redis', 'use-tracing', 'panic-bad-ttl', 'hashing']
use-redis = ["redis", "redis-async-pool"]
use-tracing = ['tracing']
override-bad-ttl = []
panic-bad-ttl = []
hashing = ["argon2"]
[dependencies]
actix-web = "4"
@ -23,10 +26,14 @@ redis = { version = "0.17", optional = true }
redis-async-pool = { version = "0.2.4", optional = true }
ring = "0.16.20"
serde = { version = "1.0.183", features = ["derive"] }
serde_json = "1.0.105"
thiserror = "1.0.44"
tokio = { version = "1.30.0", features = ["full"] }
tracing = { version = "0.1.37", optional = true }
uuid = { version = "1.4.1", features = ["v4", "serde"] }
argon2 = { version = "0.5.1", optional = true }
cookie = "0.17.0"
time = { version = "0.3.28", features = ["serde"] }
[[test]]
name = "ensure_redis_flow"

View File

@ -1,18 +1,151 @@
General purpose JWT session validator for actix_web
![docs.rs](https://img.shields.io/docsrs/actix-jwt-session)
Its designed to extract session using middleware and validate path simply by using extractors.
All in one creating session and session validation library for actix.
It's designed to extract session using middleware and validate endpoint simply by using actix-web extractors.
Currently you can extract tokens from Header or Cookie. It's possible to implement Path, Query
or Body using `[ServiceRequest::extract]` but you must have struct to which values will be
extracted so it's easy to do if you have your own fields.
Example:
```rust
use serde::Deserialize;
#[derive(Deserialize)]
struct MyJsonBody {
jwt: Option<String>,
refresh: Option<String>,
}
```
To start with this library you need to create your own `AppClaims` structure and implement
`actix_jwt_session::Claims` trait for it.
```rust
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Audience {
Web,
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub struct Claims {
#[serde(rename = "exp")]
pub expiration_time: u64,
#[serde(rename = "iat")]
pub issues_at: usize,
/// Account login
#[serde(rename = "sub")]
pub subject: String,
#[serde(rename = "aud")]
pub audience: Audience,
#[serde(rename = "jti")]
pub jwt_id: uuid::Uuid,
#[serde(rename = "aci")]
pub account_id: i32,
#[serde(rename = "nbf")]
pub not_before: u64,
}
impl actix_jwt_session::Claims for Claims {
fn jti(&self) -> uuid::Uuid {
self.jwt_id
}
fn subject(&self) -> &str {
&self.subject
}
}
impl Claims {
pub fn account_id(&self) -> i32 {
self.account_id
}
}
```
Then you must create middleware factory with session storage. Currently there's adapter only
for redis so we will goes with it in this example.
* First create connection pool to redis using `redis_async_pool`.
* Next generate or load create jwt signing keys. They are required for creating JWT from
claims.
* Finally pass keys and algorithm to builder, pass pool and add some extractors
```rust
use std::sync::Arc;
use actix_jwt_session::*;
async fn create<AppClaims: actix_jwt_session::Claims>() {
// create redis connection
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
RedisConnectionManager::new(
redis::Client::open("redis://localhost:6379").expect("Fail to connect to redis"),
true,
None,
),
5,
)
};
// load or create new keys in `./config`
let keys = JwtSigningKeys::load_or_create();
// create new [SessionStorage] and [SessionMiddlewareFactory]
let (storage, factory) = SessionMiddlewareFactory::<AppClaims>::build(
Arc::new(keys.encoding_key),
Arc::new(keys.decoding_key),
Algorithm::EdDSA
)
// pass redis connection
.with_redis_pool(redis.clone())
// Check if header "Authorization" exists and contains Bearer with encoded JWT
.with_jwt_header("Authorization")
// Check if cookie "jwt" exists and contains encoded JWT
.with_jwt_cookie("acx-a")
.with_refresh_header("ACX-Refresh")
// Check if cookie "jwt" exists and contains encoded JWT
.with_refresh_cookie("acx-r")
.finish();
}
```
As you can see we have there [SessionMiddlewareBuilder::with_refresh_cookie] and [SessionMiddlewareBuilder::with_refresh_header]. Library uses
internal structure [RefreshToken] which is created and managed internally without any additional user work.
This will be used to extend JWT lifetime. This lifetime comes from 2 structures which describe
time to live. [JwtTtl] describes how long access token should be valid, [RefreshToken]
describes how long refresh token is valid. [SessionStorage] allows to extend livetime of both
with single call of [SessionStorage::refresh] and it will change time of creating tokens to
current time.
```rust
use actix_jwt_session::{JwtTtl, RefreshTtl, Duration};
fn example_ttl() {
let jwt_ttl = JwtTtl(Duration::days(14));
let refresh_ttl = RefreshTtl(Duration::days(3 * 31));
}
```
Now you just need to add those structures to [actix_web::App] using `.app_data` and `.wrap` and
you are ready to go. Bellow you have full example of usage.
Examples usage:
```rust
use std::boxed::Box;
use std::sync::Arc;
use actix_jwt_session::*;
use actix_web::get;
use actix_web::web::Data;
use actix_web::{get, post};
use actix_web::web::{Data, Json};
use actix_web::{HttpResponse, App, HttpServer};
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use jsonwebtoken::*;
use serde::{Serialize, Deserialize};
@ -29,94 +162,233 @@ async fn main() {
5,
)
};
let keys = JwtSigningKeys::generate().unwrap();
let factory = RedisMiddlewareFactory::<AppClaims>::new(
let keys = JwtSigningKeys::load_or_create();
let (storage, factory) = SessionMiddlewareFactory::<AppClaims>::build(
Arc::new(keys.encoding_key),
Arc::new(keys.decoding_key),
Algorithm::EdDSA,
redis.clone(),
vec![
// Check if header "Authorization" exists and contains Bearer with encoded JWT
Box::new(HeaderExtractor::new("Authorization")),
// Check if cookie "jwt" exists and contains encoded JWT
Box::new(CookieExtractor::new("jwt")),
]
);
Algorithm::EdDSA
)
.with_redis_pool(redis.clone())
// Check if header "Authorization" exists and contains Bearer with encoded JWT
.with_jwt_header(JWT_HEADER_NAME)
// Check if cookie JWT exists and contains encoded JWT
.with_jwt_cookie(JWT_COOKIE_NAME)
.with_refresh_header(REFRESH_HEADER_NAME)
// Check if cookie JWT exists and contains encoded JWT
.with_refresh_cookie(REFRESH_COOKIE_NAME)
.finish();
let jwt_ttl = JwtTtl(Duration::days(14));
let refresh_ttl = RefreshTtl(Duration::days(3 * 31));
HttpServer::new(move || {
let factory = factory.clone();
App::new()
.app_data(Data::new(factory.storage()))
.wrap(factory)
.app_data(Data::new(storage.clone()))
.app_data(Data::new( jwt_ttl ))
.app_data(Data::new( refresh_ttl ))
.wrap(factory.clone())
.app_data(Data::new(redis.clone()))
.service(storage_access)
.service(must_be_signed_in)
.service(may_be_signed_in)
.service(register)
.service(sign_in)
.service(sign_out)
.service(refresh_session)
.service(session_info)
.service(root)
})
.bind(("0.0.0.0", 8080)).unwrap()
.run()
.await.unwrap();
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct AppClaims {
id: uuid::Uuid,
subject: String,
}
impl Claims for AppClaims {
fn jti(&self) -> uuid::Uuid { self.id }
fn subject(&self) -> &str { &self.subject }
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionData {
id: uuid::Uuid,
subject: String,
}
#[actix_web::post("/access-storage")]
async fn storage_access(
session_store: Data<SessionStorage<AppClaims>>,
p: actix_web::web::Json<SessionData>,
) -> HttpResponse {
let p = p.into_inner();
session_store.store(AppClaims {
id: p.id,
subject: p.subject,
}, std::time::Duration::from_secs(60 * 60 * 24 * 14) ).await.unwrap();
HttpResponse::Ok().body("")
}
#[get("/authorized")]
async fn must_be_signed_in(session: Authenticated<AppClaims>) -> HttpResponse {
use crate::actix_jwt_session::Claims;
let jit = session.jti();
HttpResponse::Ok().body("")
HttpResponse::Ok().finish()
}
#[get("/maybe-authorized")]
async fn may_be_signed_in(session: MaybeAuthenticated<AppClaims>) -> HttpResponse {
if let Some(session) = session.into_option() {
}
HttpResponse::Ok().body("")
HttpResponse::Ok().finish()
}
pub struct JwtSigningKeys {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
#[derive(Deserialize)]
struct SignUpPayload {
login: String,
password: String,
password_confirmation: String,
}
impl JwtSigningKeys {
fn generate() -> Result<Self, Box<dyn std::error::Error>> {
let doc = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())?;
let keypair = Ed25519KeyPair::from_pkcs8(doc.as_ref())?;
let encoding_key = EncodingKey::from_ed_der(doc.as_ref());
let decoding_key = DecodingKey::from_ed_der(keypair.public_key().as_ref());
Ok(JwtSigningKeys {
encoding_key,
decoding_key,
})
#[post("/session/sign-up")]
async fn register(payload: Json<SignUpPayload>) -> Result<HttpResponse, actix_web::Error> {
let payload = payload.into_inner();
// Validate payload
// Save model and return HttpResponse
let model = AccountModel {
id: -1,
login: payload.login,
// Encrypt password before saving to database
pass_hash: Hashing::encrypt(&payload.password).unwrap(),
};
// Save model
todo!()
}
#[derive(Deserialize)]
struct SignInPayload {
login: String,
password: String,
}
#[post("/session/sign-in")]
async fn sign_in(
store: Data<SessionStorage>,
payload: Json<SignInPayload>,
jwt_ttl: Data<JwtTtl>,
refresh_ttl: Data<RefreshTtl>,
) -> Result<HttpResponse, actix_web::Error> {
let payload = payload.into_inner();
let store = store.into_inner();
let account: AccountModel = {
/* load account using login */
todo!()
};
if let Err(e) = Hashing::verify(account.pass_hash.as_str(), payload.password.as_str()) {
return Ok(HttpResponse::Unauthorized().finish());
}
let claims = AppClaims {
issues_at: OffsetDateTime::now_utc().unix_timestamp() as usize,
subject: account.login.clone(),
expiration_time: jwt_ttl.0.as_seconds_f64() as u64,
audience: Audience::Web,
jwt_id: uuid::Uuid::new_v4(),
account_id: account.id,
not_before: 0,
};
let pair = store
.clone()
.store(claims, *jwt_ttl.into_inner(), *refresh_ttl.into_inner())
.await
.unwrap();
Ok(HttpResponse::Ok()
.append_header((JWT_HEADER_NAME, pair.jwt.encode().unwrap()))
.append_header((REFRESH_HEADER_NAME, pair.refresh.encode().unwrap()))
.finish())
}
#[post("/session/sign-out")]
async fn sign_out(store: Data<SessionStorage>, auth: Authenticated<AppClaims>) -> HttpResponse {
let store = store.into_inner();
store.erase::<AppClaims>(auth.jwt_id).await.unwrap();
HttpResponse::Ok()
.append_header((JWT_HEADER_NAME, ""))
.append_header((REFRESH_HEADER_NAME, ""))
.cookie(
actix_web::cookie::Cookie::build(JWT_COOKIE_NAME, "")
.expires(OffsetDateTime::now_utc())
.finish(),
)
.cookie(
actix_web::cookie::Cookie::build(REFRESH_COOKIE_NAME, "")
.expires(OffsetDateTime::now_utc())
.finish(),
)
.finish()
}
#[get("/session/info")]
async fn session_info(auth: Authenticated<AppClaims>) -> HttpResponse {
HttpResponse::Ok().json(&*auth)
}
#[get("/session/refresh")]
async fn refresh_session(
auth: Authenticated<RefreshToken>,
storage: Data<SessionStorage>,
) -> HttpResponse {
let storage = storage.into_inner();
storage.refresh(auth.refresh_jti).await.unwrap();
HttpResponse::Ok().json(&*auth)
}
#[get("/")]
async fn root() -> HttpResponse {
HttpResponse::Ok().finish()
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Audience {
Web,
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub struct AppClaims {
#[serde(rename = "exp")]
pub expiration_time: u64,
#[serde(rename = "iat")]
pub issues_at: usize,
/// Account login
#[serde(rename = "sub")]
pub subject: String,
#[serde(rename = "aud")]
pub audience: Audience,
#[serde(rename = "jti")]
pub jwt_id: uuid::Uuid,
#[serde(rename = "aci")]
pub account_id: i32,
#[serde(rename = "nbf")]
pub not_before: u64,
}
impl actix_jwt_session::Claims for AppClaims {
fn jti(&self) -> uuid::Uuid {
self.jwt_id
}
fn subject(&self) -> &str {
&self.subject
}
}
impl AppClaims {
pub fn account_id(&self) -> i32 {
self.account_id
}
}
struct AccountModel {
id: i32,
login: String,
pass_hash: String,
}
```
# Changelog:
1.0.0
* Factory is created using builder pattern
* JSON Web Token has automatically created Refresh Token
* Higher abstraction layers for Middleware, MiddlewareFactory and SessionStorage
* Build-in hashing functions
* Build-in TTL structures
* Documentation
1.0.1
* Returns new pair after refresh lifetime

View File

@ -0,0 +1,208 @@
//! Allow to create own session extractor and extract from cookie or header.
use crate::*;
/// Trait allowing to extract JWt token from [actix_web::dev::ServiceRequest]
///
/// Two extractor are implemented by default
/// * [HeaderExtractor] which is best for any PWA or micro services requests
/// * [CookieExtractor] which is best for simple server with session stored in cookie
///
/// It's possible to implement GraphQL, JSON payload or query using `req.extract::<JSON<YourStruct>>()` if this is needed.
///
/// All implementation can use [SessionExtractor::decode] method for decoding raw JWT string into
/// Claims and then [SessionExtractor::validate] to validate claims agains session stored in [SessionStorage]
#[async_trait(?Send)]
pub trait SessionExtractor<ClaimsType: Claims>: Send + Sync + 'static {
/// Extract claims from [actix_web::dev::ServiceRequest]
///
/// Examples:
///
/// ```
/// use actix_web::dev::ServiceRequest;
/// use jsonwebtoken::*;
/// use actix_jwt_session::*;
/// use std::sync::Arc;
/// use actix_web::HttpMessage;
/// use std::borrow::Cow;
///
/// # #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
/// # pub struct Claims { id: uuid::Uuid, sub: String }
/// # impl actix_jwt_session::Claims for Claims {
/// # fn jti(&self) -> uuid::Uuid { self.id }
/// # fn subject(&self) -> &str { &self.sub }
/// # }
///
/// #[derive(Debug, Clone, Copy, Default)]
/// struct ExampleExtractor;
///
/// #[async_trait::async_trait(?Send)]
/// impl SessionExtractor<Claims> for ExampleExtractor {
/// async fn extract_claims(
/// &self,
/// req: &mut ServiceRequest,
/// jwt_encoding_key: Arc<EncodingKey>,
/// jwt_decoding_key: Arc<DecodingKey>,
/// algorithm: Algorithm,
/// storage: SessionStorage,
/// ) -> Result<(), Error> {
/// if req.peer_addr().unwrap().ip().is_multicast() {
/// req.extensions_mut().insert(Authenticated {
/// claims: Arc::new(Claims { id: uuid::Uuid::default(), sub: "HUB".into() }),
/// jwt_encoding_key,
/// algorithm,
/// });
/// }
/// Ok(())
/// }
///
/// async fn extract_token_text<'req>(&self, req: &'req mut ServiceRequest) -> Option<Cow<'req, str>> { None }
/// }
/// ```
async fn extract_claims(
&self,
req: &mut ServiceRequest,
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage,
) -> Result<(), Error> {
let Some(as_str) = self.extract_token_text(req).await else {
return Ok(());
};
let decoded_claims = self.decode(&as_str, jwt_decoding_key, algorithm)?;
self.validate(&decoded_claims, storage).await?;
req.extensions_mut().insert(Authenticated {
claims: Arc::new(decoded_claims),
jwt_encoding_key,
algorithm,
});
Ok(())
}
/// Decode encrypted JWT to structure
fn decode(
&self,
value: &str,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
) -> Result<ClaimsType, Error> {
let mut validation = Validation::new(algorithm);
validation.validate_exp = false;
validation.validate_nbf = false;
validation.leeway = 0;
validation.required_spec_claims.clear();
decode::<ClaimsType>(value, &jwt_decoding_key, &validation)
.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::debug!("Failed to decode claims: {e:?}. {e}");
Error::CantDecode
})
.map(|t| t.claims)
}
/// Validate JWT Claims agains stored in storage tokens.
///
/// * Token must exists in storage
/// * Token must be exactly the same as token from storage
async fn validate(&self, claims: &ClaimsType, storage: SessionStorage) -> Result<(), Error> {
let stored = storage
.clone()
.find_jwt::<ClaimsType>(claims.jti())
.await
.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::debug!(
"Failed to load {} from storage: {e:?}",
std::any::type_name::<ClaimsType>()
);
Error::LoadError
})?;
if &stored != claims {
#[cfg(feature = "use-tracing")]
tracing::debug!("{claims:?} != {stored:?}");
Err(Error::DontMatch)
} else {
Ok(())
}
}
/// Lookup for session data as a string in [actix_web::dev::ServiceRequest]
///
/// If there's no token data in request you should returns `None`. This is not considered as an
/// error and until endpoint requires `Authenticated` this will not results in `401`.
async fn extract_token_text<'req>(
&self,
req: &'req mut ServiceRequest,
) -> Option<Cow<'req, str>>;
}
/// Extracts JWT token from HTTP Request cookies. This extractor should be used when you can't set
/// your own header, for example when user enters http links to browser and you don't have any
/// advanced frontend.
///
/// This exractor is may be used by PWA application or micro services but [HeaderExtractor] is much
/// more suitable for this purpose.
pub struct CookieExtractor<ClaimsType> {
__ty: PhantomData<ClaimsType>,
cookie_name: &'static str,
}
impl<ClaimsType: Claims> CookieExtractor<ClaimsType> {
/// Creates new cookie extractor.
/// It will extract token data from cookie with given name
pub fn new(cookie_name: &'static str) -> Self {
Self {
__ty: Default::default(),
cookie_name,
}
}
}
#[async_trait(?Send)]
impl<ClaimsType: Claims> SessionExtractor<ClaimsType> for CookieExtractor<ClaimsType> {
async fn extract_token_text<'req>(
&self,
req: &'req mut ServiceRequest,
) -> Option<Cow<'req, str>> {
req.cookie(self.cookie_name)
.map(|c| c.value().to_string().into())
}
}
/// Extracts JWT token from HTTP Request headers
///
/// This exractor is very useful for all PWA application or for micro services
/// because you can set your own headers while making http requests.
///
/// If you want to have users authorized using simple html anchor (tag A) you should use [CookieExtractor]
pub struct HeaderExtractor<ClaimsType> {
__ty: PhantomData<ClaimsType>,
header_name: &'static str,
}
impl<ClaimsType: Claims> HeaderExtractor<ClaimsType> {
/// Creates new header extractor.
/// It will extract token data from header with given name
pub fn new(header_name: &'static str) -> Self {
Self {
__ty: Default::default(),
header_name,
}
}
}
#[async_trait(?Send)]
impl<ClaimsType: Claims> SessionExtractor<ClaimsType> for HeaderExtractor<ClaimsType> {
async fn extract_token_text<'req>(
&self,
req: &'req mut ServiceRequest,
) -> Option<Cow<'req, str>> {
req.headers()
.get(self.header_name)
.and_then(|h| h.to_str().ok())
.map(|h| h.to_owned().into())
}
}

View File

@ -0,0 +1,41 @@
//! Encrypting and decrypting password
//!
//! This module is available by default or by enabling `hashing` feature.
//! Library docs covers using it in context of `register` and `sign in`.
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
/// Encrypting and decrypting password
pub struct Hashing;
impl Hashing {
/// Takes password and returns encrypted hash with random salt
pub fn encrypt(password: &str) -> argon2::password_hash::Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
}
/// Takes password hash and password and validates it.
pub fn verify(password_hash: &str, password: &str) -> argon2::password_hash::Result<()> {
let parsed_hash = PasswordHash::new(password_hash)?;
Argon2::default().verify_password(password.as_bytes(), &parsed_hash)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_always_random_salt() {
let pass = "ahs9dya8tsd7fa8tsa86tT&^R%^DS^%ARS&A";
let hash = Hashing::encrypt(pass).unwrap();
assert!(Hashing::verify(hash.as_str(), pass).is_ok());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,295 @@
//! Create session storage and build middleware factory
use crate::*;
pub use actix_web::cookie::time::{Duration, OffsetDateTime};
use actix_web::dev::Transform;
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse};
use futures_util::future::LocalBoxFuture;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
use std::future::{ready, Ready};
use std::rc::Rc;
use std::sync::Arc;
/// Session middleware factory builder
///
/// It should be constructed with [SessionMiddlewareFactory::build].
pub struct SessionMiddlewareBuilder<ClaimsType: Claims> {
pub(crate) jwt_encoding_key: Arc<EncodingKey>,
pub(crate) jwt_decoding_key: Arc<DecodingKey>,
pub(crate) algorithm: Algorithm,
pub(crate) storage: Option<SessionStorage>,
pub(crate) jwt_extractors: Vec<Box<dyn SessionExtractor<ClaimsType>>>,
pub(crate) refresh_extractors: Vec<Box<dyn SessionExtractor<RefreshToken>>>,
}
impl<ClaimsType: Claims> SessionMiddlewareBuilder<ClaimsType> {
#[doc(hidden)]
pub(crate) fn new(
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
) -> Self {
Self {
jwt_encoding_key: jwt_encoding_key.clone(),
jwt_decoding_key,
algorithm,
storage: None,
jwt_extractors: vec![],
refresh_extractors: vec![],
}
}
/// Set session storage to given instance. Good if for some reason you need to share 1 storage
/// with multiple instances of session middleware
#[must_use]
pub fn with_storage(mut self, storage: SessionStorage) -> Self {
self.storage = Some(storage);
self
}
/// Add cookie extractor for refresh token.
#[must_use]
pub fn with_refresh_cookie(mut self, name: &'static str) -> Self {
self.refresh_extractors
.push(Box::new(CookieExtractor::<RefreshToken>::new(name)));
self
}
/// Add header extractor for refresh token.
#[must_use]
pub fn with_refresh_header(mut self, name: &'static str) -> Self {
self.refresh_extractors
.push(Box::new(HeaderExtractor::<RefreshToken>::new(name)));
self
}
/// Add cookie extractor for json web token.
#[must_use]
pub fn with_jwt_cookie(mut self, name: &'static str) -> Self {
self.jwt_extractors
.push(Box::new(CookieExtractor::<ClaimsType>::new(name)));
self
}
/// Add header extractor for json web token.
#[must_use]
pub fn with_jwt_header(mut self, name: &'static str) -> Self {
self.jwt_extractors
.push(Box::new(HeaderExtractor::<ClaimsType>::new(name)));
self
}
/// Builds middleware factory and returns session storage with factory
pub fn finish(self) -> (SessionStorage, SessionMiddlewareFactory<ClaimsType>) {
let Self {
storage,
jwt_encoding_key,
jwt_decoding_key,
algorithm,
jwt_extractors,
refresh_extractors,
..
} = self;
let storage = storage
.expect("Session storage must be constracted from pool or set from existing storage");
(
storage.clone(),
SessionMiddlewareFactory {
jwt_encoding_key,
jwt_decoding_key,
algorithm,
storage,
jwt_extractors: Arc::new(jwt_extractors),
refresh_extractors: Arc::new(refresh_extractors),
},
)
}
}
/// Factory creates middlware for every single request.
///
/// All fields here are immutable and have atomic access and only pointer is copied so are very cheap
///
/// Example:
///
/// ```
/// use std::sync::Arc;
/// use actix_jwt_session::*;
///
/// # async fn create<AppClaims: actix_jwt_session::Claims>() {
/// // create redis connection
/// let redis = {
/// use redis_async_pool::{RedisConnectionManager, RedisPool};
/// RedisPool::new(
/// RedisConnectionManager::new(
/// redis::Client::open("redis://localhost:6379").expect("Fail to connect to redis"),
/// true,
/// None,
/// ),
/// 5,
/// )
/// };
///
/// // load or create new keys in `./config`
/// let keys = JwtSigningKeys::load_or_create();
///
/// // create new [SessionStorage] and [SessionMiddlewareFactory]
/// let (storage, factory) = SessionMiddlewareFactory::<AppClaims>::build(
/// Arc::new(keys.encoding_key),
/// Arc::new(keys.decoding_key),
/// Algorithm::EdDSA
/// )
/// // pass redis connection
/// .with_redis_pool(redis.clone())
/// // Check if header "Authorization" exists and contains Bearer with encoded JWT
/// .with_jwt_header("Authorization")
/// // Check if cookie "jwt" exists and contains encoded JWT
/// .with_jwt_cookie("acx-a")
/// .with_refresh_header("ACX-Refresh")
/// // Check if cookie "jwt" exists and contains encoded JWT
/// .with_refresh_cookie("acx-r")
/// .finish();
/// # }
/// ```
#[derive(Clone)]
pub struct SessionMiddlewareFactory<ClaimsType: Claims> {
pub(crate) jwt_encoding_key: Arc<EncodingKey>,
pub(crate) jwt_decoding_key: Arc<DecodingKey>,
pub(crate) algorithm: Algorithm,
pub(crate) storage: SessionStorage,
pub(crate) jwt_extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
pub(crate) refresh_extractors: Arc<Vec<Box<dyn SessionExtractor<RefreshToken>>>>,
}
impl<ClaimsType: Claims> SessionMiddlewareFactory<ClaimsType> {
pub fn build(
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
) -> SessionMiddlewareBuilder<ClaimsType> {
SessionMiddlewareBuilder::new(jwt_encoding_key, jwt_decoding_key, algorithm)
}
}
impl<S, B, ClaimsType> Transform<S, ServiceRequest> for SessionMiddlewareFactory<ClaimsType>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
ClaimsType: Claims,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Transform = SessionMiddleware<S, ClaimsType>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SessionMiddleware {
service: Rc::new(service),
storage: self.storage.clone(),
jwt_encoding_key: self.jwt_encoding_key.clone(),
jwt_decoding_key: self.jwt_decoding_key.clone(),
algorithm: self.algorithm,
jwt_extractors: self.jwt_extractors.clone(),
refresh_extractors: self.refresh_extractors.clone(),
}))
}
}
#[doc(hidden)]
pub struct SessionMiddleware<S, ClaimsType>
where
ClaimsType: Claims,
{
pub(crate) service: Rc<S>,
pub(crate) jwt_encoding_key: Arc<EncodingKey>,
pub(crate) jwt_decoding_key: Arc<DecodingKey>,
pub(crate) algorithm: Algorithm,
pub(crate) storage: SessionStorage,
pub(crate) jwt_extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
pub(crate) refresh_extractors: Arc<Vec<Box<dyn SessionExtractor<RefreshToken>>>>,
}
impl<S, ClaimsType: Claims> SessionMiddleware<S, ClaimsType> {
async fn extract_token<C: Claims>(
req: &mut ServiceRequest,
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage,
extractors: &[Box<dyn SessionExtractor<C>>],
) -> Result<(), Error> {
let mut last_error = None;
for extractor in extractors.iter() {
match extractor
.extract_claims(
req,
jwt_encoding_key.clone(),
jwt_decoding_key.clone(),
algorithm,
storage.clone(),
)
.await
{
Ok(_) => break,
Err(e) => {
last_error = Some(e);
}
};
}
if let Some(e) = last_error {
return Err(e)?;
}
Ok(())
}
}
impl<S, B, ClaimsType> Service<ServiceRequest> for SessionMiddleware<S, ClaimsType>
where
ClaimsType: Claims,
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, mut req: ServiceRequest) -> Self::Future {
use futures_lite::FutureExt;
let svc = self.service.clone();
let jwt_decoding_key = self.jwt_decoding_key.clone();
let jwt_encoding_key = self.jwt_encoding_key.clone();
let algorithm = self.algorithm;
let storage = self.storage.clone();
let jwt_extractors = self.jwt_extractors.clone();
let refresh_extractors = self.refresh_extractors.clone();
async move {
if !jwt_extractors.is_empty() {
Self::extract_token(
&mut req,
jwt_encoding_key.clone(),
jwt_decoding_key.clone(),
algorithm,
storage.clone(),
&jwt_extractors,
)
.await?;
}
if !refresh_extractors.is_empty() {
Self::extract_token(
&mut req,
jwt_encoding_key,
jwt_decoding_key,
algorithm,
storage,
&refresh_extractors,
)
.await?;
}
let res = svc.call(req).await?;
Ok(res)
}
.boxed_local()
}
}

View File

@ -7,14 +7,10 @@
//! [RedisStorage] is constructed by [RedisMiddlewareFactory] from [redis_async_pool::RedisPool] and shared
//! between all [RedisMiddleware] instances.
use super::*;
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use futures_util::future::LocalBoxFuture;
use crate::*;
use redis::aio::ConnectionLike;
use redis::AsyncCommands;
use std::future::{ready, Ready};
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::Arc;
/// Redis implementation for [TokenStorage]
@ -40,10 +36,16 @@ where
{
async fn get_by_jti(self: Arc<Self>, jti: &[u8]) -> Result<Vec<u8>, Error> {
let pool = self.pool.clone();
let mut conn = pool.get().await.map_err(|_| Error::RedisConn)?;
conn.get::<_, Vec<u8>>(jti)
.await
.map_err(|_| Error::NotFound)
let mut conn = pool.get().await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Unable to obtain redis connection: {e}");
Error::RedisConn
})?;
conn.get::<_, Vec<u8>>(jti).await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Session record not found in redis: {e}");
Error::NotFound
})
}
async fn set_by_jti(
@ -51,149 +53,143 @@ where
jwt_jti: &[u8],
refresh_jti: &[u8],
bytes: &[u8],
exp: std::time::Duration,
mut exp: Duration,
) -> Result<(), Error> {
bad_ttl!(
exp,
Duration::seconds(1),
"Expiration time is bellow 1s. This is not allowed for redis server."
);
let pool = self.pool.clone();
let mut conn = pool.get().await.map_err(|_| Error::RedisConn)?;
let mut conn = pool.get().await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Unable to obtain redis connection: {e}");
Error::RedisConn
})?;
let mut pipeline = redis::Pipeline::new();
pipeline
.set_ex(jwt_jti, bytes, exp.as_secs() as usize)
.set_ex(refresh_jti, bytes, exp.as_secs() as usize);
.set_ex(jwt_jti, bytes, exp.as_seconds_f32() as usize)
.set_ex(refresh_jti, bytes, exp.as_seconds_f32() as usize);
conn.req_packed_commands(&pipeline, 0, 2)
.await
.map_err(|_| Error::WriteFailed)?;
.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Failed to save session in redis: {e}");
Error::WriteFailed
})?;
Ok(())
}
async fn remove_by_jti(self: Arc<Self>, jti: &[u8]) -> Result<(), Error> {
let pool = self.pool.clone();
let mut conn = pool.get().await.map_err(|_| Error::RedisConn)?;
conn.del(jti).await.map_err(|_| Error::NotFound)?;
let mut conn = pool.get().await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Unable to obtain redis connection: {e}");
Error::RedisConn
})?;
conn.del(jti).await.map_err(|e| {
#[cfg(feature = "use-tracing")]
tracing::error!("Session record can't be removed from redis: {e}");
Error::NotFound
})?;
Ok(())
}
}
pub struct RedisMiddleware<S, ClaimsType>
where
ClaimsType: Claims,
{
_claims_type_marker: std::marker::PhantomData<ClaimsType>,
service: Rc<S>,
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage<ClaimsType>,
extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
}
impl<S, B, ClaimsType> Service<ServiceRequest> for RedisMiddleware<S, ClaimsType>
where
ClaimsType: Claims,
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
use futures_lite::FutureExt;
let svc = self.service.clone();
let jwt_decoding_key = self.jwt_decoding_key.clone();
let jwt_encoding_key = self.jwt_encoding_key.clone();
let algorithm = self.algorithm;
let storage = self.storage.clone();
let extractors = self.extractors.clone();
async move {
let mut last_error = None;
for extractor in extractors.iter() {
match extractor
.extract_jwt(
&req,
jwt_encoding_key.clone(),
jwt_decoding_key.clone(),
algorithm,
storage.clone(),
)
.await
{
Ok(_) => break,
Err(e) => {
last_error = Some(e);
}
};
}
if let Some(e) = last_error {
return Err(e)?;
}
let res = svc.call(req).await?;
Ok(res)
}
.boxed_local()
}
}
#[derive(Clone)]
pub struct RedisMiddlewareFactory<ClaimsType: Claims> {
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage<ClaimsType>,
extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
_claims_type_marker: PhantomData<ClaimsType>,
}
impl<ClaimsType: Claims> RedisMiddlewareFactory<ClaimsType> {
pub fn new(
jwt_encoding_key: Arc<EncodingKey>,
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
pool: redis_async_pool::RedisPool,
extractors: Vec<Box<dyn SessionExtractor<ClaimsType>>>,
) -> (SessionStorage<ClaimsType>, Self) {
impl<ClaimsType: Claims> SessionMiddlewareBuilder<ClaimsType> {
#[must_use]
pub fn with_redis_pool(mut self, pool: redis_async_pool::RedisPool) -> Self {
let storage = Arc::new(RedisStorage::<ClaimsType>::new(pool));
let storage = SessionStorage::new(storage, jwt_encoding_key.clone(), algorithm);
(
storage.clone(),
Self {
jwt_encoding_key: jwt_encoding_key.clone(),
jwt_decoding_key,
algorithm,
storage,
extractors: Arc::new(extractors),
_claims_type_marker: Default::default(),
},
let storage = SessionStorage::new(storage, self.jwt_encoding_key.clone(), self.algorithm);
self.storage = Some(storage);
self
}
}
#[cfg(test)]
mod tests {
use actix_web::cookie::time::*;
use super::*;
use std::ops::Add;
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub struct Claims {
#[serde(rename = "exp")]
pub expires_at: usize,
#[serde(rename = "iat")]
pub issues_at: usize,
/// Account login
#[serde(rename = "sub")]
pub subject: String,
#[serde(rename = "aud")]
pub audience: String,
#[serde(rename = "jti")]
pub jwt_id: uuid::Uuid,
#[serde(rename = "aci")]
pub account_id: i32,
}
impl crate::Claims for Claims {
fn jti(&self) -> uuid::Uuid {
self.jwt_id
}
fn subject(&self) -> &str {
&self.subject
}
}
async fn create_storage() -> (SessionStorage, SessionMiddlewareFactory<Claims>) {
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
RedisConnectionManager::new(
redis::Client::open("redis://localhost:6379")
.expect("Fail to connect to redis"),
true,
None,
),
5,
)
};
let jwt_signing_keys = JwtSigningKeys::generate(false).unwrap();
SessionMiddlewareFactory::<Claims>::build(
Arc::new(jwt_signing_keys.encoding_key),
Arc::new(jwt_signing_keys.decoding_key),
Algorithm::EdDSA,
)
.with_redis_pool(redis)
.with_refresh_cookie(REFRESH_COOKIE_NAME)
.with_refresh_header(REFRESH_HEADER_NAME)
.with_jwt_cookie(JWT_COOKIE_NAME)
.with_jwt_header(JWT_HEADER_NAME)
.finish()
}
pub fn storage(&self) -> SessionStorage<ClaimsType> {
self.storage.clone()
}
}
impl<S, B, ClaimsType> Transform<S, ServiceRequest> for RedisMiddlewareFactory<ClaimsType>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
ClaimsType: Claims,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Transform = RedisMiddleware<S, ClaimsType>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(RedisMiddleware {
service: Rc::new(service),
storage: self.storage.clone(),
jwt_encoding_key: self.jwt_encoding_key.clone(),
jwt_decoding_key: self.jwt_decoding_key.clone(),
algorithm: self.algorithm,
extractors: self.extractors.clone(),
_claims_type_marker: PhantomData,
}))
#[tokio::test]
async fn check_encode() {
let (store, _) = create_storage().await;
let jwt_exp = JwtTtl(Duration::days(31));
let refresh_exp = RefreshTtl(Duration::days(31));
let original = Claims {
subject: "me".into(),
expires_at: OffsetDateTime::now_utc()
.add(Duration::days(31))
.unix_timestamp() as usize,
issues_at: OffsetDateTime::now_utc().unix_timestamp() as usize,
audience: "web".into(),
jwt_id: Uuid::new_v4(),
account_id: 24234,
};
store
.store(original.clone(), jwt_exp, refresh_exp)
.await
.unwrap();
let loaded = store.find_jwt(original.jwt_id).await.unwrap();
assert_eq!(original, loaded);
}
}

View File

@ -1,16 +1,12 @@
use std::sync::Arc;
use actix_jwt_session::{
Authenticated, HeaderExtractor, RedisMiddlewareFactory, SessionStorage, JWT_HEADER_NAME,
};
use actix_web::http::StatusCode;
use actix_jwt_session::*;
use actix_web::dev::ServiceResponse;
use actix_web::http::{Method, StatusCode};
use actix_web::web::{Data, Json};
use actix_web::HttpResponse;
use actix_web::{get, post};
use actix_web::{http::header::ContentType, test, App};
use jsonwebtoken::*;
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@ -30,7 +26,7 @@ impl actix_jwt_session::Claims for Claims {
}
#[tokio::test(flavor = "multi_thread")]
async fn not_authenticated() {
async fn full_flow() {
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
@ -43,26 +39,36 @@ async fn not_authenticated() {
)
};
let keys = JwtSigningKeys::generate().unwrap();
let factory = RedisMiddlewareFactory::<Claims>::new(
let keys = JwtSigningKeys::generate(false).unwrap();
let (storage, factory) = SessionMiddlewareFactory::<Claims>::build(
Arc::new(keys.encoding_key),
Arc::new(keys.decoding_key),
Algorithm::EdDSA,
redis.clone(),
vec![Box::new(HeaderExtractor::new(JWT_HEADER_NAME))],
);
)
.with_redis_pool(redis.clone())
.with_jwt_header(JWT_HEADER_NAME)
.with_refresh_header(REFRESH_HEADER_NAME)
.with_jwt_cookie(JWT_COOKIE_NAME)
.with_refresh_cookie(REFRESH_COOKIE_NAME)
.finish();
let app = App::new()
.app_data(Data::new(factory.storage()))
.app_data(Data::new(storage.clone()))
.wrap(factory.clone())
.app_data(Data::new(redis.clone()))
.app_data(Data::new(JwtTtl(Duration::seconds(1))))
.app_data(Data::new(RefreshTtl(Duration::seconds(30))))
.service(sign_in)
.service(sign_out)
.service(session)
.service(refresh_session)
.service(root);
let app = actix_web::test::init_service(app).await;
// -----------------------------------------------------------------------------
// Assert authorization is ignored when token is not needed
// -----------------------------------------------------------------------------
let res = test::call_service(
&app,
test::TestRequest::default()
@ -72,24 +78,25 @@ async fn not_authenticated() {
.await;
assert!(res.status().is_success());
let res = test::call_service(
&app,
test::TestRequest::default()
.uri("/s")
.insert_header(ContentType::plaintext())
.to_request(),
)
.await;
// -----------------------------------------------------------------
// Assert signed out when active session
// -----------------------------------------------------------------
let res = test::call_service(&app, session_request("", "").to_request()).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let origina_claims = Claims {
id: Uuid::new_v4(),
subject: "foo".to_string(),
};
// ----------------------------------------------
// Create session
// ----------------------------------------------
println!("-> Creating session");
let res = test::call_service(
&app,
test::TestRequest::default()
.uri("/in")
.uri("/session/sign-in")
.method(actix_web::http::Method::POST)
.insert_header(ContentType::json())
.set_json(&origina_claims)
@ -97,52 +104,157 @@ async fn not_authenticated() {
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
let auth_bearer = res
.headers()
.get(JWT_HEADER_NAME)
.unwrap()
.to_str()
.unwrap();
let refresh_bearer = res
.headers()
.get(REFRESH_HEADER_NAME)
.unwrap()
.to_str()
.unwrap();
// ----------------------------------------------
// Assert signed in
// ----------------------------------------------
println!("-> Assert signed in");
let res = test::call_service(
&app,
session_request(&auth_bearer, &refresh_bearer).to_request(),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// ----------------------------------------------
// Access Token TTL expires
// ----------------------------------------------
println!("-> Access Token TTL expires");
let res = test::try_call_service(
&app,
session_request(&auth_bearer, &refresh_bearer).to_request(),
)
.await;
expect_invalid_session(res);
println!(" <- OK");
// ----------------------------------------------
// Refresh token
// ----------------------------------------------
println!("-> Refresh token");
let res = test::call_service(
&app,
test::TestRequest::default()
.uri("/session/refresh")
.method(Method::GET)
.insert_header((REFRESH_HEADER_NAME, refresh_bearer))
.to_request(),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
// ----------------------------------------------
// Logout
// ----------------------------------------------
println!("-> Logout");
let res = test::call_service(
&app,
test::TestRequest::default()
.uri("/session/sign-out")
.method(Method::POST)
.insert_header((JWT_HEADER_NAME, auth_bearer))
.insert_header((REFRESH_HEADER_NAME, refresh_bearer))
.to_request(),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
println!(" <- OK");
// --------------------------------------------------------------
// Assert signed out - session destroyed
// --------------------------------------------------------------
println!("-> Assert signed out - session destroyed");
let res = test::try_call_service(
&app,
session_request(&auth_bearer, &refresh_bearer).to_request(),
)
.await;
expect_invalid_session(res);
println!(" <- OK");
}
#[post("/in")]
#[post("/session/sign-in")]
async fn sign_in(
store: Data<SessionStorage<Claims>>,
store: Data<SessionStorage>,
claims: Json<Claims>,
jwt_ttl: Data<JwtTtl>,
refresh_ttl: Data<RefreshTtl>,
) -> Result<HttpResponse, actix_web::Error> {
let claims = claims.into_inner();
let store = store.into_inner();
store
let pair = store
.clone()
.set_by_jti(claims, std::time::Duration::from_secs(300))
.store(claims, *jwt_ttl.into_inner(), *refresh_ttl.into_inner())
.await
.unwrap();
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::Ok()
.append_header((JWT_HEADER_NAME, pair.jwt.encode().unwrap()))
.append_header((REFRESH_HEADER_NAME, pair.refresh.encode().unwrap()))
.finish())
}
#[post("/out")]
async fn sign_out(_store: Data<SessionStorage<Claims>>) -> HttpResponse {
HttpResponse::Ok().body("")
#[post("/session/sign-out")]
async fn sign_out(store: Data<SessionStorage>, auth: Authenticated<Claims>) -> HttpResponse {
let store = store.into_inner();
store.erase::<Claims>(auth.id).await.unwrap();
HttpResponse::Ok().finish()
}
#[get("/s")]
#[get("/session/info")]
async fn session(auth: Authenticated<Claims>) -> HttpResponse {
HttpResponse::Ok().json(&*auth)
}
#[get("/session/refresh")]
async fn refresh_session(
auth: Authenticated<RefreshToken>,
storage: Data<SessionStorage>,
) -> HttpResponse {
let storage = storage.into_inner();
storage.refresh::<Claims>(auth.refresh_jti).await.unwrap();
HttpResponse::Ok().json(&*auth)
}
#[get("/")]
async fn root() -> HttpResponse {
HttpResponse::Ok().body("")
HttpResponse::Ok().finish()
}
pub struct JwtSigningKeys {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
}
impl JwtSigningKeys {
fn generate() -> Result<Self, Box<dyn std::error::Error>> {
let doc = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())?;
let keypair = Ed25519KeyPair::from_pkcs8(doc.as_ref())?;
let encoding_key = EncodingKey::from_ed_der(doc.as_ref());
let decoding_key = DecodingKey::from_ed_der(keypair.public_key().as_ref());
Ok(JwtSigningKeys {
encoding_key,
decoding_key,
})
fn session_request(auth_bearer: &str, refresh_bearer: &str) -> actix_web::test::TestRequest {
let req = test::TestRequest::default()
.uri("/session/info")
.method(Method::GET);
if !auth_bearer.is_empty() {
req.insert_header((JWT_HEADER_NAME, auth_bearer))
.insert_header((REFRESH_HEADER_NAME, refresh_bearer))
} else {
req
}
}
fn expect_invalid_session(res: Result<ServiceResponse, actix_web::Error>) {
let err = res
.expect_err("Must be unauthorized")
.as_error::<actix_jwt_session::Error>()
.expect("Must be authorization error")
.clone();
assert_eq!(err, actix_jwt_session::Error::LoadError);
}

View File

@ -54,6 +54,7 @@ struct AllPartialParkingSpace {
parking_space_rents: Vec<parking_space_rents::Model>,
parking_space_by_id: HashMap<i32, parking_spaces::Model>,
account_by_id: HashMap<i32, accounts::Model>,
#[allow(dead_code)]
locations: Vec<parking_space_locations::Model>,
session: Option<SessionOpts>,
}
@ -225,7 +226,7 @@ async fn create(
let model = ActiveModel {
id: NotSet,
location_id: Set(Some(location_id.clone() as i32)),
location_id: Set(Some(location_id as i32)),
spot: Set(spot.map(|n| n as i32)),
account_id: Set(session.account_id()),
..Default::default()

View File

@ -1,29 +0,0 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn encrypt(password: &str) -> argon2::password_hash::Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
}
pub fn verify(password_hash: &str, password: &str) -> argon2::password_hash::Result<()> {
let parsed_hash = PasswordHash::new(&password_hash)?;
Argon2::default().verify_password(password.as_bytes(), &parsed_hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_always_random_salt() {
let pass = "ahs9dya8tsd7fa8tsa86tT&^R%^DS^%ARS&A";
let hash = encrypt(pass).unwrap();
assert!(verify(hash.as_str(), pass).is_ok());
}
}

View File

@ -1,34 +1,21 @@
use std::ops::Add;
use std::sync::Arc;
use actix_jwt_session::{
CookieExtractor, HeaderExtractor, JwtSigningKeys, SessionStorage, JWT_HEADER_NAME,
};
pub use actix_jwt_session::{Error, RedisMiddlewareFactory};
pub use actix_jwt_session::*;
use actix_web::cookie::Cookie;
use actix_web::web::{Data, Form, ServiceConfig};
use actix_web::{get, post, HttpRequest, HttpResponse};
use askama_actix::Template;
use autometrics::autometrics;
use garde::Validate;
use jsonwebtoken::*;
use oswilno_view::{Blank, Errors, Lang, Layout, Main, MainOpts, TranslationStorage};
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
mod hashing;
pub use oswilno_view::filters;
pub type Authenticated = actix_jwt_session::Authenticated<Claims>;
pub type MaybeAuthenticated = actix_jwt_session::MaybeAuthenticated<Claims>;
#[derive(Clone, Copy)]
pub struct JWTTtl(std::time::Duration);
#[derive(Clone, Copy)]
pub struct RefreshTtl(std::time::Duration);
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Audience {
@ -39,7 +26,7 @@ pub enum Audience {
#[serde(rename_all = "snake_case")]
pub struct Claims {
#[serde(rename = "exp")]
pub expires_at: usize,
pub expiration_time: u64,
#[serde(rename = "iat")]
pub issues_at: usize,
/// Account login
@ -51,6 +38,8 @@ pub struct Claims {
pub jwt_id: uuid::Uuid,
#[serde(rename = "aci")]
pub account_id: i32,
#[serde(rename = "nbf")]
pub not_before: u64,
}
impl actix_jwt_session::Claims for Claims {
@ -80,10 +69,10 @@ pub struct LoginResponse {
#[derive(Clone)]
pub struct SessionConfigurator {
jwt_ttl: Data<JWTTtl>,
jwt_ttl: Data<JwtTtl>,
refresh_ttl: Data<RefreshTtl>,
factory: RedisMiddlewareFactory<Claims>,
session_storage: SessionStorage<Claims>,
factory: SessionMiddlewareFactory<Claims>,
session_storage: SessionStorage,
}
impl SessionConfigurator {
@ -97,10 +86,11 @@ impl SessionConfigurator {
.service(logout)
.service(session_info)
.service(register)
.service(register_view);
.service(register_view)
.service(refresh_token);
}
pub fn factory(&self) -> RedisMiddlewareFactory<Claims> {
pub fn factory(&self) -> SessionMiddlewareFactory<Claims> {
self.factory.clone()
}
@ -127,22 +117,23 @@ impl SessionConfigurator {
}
pub fn new(redis: redis_async_pool::RedisPool) -> Self {
let jwt_ttl = JWTTtl(std::time::Duration::from_secs(31 * 60 * 60));
let refresh_ttl = RefreshTtl(std::time::Duration::from_secs(6 * 31 * 60 * 60));
let jwt_ttl = JwtTtl(Duration::days(31));
let refresh_ttl = RefreshTtl(Duration::days(6 * 31));
std::fs::create_dir_all("./config").ok();
let jwt_signing_keys = JwtSigningKeys::load_or_create();
let (session_storage, auth_middleware_factory) = RedisMiddlewareFactory::<Claims>::new(
let (session_storage, auth_middleware_factory) = SessionMiddlewareFactory::<Claims>::build(
Arc::new(jwt_signing_keys.encoding_key),
Arc::new(jwt_signing_keys.decoding_key),
Algorithm::EdDSA,
redis,
vec![
Box::new(CookieExtractor::<Claims>::new(JWT_HEADER_NAME)),
Box::new(HeaderExtractor::<Claims>::new(JWT_HEADER_NAME)),
],
);
)
.with_redis_pool(redis)
.with_refresh_cookie(REFRESH_COOKIE_NAME)
.with_refresh_header(REFRESH_HEADER_NAME)
.with_jwt_cookie(JWT_COOKIE_NAME)
.with_jwt_header(JWT_HEADER_NAME)
.finish();
Self {
jwt_ttl: Data::new(jwt_ttl),
@ -212,28 +203,29 @@ async fn login_view(req: HttpRequest, t: Data<TranslationStorage>) -> HttpRespon
#[autometrics]
#[post("/login")]
async fn login(
jwt_ttl: Data<JWTTtl>,
jwt_ttl: Data<JwtTtl>,
refresh_ttl: Data<RefreshTtl>,
db: Data<DatabaseConnection>,
redis: Data<SessionStorage<Claims>>,
redis: Data<SessionStorage>,
payload: Form<SignInPayload>,
t: Data<oswilno_view::TranslationStorage>,
lang: Lang,
) -> Result<HttpResponse, Error> {
let t = t.into_inner();
let mut errors = Errors::default();
let form = payload.into_inner();
match login_inner(
jwt_ttl.into_inner(),
refresh_ttl.into_inner(),
payload.into_inner(),
&form,
db.into_inner(),
redis.into_inner(),
&mut errors,
)
.await
{
Ok(res) => Ok(res),
Err(form) => Ok(HttpResponse::Ok().body(
Some(res) => Ok(res),
None => Ok(HttpResponse::Ok().body(
(SignInPartialTemplate {
form,
lang,
@ -247,19 +239,94 @@ async fn login(
}
async fn login_inner(
jwt_ttl: Arc<JWTTtl>,
jwt_ttl: Arc<JwtTtl>,
refresh_ttl: Arc<RefreshTtl>,
payload: SignInPayload,
payload: &SignInPayload,
db: Arc<DatabaseConnection>,
redis: Arc<SessionStorage<Claims>>,
redis: Arc<SessionStorage>,
errors: &mut Errors,
) -> Result<HttpResponse, SignInPayload> {
) -> Option<HttpResponse> {
let iat = OffsetDateTime::now_utc().unix_timestamp() as usize;
let expires_at = OffsetDateTime::now_utc().add(jwt_ttl.0);
let exp = expires_at.unix_timestamp() as usize;
let account = find_account(payload, errors, db.clone()).await?;
if let Err(e) = Hashing::verify(account.pass_hash.as_str(), payload.password.as_str()) {
tracing::warn!("Hashing verification failed: {e}");
errors.push_global("Bad credentials");
return None;
}
let jwt_claims = Claims {
issues_at: iat,
subject: account.login.clone(),
expiration_time: jwt_ttl.0.as_seconds_f64() as u64,
audience: Audience::Web,
jwt_id: uuid::Uuid::new_v4(),
account_id: account.id,
not_before: 0,
};
let pair = redis
.store(jwt_claims.clone(), *jwt_ttl, *refresh_ttl)
.await
.map_err(|e| {
tracing::warn!("Failed to set sign-in claims in redis: {e}");
errors.push_global("Failed to sign in. Please try later");
})
.ok()?;
let encrypted_jwt = pair
.jwt
.encode()
.map_err(|e| {
tracing::warn!("Failed to encode claims: {e}");
errors.push_global("Failed to sign in. Please try later");
})
.ok()?;
let encrypted_refresh = pair
.refresh
.encode()
.map_err(|e| {
tracing::warn!("Failed to encode claims: {e}");
errors.push_global("Failed to sign in. Please try later");
})
.ok()?;
Some(
HttpResponse::Ok()
.append_header((
actix_jwt_session::JWT_HEADER_NAME,
format!("Bearer {encrypted_jwt}").as_str(),
))
.append_header((
actix_jwt_session::REFRESH_HEADER_NAME,
format!("Bearer {}", encrypted_refresh).as_str(),
))
.append_header((
"ACX-JWT-TTL",
(pair.refresh.issues_at + pair.refresh.refresh_ttl.0).to_string(),
))
.append_header(("HX-Redirect", "/"))
.append_header(("HX-Retarget", "main"))
.cookie(
Cookie::build(actix_jwt_session::JWT_COOKIE_NAME, &encrypted_jwt)
.http_only(true)
.finish(),
)
.cookie(
Cookie::build(actix_jwt_session::REFRESH_COOKIE_NAME, &encrypted_refresh)
.http_only(true)
.finish(),
)
.finish(),
)
}
async fn find_account(
payload: &SignInPayload,
errors: &mut Errors,
db: Arc<DatabaseConnection>,
) -> Option<oswilno_contract::accounts::Model> {
use sea_orm::*;
let account = match oswilno_contract::accounts::Entity::find()
match oswilno_contract::accounts::Entity::find()
.filter(oswilno_contract::accounts::Column::Login.eq(payload.login.as_str()))
.filter(oswilno_contract::accounts::Column::Banned.eq(false))
// TODO: Add email confirmation
@ -267,78 +334,62 @@ async fn login_inner(
.one(&*db)
.await
{
Ok(Some(a)) => a,
Ok(Some(a)) => Some(a),
Ok(None) => {
errors.push_global("Bad credentials");
return Err(payload);
None
}
Err(e) => {
tracing::warn!("Failed to find account: {e}");
errors.push_global("Bad credentials");
return Err(payload);
None
}
};
if let Err(e) = hashing::verify(account.pass_hash.as_str(), payload.password.as_str()) {
tracing::warn!("Hashing verification failed: {e}");
errors.push_global("Bad credentials");
return Err(payload);
}
}
let jwt_claims = Claims {
issues_at: iat,
subject: account.login.clone(),
expires_at: exp,
audience: Audience::Web,
jwt_id: uuid::Uuid::new_v4(),
account_id: account.id,
};
let jwt_token = match redis
.store(jwt_claims.clone(), jwt_ttl.0, refresh_ttl.0)
.await
{
#[autometrics]
#[post("/refresh")]
async fn refresh_token(
refresh_token: actix_jwt_session::Authenticated<RefreshToken>,
storage: Data<SessionStorage>,
) -> HttpResponse {
let s = storage.into_inner();
let pair = match s.refresh::<Claims>(refresh_token.access_jti()).await {
Err(e) => {
tracing::warn!("Failed to set sign-in claims in redis: {e}");
errors.push_global("Failed to sign in. Please try later");
return Err(payload);
}
Ok(jwt_token) => jwt_token,
};
let bearer_token = match jwt_token.jwt.encode() {
Ok(token) => token,
Err(e) => {
tracing::warn!("Failed to encode claims: {e}");
errors.push_global("Failed to sign in. Please try later");
return Err(payload);
}
};
let refresh_token = match jwt_token.refresh.encode() {
Ok(token) => token,
Err(e) => {
tracing::warn!("Failed to encode claims: {e}");
errors.push_global("Failed to sign in. Please try later");
return Err(payload);
tracing::warn!("Failed to refresh token: {e}");
return HttpResponse::BadRequest().finish();
}
Ok(pair) => pair,
};
let jwt_cookie =
actix_web::cookie::Cookie::build(actix_jwt_session::JWT_COOKIE_NAME, &bearer_token)
.http_only(true)
.finish();
let refresh_cookie =
actix_web::cookie::Cookie::build(actix_jwt_session::REFRESH_COOKIE_NAME, &refresh_token)
.http_only(true)
.finish();
Ok(HttpResponse::SeeOther()
let encrypted_jwt = match pair.jwt.encode() {
Ok(text) => text,
Err(e) => {
tracing::warn!("Failed to encode claims: {e}");
return HttpResponse::InternalServerError().finish();
}
};
let encrypted_refresh = match pair.refresh.encode() {
Err(e) => {
tracing::warn!("Failed to encode claims: {e}");
return HttpResponse::InternalServerError().finish();
}
Ok(text) => text,
};
HttpResponse::Ok()
.append_header((
actix_jwt_session::JWT_HEADER_NAME,
format!("Bearer {bearer_token}").as_str(),
format!("Bearer {encrypted_jwt}").as_str(),
))
.append_header(("Location", "/"))
.append_header(("HX-Redirect", "/"))
.append_header(("HX-Retarget", "main"))
.cookie(jwt_cookie)
.cookie(refresh_cookie)
.body(""))
.append_header((
actix_jwt_session::REFRESH_HEADER_NAME,
format!("Bearer {}", encrypted_refresh).as_str(),
))
.append_header((
"ACX-JWT-TTL",
(pair.refresh.issues_at + pair.refresh.refresh_ttl.0).to_string(),
))
.finish()
}
#[autometrics]
@ -351,13 +402,26 @@ async fn session_info(authenticated: Authenticated) -> Result<HttpResponse, Erro
#[get("/logout")]
async fn logout(
authenticated: Authenticated,
redis: Data<SessionStorage<Claims>>,
redis: Data<SessionStorage>,
) -> Result<HttpResponse, Error> {
let jwt_id = authenticated.jwt_id;
if let Err(_e) = redis.erase(jwt_id).await {};
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/"))
.body(""))
if let Err(_e) = redis.erase::<Claims>(jwt_id).await {};
Ok(HttpResponse::Ok()
.append_header((JWT_HEADER_NAME, ""))
.append_header((REFRESH_HEADER_NAME, ""))
.cookie(
actix_web::cookie::Cookie::build(JWT_COOKIE_NAME, "")
.expires(OffsetDateTime::now_utc())
.finish(),
)
.cookie(
actix_web::cookie::Cookie::build(REFRESH_COOKIE_NAME, "")
.expires(OffsetDateTime::now_utc())
.finish(),
)
.append_header(("HX-Redirect", "/"))
.append_header(("HX-Retarget", "main"))
.finish())
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq, garde::Validate)]
@ -500,7 +564,7 @@ fn is_strong_password(value: &str, _context: &RegisterContext) -> garde::Result
spec = spec || !c.is_alphanumeric();
}
return Err(garde::Error::new(WEAK_PASS));
Err(garde::Error::new(WEAK_PASS))
}
async fn register_internal(
@ -525,7 +589,9 @@ async fn register_internal(
p.clone()
})?;
let (login_taken, email_taken) = if let Some(query_result) = query_result {
let Ok((login_taken, email_taken)): Result<(bool,bool), _> = query_result.try_get_many("", &["login_taken".into(), "email_taken".into()]) else {
let Ok((login_taken, email_taken)): Result<(bool, bool), _> =
query_result.try_get_many("", &["login_taken".into(), "email_taken".into()])
else {
tracing::warn!("Failed to fetch fields from query result while checking if account info exists in db");
errors.push_global("Something went wrong");
return Err(p);
@ -545,7 +611,7 @@ async fn register_internal(
}
tracing::warn!("{errors:#?}");
let pass = match hashing::encrypt(p.password.as_str()) {
let pass = match Hashing::encrypt(p.password.as_str()) {
Ok(p) => p,
Err(e) => {
tracing::warn!("{e}");

View File

@ -214,7 +214,7 @@ pub enum Translation {
impl std::fmt::Display for Translation {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Simple(s) => f.write_str(&s),
Self::Simple(s) => f.write_str(s),
Self::Format(inner) => inner.fmt(f),
}
}

View File

@ -2,8 +2,9 @@ import './elements/oswilno-price.js';
import './elements/oswilno-error.js';
import("https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js");
const READ_AUTH_HEADER = 'Authorization';
const AUTH_HEADER = 'ACX-Authorization';
const REFRESH_HEADER = '';
const REFRESH_HEADER = 'ACX-Refresh';
const body = document.body;
body.addEventListener('htmx:beforeOnLoad', function (evt) {
const detail = evt.detail;
@ -12,8 +13,9 @@ body.addEventListener('htmx:beforeOnLoad', function (evt) {
const successful = detail.successful;
if (status === 200) {
const bearer = xhr.getResponseHeader(AUTH_HEADER);
const bearer = xhr.getResponseHeader(READ_AUTH_HEADER);
if (bearer) {
console.log(xhr);
localStorage.setItem('jwt', bearer.replace(/^Bearer /i, ''));
}
const refresh = xhr.getResponseHeader(REFRESH_HEADER);
@ -31,7 +33,8 @@ body.addEventListener('htmx:beforeOnLoad', function (evt) {
body.addEventListener('htmx:configRequest', function (evt) {
if (localStorage.getItem('jwt')) {
evt.detail.headers[AUTH_HEADER] = 'Bearer ' + (localStorage.getItem('jwt') || '');
evt.detail.headers[REFRESH_HEADER] = 'Bearer ' + (localStorage.getItem('refresh') || '');
evt.detail.headers[READ_AUTH_HEADER] = 'Bearer ' + (localStorage.getItem('jwt') || '');
evt.detail.headers[REFRESH_HEADER] = (localStorage.getItem('refresh') || '');
}
});