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

@@ -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::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
WidgetId,
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
WidgetConfig, WidgetId,
};
use std::collections::HashMap;
use std::sync::RwLock;
@@ -16,6 +16,7 @@ pub struct MemoryConfigStore {
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
layout: RwLock<Option<Layout>>,
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
users: RwLock<Vec<User>>,
}
impl Default for MemoryConfigStore {
@@ -25,6 +26,7 @@ impl Default for MemoryConfigStore {
data_sources: RwLock::new(HashMap::new()),
layout: RwLock::new(None),
presets: RwLock::new(HashMap::new()),
users: RwLock::new(Vec::new()),
}
}
}
@@ -156,4 +158,30 @@ impl ConfigRepository for MemoryConfigStore {
guard.remove(&id);
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 serialization;
use domain::SecretStore;
use sqlx::SqlitePool;
use std::sync::Arc;
pub use error::SqliteConfigError;
pub struct SqliteConfigStore {
pool: SqlitePool,
secrets: Option<Arc<dyn SecretStore + Send + Sync>>,
}
impl SqliteConfigStore {
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 store = Self { pool };
let store = Self { pool, secrets };
store.migrate().await?;
Ok(store)
}
pub(crate) fn secrets(&self) -> Option<&(dyn SecretStore + Send + Sync)> {
self.secrets.as_deref()
}
async fn migrate(&self) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS widgets (
@@ -63,6 +77,16 @@ impl SqliteConfigStore {
.execute(&self.pool)
.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(())
}
}

View File

@@ -16,7 +16,7 @@ impl SqliteConfigStore {
match row {
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
.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(
&self,
source: &DataSource,
) -> 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);
sqlx::query(
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
VALUES (?, ?, ?, ?, ?)"
VALUES (?, ?, ?, ?, ?)",
)
.bind(source.id as i64)
.bind(&source.name)

View File

@@ -1,13 +1,14 @@
mod data_sources;
mod layout;
mod presets;
mod users;
mod widgets;
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
WidgetId,
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
WidgetConfig, WidgetId,
};
impl ConfigRepository for SqliteConfigStore {
@@ -68,4 +69,16 @@ impl ConfigRepository for SqliteConfigStore {
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
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 domain::{DataSource, DataSourceConfig, DataSourceType};
use domain::{DataSource, DataSourceConfig, DataSourceType, SecretStore};
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
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 {
match t {
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!({
"url": config.url,
"headers": config.headers,
"api_key": config.api_key,
"headers": headers,
"api_key": api_key,
"encrypted": secrets.is_some(),
});
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 =
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 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() {
Some(arr) => arr
.iter()
.filter_map(|h| {
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(),
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 name: String = row.get("name");
let type_str: String = row.get("source_type");
@@ -72,6 +133,6 @@ pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigE
name,
source_type: data_source_type_from_str(&type_str)?,
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;
use axum::Router;
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
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 events: Arc<E>,
pub widget_states: Arc<W>,
pub broadcaster: Arc<B>,
pub clients: Arc<R>,
pub auth: Arc<A>,
pub hasher: Arc<H>,
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 {
Self {
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(),
broadcaster: self.broadcaster.clone(),
clients: self.clients.clone(),
auth: self.auth.clone(),
hasher: self.hasher.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
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
@@ -38,6 +46,8 @@ where
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{
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,
state: AppState<C, E, W, B, R>,
state: AppState<C, E, W, B, R, A, H>,
) -> Result<(), std::io::Error>
where
C: ConfigRepository + Send + Sync + 'static,
@@ -67,6 +77,8 @@ where
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{
let app = router(state);
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::extractors::AuthUser;
use api_types::ClientDto;
use axum::extract::State;
use axum::response::Json;
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
C: ConfigRepository,
C::Error: std::fmt::Debug,

View File

@@ -1,4 +1,5 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::DataSourceDto;
use application::ConfigService;
use axum::{
@@ -8,10 +9,11 @@ use axum::{
};
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>(
State(state): S<C, E, W, B, R>,
pub async fn list_data_sources<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
where
C: ConfigRepository,
@@ -27,8 +29,9 @@ where
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
}
pub async fn get_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn get_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<DataSourceDto>, StatusCode>
where
@@ -48,8 +51,9 @@ where
}
}
pub async fn create_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn create_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<DataSourceDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
@@ -68,8 +72,9 @@ where
Ok(StatusCode::CREATED)
}
pub async fn update_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn update_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(_id): Path<u16>,
Json(body): Json<DataSourceDto>,
) -> Result<StatusCode, (StatusCode, String)>
@@ -89,8 +94,9 @@ where
Ok(StatusCode::OK)
}
pub async fn delete_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn delete_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where

View File

@@ -1,13 +1,15 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::LayoutDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
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>(
State(state): S<C, E, W, B, R>,
pub async fn get_layout<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Option<LayoutDto>>, StatusCode>
where
C: ConfigRepository,
@@ -23,8 +25,9 @@ where
Ok(Json(layout.as_ref().map(LayoutDto::from)))
}
pub async fn update_layout<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn update_layout<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<LayoutDto>,
) -> Result<StatusCode, (StatusCode, String)>
where

View File

@@ -1,3 +1,4 @@
mod auth;
mod clients;
mod data_sources;
mod layout;
@@ -8,9 +9,12 @@ mod widgets;
use crate::AppState;
use axum::Router;
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
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
@@ -20,55 +24,72 @@ where
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{
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(
"/widgets",
get(widgets::list_widgets::<C, E, W, B, R>)
.post(widgets::create_widget::<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, A, H>),
)
.route(
"/widgets/{id}",
get(widgets::get_widget::<C, E, W, B, R>)
.put(widgets::update_widget::<C, E, W, B, R>)
.delete(widgets::delete_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, A, H>)
.delete(widgets::delete_widget::<C, E, W, B, R, A, H>),
)
.route(
"/widgets/{id}/preview",
get(widgets::preview_widget::<C, E, W, B, R>),
get(widgets::preview_widget::<C, E, W, B, R, A, H>),
)
.route(
"/data-sources",
get(data_sources::list_data_sources::<C, E, W, B, R>)
.post(data_sources::create_data_source::<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, A, H>),
)
.route(
"/data-sources/{id}",
get(data_sources::get_data_source::<C, E, W, B, R>)
.put(data_sources::update_data_source::<C, E, W, B, R>)
.delete(data_sources::delete_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, A, H>)
.delete(data_sources::delete_data_source::<C, E, W, B, R, A, H>),
)
.route(
"/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(
"/presets",
get(presets::list_presets::<C, E, W, B, R>)
.post(presets::create_preset::<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, A, H>),
)
.route(
"/presets/{id}",
get(presets::get_preset::<C, E, W, B, R>)
.delete(presets::delete_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, A, H>),
)
.route(
"/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(
"/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::extractors::AuthUser;
use api_types::{CreatePresetDto, PresetDto};
use application::ConfigService;
use axum::{
@@ -8,10 +9,11 @@ use axum::{
};
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>(
State(state): S<C, E, W, B, R>,
pub async fn list_presets<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<PresetDto>>, StatusCode>
where
C: ConfigRepository,
@@ -27,8 +29,9 @@ where
Ok(Json(presets.iter().map(PresetDto::from).collect()))
}
pub async fn get_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn get_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<PresetDto>, StatusCode>
where
@@ -48,8 +51,9 @@ where
}
}
pub async fn create_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn create_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<CreatePresetDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
@@ -68,8 +72,9 @@ where
Ok(StatusCode::CREATED)
}
pub async fn delete_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn delete_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where
@@ -85,8 +90,9 @@ where
Ok(StatusCode::NO_CONTENT)
}
pub async fn load_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn load_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, (StatusCode, String)>
where

View File

@@ -4,10 +4,10 @@ use axum::http::StatusCode;
use axum::response::Json;
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>(
State(state): S<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, A, H>,
Path(source_id): Path<u16>,
Json(body): Json<serde_json::Value>,
) -> Result<StatusCode, (StatusCode, String)>

View File

@@ -1,4 +1,5 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{
@@ -8,10 +9,11 @@ use axum::{
};
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>(
State(state): S<C, E, W, B, R>,
pub async fn list_widgets<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<WidgetDto>>, StatusCode>
where
C: ConfigRepository,
@@ -27,8 +29,9 @@ where
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
}
pub async fn get_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn get_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<WidgetDto>, StatusCode>
where
@@ -48,8 +51,9 @@ where
}
}
pub async fn create_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn create_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<CreateWidgetDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
@@ -68,8 +72,9 @@ where
Ok(StatusCode::CREATED)
}
pub async fn update_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn update_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(_id): Path<u16>,
Json(body): Json<CreateWidgetDto>,
) -> Result<StatusCode, (StatusCode, String)>
@@ -89,8 +94,9 @@ where
Ok(StatusCode::OK)
}
pub async fn delete_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn delete_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where
@@ -106,8 +112,9 @@ where
Ok(StatusCode::NO_CONTENT)
}
pub async fn preview_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn preview_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<serde_json::Value>, StatusCode>
where

View File

@@ -2,11 +2,32 @@ use application::DataProjection;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use config_memory::MemoryConfigStore;
use domain::{AuthPort, PasswordHashPort, UserId};
use http_api::{AppState, router};
use std::sync::Arc;
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
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 {
let state = AppState {
config: Arc::new(MemoryConfigStore::new()),
@@ -14,13 +35,29 @@ fn test_app() -> axum::Router {
widget_states: Arc::new(DataProjection::new()),
broadcaster: Arc::new(TcpBroadcaster::new(16)),
clients: Arc::new(ClientTracker::new()),
auth: Arc::new(TestAuth),
hasher: Arc::new(TestHasher),
spa_dir: None,
};
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> {
let mut builder = Request::builder()
let builder = Request::builder()
.method(method)
.uri(uri)
.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]
async fn create_and_get_widget() {
let app = test_app();
@@ -46,13 +93,13 @@ async fn create_and_get_widget() {
let resp = app
.clone()
.oneshot(json_request("POST", "/api/widgets", Some(body)))
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app
.oneshot(json_request("GET", "/api/widgets/1", None))
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
@@ -62,8 +109,6 @@ async fn create_and_get_widget() {
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "weather");
assert_eq!(json["display_hint"], "icon_value");
assert_eq!(json["data_source_id"], 10);
}
#[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":[]}"#;
app.clone()
.oneshot(json_request("POST", "/api/widgets", Some(w1)))
.oneshot(authed_json_request("POST", "/api/widgets", Some(w1)))
.await
.unwrap();
app.clone()
.oneshot(json_request("POST", "/api/widgets", Some(w2)))
.oneshot(authed_json_request("POST", "/api/widgets", Some(w2)))
.await
.unwrap();
let resp = app
.oneshot(json_request("GET", "/api/widgets", None))
.oneshot(authed_json_request("GET", "/api/widgets", None))
.await
.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
@@ -100,19 +145,19 @@ async fn delete_widget() {
let body =
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
app.clone()
.oneshot(json_request("POST", "/api/widgets", Some(body)))
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
.await
.unwrap();
let resp = app
.clone()
.oneshot(json_request("DELETE", "/api/widgets/1", None))
.oneshot(authed_json_request("DELETE", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app
.oneshot(json_request("GET", "/api/widgets/1", None))
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
@@ -134,23 +179,16 @@ async fn create_and_get_data_source() {
let resp = app
.clone()
.oneshot(json_request("POST", "/api/data-sources", Some(body)))
.oneshot(authed_json_request("POST", "/api/data-sources", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app
.oneshot(json_request("GET", "/api/data-sources/10", None))
.oneshot(authed_json_request("GET", "/api/data-sources/10", 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["name"], "weather_api");
assert_eq!(json["poll_interval_secs"], 300);
}
#[tokio::test]
@@ -172,24 +210,16 @@ async fn update_and_get_layout() {
let resp = app
.clone()
.oneshot(json_request("PUT", "/api/layout", Some(body)))
.oneshot(authed_json_request("PUT", "/api/layout", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = app
.oneshot(json_request("GET", "/api/layout", None))
.oneshot(authed_json_request("GET", "/api/layout", 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["root"]["type"], "container");
assert_eq!(json["root"]["direction"], "row");
assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2);
}
#[tokio::test]
@@ -198,14 +228,23 @@ async fn get_nonexistent_returns_404() {
let resp = app
.clone()
.oneshot(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))
.oneshot(authed_json_request("GET", "/api/widgets/99", None))
.await
.unwrap();
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 data_projection;

View File

@@ -1,6 +1,6 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
LayoutPresetId, WidgetConfig, WidgetId,
LayoutPresetId, User, WidgetConfig, WidgetId,
};
use std::collections::HashMap;
use std::sync::Mutex;
@@ -112,6 +112,18 @@ impl ConfigRepository for InMemoryConfigRepository {
self.presets.lock().unwrap().remove(&id);
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 {

View File

@@ -12,6 +12,8 @@ http-api.workspace = true
http-json.workspace = true
media-adapter.workspace = true
rss-adapter.workspace = true
kframe-auth.workspace = true
secret-store.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true

View File

@@ -6,6 +6,8 @@ use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use http_api::AppState;
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
use secret_store::AesSecretStore;
use std::sync::Arc;
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
use tracing::{error, info};
@@ -23,13 +25,20 @@ async fn main() -> Result<()> {
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");
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 broadcaster = Arc::new(TcpBroadcaster::new(64));
let projection = Arc::new(DataProjection::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_bc = broadcaster.clone();
@@ -50,6 +59,8 @@ async fn main() -> Result<()> {
widget_states: projection.clone(),
broadcaster: broadcaster.clone(),
clients: tracker.clone(),
auth: auth.clone(),
hasher: hasher.clone(),
spa_dir: cfg.spa_dir,
};
tokio::spawn(async move {

View File

@@ -1,9 +1,11 @@
mod data_source;
mod layout_preset;
mod user;
mod widget_config;
pub use data_source::{
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
};
pub use layout_preset::{LayoutPreset, LayoutPresetId};
pub use user::{User, UserId};
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::{
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
LayoutPreset, LayoutPresetId, User, UserId, WidgetConfig, WidgetId,
};
pub use events::DomainEvent;
pub use ports::{
BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
EventPublisher, WidgetStateReader,
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
};
pub use value_objects::{
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::{
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
};
use crate::value_objects::Layout;
use std::future::Future;
@@ -50,4 +50,11 @@ pub trait ConfigRepository {
&self,
id: LayoutPresetId,
) -> 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 client_registry;
mod config_repository;
mod data_source_port;
mod event;
mod secret_store;
mod widget_state_reader;
pub use auth::{AuthPort, PasswordHashPort};
pub use broadcast::BroadcastPort;
pub use client_registry::{ClientRegistry, ConnectedClient};
pub use config_repository::ConfigRepository;
pub use data_source_port::DataSourcePort;
pub use event::EventPublisher;
pub use secret_store::SecretStore;
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;
}