feat: basic register and login
This commit is contained in:
parent
98be8dd574
commit
327f0cd5b9
39 changed files with 990 additions and 66 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_size = 4
|
||||||
|
max_line_length = 100
|
||||||
|
|
||||||
|
|
1
.env
Normal file
1
.env
Normal file
|
@ -0,0 +1 @@
|
||||||
|
APP_JWT_SECRET=bc1996ea-5464-424a-9a38-5604f2bc865a
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
||||||
/target
|
/target
|
||||||
|
/tmp/dbs/*.db
|
||||||
|
|
248
Cargo.lock
generated
248
Cargo.lock
generated
|
@ -245,6 +245,29 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-macros"
|
name = "axum-macros"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
|
@ -289,12 +312,24 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
|
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64ct"
|
name = "base64ct"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
@ -404,6 +439,17 @@ version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6"
|
checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
|
@ -470,6 +516,15 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
|
@ -576,6 +631,21 @@ version = "1.0.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
@ -585,6 +655,18 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "frank_jwt"
|
||||||
|
version = "3.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9febc9f09c7569636ba0e3d98a12addd6b11b3b3bc1d7baad06d52c60c1bbadd"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.13.1",
|
||||||
|
"openssl",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fully_pub"
|
name = "fully_pub"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
@ -684,8 +766,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -930,6 +1014,36 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jwt"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.13.1",
|
||||||
|
"crypto-common",
|
||||||
|
"digest",
|
||||||
|
"hmac",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
@ -1030,14 +1144,20 @@ dependencies = [
|
||||||
"argh",
|
"argh",
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
"axum-macros",
|
"axum-macros",
|
||||||
"axum-template",
|
"axum-template",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"frank_jwt",
|
||||||
"fully_pub",
|
"fully_pub",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"jwt",
|
||||||
"log",
|
"log",
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"minijinja-embed",
|
"minijinja-embed",
|
||||||
|
"rand_core",
|
||||||
"redis",
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
@ -1129,6 +1249,12 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
|
@ -1174,6 +1300,44 @@ version = "1.20.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
|
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.68"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.79",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
|
@ -1214,6 +1378,16 @@ version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
@ -1268,6 +1442,12 @@ version = "0.3.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
|
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.20"
|
version = "0.2.20"
|
||||||
|
@ -1378,6 +1558,21 @@ version = "0.8.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
"spin",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
|
@ -1536,6 +1731,18 @@ dependencies = [
|
||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
|
@ -1690,7 +1897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
|
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64 0.21.7",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
@ -1734,7 +1941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
|
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64 0.21.7",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
@ -1889,6 +2096,37 @@ dependencies = [
|
||||||
"syn 2.0.79",
|
"syn 2.0.79",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"itoa",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
@ -2141,6 +2379,12 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.2"
|
version = "2.5.2"
|
||||||
|
|
|
@ -42,6 +42,12 @@ tower-http = { version = "0.6.1", features = ["fs"] }
|
||||||
totp-rs = "5.6"
|
totp-rs = "5.6"
|
||||||
minijinja-embed = "2.3.1"
|
minijinja-embed = "2.3.1"
|
||||||
axum-macros = "0.4.2"
|
axum-macros = "0.4.2"
|
||||||
|
rand_core = { version = "0.6.4", features = ["std"] }
|
||||||
|
jwt = "0.16.0"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
frank_jwt = "3.1.3"
|
||||||
|
jsonwebtoken = "9.3.0"
|
||||||
|
axum-extra = { version = "0.9.4", features = ["cookie"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
minijinja-embed = "2.3.1"
|
minijinja-embed = "2.3.1"
|
||||||
|
|
7
README.md
Normal file
7
README.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Minauth
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [x] register
|
||||||
|
- [x] login
|
||||||
|
- [ ] authorize
|
2
assets/.gitignore
vendored
Normal file
2
assets/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
vendor/
|
||||||
|
node_modules/
|
46
assets/package-lock.json
generated
Normal file
46
assets/package-lock.json
generated
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "assets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"bootstrap": "^5.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bootstrap": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/twbs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/bootstrap"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
assets/package.json
Normal file
15
assets/package.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"bootstrap": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
3
build.rs
Normal file
3
build.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
minijinja_embed::embed_templates!("src/templates");
|
||||||
|
}
|
9
config.toml
Normal file
9
config.toml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
[instance]
|
||||||
|
name = "Example org"
|
||||||
|
logo_uri = "https://example.org/logo.png"
|
||||||
|
|
||||||
|
[[applications]]
|
||||||
|
slug = "demo_app"
|
||||||
|
name = "Demo app"
|
||||||
|
client_id = "a1785786-8be1-443c-9a6f-35feed703609"
|
||||||
|
client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a"
|
18
justfile
Normal file
18
justfile
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export RUST_BACKTRACE := "1"
|
||||||
|
export RUST_LOG := "trace"
|
||||||
|
|
||||||
|
watch-run:
|
||||||
|
cargo-watch -x 'run -- --config ./config.toml --database ./tmp/dbs/minauth.db --static-assets ./assets'
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run -- --database ./tmp/dbs/minauth.db --config ./config.toml --static-assets ./assets
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run -p 3085:8080 -v ./tmp/docker/config:/etc/minauth -v ./tmp/docker/db:/var/lib/minauth minauth
|
||||||
|
|
||||||
|
docker-init-db:
|
||||||
|
docker run -v ./tmp/docker/config:/etc/minauth -v ./tmp/docker/db:/var/lib/minauth autotasker /usr/local/bin/minauth_init_db.sh
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t minauth .
|
||||||
|
|
15
migrations/all.sql
Normal file
15
migrations/all.sql
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
handle TEXT NOT NULL,
|
||||||
|
full_name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
website TEXT,
|
||||||
|
picture BLOB,
|
||||||
|
|
||||||
|
status TEXT CHECK(status IN ('Active','Disabled')) NOT NULL DEFAULT 'Disabled',
|
||||||
|
password_hash TEXT,
|
||||||
|
activation_token TEXT,
|
||||||
|
last_login_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
10
src/cli.rs
10
src/cli.rs
|
@ -1,8 +1,8 @@
|
||||||
use argh::FromArgs;
|
use argh::FromArgs;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use log::info;
|
use log::info;
|
||||||
|
|
||||||
use crate::{get_app_context, server::{start_http_server, ServerConfig}};
|
use crate::{get_app_context, server::{start_http_server, ServerConfig}, DEFAULT_ASSETS_PATH};
|
||||||
|
|
||||||
#[derive(Debug, FromArgs)]
|
#[derive(Debug, FromArgs)]
|
||||||
/// Autotasker daemon
|
/// Autotasker daemon
|
||||||
|
@ -27,12 +27,11 @@ struct ServerCliFlags {
|
||||||
listen_port: u32
|
listen_port: u32
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets";
|
/// handle CLI arguments to start process daemon
|
||||||
|
|
||||||
pub async fn start_server_cli() -> Result<()> {
|
pub async fn start_server_cli() -> Result<()> {
|
||||||
info!("Starting minauth");
|
info!("Starting minauth");
|
||||||
let flags: ServerCliFlags = argh::from_env();
|
let flags: ServerCliFlags = argh::from_env();
|
||||||
let (config, db_pool) = get_app_context(crate::StartAppConfig {
|
let (config, secrets, db_pool) = get_app_context(crate::StartAppConfig {
|
||||||
config_path: flags.config,
|
config_path: flags.config,
|
||||||
database_path: flags.database
|
database_path: flags.database
|
||||||
}).await.context("Getting app context")?;
|
}).await.context("Getting app context")?;
|
||||||
|
@ -43,6 +42,7 @@ pub async fn start_server_cli() -> Result<()> {
|
||||||
listen_port: flags.listen_port
|
listen_port: flags.listen_port
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
|
secrets,
|
||||||
db_pool
|
db_pool
|
||||||
).await
|
).await
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}};
|
||||||
|
use minijinja::context;
|
||||||
|
|
||||||
|
use crate::server::AppState;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn authorize_form(
|
||||||
|
State(app_state): State<AppState>
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// 1. Verify if login
|
||||||
|
|
||||||
|
Html(
|
||||||
|
app_state.templating_env.get_template("pages/authorize.html").unwrap()
|
||||||
|
.render(context!())
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,26 +1,99 @@
|
||||||
use axum::{extract::State, response::{Html, IntoResponse}};
|
use chrono::{Duration, SecondsFormat, Utc};
|
||||||
|
use log::info;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form};
|
||||||
|
use fully_pub::fully_pub;
|
||||||
use minijinja::context;
|
use minijinja::context;
|
||||||
|
|
||||||
use crate::server::AppState;
|
use crate::{
|
||||||
|
models::user::{User, UserStatus},
|
||||||
|
server::AppState,
|
||||||
|
services::{password::verify_password_hash, session::create_token}
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn login_form(
|
pub async fn login_form(
|
||||||
State(app_state): State<AppState>
|
State(app_state): State<AppState>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
Html(
|
||||||
app_state.templating_env.get_template("pages/home.html").unwrap()
|
app_state.templating_env.get_template("pages/login.html").unwrap()
|
||||||
.render(context!())
|
.render(context!())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct LoginForm {
|
||||||
|
/// handle or email or user_id
|
||||||
|
login: String,
|
||||||
|
password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7kUIXjWM4g$6XqwuHt/+xl0d5J4BYKuIbg2acBp6udxMCnmJ6QfceY";
|
||||||
|
|
||||||
pub async fn perform_login(
|
pub async fn perform_login(
|
||||||
State(app_state): State<AppState>
|
State(app_state): State<AppState>,
|
||||||
|
Form(login): Form<LoginForm>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
// get user from db
|
||||||
|
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
|
||||||
|
.bind(&login.login)
|
||||||
|
.bind(&login.login)
|
||||||
|
.fetch_one(&app_state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let password_hash = match &user_res {
|
||||||
|
Ok(u) => u
|
||||||
|
.password_hash
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(DUMMY_PASSWORD_HASH.into()),
|
||||||
|
Err(_e) => DUMMY_PASSWORD_HASH.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let templ = app_state.templating_env.get_template("pages/login.html").unwrap();
|
||||||
|
|
||||||
|
if verify_password_hash(password_hash, login.password).is_err() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
Html(
|
Html(
|
||||||
app_state.templating_env.get_template("pages/home.html").unwrap()
|
templ.render(context!(
|
||||||
.render(context!())
|
error => Some("Invalid login or password.".to_string())
|
||||||
.unwrap()
|
)).unwrap()
|
||||||
)
|
)
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = user_res.expect("Expected User to be found.");
|
||||||
|
if user.status == UserStatus::Disabled {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html(
|
||||||
|
templ.render(context!(
|
||||||
|
error => Some("This account is disabled.".to_string())
|
||||||
|
)).unwrap()
|
||||||
|
)
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("User {:?} {:?} logged in", &user.handle, &user.email);
|
||||||
|
let _result = sqlx::query("UPDATE users SET last_login_at = $2 WHERE id = $1")
|
||||||
|
.bind(user.id.clone())
|
||||||
|
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||||
|
.execute(&app_state.db)
|
||||||
|
.await.unwrap();
|
||||||
|
|
||||||
|
let jwt = create_token(&app_state.secrets, &user);
|
||||||
|
|
||||||
|
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting
|
||||||
|
// is true
|
||||||
|
let cookie_max_age = Duration::days(7).num_seconds();
|
||||||
|
let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}");
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
|
||||||
|
headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap());
|
||||||
|
|
||||||
|
(StatusCode::FOUND, headers, Html(
|
||||||
|
templ.render(context!()).unwrap()
|
||||||
|
)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
12
src/controllers/ui/logout.rs
Normal file
12
src/controllers/ui/logout.rs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
use axum::{http::StatusCode, response::Redirect};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
|
pub async fn perform_logout(
|
||||||
|
cookies: CookieJar
|
||||||
|
) -> Result<(CookieJar, Redirect), StatusCode> {
|
||||||
|
Ok((
|
||||||
|
cookies.remove("minauth_jwt"),
|
||||||
|
Redirect::to("/")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
18
src/controllers/ui/me.rs
Normal file
18
src/controllers/ui/me.rs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
use axum::{extract::State, response::{Html, IntoResponse}, Extension};
|
||||||
|
use minijinja::context;
|
||||||
|
|
||||||
|
use crate::{server::AppState, services::session::TokenClaims};
|
||||||
|
|
||||||
|
pub async fn me_page(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
Extension(token_claims): Extension<TokenClaims>
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
Html(
|
||||||
|
app_state.templating_env.get_template("pages/me.html").unwrap()
|
||||||
|
.render(context!(
|
||||||
|
token_claims => token_claims
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -2,3 +2,5 @@ pub mod home;
|
||||||
pub mod authorize;
|
pub mod authorize;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
|
pub mod me;
|
||||||
|
pub mod logout;
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
use axum::{extract::State, response::{Html, IntoResponse}, Form};
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use minijinja::context;
|
||||||
|
use fully_pub::fully_pub;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{models::user::{User, UserStatus}, server::AppState, services::password::get_password_hash};
|
||||||
|
|
||||||
|
pub async fn register_form(
|
||||||
|
State(app_state): State<AppState>
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
Html(
|
||||||
|
app_state.templating_env.get_template("pages/register.html").unwrap()
|
||||||
|
.render(context!())
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct RegisterForm {
|
||||||
|
handle: String,
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn perform_register(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
Form(register): Form<RegisterForm>
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let templ = app_state.templating_env.get_template("pages/register.html").unwrap();
|
||||||
|
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
|
||||||
|
.bind(®ister.handle)
|
||||||
|
.bind(®ister.email)
|
||||||
|
.fetch_one(&app_state.db)
|
||||||
|
.await;
|
||||||
|
if user_res.is_ok() {
|
||||||
|
// user already exists
|
||||||
|
return Html(
|
||||||
|
templ.render(context!(
|
||||||
|
success => true
|
||||||
|
)).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = Some(
|
||||||
|
get_password_hash(register.password)
|
||||||
|
.expect("To process password").1
|
||||||
|
);
|
||||||
|
let user = User {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
email: Some(register.email),
|
||||||
|
handle: register.handle,
|
||||||
|
full_name: None,
|
||||||
|
picture: None,
|
||||||
|
|
||||||
|
password_hash,
|
||||||
|
activation_token: None,
|
||||||
|
status: UserStatus::Active,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
website: None,
|
||||||
|
last_login_at: None
|
||||||
|
};
|
||||||
|
// save in DB
|
||||||
|
let _result = sqlx::query("INSERT INTO users (id, handle, email, status, password_hash, created_at) VALUES ($1, $2, $3, $4, $5, $6)")
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(user.handle)
|
||||||
|
.bind(user.email)
|
||||||
|
.bind(user.status.to_string())
|
||||||
|
.bind(user.password_hash)
|
||||||
|
.bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||||
|
.execute(&app_state.db)
|
||||||
|
.await.unwrap();
|
||||||
|
|
||||||
|
Html(
|
||||||
|
templ
|
||||||
|
.render(context!(
|
||||||
|
success => true
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
37
src/main.rs
37
src/main.rs
|
@ -4,20 +4,21 @@ pub mod router;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod utils;
|
||||||
|
pub mod services;
|
||||||
|
pub mod middlewares;
|
||||||
|
|
||||||
use std::fs;
|
use std::{env, fs};
|
||||||
use anyhow::{Result, Context, anyhow};
|
use anyhow::{Result, Context, anyhow};
|
||||||
|
|
||||||
use database::prepare_database;
|
use database::prepare_database;
|
||||||
use log::info;
|
use log::info;
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
use sqlx::{Pool, Sqlite};
|
||||||
use sqlx::{ConnectOptions, Pool, Sqlite};
|
use models::config::{AppSecrets, Config};
|
||||||
use models::config::Config;
|
|
||||||
use minijinja::{context, Environment};
|
|
||||||
|
|
||||||
const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db";
|
pub const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db";
|
||||||
const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets";
|
pub const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets";
|
||||||
const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml";
|
pub const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml";
|
||||||
|
|
||||||
fn get_config(path: String) -> Result<Config> {
|
fn get_config(path: String) -> Result<Config> {
|
||||||
let inp_def_yaml = fs::read_to_string(path)
|
let inp_def_yaml = fs::read_to_string(path)
|
||||||
|
@ -37,15 +38,23 @@ async fn main() -> Result<()> {
|
||||||
cli::start_server_cli().await
|
cli::start_server_cli().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, Pool<Sqlite>)> {
|
async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, AppSecrets, Pool<Sqlite>)> {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
let pool = prepare_database(
|
let _ = dotenvy::dotenv();
|
||||||
&start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string())
|
|
||||||
).await.context("Could not prepare db")?;
|
let database_path = &start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string());
|
||||||
|
info!("Using database file at {}", database_path);
|
||||||
|
let pool = prepare_database(&database_path).await.context("Could not prepare db.")?;
|
||||||
|
|
||||||
let config_path = start_app_config.config_path.unwrap_or(DEFAULT_CONFIG_PATH.to_string());
|
let config_path = start_app_config.config_path.unwrap_or(DEFAULT_CONFIG_PATH.to_string());
|
||||||
info!("Using config file at {}", &config_path);
|
info!("Using config file at {}", &config_path);
|
||||||
let config: Config = get_config(config_path).expect("Cannot get config");
|
let config: Config = get_config(config_path)
|
||||||
|
.expect("Cannot get config.");
|
||||||
|
|
||||||
Ok((config, pool))
|
dotenvy::dotenv().context("loading .env")?;
|
||||||
|
let secrets = AppSecrets {
|
||||||
|
jwt_secret: env::var("APP_JWT_SECRET").context("Expecting APP_JWT_SECRET env var.")?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((config, secrets, pool))
|
||||||
}
|
}
|
||||||
|
|
28
src/middlewares/auth.rs
Normal file
28
src/middlewares/auth.rs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
use axum::{extract::{Request, State}, http::{HeaderMap, StatusCode}, middleware::Next, response::{Response, Html, IntoResponse}};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
|
use crate::{server::AppState, services::session::verify_token};
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
cookies: CookieJar,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let jwt = match cookies.get("minauth_jwt") {
|
||||||
|
Some(cookie) => cookie.value(),
|
||||||
|
None => {
|
||||||
|
// return Err((StatusCode::UNAUTHORIZED, Html("Did not found header")));
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let token_claims = match verify_token(&app_state.secrets, &jwt) {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(_e) => {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.extensions_mut().insert(token_claims);
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
1
src/middlewares/mod.rs
Normal file
1
src/middlewares/mod.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub mod auth;
|
|
@ -10,15 +10,30 @@ use uuid::Uuid;
|
||||||
/// Instance branding/customization config
|
/// Instance branding/customization config
|
||||||
struct InstanceConfig {
|
struct InstanceConfig {
|
||||||
name: String,
|
name: String,
|
||||||
logo_uri: String
|
logo_uri: Option<String>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct Application {
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
client_id: String,
|
||||||
|
client_secret: String
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
/// Configuration of this minauthator instance
|
/// Configuration of this minauthator instance
|
||||||
struct Config {
|
struct Config {
|
||||||
/// configure current autotasker instance
|
/// configure current autotasker instance
|
||||||
instance: Option<InstanceConfig>,
|
instance: InstanceConfig,
|
||||||
|
applications: Vec<Application>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[fully_pub]
|
||||||
|
pub struct AppSecrets {
|
||||||
|
jwt_secret: String
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
pub mod app;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
use fully_pub::fully_pub;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[derive(strum_macros::Display)]
|
||||||
|
#[fully_pub]
|
||||||
|
enum UserStatus {
|
||||||
|
Active,
|
||||||
|
Disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct User {
|
||||||
|
/// uuid
|
||||||
|
id: String,
|
||||||
|
handle: String,
|
||||||
|
full_name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
website: Option<String>,
|
||||||
|
picture: Option<String>, // embeded blob to store profile pic
|
||||||
|
password_hash: Option<String>, // argon2 password hash
|
||||||
|
status: UserStatus,
|
||||||
|
activation_token: Option<String>,
|
||||||
|
|
||||||
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
|
created_at: DateTime<Utc>
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,27 @@
|
||||||
use axum::{routing::{get, post}, Router};
|
use axum::{middleware, routing::{get, post}, Router};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
use crate::{controllers::ui, server::AppState};
|
use crate::{controllers::ui, middlewares::auth::auth_middleware, server::{AppState, ServerConfig}};
|
||||||
|
|
||||||
pub fn build_router() -> Router<AppState> {
|
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
|
||||||
Router::new()
|
let public_routes = Router::new()
|
||||||
.route("/", get(ui::home::home))
|
.route("/", get(ui::home::home))
|
||||||
|
.route("/register", get(ui::register::register_form))
|
||||||
|
.route("/register", post(ui::register::perform_register))
|
||||||
.route("/login", get(ui::login::login_form))
|
.route("/login", get(ui::login::login_form))
|
||||||
.route("/login", post(ui::login::perform_login))
|
.route("/login", post(ui::login::perform_login));
|
||||||
|
|
||||||
|
let user_routes = Router::new()
|
||||||
|
.route("/me", get(ui::me::me_page))
|
||||||
|
.route("/logout", get(ui::logout::perform_logout))
|
||||||
|
.route("/authorize", get(ui::authorize::authorize_form))
|
||||||
|
.layer(middleware::from_fn_with_state(app_state, auth_middleware));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.merge(public_routes)
|
||||||
|
.merge(user_routes)
|
||||||
|
.nest_service(
|
||||||
|
"/assets",
|
||||||
|
ServeDir::new(server_config.assets_path.clone())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,23 +4,17 @@ use anyhow::{Result, Context, anyhow};
|
||||||
use log::info;
|
use log::info;
|
||||||
use minijinja::{context, Environment};
|
use minijinja::{context, Environment};
|
||||||
use sqlx::{Pool, Sqlite};
|
use sqlx::{Pool, Sqlite};
|
||||||
use crate::{models::config::Config, router::build_router};
|
use crate::{models::config::{AppSecrets, Config}, router::build_router};
|
||||||
|
|
||||||
fn build_templating_env(config: &Config) -> Environment<'static> {
|
fn build_templating_env(config: &Config) -> Environment<'static> {
|
||||||
let mut templating_env = Environment::new();
|
let mut env = Environment::new();
|
||||||
|
|
||||||
let _ = templating_env
|
minijinja_embed::load_templates!(&mut env);
|
||||||
.add_template("layouts/base.html", include_str!("./templates/layouts/base.html"));
|
|
||||||
let _ = templating_env
|
|
||||||
.add_template("pages/home.html", include_str!("./templates/pages/home.html"));
|
|
||||||
|
|
||||||
// TODO: better loading with embed https://docs.rs/minijinja-embed/latest/minijinja_embed/
|
env.add_global("gl", context! {
|
||||||
templating_env.add_global("gl", context! {
|
instance => config.instance
|
||||||
instance => context! {
|
|
||||||
version => "1.243".to_string()
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
templating_env
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -35,24 +29,30 @@ pub struct ServerConfig {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
|
secrets: AppSecrets,
|
||||||
config: Config,
|
config: Config,
|
||||||
db: Pool<Sqlite>,
|
db: Pool<Sqlite>,
|
||||||
templating_env: Environment<'static>
|
templating_env: Environment<'static>
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_http_server(server_config: ServerConfig, config: Config, db_pool: Pool<Sqlite>) -> Result<()> {
|
pub async fn start_http_server(
|
||||||
|
server_config: ServerConfig,
|
||||||
|
config: Config,
|
||||||
|
secrets: AppSecrets,
|
||||||
|
db_pool: Pool<Sqlite>
|
||||||
|
) -> Result<()> {
|
||||||
// build state
|
// build state
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
templating_env: build_templating_env(&config),
|
templating_env: build_templating_env(&config),
|
||||||
config,
|
config,
|
||||||
|
secrets,
|
||||||
db: db_pool
|
db: db_pool
|
||||||
};
|
};
|
||||||
|
|
||||||
// build routes
|
// build routes
|
||||||
let services = build_router()
|
let services = build_router(
|
||||||
.nest_service(
|
&server_config,
|
||||||
"/assets",
|
state.clone()
|
||||||
ServeDir::new(server_config.assets_path)
|
|
||||||
)
|
)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
2
src/services/mod.rs
Normal file
2
src/services/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod password;
|
||||||
|
pub mod session;
|
35
src/services/password.rs
Normal file
35
src/services/password.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{
|
||||||
|
rand_core::OsRng,
|
||||||
|
PasswordHash, PasswordHasher, PasswordVerifier, SaltString
|
||||||
|
},
|
||||||
|
Argon2
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn get_password_hash(password: String) -> Result<(String, String)> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
||||||
|
// Argon2 with default params (Argon2id v19)
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
// Hash password to PHC string ($argon2id$v=19$...)
|
||||||
|
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||||
|
Ok(val) => Ok((salt.to_string(), val.to_string())),
|
||||||
|
Err(_) => Err(anyhow!("Failed to process password."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_password_hash(password_hash: String, password: String) -> Result<()> {
|
||||||
|
let parsed_hash = match PasswordHash::new(&password_hash) {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(anyhow!("Failed to parse password hash"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(_) => Err(anyhow!("Failed to verify password."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
38
src/services/session.rs
Normal file
38
src/services/session.rs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
use fully_pub::fully_pub;
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use jsonwebtoken::{encode, decode, get_current_timestamp, Header, Algorithm, Validation, EncodingKey, DecodingKey};
|
||||||
|
|
||||||
|
use crate::models::{config::AppSecrets, user::User};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
#[fully_pub]
|
||||||
|
struct TokenClaims {
|
||||||
|
sub: String, // user id
|
||||||
|
exp: u64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_token(secrets: &AppSecrets, user: &User) -> String {
|
||||||
|
let claims = TokenClaims {
|
||||||
|
sub: user.id.clone(),
|
||||||
|
exp: get_current_timestamp() + 86_400
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(&secrets.jwt_secret.as_bytes())
|
||||||
|
).expect("Create token");
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_token(secrets: &AppSecrets, jwt: &str) -> Result<TokenClaims> {
|
||||||
|
let token_data = decode::<TokenClaims>(
|
||||||
|
&jwt,
|
||||||
|
&DecodingKey::from_secret(&secrets.jwt_secret.as_bytes()),
|
||||||
|
&Validation::new(Algorithm::HS256)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
15
src/templates/components/footer.html
Normal file
15
src/templates/components/footer.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
||||||
|
<p class="col-md-4 mb-0 text-muted">© 2022 Company, Inc</p>
|
||||||
|
|
||||||
|
<a href="/" class="col-md-4 d-flex align-items-center justify-content-center mb-3 mb-md-0 me-md-auto link-dark text-decoration-none">
|
||||||
|
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"></use></svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="nav col-md-4 justify-content-end">
|
||||||
|
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Home</a></li>
|
||||||
|
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Features</a></li>
|
||||||
|
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Pricing</a></li>
|
||||||
|
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">FAQs</a></li>
|
||||||
|
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">About</a></li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
26
src/templates/components/header.html
Normal file
26
src/templates/components/header.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<header>
|
||||||
|
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">Minauth</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" aria-current="page" href="#">Home</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/login">Login</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/register">Register</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/logout">Logout</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
|
@ -4,19 +4,14 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Minauth</title>
|
<title>Minauth</title>
|
||||||
<link href="/assets/styles/simple.css" rel="stylesheet">
|
<link href="/assets/node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/assets/styles/app.css" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
{% include "components/header.html" %}
|
||||||
Minauth
|
|
||||||
</header>
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
{% include "components/footer.html" %}
|
||||||
Minauth {{ gl.instance.version }}
|
|
||||||
</footer>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Login</h1>
|
||||||
|
<!-- Login form -->
|
||||||
|
{% if error %}
|
||||||
|
<div>
|
||||||
|
Error: {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form id="login-form" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="login" class="form-label">Email or username</label>
|
||||||
|
<input
|
||||||
|
id="login" name="login" type="text"
|
||||||
|
required
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
id="password" name="password" type="password"
|
||||||
|
required
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input id="keep_session" type="checkbox" class="form-check-input">
|
||||||
|
<label class="form-check-label" for="keep_session">Check me out</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
9
src/templates/pages/me.html
Normal file
9
src/templates/pages/me.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Me page</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ token_claims.sub }}
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
47
src/templates/pages/register.html
Normal file
47
src/templates/pages/register.html
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<!-- Register form -->
|
||||||
|
<h1>Register</h1>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
Error: {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
If all the information you submitted are valid and unique, you're account
|
||||||
|
has been created and we've sent you a confirmation email.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form id="register-form" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="handle" class="form-label">Handle</label>
|
||||||
|
<input
|
||||||
|
id="handle" name="handle" type="text"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="255"
|
||||||
|
required
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email" name="email" type="email"
|
||||||
|
required
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password" name="password" type="password"
|
||||||
|
required
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
35
src/utils.rs
Normal file
35
src/utils.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{
|
||||||
|
rand_core::OsRng,
|
||||||
|
PasswordHash, PasswordHasher, PasswordVerifier, SaltString
|
||||||
|
},
|
||||||
|
Argon2
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn get_password_hash(password: String) -> Result<(String, String)> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
||||||
|
// Argon2 with default params (Argon2id v19)
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
// Hash password to PHC string ($argon2id$v=19$...)
|
||||||
|
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||||
|
Ok(val) => Ok((salt.to_string(), val.to_string())),
|
||||||
|
Err(_) => Err(anyhow!("Failed to process password."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_password_hash(password_hash: String, password: String) -> Result<()> {
|
||||||
|
let parsed_hash = match PasswordHash::new(&password_hash) {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(anyhow!("Failed to parse password hash"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(_) => Err(anyhow!("Failed to verify password."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue