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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal file
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::SqliteConfigStore;
|
||||
use crate::error::SqliteConfigError;
|
||||
use domain::User;
|
||||
use sqlx::Row;
|
||||
|
||||
impl SqliteConfigStore {
|
||||
pub(crate) async fn get_user_by_username_impl(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<User>, SqliteConfigError> {
|
||||
let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(row.map(|r| {
|
||||
let id: i64 = r.get("id");
|
||||
User {
|
||||
id: id as u32,
|
||||
username: r.get("username"),
|
||||
password_hash: r.get("password_hash"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn save_user_impl(&self, user: &User) -> Result<(), SqliteConfigError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash",
|
||||
)
|
||||
.bind(&user.username)
|
||||
.bind(&user.password_hash)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn count_users_impl(&self) -> Result<u32, SqliteConfigError> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM users")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
let count: i64 = row.get("cnt");
|
||||
Ok(count as u32)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
use crate::error::SqliteConfigError;
|
||||
use 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)?,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user