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

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