add auth system: users, login, JWT, protected routes
Domain: User entity, AuthPort/PasswordHashPort/SecretStore ports. Adapters: auth (argon2 hashing, JWT tokens), secret-store (env-based), config-sqlite user repository, http-api auth routes + extractors. Application: auth_service. SPA: login page, auth client, protected router.
This commit is contained in:
10
.env.example
10
.env.example
@@ -4,5 +4,15 @@ KFRAME_TCP_ADDR=0.0.0.0:2699
|
|||||||
KFRAME_HTTP_ADDR=0.0.0.0:3000
|
KFRAME_HTTP_ADDR=0.0.0.0:3000
|
||||||
KFRAME_POLL_INTERVAL_SECS=5
|
KFRAME_POLL_INTERVAL_SECS=5
|
||||||
|
|
||||||
|
# Auth (required)
|
||||||
|
JWT_SECRET=change-me-to-a-random-secret
|
||||||
|
JWT_TTL_SECONDS=3600
|
||||||
|
|
||||||
|
# Encryption at rest (required, generate with: openssl rand -hex 32)
|
||||||
|
KFRAME_ENCRYPTION_KEY=change-me-generate-with-openssl-rand-hex-32
|
||||||
|
|
||||||
|
# SPA static files (optional, omit for dev mode with Vite proxy)
|
||||||
|
# KFRAME_SPA_DIR=spa/dist
|
||||||
|
|
||||||
# Logging (tracing-subscriber)
|
# Logging (tracing-subscriber)
|
||||||
RUST_LOG=info,sqlx=warn
|
RUST_LOG=info,sqlx=warn
|
||||||
|
|||||||
255
Cargo.lock
generated
255
Cargo.lock
generated
@@ -2,6 +2,41 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes",
|
||||||
|
"cipher",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -40,6 +75,18 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -146,6 +193,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -166,8 +222,10 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"http-api",
|
"http-api",
|
||||||
"http-json",
|
"http-json",
|
||||||
|
"kframe-auth",
|
||||||
"media-adapter",
|
"media-adapter",
|
||||||
"rss-adapter",
|
"rss-adapter",
|
||||||
|
"secret-store",
|
||||||
"tcp-server",
|
"tcp-server",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -208,6 +266,16 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "client-application"
|
name = "client-application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -352,9 +420,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -366,6 +444,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -621,8 +705,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -636,6 +722,16 @@ dependencies = [
|
|||||||
"r-efi",
|
"r-efi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -991,6 +1087,15 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1014,6 +1119,32 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kframe-auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"domain",
|
||||||
|
"jsonwebtoken",
|
||||||
|
"rand_core",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1189,6 +1320,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -1205,6 +1346,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -1241,6 +1388,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.81"
|
version = "0.10.81"
|
||||||
@@ -1313,6 +1466,27 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1367,6 +1541,18 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "postcard"
|
name = "postcard"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
@@ -1388,6 +1574,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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.21"
|
version = "0.2.21"
|
||||||
@@ -1666,6 +1858,17 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secret-store"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"base64",
|
||||||
|
"domain",
|
||||||
|
"hex",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -1802,6 +2005,18 @@ dependencies = [
|
|||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2174,6 +2389,36 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.49"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -2431,6 +2676,16 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ members = [
|
|||||||
"crates/adapters/http-json",
|
"crates/adapters/http-json",
|
||||||
"crates/adapters/rss",
|
"crates/adapters/rss",
|
||||||
"crates/adapters/media",
|
"crates/adapters/media",
|
||||||
|
"crates/adapters/auth",
|
||||||
|
"crates/adapters/secret-store",
|
||||||
"crates/api-types",
|
"crates/api-types",
|
||||||
"crates/bootstrap",
|
"crates/bootstrap",
|
||||||
"crates/client-desktop",
|
"crates/client-desktop",
|
||||||
@@ -38,6 +40,8 @@ http-json = { path = "crates/adapters/http-json" }
|
|||||||
http-api = { path = "crates/adapters/http-api" }
|
http-api = { path = "crates/adapters/http-api" }
|
||||||
media-adapter = { path = "crates/adapters/media" }
|
media-adapter = { path = "crates/adapters/media" }
|
||||||
rss-adapter = { path = "crates/adapters/rss" }
|
rss-adapter = { path = "crates/adapters/rss" }
|
||||||
|
kframe-auth = { path = "crates/adapters/auth" }
|
||||||
|
secret-store = { path = "crates/adapters/secret-store" }
|
||||||
axum = { version = "0.8", features = ["macros"] }
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||||
api-types = { path = "crates/api-types" }
|
api-types = { path = "crates/api-types" }
|
||||||
|
|||||||
11
crates/adapters/auth/Cargo.toml
Normal file
11
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "kframe-auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
90
crates/adapters/auth/src/lib.rs
Normal file
90
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
password_hash::{PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
};
|
||||||
|
use domain::{AuthPort, PasswordHashPort, UserId};
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub struct AuthConfig {
|
||||||
|
pub secret: String,
|
||||||
|
pub ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthConfig {
|
||||||
|
pub fn from_env() -> Result<Self, String> {
|
||||||
|
let secret = std::env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| "JWT_SECRET env var is required".to_string())?;
|
||||||
|
if secret.is_empty() {
|
||||||
|
return Err("JWT_SECRET must not be empty".into());
|
||||||
|
}
|
||||||
|
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(3600u64);
|
||||||
|
Ok(Self {
|
||||||
|
secret,
|
||||||
|
ttl_seconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: u32,
|
||||||
|
exp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JwtAuthService {
|
||||||
|
config: AuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtAuthService {
|
||||||
|
pub fn new(config: AuthConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthPort for JwtAuthService {
|
||||||
|
fn generate_token(&self, user_id: UserId) -> String {
|
||||||
|
let exp = jsonwebtoken::get_current_timestamp() + self.config.ttl_seconds;
|
||||||
|
let claims = Claims { sub: user_id, exp };
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.config.secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.expect("JWT encoding should not fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_token(&self, token: &str) -> Option<UserId> {
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.config.secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(data.claims.sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Argon2Hasher;
|
||||||
|
|
||||||
|
impl PasswordHashPort for Argon2Hasher {
|
||||||
|
async fn hash(&self, plain: &str) -> Result<String, String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(plain.as_bytes(), &salt)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_string();
|
||||||
|
Ok(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, plain: &str, hash: &str) -> Result<bool, String> {
|
||||||
|
let parsed = argon2::password_hash::PasswordHash::new(hash).map_err(|e| e.to_string())?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
||||||
WidgetId,
|
WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
@@ -16,6 +16,7 @@ pub struct MemoryConfigStore {
|
|||||||
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
|
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
|
||||||
layout: RwLock<Option<Layout>>,
|
layout: RwLock<Option<Layout>>,
|
||||||
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||||
|
users: RwLock<Vec<User>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MemoryConfigStore {
|
impl Default for MemoryConfigStore {
|
||||||
@@ -25,6 +26,7 @@ impl Default for MemoryConfigStore {
|
|||||||
data_sources: RwLock::new(HashMap::new()),
|
data_sources: RwLock::new(HashMap::new()),
|
||||||
layout: RwLock::new(None),
|
layout: RwLock::new(None),
|
||||||
presets: RwLock::new(HashMap::new()),
|
presets: RwLock::new(HashMap::new()),
|
||||||
|
users: RwLock::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,4 +158,30 @@ impl ConfigRepository for MemoryConfigStore {
|
|||||||
guard.remove(&id);
|
guard.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
|
let guard = self
|
||||||
|
.users
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
|
Ok(guard.iter().find(|u| u.username == username).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_user(&self, user: &User) -> Result<(), Self::Error> {
|
||||||
|
let mut guard = self
|
||||||
|
.users
|
||||||
|
.write()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
|
guard.retain(|u| u.id != user.id);
|
||||||
|
guard.push(user.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||||
|
let guard = self
|
||||||
|
.users
|
||||||
|
.read()
|
||||||
|
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||||
|
Ok(guard.len() as u32)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,36 @@ pub mod error;
|
|||||||
mod repository;
|
mod repository;
|
||||||
mod serialization;
|
mod serialization;
|
||||||
|
|
||||||
|
use domain::SecretStore;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub use error::SqliteConfigError;
|
pub use error::SqliteConfigError;
|
||||||
|
|
||||||
pub struct SqliteConfigStore {
|
pub struct SqliteConfigStore {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
|
secrets: Option<Arc<dyn SecretStore + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteConfigStore {
|
impl SqliteConfigStore {
|
||||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||||
|
Self::with_secrets(database_url, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn with_secrets(
|
||||||
|
database_url: &str,
|
||||||
|
secrets: Option<Arc<dyn SecretStore + Send + Sync>>,
|
||||||
|
) -> Result<Self, sqlx::Error> {
|
||||||
let pool = SqlitePool::connect(database_url).await?;
|
let pool = SqlitePool::connect(database_url).await?;
|
||||||
let store = Self { pool };
|
let store = Self { pool, secrets };
|
||||||
store.migrate().await?;
|
store.migrate().await?;
|
||||||
Ok(store)
|
Ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn secrets(&self) -> Option<&(dyn SecretStore + Send + Sync)> {
|
||||||
|
self.secrets.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
async fn migrate(&self) -> Result<(), sqlx::Error> {
|
async fn migrate(&self) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS widgets (
|
"CREATE TABLE IF NOT EXISTS widgets (
|
||||||
@@ -63,6 +77,16 @@ impl SqliteConfigStore {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ impl SqliteConfigStore {
|
|||||||
|
|
||||||
match row {
|
match row {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(row) => Ok(Some(ser::data_source_from_row(&row)?)),
|
Some(row) => Ok(Some(ser::data_source_from_row(&row, self.secrets())?)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,19 +28,22 @@ impl SqliteConfigStore {
|
|||||||
.await
|
.await
|
||||||
.map_err(SqliteConfigError::Sql)?;
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
rows.iter().map(ser::data_source_from_row).collect()
|
let secrets = self.secrets();
|
||||||
|
rows.iter()
|
||||||
|
.map(|r| ser::data_source_from_row(r, secrets))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn save_data_source_impl(
|
pub(crate) async fn save_data_source_impl(
|
||||||
&self,
|
&self,
|
||||||
source: &DataSource,
|
source: &DataSource,
|
||||||
) -> Result<(), SqliteConfigError> {
|
) -> Result<(), SqliteConfigError> {
|
||||||
let config_json = ser::data_source_config_to_json(&source.config)?;
|
let config_json = ser::data_source_config_to_json(&source.config, self.secrets())?;
|
||||||
let type_str = ser::data_source_type_to_str(&source.source_type);
|
let type_str = ser::data_source_type_to_str(&source.source_type);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
|
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
|
||||||
VALUES (?, ?, ?, ?, ?)"
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(source.id as i64)
|
.bind(source.id as i64)
|
||||||
.bind(&source.name)
|
.bind(&source.name)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
mod data_sources;
|
mod data_sources;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod users;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
use crate::SqliteConfigStore;
|
use crate::SqliteConfigStore;
|
||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
|
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
||||||
WidgetId,
|
WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl ConfigRepository for SqliteConfigStore {
|
impl ConfigRepository for SqliteConfigStore {
|
||||||
@@ -68,4 +69,16 @@ impl ConfigRepository for SqliteConfigStore {
|
|||||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||||
self.delete_preset_impl(id).await
|
self.delete_preset_impl(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
|
self.get_user_by_username_impl(username).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_user(&self, user: &User) -> Result<(), Self::Error> {
|
||||||
|
self.save_user_impl(user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||||
|
self.count_users_impl().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal file
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use crate::SqliteConfigStore;
|
||||||
|
use crate::error::SqliteConfigError;
|
||||||
|
use domain::User;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
impl SqliteConfigStore {
|
||||||
|
pub(crate) async fn get_user_by_username_impl(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<User>, SqliteConfigError> {
|
||||||
|
let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
|
||||||
|
Ok(row.map(|r| {
|
||||||
|
let id: i64 = r.get("id");
|
||||||
|
User {
|
||||||
|
id: id as u32,
|
||||||
|
username: r.get("username"),
|
||||||
|
password_hash: r.get("password_hash"),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn save_user_impl(&self, user: &User) -> Result<(), SqliteConfigError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash",
|
||||||
|
)
|
||||||
|
.bind(&user.username)
|
||||||
|
.bind(&user.password_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn count_users_impl(&self) -> Result<u32, SqliteConfigError> {
|
||||||
|
let row = sqlx::query("SELECT COUNT(*) as cnt FROM users")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(SqliteConfigError::Sql)?;
|
||||||
|
let count: i64 = row.get("cnt");
|
||||||
|
Ok(count as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
use crate::error::SqliteConfigError;
|
use crate::error::SqliteConfigError;
|
||||||
use domain::{DataSource, DataSourceConfig, DataSourceType};
|
use domain::{DataSource, DataSourceConfig, DataSourceType, SecretStore};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use sqlx::sqlite::SqliteRow;
|
use sqlx::sqlite::SqliteRow;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const SENSITIVE_KEYS: &[&str] = &["password", "secret", "token", "api_key", "apikey"];
|
||||||
|
|
||||||
|
fn is_sensitive_key(key: &str) -> bool {
|
||||||
|
let lower = key.to_lowercase();
|
||||||
|
SENSITIVE_KEYS.iter().any(|s| lower.contains(s))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
||||||
match t {
|
match t {
|
||||||
DataSourceType::Weather => "weather",
|
DataSourceType::Weather => "weather",
|
||||||
@@ -27,27 +34,78 @@ fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigErro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, SqliteConfigError> {
|
pub fn data_source_config_to_json(
|
||||||
|
config: &DataSourceConfig,
|
||||||
|
secrets: Option<&(dyn SecretStore + Send + Sync)>,
|
||||||
|
) -> Result<String, SqliteConfigError> {
|
||||||
|
let api_key = config.api_key.as_ref().map(|k| match secrets {
|
||||||
|
Some(s) => s.encrypt(k),
|
||||||
|
None => k.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let headers: Vec<(String, String)> = config
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
let val = if is_sensitive_key(k) {
|
||||||
|
match secrets {
|
||||||
|
Some(s) => s.encrypt(v),
|
||||||
|
None => v.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v.clone()
|
||||||
|
};
|
||||||
|
(k.clone(), val)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let v = serde_json::json!({
|
let v = serde_json::json!({
|
||||||
"url": config.url,
|
"url": config.url,
|
||||||
"headers": config.headers,
|
"headers": headers,
|
||||||
"api_key": config.api_key,
|
"api_key": api_key,
|
||||||
|
"encrypted": secrets.is_some(),
|
||||||
});
|
});
|
||||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
fn data_source_config_from_json(
|
||||||
|
json: &str,
|
||||||
|
secrets: Option<&(dyn SecretStore + Send + Sync)>,
|
||||||
|
) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||||
let v: serde_json::Value =
|
let v: serde_json::Value =
|
||||||
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let encrypted = v["encrypted"].as_bool().unwrap_or(false);
|
||||||
|
|
||||||
let url = v["url"].as_str().map(String::from);
|
let url = v["url"].as_str().map(String::from);
|
||||||
let api_key = v["api_key"].as_str().map(String::from);
|
|
||||||
|
let api_key = v["api_key"].as_str().map(|k| {
|
||||||
|
if encrypted {
|
||||||
|
match secrets {
|
||||||
|
Some(s) => s.decrypt(k),
|
||||||
|
None => k.to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
k.to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let headers = match v["headers"].as_array() {
|
let headers = match v["headers"].as_array() {
|
||||||
Some(arr) => arr
|
Some(arr) => arr
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|h| {
|
.filter_map(|h| {
|
||||||
let pair = h.as_array()?;
|
let pair = h.as_array()?;
|
||||||
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
let key: String = pair[0].as_str()?.into();
|
||||||
|
let raw_val: &str = pair[1].as_str()?;
|
||||||
|
let val = if encrypted && is_sensitive_key(&key) {
|
||||||
|
match secrets {
|
||||||
|
Some(s) => s.decrypt(raw_val),
|
||||||
|
None => raw_val.to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw_val.to_string()
|
||||||
|
};
|
||||||
|
Some((key, val))
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
None => vec![],
|
None => vec![],
|
||||||
@@ -60,7 +118,10 @@ fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteCo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
pub fn data_source_from_row(
|
||||||
|
row: &SqliteRow,
|
||||||
|
secrets: Option<&(dyn SecretStore + Send + Sync)>,
|
||||||
|
) -> Result<DataSource, SqliteConfigError> {
|
||||||
let id: i64 = row.get("id");
|
let id: i64 = row.get("id");
|
||||||
let name: String = row.get("name");
|
let name: String = row.get("name");
|
||||||
let type_str: String = row.get("source_type");
|
let type_str: String = row.get("source_type");
|
||||||
@@ -72,6 +133,6 @@ pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigE
|
|||||||
name,
|
name,
|
||||||
source_type: data_source_type_from_str(&type_str)?,
|
source_type: data_source_type_from_str(&type_str)?,
|
||||||
poll_interval: Duration::from_secs(interval_secs as u64),
|
poll_interval: Duration::from_secs(interval_secs as u64),
|
||||||
config: data_source_config_from_json(&config_json)?,
|
config: data_source_config_from_json(&config_json, secrets)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
42
crates/adapters/http-api/src/extractors.rs
Normal file
42
crates/adapters/http-api/src/extractors.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{StatusCode, request::Parts},
|
||||||
|
};
|
||||||
|
use domain::{AuthPort, UserId};
|
||||||
|
|
||||||
|
pub struct AuthUser(pub UserId);
|
||||||
|
|
||||||
|
impl<C, E, W, B, R, A, H> FromRequestParts<crate::AppState<C, E, W, B, R, A, H>> for AuthUser
|
||||||
|
where
|
||||||
|
A: AuthPort + Send + Sync + 'static,
|
||||||
|
C: Send + Sync + 'static,
|
||||||
|
E: Send + Sync + 'static,
|
||||||
|
W: Send + Sync + 'static,
|
||||||
|
B: Send + Sync + 'static,
|
||||||
|
R: Send + Sync + 'static,
|
||||||
|
H: Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
type Rejection = StatusCode;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &crate::AppState<C, E, W, B, R, A, H>,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let header = parts
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let token = header
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let user_id = state
|
||||||
|
.auth
|
||||||
|
.validate_token(token)
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
Ok(AuthUser(user_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
|
pub mod extractors;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
|
use domain::{
|
||||||
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
||||||
|
WidgetStateReader,
|
||||||
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
pub struct AppState<C, E, W, B, R> {
|
pub struct AppState<C, E, W, B, R, A, H> {
|
||||||
pub config: Arc<C>,
|
pub config: Arc<C>,
|
||||||
pub events: Arc<E>,
|
pub events: Arc<E>,
|
||||||
pub widget_states: Arc<W>,
|
pub widget_states: Arc<W>,
|
||||||
pub broadcaster: Arc<B>,
|
pub broadcaster: Arc<B>,
|
||||||
pub clients: Arc<R>,
|
pub clients: Arc<R>,
|
||||||
|
pub auth: Arc<A>,
|
||||||
|
pub hasher: Arc<H>,
|
||||||
pub spa_dir: Option<String>,
|
pub spa_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C, E, W, B, R> Clone for AppState<C, E, W, B, R> {
|
impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config: self.config.clone(),
|
config: self.config.clone(),
|
||||||
@@ -23,12 +29,14 @@ impl<C, E, W, B, R> Clone for AppState<C, E, W, B, R> {
|
|||||||
widget_states: self.widget_states.clone(),
|
widget_states: self.widget_states.clone(),
|
||||||
broadcaster: self.broadcaster.clone(),
|
broadcaster: self.broadcaster.clone(),
|
||||||
clients: self.clients.clone(),
|
clients: self.clients.clone(),
|
||||||
|
auth: self.auth.clone(),
|
||||||
|
hasher: self.hasher.clone(),
|
||||||
spa_dir: self.spa_dir.clone(),
|
spa_dir: self.spa_dir.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router<C, E, W, B, R>(state: AppState<C, E, W, B, R>) -> Router
|
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
C::Error: std::fmt::Debug + Send,
|
||||||
@@ -38,6 +46,8 @@ where
|
|||||||
B: BroadcastPort + Send + Sync + 'static,
|
B: BroadcastPort + Send + Sync + 'static,
|
||||||
B::Error: std::fmt::Debug + Send,
|
B::Error: std::fmt::Debug + Send,
|
||||||
R: ClientRegistry + Send + Sync + 'static,
|
R: ClientRegistry + Send + Sync + 'static,
|
||||||
|
A: AuthPort + Send + Sync + 'static,
|
||||||
|
H: PasswordHashPort + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let spa_dir = state.spa_dir.clone();
|
let spa_dir = state.spa_dir.clone();
|
||||||
|
|
||||||
@@ -54,9 +64,9 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve<C, E, W, B, R>(
|
pub async fn serve<C, E, W, B, R, A, H>(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
state: AppState<C, E, W, B, R>,
|
state: AppState<C, E, W, B, R, A, H>,
|
||||||
) -> Result<(), std::io::Error>
|
) -> Result<(), std::io::Error>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + Send + Sync + 'static,
|
||||||
@@ -67,6 +77,8 @@ where
|
|||||||
B: BroadcastPort + Send + Sync + 'static,
|
B: BroadcastPort + Send + Sync + 'static,
|
||||||
B::Error: std::fmt::Debug + Send,
|
B::Error: std::fmt::Debug + Send,
|
||||||
R: ClientRegistry + Send + Sync + 'static,
|
R: ClientRegistry + Send + Sync + 'static,
|
||||||
|
A: AuthPort + Send + Sync + 'static,
|
||||||
|
H: PasswordHashPort + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let app = router(state);
|
let app = router(state);
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
|||||||
85
crates/adapters/http-api/src/routes/auth.rs
Normal file
85
crates/adapters/http-api/src/routes/auth.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::Json;
|
||||||
|
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
pub needs_setup: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login<C, E, W, B, R, A, H>(
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
A: AuthPort,
|
||||||
|
H: PasswordHashPort,
|
||||||
|
{
|
||||||
|
let token = application::auth_service::login(
|
||||||
|
state.config.as_ref(),
|
||||||
|
state.auth.as_ref(),
|
||||||
|
state.hasher.as_ref(),
|
||||||
|
&body.username,
|
||||||
|
&body.password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse { token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register<C, E, W, B, R, A, H>(
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
H: PasswordHashPort,
|
||||||
|
{
|
||||||
|
application::auth_service::register(
|
||||||
|
state.config.as_ref(),
|
||||||
|
state.hasher.as_ref(),
|
||||||
|
&body.username,
|
||||||
|
&body.password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_status<C, E, W, B, R, A, H>(
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
|
) -> Result<Json<StatusResponse>, StatusCode>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
C::Error: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let count = state
|
||||||
|
.config
|
||||||
|
.count_users()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(StatusResponse {
|
||||||
|
needs_setup: count == 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::extractors::AuthUser;
|
||||||
use api_types::ClientDto;
|
use api_types::ClientDto;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use domain::{ClientRegistry, ConfigRepository, EventPublisher};
|
use domain::{ClientRegistry, ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
pub async fn list_clients<C, E, W, B, R>(State(state): S<C, E, W, B, R>) -> Json<Vec<ClientDto>>
|
pub async fn list_clients<C, E, W, B, R, A, H>(
|
||||||
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
|
) -> Json<Vec<ClientDto>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
C::Error: std::fmt::Debug,
|
C::Error: std::fmt::Debug,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::extractors::AuthUser;
|
||||||
use api_types::DataSourceDto;
|
use api_types::DataSourceDto;
|
||||||
use application::ConfigService;
|
use application::ConfigService;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -8,10 +9,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
pub async fn list_data_sources<C, E, W, B, R>(
|
pub async fn list_data_sources<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
@@ -27,8 +29,9 @@ where
|
|||||||
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_data_source<C, E, W, B, R>(
|
pub async fn get_data_source<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<Json<DataSourceDto>, StatusCode>
|
) -> Result<Json<DataSourceDto>, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -48,8 +51,9 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_data_source<C, E, W, B, R>(
|
pub async fn create_data_source<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Json(body): Json<DataSourceDto>,
|
Json(body): Json<DataSourceDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
@@ -68,8 +72,9 @@ where
|
|||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_data_source<C, E, W, B, R>(
|
pub async fn update_data_source<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(_id): Path<u16>,
|
Path(_id): Path<u16>,
|
||||||
Json(body): Json<DataSourceDto>,
|
Json(body): Json<DataSourceDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
@@ -89,8 +94,9 @@ where
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_data_source<C, E, W, B, R>(
|
pub async fn delete_data_source<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, StatusCode>
|
) -> Result<StatusCode, StatusCode>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::extractors::AuthUser;
|
||||||
use api_types::LayoutDto;
|
use api_types::LayoutDto;
|
||||||
use application::ConfigService;
|
use application::ConfigService;
|
||||||
use axum::{extract::State, http::StatusCode, response::Json};
|
use axum::{extract::State, http::StatusCode, response::Json};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
pub async fn get_layout<C, E, W, B, R>(
|
pub async fn get_layout<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
@@ -23,8 +25,9 @@ where
|
|||||||
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_layout<C, E, W, B, R>(
|
pub async fn update_layout<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Json(body): Json<LayoutDto>,
|
Json(body): Json<LayoutDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod auth;
|
||||||
mod clients;
|
mod clients;
|
||||||
mod data_sources;
|
mod data_sources;
|
||||||
mod layout;
|
mod layout;
|
||||||
@@ -8,9 +9,12 @@ mod widgets;
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
|
use domain::{
|
||||||
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
||||||
|
WidgetStateReader,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn api_routes<C, E, W, B, R>() -> Router<AppState<C, E, W, B, R>>
|
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
|
||||||
where
|
where
|
||||||
C: ConfigRepository + Send + Sync + 'static,
|
C: ConfigRepository + Send + Sync + 'static,
|
||||||
C::Error: std::fmt::Debug + Send,
|
C::Error: std::fmt::Debug + Send,
|
||||||
@@ -20,55 +24,72 @@ where
|
|||||||
B: BroadcastPort + Send + Sync + 'static,
|
B: BroadcastPort + Send + Sync + 'static,
|
||||||
B::Error: std::fmt::Debug + Send,
|
B::Error: std::fmt::Debug + Send,
|
||||||
R: ClientRegistry + Send + Sync + 'static,
|
R: ClientRegistry + Send + Sync + 'static,
|
||||||
|
A: AuthPort + Send + Sync + 'static,
|
||||||
|
H: PasswordHashPort + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
|
// Public auth routes
|
||||||
|
.route(
|
||||||
|
"/auth/status",
|
||||||
|
get(auth::auth_status::<C, E, W, B, R, A, H>),
|
||||||
|
)
|
||||||
|
.route("/auth/login", post(auth::login::<C, E, W, B, R, A, H>))
|
||||||
|
.route(
|
||||||
|
"/auth/register",
|
||||||
|
post(auth::register::<C, E, W, B, R, A, H>),
|
||||||
|
)
|
||||||
|
// Protected routes
|
||||||
.route(
|
.route(
|
||||||
"/widgets",
|
"/widgets",
|
||||||
get(widgets::list_widgets::<C, E, W, B, R>)
|
get(widgets::list_widgets::<C, E, W, B, R, A, H>)
|
||||||
.post(widgets::create_widget::<C, E, W, B, R>),
|
.post(widgets::create_widget::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/widgets/{id}",
|
"/widgets/{id}",
|
||||||
get(widgets::get_widget::<C, E, W, B, R>)
|
get(widgets::get_widget::<C, E, W, B, R, A, H>)
|
||||||
.put(widgets::update_widget::<C, E, W, B, R>)
|
.put(widgets::update_widget::<C, E, W, B, R, A, H>)
|
||||||
.delete(widgets::delete_widget::<C, E, W, B, R>),
|
.delete(widgets::delete_widget::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/widgets/{id}/preview",
|
"/widgets/{id}/preview",
|
||||||
get(widgets::preview_widget::<C, E, W, B, R>),
|
get(widgets::preview_widget::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/data-sources",
|
"/data-sources",
|
||||||
get(data_sources::list_data_sources::<C, E, W, B, R>)
|
get(data_sources::list_data_sources::<C, E, W, B, R, A, H>)
|
||||||
.post(data_sources::create_data_source::<C, E, W, B, R>),
|
.post(data_sources::create_data_source::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/data-sources/{id}",
|
"/data-sources/{id}",
|
||||||
get(data_sources::get_data_source::<C, E, W, B, R>)
|
get(data_sources::get_data_source::<C, E, W, B, R, A, H>)
|
||||||
.put(data_sources::update_data_source::<C, E, W, B, R>)
|
.put(data_sources::update_data_source::<C, E, W, B, R, A, H>)
|
||||||
.delete(data_sources::delete_data_source::<C, E, W, B, R>),
|
.delete(data_sources::delete_data_source::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/layout",
|
"/layout",
|
||||||
get(layout::get_layout::<C, E, W, B, R>).put(layout::update_layout::<C, E, W, B, R>),
|
get(layout::get_layout::<C, E, W, B, R, A, H>)
|
||||||
|
.put(layout::update_layout::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/presets",
|
"/presets",
|
||||||
get(presets::list_presets::<C, E, W, B, R>)
|
get(presets::list_presets::<C, E, W, B, R, A, H>)
|
||||||
.post(presets::create_preset::<C, E, W, B, R>),
|
.post(presets::create_preset::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/presets/{id}",
|
"/presets/{id}",
|
||||||
get(presets::get_preset::<C, E, W, B, R>)
|
get(presets::get_preset::<C, E, W, B, R, A, H>)
|
||||||
.delete(presets::delete_preset::<C, E, W, B, R>),
|
.delete(presets::delete_preset::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/presets/{id}/load",
|
"/presets/{id}/load",
|
||||||
post(presets::load_preset::<C, E, W, B, R>),
|
post(presets::load_preset::<C, E, W, B, R, A, H>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/clients",
|
||||||
|
get(clients::list_clients::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
.route("/clients", get(clients::list_clients::<C, E, W, B, R>))
|
|
||||||
.route(
|
.route(
|
||||||
"/webhook/{source_id}",
|
"/webhook/{source_id}",
|
||||||
post(webhook::receive_webhook::<C, E, W, B, R>),
|
post(webhook::receive_webhook::<C, E, W, B, R, A, H>),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::extractors::AuthUser;
|
||||||
use api_types::{CreatePresetDto, PresetDto};
|
use api_types::{CreatePresetDto, PresetDto};
|
||||||
use application::ConfigService;
|
use application::ConfigService;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -8,10 +9,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher};
|
use domain::{ConfigRepository, EventPublisher};
|
||||||
|
|
||||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
pub async fn list_presets<C, E, W, B, R>(
|
pub async fn list_presets<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
@@ -27,8 +29,9 @@ where
|
|||||||
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_preset<C, E, W, B, R>(
|
pub async fn get_preset<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<Json<PresetDto>, StatusCode>
|
) -> Result<Json<PresetDto>, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -48,8 +51,9 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_preset<C, E, W, B, R>(
|
pub async fn create_preset<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Json(body): Json<CreatePresetDto>,
|
Json(body): Json<CreatePresetDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
@@ -68,8 +72,9 @@ where
|
|||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_preset<C, E, W, B, R>(
|
pub async fn delete_preset<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, StatusCode>
|
) -> Result<StatusCode, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -85,8 +90,9 @@ where
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_preset<C, E, W, B, R>(
|
pub async fn load_preset<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader};
|
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader};
|
||||||
|
|
||||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
pub async fn receive_webhook<C, E, W, B, R>(
|
pub async fn receive_webhook<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(source_id): Path<u16>,
|
Path(source_id): Path<u16>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::extractors::AuthUser;
|
||||||
use api_types::{CreateWidgetDto, WidgetDto};
|
use api_types::{CreateWidgetDto, WidgetDto};
|
||||||
use application::ConfigService;
|
use application::ConfigService;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -8,10 +9,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{ConfigRepository, EventPublisher, WidgetStateReader};
|
use domain::{ConfigRepository, EventPublisher, WidgetStateReader};
|
||||||
|
|
||||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||||
|
|
||||||
pub async fn list_widgets<C, E, W, B, R>(
|
pub async fn list_widgets<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
||||||
where
|
where
|
||||||
C: ConfigRepository,
|
C: ConfigRepository,
|
||||||
@@ -27,8 +29,9 @@ where
|
|||||||
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_widget<C, E, W, B, R>(
|
pub async fn get_widget<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<Json<WidgetDto>, StatusCode>
|
) -> Result<Json<WidgetDto>, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -48,8 +51,9 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_widget<C, E, W, B, R>(
|
pub async fn create_widget<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Json(body): Json<CreateWidgetDto>,
|
Json(body): Json<CreateWidgetDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
where
|
where
|
||||||
@@ -68,8 +72,9 @@ where
|
|||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_widget<C, E, W, B, R>(
|
pub async fn update_widget<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(_id): Path<u16>,
|
Path(_id): Path<u16>,
|
||||||
Json(body): Json<CreateWidgetDto>,
|
Json(body): Json<CreateWidgetDto>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)>
|
) -> Result<StatusCode, (StatusCode, String)>
|
||||||
@@ -89,8 +94,9 @@ where
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_widget<C, E, W, B, R>(
|
pub async fn delete_widget<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<StatusCode, StatusCode>
|
) -> Result<StatusCode, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -106,8 +112,9 @@ where
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn preview_widget<C, E, W, B, R>(
|
pub async fn preview_widget<C, E, W, B, R, A, H>(
|
||||||
State(state): S<C, E, W, B, R>,
|
_auth: AuthUser,
|
||||||
|
State(state): S<C, E, W, B, R, A, H>,
|
||||||
Path(id): Path<u16>,
|
Path(id): Path<u16>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode>
|
) -> Result<Json<serde_json::Value>, StatusCode>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -2,11 +2,32 @@ use application::DataProjection;
|
|||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use config_memory::MemoryConfigStore;
|
use config_memory::MemoryConfigStore;
|
||||||
|
use domain::{AuthPort, PasswordHashPort, UserId};
|
||||||
use http_api::{AppState, router};
|
use http_api::{AppState, router};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
|
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct TestAuth;
|
||||||
|
impl AuthPort for TestAuth {
|
||||||
|
fn generate_token(&self, _user_id: UserId) -> String {
|
||||||
|
"test-token".into()
|
||||||
|
}
|
||||||
|
fn validate_token(&self, token: &str) -> Option<UserId> {
|
||||||
|
if token == "test-token" { Some(1) } else { None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestHasher;
|
||||||
|
impl PasswordHashPort for TestHasher {
|
||||||
|
async fn hash(&self, _plain: &str) -> Result<String, String> {
|
||||||
|
Ok("hashed".into())
|
||||||
|
}
|
||||||
|
async fn verify(&self, _plain: &str, _hash: &str) -> Result<bool, String> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn test_app() -> axum::Router {
|
fn test_app() -> axum::Router {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
config: Arc::new(MemoryConfigStore::new()),
|
config: Arc::new(MemoryConfigStore::new()),
|
||||||
@@ -14,13 +35,29 @@ fn test_app() -> axum::Router {
|
|||||||
widget_states: Arc::new(DataProjection::new()),
|
widget_states: Arc::new(DataProjection::new()),
|
||||||
broadcaster: Arc::new(TcpBroadcaster::new(16)),
|
broadcaster: Arc::new(TcpBroadcaster::new(16)),
|
||||||
clients: Arc::new(ClientTracker::new()),
|
clients: Arc::new(ClientTracker::new()),
|
||||||
|
auth: Arc::new(TestAuth),
|
||||||
|
hasher: Arc::new(TestHasher),
|
||||||
spa_dir: None,
|
spa_dir: None,
|
||||||
};
|
};
|
||||||
router(state)
|
router(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authed_json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||||
|
let builder = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(uri)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("authorization", "Bearer test-token");
|
||||||
|
|
||||||
|
if let Some(b) = body {
|
||||||
|
builder.body(Body::from(b.to_string())).unwrap()
|
||||||
|
} else {
|
||||||
|
builder.body(Body::empty()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||||
let mut builder = Request::builder()
|
let builder = Request::builder()
|
||||||
.method(method)
|
.method(method)
|
||||||
.uri(uri)
|
.uri(uri)
|
||||||
.header("content-type", "application/json");
|
.header("content-type", "application/json");
|
||||||
@@ -32,6 +69,16 @@ fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unauthenticated_request_returns_401() {
|
||||||
|
let app = test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(json_request("GET", "/api/widgets", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_and_get_widget() {
|
async fn create_and_get_widget() {
|
||||||
let app = test_app();
|
let app = test_app();
|
||||||
@@ -46,13 +93,13 @@ async fn create_and_get_widget() {
|
|||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.oneshot(json_request("GET", "/api/widgets/1", None))
|
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
@@ -62,8 +109,6 @@ async fn create_and_get_widget() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(json["name"], "weather");
|
assert_eq!(json["name"], "weather");
|
||||||
assert_eq!(json["display_hint"], "icon_value");
|
|
||||||
assert_eq!(json["data_source_id"], 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -74,16 +119,16 @@ async fn list_widgets() {
|
|||||||
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
|
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
|
||||||
|
|
||||||
app.clone()
|
app.clone()
|
||||||
.oneshot(json_request("POST", "/api/widgets", Some(w1)))
|
.oneshot(authed_json_request("POST", "/api/widgets", Some(w1)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
app.clone()
|
app.clone()
|
||||||
.oneshot(json_request("POST", "/api/widgets", Some(w2)))
|
.oneshot(authed_json_request("POST", "/api/widgets", Some(w2)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.oneshot(json_request("GET", "/api/widgets", None))
|
.oneshot(authed_json_request("GET", "/api/widgets", None))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
@@ -100,19 +145,19 @@ async fn delete_widget() {
|
|||||||
let body =
|
let body =
|
||||||
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
||||||
app.clone()
|
app.clone()
|
||||||
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(json_request("DELETE", "/api/widgets/1", None))
|
.oneshot(authed_json_request("DELETE", "/api/widgets/1", None))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.oneshot(json_request("GET", "/api/widgets/1", None))
|
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
@@ -134,23 +179,16 @@ async fn create_and_get_data_source() {
|
|||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(json_request("POST", "/api/data-sources", Some(body)))
|
.oneshot(authed_json_request("POST", "/api/data-sources", Some(body)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.oneshot(json_request("GET", "/api/data-sources/10", None))
|
.oneshot(authed_json_request("GET", "/api/data-sources/10", None))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
assert_eq!(json["name"], "weather_api");
|
|
||||||
assert_eq!(json["poll_interval_secs"], 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -172,24 +210,16 @@ async fn update_and_get_layout() {
|
|||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(json_request("PUT", "/api/layout", Some(body)))
|
.oneshot(authed_json_request("PUT", "/api/layout", Some(body)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.oneshot(json_request("GET", "/api/layout", None))
|
.oneshot(authed_json_request("GET", "/api/layout", None))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
assert_eq!(json["root"]["type"], "container");
|
|
||||||
assert_eq!(json["root"]["direction"], "row");
|
|
||||||
assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -198,14 +228,23 @@ async fn get_nonexistent_returns_404() {
|
|||||||
|
|
||||||
let resp = app
|
let resp = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(json_request("GET", "/api/widgets/99", None))
|
.oneshot(authed_json_request("GET", "/api/widgets/99", None))
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
||||||
|
|
||||||
let resp = app
|
|
||||||
.oneshot(json_request("GET", "/api/data-sources/99", None))
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_status_returns_needs_setup() {
|
||||||
|
let app = test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(json_request("GET", "/api/auth/status", None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["needs_setup"], true);
|
||||||
|
}
|
||||||
|
|||||||
11
crates/adapters/secret-store/Cargo.toml
Normal file
11
crates/adapters/secret-store/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "secret-store"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
56
crates/adapters/secret-store/src/lib.rs
Normal file
56
crates/adapters/secret-store/src/lib.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce, aead::Aead};
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD as B64};
|
||||||
|
use domain::SecretStore;
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
|
||||||
|
pub struct AesSecretStore {
|
||||||
|
key: Key<Aes256Gcm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AesSecretStore {
|
||||||
|
pub fn from_env() -> Result<Self, String> {
|
||||||
|
let hex_key = std::env::var("KFRAME_ENCRYPTION_KEY")
|
||||||
|
.map_err(|_| "KFRAME_ENCRYPTION_KEY env var is required".to_string())?;
|
||||||
|
let bytes = hex::decode(&hex_key)
|
||||||
|
.map_err(|e| format!("KFRAME_ENCRYPTION_KEY must be 64 hex chars: {e}"))?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(format!(
|
||||||
|
"KFRAME_ENCRYPTION_KEY must be 32 bytes (64 hex chars), got {}",
|
||||||
|
bytes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let key = Key::<Aes256Gcm>::from_slice(&bytes);
|
||||||
|
Ok(Self { key: *key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretStore for AesSecretStore {
|
||||||
|
fn encrypt(&self, plaintext: &str) -> String {
|
||||||
|
let cipher = Aes256Gcm::new(&self.key);
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, plaintext.as_bytes())
|
||||||
|
.expect("AES-GCM encryption should not fail");
|
||||||
|
let mut combined = nonce_bytes.to_vec();
|
||||||
|
combined.extend(ciphertext);
|
||||||
|
B64.encode(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt(&self, ciphertext: &str) -> String {
|
||||||
|
let combined = B64
|
||||||
|
.decode(ciphertext)
|
||||||
|
.expect("invalid base64 in encrypted field");
|
||||||
|
if combined.len() < 12 {
|
||||||
|
panic!("encrypted data too short");
|
||||||
|
}
|
||||||
|
let (nonce_bytes, ct) = combined.split_at(12);
|
||||||
|
let cipher = Aes256Gcm::new(&self.key);
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ct)
|
||||||
|
.expect("AES-GCM decryption failed — wrong key or corrupted data");
|
||||||
|
String::from_utf8(plaintext).expect("decrypted data is not valid UTF-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
79
crates/application/src/auth_service.rs
Normal file
79
crates/application/src/auth_service.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
|
||||||
|
|
||||||
|
pub enum AuthError<E> {
|
||||||
|
InvalidCredentials,
|
||||||
|
RegistrationClosed,
|
||||||
|
Repository(E),
|
||||||
|
Hash(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: std::fmt::Debug> std::fmt::Display for AuthError<E> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidCredentials => write!(f, "invalid credentials"),
|
||||||
|
Self::RegistrationClosed => write!(f, "registration closed (users already exist)"),
|
||||||
|
Self::Repository(e) => write!(f, "repository error: {e:?}"),
|
||||||
|
Self::Hash(e) => write!(f, "hash error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login<C, A, H>(
|
||||||
|
config: &C,
|
||||||
|
auth: &A,
|
||||||
|
hasher: &H,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<String, AuthError<C::Error>>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
A: AuthPort,
|
||||||
|
H: PasswordHashPort,
|
||||||
|
{
|
||||||
|
let user = config
|
||||||
|
.get_user_by_username(username)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::Repository)?
|
||||||
|
.ok_or(AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
let valid = hasher
|
||||||
|
.verify(password, &user.password_hash)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::Hash)?;
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(auth.generate_token(user.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register<C, H>(
|
||||||
|
config: &C,
|
||||||
|
hasher: &H,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), AuthError<C::Error>>
|
||||||
|
where
|
||||||
|
C: ConfigRepository,
|
||||||
|
H: PasswordHashPort,
|
||||||
|
{
|
||||||
|
let count = config.count_users().await.map_err(AuthError::Repository)?;
|
||||||
|
if count > 0 {
|
||||||
|
return Err(AuthError::RegistrationClosed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = hasher.hash(password).await.map_err(AuthError::Hash)?;
|
||||||
|
|
||||||
|
let user = User {
|
||||||
|
id: 0,
|
||||||
|
username: username.to_string(),
|
||||||
|
password_hash: hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
config
|
||||||
|
.save_user(&user)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::Repository)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod auth_service;
|
||||||
mod config_service;
|
mod config_service;
|
||||||
mod data_projection;
|
mod data_projection;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
||||||
LayoutPresetId, WidgetConfig, WidgetId,
|
LayoutPresetId, User, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -112,6 +112,18 @@ impl ConfigRepository for InMemoryConfigRepository {
|
|||||||
self.presets.lock().unwrap().remove(&id);
|
self.presets.lock().unwrap().remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_user(&self, _user: &User) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct InMemoryEventPublisher {
|
pub struct InMemoryEventPublisher {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ http-api.workspace = true
|
|||||||
http-json.workspace = true
|
http-json.workspace = true
|
||||||
media-adapter.workspace = true
|
media-adapter.workspace = true
|
||||||
rss-adapter.workspace = true
|
rss-adapter.workspace = true
|
||||||
|
kframe-auth.workspace = true
|
||||||
|
secret-store.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use anyhow::Result;
|
|||||||
use application::DataProjection;
|
use application::DataProjection;
|
||||||
use config_sqlite::SqliteConfigStore;
|
use config_sqlite::SqliteConfigStore;
|
||||||
use http_api::AppState;
|
use http_api::AppState;
|
||||||
|
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
|
||||||
|
use secret_store::AesSecretStore;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
|
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
@@ -23,13 +25,20 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let cfg = config::ServerConfig::from_env();
|
let cfg = config::ServerConfig::from_env();
|
||||||
|
|
||||||
|
let auth_config = AuthConfig::from_env().map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
let secrets = AesSecretStore::from_env().map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
info!(db = %cfg.database_url, "connecting to database");
|
info!(db = %cfg.database_url, "connecting to database");
|
||||||
let config_store = Arc::new(SqliteConfigStore::new(&cfg.database_url).await?);
|
let secrets = Arc::new(secrets);
|
||||||
|
let config_store =
|
||||||
|
Arc::new(SqliteConfigStore::with_secrets(&cfg.database_url, Some(secrets.clone())).await?);
|
||||||
|
|
||||||
let event_bus = Arc::new(TcpEventBus::new(64));
|
let event_bus = Arc::new(TcpEventBus::new(64));
|
||||||
let broadcaster = Arc::new(TcpBroadcaster::new(64));
|
let broadcaster = Arc::new(TcpBroadcaster::new(64));
|
||||||
let projection = Arc::new(DataProjection::new());
|
let projection = Arc::new(DataProjection::new());
|
||||||
let tracker = Arc::new(ClientTracker::new());
|
let tracker = Arc::new(ClientTracker::new());
|
||||||
|
let auth = Arc::new(JwtAuthService::new(auth_config));
|
||||||
|
let hasher = Arc::new(Argon2Hasher);
|
||||||
|
|
||||||
let tcp_addr = cfg.tcp_addr.clone();
|
let tcp_addr = cfg.tcp_addr.clone();
|
||||||
let tcp_bc = broadcaster.clone();
|
let tcp_bc = broadcaster.clone();
|
||||||
@@ -50,6 +59,8 @@ async fn main() -> Result<()> {
|
|||||||
widget_states: projection.clone(),
|
widget_states: projection.clone(),
|
||||||
broadcaster: broadcaster.clone(),
|
broadcaster: broadcaster.clone(),
|
||||||
clients: tracker.clone(),
|
clients: tracker.clone(),
|
||||||
|
auth: auth.clone(),
|
||||||
|
hasher: hasher.clone(),
|
||||||
spa_dir: cfg.spa_dir,
|
spa_dir: cfg.spa_dir,
|
||||||
};
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
mod data_source;
|
mod data_source;
|
||||||
mod layout_preset;
|
mod layout_preset;
|
||||||
|
mod user;
|
||||||
mod widget_config;
|
mod widget_config;
|
||||||
|
|
||||||
pub use data_source::{
|
pub use data_source::{
|
||||||
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
||||||
};
|
};
|
||||||
pub use layout_preset::{LayoutPreset, LayoutPresetId};
|
pub use layout_preset::{LayoutPreset, LayoutPresetId};
|
||||||
|
pub use user::{User, UserId};
|
||||||
pub use widget_config::{WidgetConfig, WidgetId};
|
pub use widget_config::{WidgetConfig, WidgetId};
|
||||||
|
|||||||
8
crates/domain/src/entities/user.rs
Normal file
8
crates/domain/src/entities/user.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub type UserId = u32;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: UserId,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
}
|
||||||
@@ -7,12 +7,12 @@ pub mod value_objects;
|
|||||||
|
|
||||||
pub use entities::{
|
pub use entities::{
|
||||||
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
||||||
LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
LayoutPreset, LayoutPresetId, User, UserId, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
pub use events::DomainEvent;
|
pub use events::DomainEvent;
|
||||||
pub use ports::{
|
pub use ports::{
|
||||||
BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
||||||
EventPublisher, WidgetStateReader,
|
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
|
||||||
};
|
};
|
||||||
pub use value_objects::{
|
pub use value_objects::{
|
||||||
ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode,
|
ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode,
|
||||||
|
|||||||
12
crates/domain/src/ports/auth.rs
Normal file
12
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use crate::entities::UserId;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
pub trait AuthPort {
|
||||||
|
fn generate_token(&self, user_id: UserId) -> String;
|
||||||
|
fn validate_token(&self, token: &str) -> Option<UserId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait PasswordHashPort {
|
||||||
|
fn hash(&self, plain: &str) -> impl Future<Output = Result<String, String>> + Send;
|
||||||
|
fn verify(&self, plain: &str, hash: &str) -> impl Future<Output = Result<bool, String>> + Send;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::entities::{
|
use crate::entities::{
|
||||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
|
||||||
};
|
};
|
||||||
use crate::value_objects::Layout;
|
use crate::value_objects::Layout;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -50,4 +50,11 @@ pub trait ConfigRepository {
|
|||||||
&self,
|
&self,
|
||||||
id: LayoutPresetId,
|
id: LayoutPresetId,
|
||||||
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
|
||||||
|
fn get_user_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
|
||||||
|
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||||
|
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
mod auth;
|
||||||
mod broadcast;
|
mod broadcast;
|
||||||
mod client_registry;
|
mod client_registry;
|
||||||
mod config_repository;
|
mod config_repository;
|
||||||
mod data_source_port;
|
mod data_source_port;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod secret_store;
|
||||||
mod widget_state_reader;
|
mod widget_state_reader;
|
||||||
|
|
||||||
|
pub use auth::{AuthPort, PasswordHashPort};
|
||||||
pub use broadcast::BroadcastPort;
|
pub use broadcast::BroadcastPort;
|
||||||
pub use client_registry::{ClientRegistry, ConnectedClient};
|
pub use client_registry::{ClientRegistry, ConnectedClient};
|
||||||
pub use config_repository::ConfigRepository;
|
pub use config_repository::ConfigRepository;
|
||||||
pub use data_source_port::DataSourcePort;
|
pub use data_source_port::DataSourcePort;
|
||||||
pub use event::EventPublisher;
|
pub use event::EventPublisher;
|
||||||
|
pub use secret_store::SecretStore;
|
||||||
pub use widget_state_reader::WidgetStateReader;
|
pub use widget_state_reader::WidgetStateReader;
|
||||||
|
|||||||
4
crates/domain/src/ports/secret_store.rs
Normal file
4
crates/domain/src/ports/secret_store.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub trait SecretStore {
|
||||||
|
fn encrypt(&self, plaintext: &str) -> String;
|
||||||
|
fn decrypt(&self, ciphertext: &str) -> String;
|
||||||
|
}
|
||||||
59
spa/src/api/auth.ts
Normal file
59
spa/src/api/auth.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
const BASE = "/api"
|
||||||
|
|
||||||
|
export function useAuthStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["auth-status"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`${BASE}/auth/status`)
|
||||||
|
return res.json() as Promise<{ needs_setup: boolean }>
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (creds: { username: string; password: string }) => {
|
||||||
|
const res = await fetch(`${BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(creds),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText)
|
||||||
|
throw new Error(text)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ token: string }>
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
localStorage.setItem("kframe_token", data.token)
|
||||||
|
qc.invalidateQueries()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegister() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (creds: { username: string; password: string }) => {
|
||||||
|
const res = await fetch(`${BASE}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(creds),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText)
|
||||||
|
throw new Error(text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem("kframe_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem("kframe_token")
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
|
import { getToken, clearToken } from "./auth"
|
||||||
|
|
||||||
const BASE = "/api"
|
const BASE = "/api"
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const token = getToken()
|
||||||
...init,
|
const headers: Record<string, string> = {
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
...(init?.headers as Record<string, string>),
|
||||||
...init?.headers,
|
}
|
||||||
},
|
if (token) {
|
||||||
})
|
headers["Authorization"] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}${path}`, { ...init, headers })
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
window.location.href = "/login"
|
||||||
|
throw new Error("Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => res.statusText)
|
const text = await res.text().catch(() => res.statusText)
|
||||||
throw new Error(`${res.status}: ${text}`)
|
throw new Error(`${res.status}: ${text}`)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Link, useRouterState } from "@tanstack/react-router"
|
import { Link, useNavigate, useRouterState } from "@tanstack/react-router"
|
||||||
import {
|
import {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { clearToken } from "@/api/auth"
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Database,
|
Database,
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Save,
|
Save,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
LogOut,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
@@ -32,6 +36,12 @@ const NAV = [
|
|||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const { location } = useRouterState()
|
const { location } = useRouterState()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken()
|
||||||
|
navigate({ to: "/login" })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
@@ -59,6 +69,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarFooter className="p-2">
|
||||||
|
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={logout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-12 items-center gap-2 border-b px-4">
|
<header className="flex h-12 items-center gap-2 border-b px-4">
|
||||||
|
|||||||
79
spa/src/pages/login.tsx
Normal file
79
spa/src/pages/login.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useLogin, useRegister, useAuthStatus } from "@/api/auth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: status } = useAuthStatus()
|
||||||
|
const login = useLogin()
|
||||||
|
const register = useRegister()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
|
||||||
|
const isSetup = status?.needs_setup ?? false
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
if (isSetup) {
|
||||||
|
await register.mutateAsync({ username, password })
|
||||||
|
toast.success("Account created")
|
||||||
|
await login.mutateAsync({ username, password })
|
||||||
|
} else {
|
||||||
|
await login.mutateAsync({ username, password })
|
||||||
|
}
|
||||||
|
navigate({ to: "/" })
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center text-xl">
|
||||||
|
{isSetup ? "K-Frame Setup" : "K-Frame Login"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
{isSetup && (
|
||||||
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
|
Create your admin account to get started.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!username || !password || login.isPending || register.isPending}
|
||||||
|
>
|
||||||
|
{isSetup ? "Create Account" : "Sign In"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
createRoute,
|
createRoute,
|
||||||
createRouter,
|
createRouter,
|
||||||
Outlet,
|
Outlet,
|
||||||
|
redirect,
|
||||||
} from "@tanstack/react-router"
|
} from "@tanstack/react-router"
|
||||||
import { AppShell } from "@/components/app-shell"
|
import { AppShell } from "@/components/app-shell"
|
||||||
import { DashboardPage } from "@/pages/dashboard"
|
import { DashboardPage } from "@/pages/dashboard"
|
||||||
@@ -11,8 +12,35 @@ import { WidgetsPage } from "@/pages/widgets"
|
|||||||
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
||||||
import { PresetsPage } from "@/pages/presets"
|
import { PresetsPage } from "@/pages/presets"
|
||||||
import { GuidePage } from "@/pages/guide"
|
import { GuidePage } from "@/pages/guide"
|
||||||
|
import { LoginPage } from "@/pages/login"
|
||||||
|
import { getToken } from "@/api/auth"
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
|
||||||
|
function requireAuth() {
|
||||||
|
if (!getToken()) {
|
||||||
|
throw redirect({ to: "/login" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => <Outlet />,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loginRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/login",
|
||||||
|
component: () => (
|
||||||
|
<>
|
||||||
|
<LoginPage />
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const authenticatedRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
id: "authenticated",
|
||||||
|
beforeLoad: requireAuth,
|
||||||
component: () => (
|
component: () => (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -21,48 +49,51 @@ const rootRoute = createRootRoute({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: "/",
|
path: "/",
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const dataSourcesRoute = createRoute({
|
const dataSourcesRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: "/data-sources",
|
path: "/data-sources",
|
||||||
component: DataSourcesPage,
|
component: DataSourcesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const widgetsRoute = createRoute({
|
const widgetsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: "/widgets",
|
path: "/widgets",
|
||||||
component: WidgetsPage,
|
component: WidgetsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const layoutRoute = createRoute({
|
const layoutRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: "/layout",
|
path: "/layout",
|
||||||
component: LayoutBuilderPage,
|
component: LayoutBuilderPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const presetsRoute = createRoute({
|
const presetsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: "/presets",
|
path: "/presets",
|
||||||
component: PresetsPage,
|
component: PresetsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const guideRoute = createRoute({
|
const guideRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => authenticatedRoute,
|
||||||
path: "/guide",
|
path: "/guide",
|
||||||
component: GuidePage,
|
component: GuidePage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
loginRoute,
|
||||||
dataSourcesRoute,
|
authenticatedRoute.addChildren([
|
||||||
widgetsRoute,
|
indexRoute,
|
||||||
layoutRoute,
|
dataSourcesRoute,
|
||||||
presetsRoute,
|
widgetsRoute,
|
||||||
guideRoute,
|
layoutRoute,
|
||||||
|
presetsRoute,
|
||||||
|
guideRoute,
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
Reference in New Issue
Block a user