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:
2026-06-19 01:39:42 +02:00
parent 4139330234
commit adda731dc6
41 changed files with 1331 additions and 153 deletions

View File

@@ -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
View File

@@ -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"

View File

@@ -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" }

View 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"] }

View 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())
}
}

View File

@@ -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)
}
} }

View File

@@ -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(())
} }
} }

View File

@@ -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)

View File

@@ -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
}
} }

View 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)
}
}

View File

@@ -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)?,
}) })
} }

View 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))
}
}

View File

@@ -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?;

View 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,
}))
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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>),
) )
} }

View File

@@ -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

View File

@@ -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)>

View File

@@ -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

View File

@@ -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);
}

View 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"] }

View 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")
}
}

View 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(())
}

View File

@@ -1,3 +1,4 @@
pub mod auth_service;
mod config_service; mod config_service;
mod data_projection; mod data_projection;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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};

View 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,
}

View File

@@ -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,

View 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;
}

View File

@@ -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;
} }

View File

@@ -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;

View 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
View 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")
}

View File

@@ -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}`)

View File

@@ -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
View 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>
)
}

View File

@@ -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 })