diff --git a/.sqlx/query-0195268daddd2d171577c93d1bae1b8937405bcefffa8f1f9b9c9f7f2084088f.json b/.sqlx/query-0195268daddd2d171577c93d1bae1b8937405bcefffa8f1f9b9c9f7f2084088f.json new file mode 100644 index 0000000..9e9f9c2 --- /dev/null +++ b/.sqlx/query-0195268daddd2d171577c93d1bae1b8937405bcefffa8f1f9b9c9f7f2084088f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM app_tokens WHERE (principal, id) = (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "0195268daddd2d171577c93d1bae1b8937405bcefffa8f1f9b9c9f7f2084088f" +} diff --git a/.sqlx/query-0fd3167a58cbfb4ee44249dbc346d2d9077adfa04c35c8c6f2a1e24720baf753.json b/.sqlx/query-0fd3167a58cbfb4ee44249dbc346d2d9077adfa04c35c8c6f2a1e24720baf753.json new file mode 100644 index 0000000..23492eb --- /dev/null +++ b/.sqlx/query-0fd3167a58cbfb4ee44249dbc346d2d9077adfa04c35c8c6f2a1e24720baf753.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO app_tokens\n (id, principal, token, displayname)\n VALUES (?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "0fd3167a58cbfb4ee44249dbc346d2d9077adfa04c35c8c6f2a1e24720baf753" +} diff --git a/.sqlx/query-1ebaf3fd99bee2382abc931a1eeb29badc3aabcf6b8fd58e4cf92721588a9966.json b/.sqlx/query-1ebaf3fd99bee2382abc931a1eeb29badc3aabcf6b8fd58e4cf92721588a9966.json new file mode 100644 index 0000000..67c058b --- /dev/null +++ b/.sqlx/query-1ebaf3fd99bee2382abc931a1eeb29badc3aabcf6b8fd58e4cf92721588a9966.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, displayname AS name, token, created_at AS \"created_at: _\" FROM app_tokens WHERE principal = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "token", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at: _", + "ordinal": 3, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "1ebaf3fd99bee2382abc931a1eeb29badc3aabcf6b8fd58e4cf92721588a9966" +} diff --git a/.sqlx/query-23a07f4a732f95ff7483cd1cfe3b74af4fe6b97546a631bc96d03bdc3d764ed0.json b/.sqlx/query-23a07f4a732f95ff7483cd1cfe3b74af4fe6b97546a631bc96d03bdc3d764ed0.json new file mode 100644 index 0000000..c979159 --- /dev/null +++ b/.sqlx/query-23a07f4a732f95ff7483cd1cfe3b74af4fe6b97546a631bc96d03bdc3d764ed0.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id, displayname, principal_type, password_hash, json_group_array(member_of) AS \"memberships: Json>>\"\n FROM principals\n LEFT JOIN memberships ON principals.id == memberships.principal\n GROUP BY principals.id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "displayname", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "principal_type", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "memberships: Json>>", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true, + false, + true, + true + ] + }, + "hash": "23a07f4a732f95ff7483cd1cfe3b74af4fe6b97546a631bc96d03bdc3d764ed0" +} diff --git a/.sqlx/query-2f043f62a7c0eae1023e319f0bc8f35dfdcf6a8247e03b1de3e2cabb2d3ab8ae.json b/.sqlx/query-2f043f62a7c0eae1023e319f0bc8f35dfdcf6a8247e03b1de3e2cabb2d3ab8ae.json new file mode 100644 index 0000000..e9e8047 --- /dev/null +++ b/.sqlx/query-2f043f62a7c0eae1023e319f0bc8f35dfdcf6a8247e03b1de3e2cabb2d3ab8ae.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n REPLACE INTO principals\n (id, displayname, principal_type, password_hash)\n VALUES (?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "2f043f62a7c0eae1023e319f0bc8f35dfdcf6a8247e03b1de3e2cabb2d3ab8ae" +} diff --git a/.sqlx/query-3a1dbfbe9d22a62f1830d004548b7e805bcb9fdd24b49c8c9efa93df149b1002.json b/.sqlx/query-3a1dbfbe9d22a62f1830d004548b7e805bcb9fdd24b49c8c9efa93df149b1002.json new file mode 100644 index 0000000..3b83ff3 --- /dev/null +++ b/.sqlx/query-3a1dbfbe9d22a62f1830d004548b7e805bcb9fdd24b49c8c9efa93df149b1002.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM principals WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3a1dbfbe9d22a62f1830d004548b7e805bcb9fdd24b49c8c9efa93df149b1002" +} diff --git a/.sqlx/query-879e2717335db3b04884fc91173c8507272f1804b27b6a7f61cbe1fbb01265cd.json b/.sqlx/query-879e2717335db3b04884fc91173c8507272f1804b27b6a7f61cbe1fbb01265cd.json new file mode 100644 index 0000000..df566b1 --- /dev/null +++ b/.sqlx/query-879e2717335db3b04884fc91173c8507272f1804b27b6a7f61cbe1fbb01265cd.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM memberships WHERE (principal, member_of) = (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "879e2717335db3b04884fc91173c8507272f1804b27b6a7f61cbe1fbb01265cd" +} diff --git a/.sqlx/query-95dce97b2e3224c327690c36777e3ece84a9529551696198b745dd8c743c8a38.json b/.sqlx/query-95dce97b2e3224c327690c36777e3ece84a9529551696198b745dd8c743c8a38.json new file mode 100644 index 0000000..7edde25 --- /dev/null +++ b/.sqlx/query-95dce97b2e3224c327690c36777e3ece84a9529551696198b745dd8c743c8a38.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id, displayname, principal_type, password_hash, json_group_array(member_of) AS \"memberships: Json>>\"\n FROM (SELECT * FROM principals WHERE id = ?) AS principals\n LEFT JOIN memberships ON principals.id == memberships.principal\n GROUP BY principals.id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "displayname", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "principal_type", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "memberships: Json>>", + "ordinal": 4, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + true, + null + ] + }, + "hash": "95dce97b2e3224c327690c36777e3ece84a9529551696198b745dd8c743c8a38" +} diff --git a/.sqlx/query-e947709ba03b108765082d1c4cff3dd8cb485fba5819ac914e20cb8e97037da9.json b/.sqlx/query-e947709ba03b108765082d1c4cff3dd8cb485fba5819ac914e20cb8e97037da9.json new file mode 100644 index 0000000..adb8f5e --- /dev/null +++ b/.sqlx/query-e947709ba03b108765082d1c4cff3dd8cb485fba5819ac914e20cb8e97037da9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "REPLACE INTO memberships (principal, member_of) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e947709ba03b108765082d1c4cff3dd8cb485fba5819ac914e20cb8e97037da9" +} diff --git a/Cargo.lock b/Cargo.lock index b0403ff..454f946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3197,8 +3197,6 @@ dependencies = [ "derive_more 2.0.1", "ical", "lazy_static", - "password-auth", - "pbkdf2", "rand 0.8.5", "regex", "rstest", @@ -3210,7 +3208,6 @@ dependencies = [ "sha2", "thiserror 2.0.12", "tokio", - "toml", "tracing", "uuid", ] @@ -3222,12 +3219,17 @@ dependencies = [ "async-trait", "chrono", "derive_more 2.0.1", + "password-auth", + "password-hash", + "pbkdf2", + "rand 0.8.5", "rustical_store", "serde", "sqlx", "thiserror 2.0.12", "tokio", "tracing", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 04d275b..c1fcde0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,9 @@ sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio", "macros", "migrate", + "json", ] } +serde_json = { version = "1.0", features = ["raw_value"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] } ical = { version = "0.11", features = ["generator", "serde"] } toml = "0.8" diff --git a/crates/frontend/public/templates/pages/user.html b/crates/frontend/public/templates/pages/user.html index 72a73ab..4476488 100644 --- a/crates/frontend/public/templates/pages/user.html +++ b/crates/frontend/public/templates/pages/user.html @@ -16,7 +16,7 @@

Groups

    - {% for group in user.memberships %} + {% for group in user.memberships() %}
  • {{ group }}
  • {% endfor %}
@@ -28,7 +28,7 @@ Created at - {% for app_token in user.app_tokens %} + {% for app_token in app_tokens %} {{ app_token.name }} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 6879eac..9d4ca68 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -24,7 +24,7 @@ use routes::{ use rustical_oidc::{OidcConfig, OidcServiceConfig, UserStore, configure_oidc}; use rustical_store::{ Addressbook, AddressbookStore, Calendar, CalendarStore, - auth::{AuthenticationMiddleware, AuthenticationProvider, User}, + auth::{AuthenticationMiddleware, AuthenticationProvider, User, user::AppToken}, }; use serde::Deserialize; use std::sync::Arc; @@ -51,16 +51,18 @@ pub fn generate_app_token() -> String { #[template(path = "pages/user.html")] struct UserPage { pub user: User, + pub app_tokens: Vec, pub calendars: Vec, pub deleted_calendars: Vec, pub addressbooks: Vec, pub deleted_addressbooks: Vec, } -async fn route_user_named( +async fn route_user_named( path: Path, cal_store: Data, addr_store: Data, + auth_provider: Data, user: User, req: HttpRequest, ) -> impl Responder { @@ -91,6 +93,7 @@ async fn route_user_named( } UserPage { + app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(), calendars, deleted_calendars, addressbooks, @@ -216,7 +219,7 @@ pub fn configure_frontend) + .get(route_user_named::) .name(ROUTE_USER_NAMED), ) // App token management @@ -287,7 +290,6 @@ impl UserStore for OidcUserStore { displayname: None, principal_type: Default::default(), password: None, - app_tokens: vec![], memberships: vec![], }, false, diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index a427fa8..2e9595d 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -16,16 +16,13 @@ chrono = { workspace = true } regex = { workspace = true } lazy_static = { workspace = true } thiserror = { workspace = true } -password-auth = { workspace = true } actix-web = { workspace = true } actix-session = { workspace = true } actix-web-httpauth = { workspace = true } tracing = { workspace = true } -pbkdf2 = { workspace = true } chrono-tz = { workspace = true } derive_more = { workspace = true } rustical_xml.workspace = true -toml.workspace = true tokio.workspace = true rand.workspace = true uuid.workspace = true diff --git a/crates/store/src/auth/mod.rs b/crates/store/src/auth/mod.rs index 64bf302..9a345cb 100644 --- a/crates/store/src/auth/mod.rs +++ b/crates/store/src/auth/mod.rs @@ -1,5 +1,4 @@ pub mod middleware; -pub mod toml_user_store; pub mod user; use crate::error::Error; use async_trait::async_trait; @@ -21,8 +20,12 @@ pub trait AuthenticationProvider: 'static { token: String, ) -> Result; async fn remove_app_token(&self, user_id: &str, token_id: &str) -> Result<(), Error>; + async fn get_app_tokens(&self, principal: &str) -> Result, Error>; + + async fn add_membership(&self, principal: &str, member_of: &str) -> Result<(), Error>; + async fn remove_membership(&self, principal: &str, member_of: &str) -> Result<(), Error>; } pub use middleware::AuthenticationMiddleware; -pub use toml_user_store::{TomlPrincipalStore, TomlUserStoreConfig}; +use user::AppToken; pub use user::User; diff --git a/crates/store/src/auth/toml_user_store.rs b/crates/store/src/auth/toml_user_store.rs deleted file mode 100644 index 7fb0cdc..0000000 --- a/crates/store/src/auth/toml_user_store.rs +++ /dev/null @@ -1,170 +0,0 @@ -use super::{AuthenticationProvider, user::AppToken}; -use crate::{auth::User, error::Error}; -use anyhow::anyhow; -use async_trait::async_trait; -use password_hash::PasswordHasher; -use pbkdf2::{ - Params, - password_hash::{self, SaltString, rand_core::OsRng}, -}; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fs, io, ops::Deref}; -use tokio::sync::RwLock; - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -struct TomlDataModel { - principals: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct TomlUserStoreConfig { - pub path: String, -} - -#[derive(Debug)] -pub struct TomlPrincipalStore { - pub principals: RwLock>, - config: TomlUserStoreConfig, -} - -#[derive(thiserror::Error, Debug)] -pub enum TomlStoreError { - #[error("IO error: {0}")] - Io(#[from] io::Error), - #[error("Error parsing users toml: {0}")] - Toml(#[from] toml::de::Error), -} - -impl TomlPrincipalStore { - pub fn new(config: TomlUserStoreConfig) -> Result { - let TomlDataModel { principals } = toml::from_str(&fs::read_to_string(&config.path)?)?; - Ok(Self { - principals: RwLock::new(HashMap::from_iter( - principals.into_iter().map(|user| (user.id.clone(), user)), - )), - config, - }) - } - - fn save(&self, principals: &HashMap) -> Result<(), Error> { - let out = toml::to_string_pretty(&TomlDataModel { - principals: principals - .iter() - .map(|(_, value)| value.to_owned()) - .collect(), - }) - .map_err(|_| anyhow!("Error saving principal database"))?; - fs::write(&self.config.path, out)?; - Ok(()) - } -} - -#[async_trait] -impl AuthenticationProvider for TomlPrincipalStore { - async fn get_principals(&self) -> Result, crate::Error> { - Ok(self.principals.read().await.values().cloned().collect()) - } - - async fn get_principal(&self, id: &str) -> Result, crate::Error> { - Ok(self.principals.read().await.get(id).cloned()) - } - - async fn insert_principal(&self, user: User, overwrite: bool) -> Result<(), crate::Error> { - let mut principals = self.principals.write().await; - if !overwrite && principals.contains_key(&user.id) { - return Err(Error::AlreadyExists); - } - principals.insert(user.id.clone(), user); - self.save(principals.deref())?; - Ok(()) - } - - async fn remove_principal(&self, id: &str) -> Result<(), crate::Error> { - let mut principals = self.principals.write().await; - principals.remove(id); - self.save(principals.deref())?; - Ok(()) - } - - async fn validate_password( - &self, - user_id: &str, - password_input: &str, - ) -> Result, Error> { - let user: User = match self.get_principal(user_id).await? { - Some(user) => user, - None => return Ok(None), - }; - let password = match &user.password { - Some(password) => password, - None => return Ok(None), - }; - - if password_auth::verify_password(password_input, password.as_ref()).is_ok() { - return Ok(Some(user)); - } - Ok(None) - } - - async fn validate_app_token(&self, user_id: &str, token: &str) -> Result, Error> { - let user: User = match self.get_principal(user_id).await? { - Some(user) => user, - None => return Ok(None), - }; - - for app_token in &user.app_tokens { - if password_auth::verify_password(token, app_token.token.as_ref()).is_ok() { - return Ok(Some(user)); - } - } - Ok(None) - } - - /// Returns an identifier for the new app token - async fn add_app_token( - &self, - user_id: &str, - name: String, - token: String, - ) -> Result { - let mut principals = self.principals.write().await; - if let Some(principal) = principals.get_mut(user_id) { - let id = uuid::Uuid::new_v4().to_string(); - let salt = SaltString::generate(OsRng); - let token_hash = pbkdf2::Pbkdf2 - .hash_password_customized( - token.as_bytes(), - None, - None, - Params { - rounds: 1000, - ..Default::default() - }, - &salt, - ) - .map_err(|_| Error::PasswordHash)? - .to_string(); - principal.app_tokens.push(AppToken { - name, - token: token_hash.into(), - created_at: Some(chrono::Utc::now()), - id: id.clone(), - }); - self.save(principals.deref())?; - Ok(id) - } else { - Err(Error::NotFound) - } - } - - async fn remove_app_token(&self, user_id: &str, token_id: &str) -> Result<(), Error> { - let mut principals = self.principals.write().await; - if let Some(principal) = principals.get_mut(user_id) { - principal - .app_tokens - .retain(|AppToken { id, .. }| token_id != id); - self.save(principals.deref())?; - } - Ok(()) - } -} diff --git a/crates/store/src/auth/user.rs b/crates/store/src/auth/user.rs index 71bf200..2602da8 100644 --- a/crates/store/src/auth/user.rs +++ b/crates/store/src/auth/user.rs @@ -7,12 +7,15 @@ use chrono::{DateTime, Utc}; use derive_more::Display; use rustical_xml::ValueSerialize; use serde::{Deserialize, Serialize}; -use std::future::{Ready, ready}; +use std::{ + fmt::Display, + future::{Ready, ready}, +}; use crate::Secret; /// https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.3 -#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Display, clap::ValueEnum)] +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, clap::ValueEnum)] #[serde(rename_all = "lowercase")] pub enum PrincipalType { #[default] @@ -24,8 +27,27 @@ pub enum PrincipalType { // TODO: X-Name, IANA-token } -impl ValueSerialize for PrincipalType { - fn serialize(&self) -> String { +impl TryFrom<&str> for PrincipalType { + type Error = crate::Error; + + fn try_from(value: &str) -> Result { + Ok(match value { + "INDIVIDUAL" => Self::Individual, + "GROUP" => Self::Group, + "RESOURCE" => Self::Resource, + "ROOM" => Self::Room, + "UNKNOWN" => Self::Unknown, + _ => { + return Err(crate::Error::InvalidData( + "Invalid principal type".to_owned(), + )); + } + }) + } +} + +impl PrincipalType { + pub fn as_str(&self) -> &'static str { match self { PrincipalType::Individual => "INDIVIDUAL", PrincipalType::Group => "GROUP", @@ -33,7 +55,18 @@ impl ValueSerialize for PrincipalType { PrincipalType::Room => "ROOM", PrincipalType::Unknown => "UNKNOWN", } - .to_owned() + } +} + +impl Display for PrincipalType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl ValueSerialize for PrincipalType { + fn serialize(&self) -> String { + self.to_string() } } @@ -55,8 +88,6 @@ pub struct User { pub principal_type: PrincipalType, pub password: Option>, #[serde(default)] - pub app_tokens: Vec, - #[serde(default)] pub memberships: Vec, } diff --git a/crates/store/src/secret.rs b/crates/store/src/secret.rs index e82679d..df4cbdb 100644 --- a/crates/store/src/secret.rs +++ b/crates/store/src/secret.rs @@ -5,6 +5,12 @@ use serde::{Deserialize, Serialize}; #[derive(From, Clone, Deserialize, Serialize, AsRef)] pub struct Secret(pub T); +impl Secret { + pub fn into_inner(self) -> T { + self.0 + } +} + impl std::fmt::Debug for Secret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("Secret") diff --git a/crates/store_sqlite/Cargo.toml b/crates/store_sqlite/Cargo.toml index a2eb52a..c1afd17 100644 --- a/crates/store_sqlite/Cargo.toml +++ b/crates/store_sqlite/Cargo.toml @@ -16,3 +16,8 @@ thiserror = { workspace = true } tracing = { workspace = true } derive_more.workspace = true chrono.workspace = true +password-auth.workspace = true +password-hash.workspace = true +uuid.workspace = true +rand.workspace = true +pbkdf2.workspace = true diff --git a/crates/store_sqlite/migrations/4_principals.sql b/crates/store_sqlite/migrations/4_principals.sql new file mode 100644 index 0000000..b2bb354 --- /dev/null +++ b/crates/store_sqlite/migrations/4_principals.sql @@ -0,0 +1,28 @@ +CREATE TABLE principals ( + id TEXT NOT NULL, + displayname TEXT, + principal_type TEXT NOT NULL, + password_hash TEXT, + PRIMARY KEY (id) +); + +CREATE TABLE app_tokens ( + id TEXT NOT NULL, + principal TEXT NOT NULL, + token TEXT NOT NULL, + displayname TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + FOREIGN KEY (principal) + REFERENCES principals (id) ON DELETE CASCADE +); + +CREATE TABLE memberships ( + principal TEXT NOT NULL, + member_of TEXT NOT NULL, + PRIMARY KEY (principal, member_of), + CONSTRAINT fk_membership_principal + FOREIGN KEY (principal) REFERENCES principals (id) ON DELETE CASCADE, + CONSTRAINT fk_membership_member_of + FOREIGN KEY (member_of) REFERENCES principals (id) ON DELETE CASCADE +); diff --git a/crates/store_sqlite/src/lib.rs b/crates/store_sqlite/src/lib.rs index 80d9055..7bc6126 100644 --- a/crates/store_sqlite/src/lib.rs +++ b/crates/store_sqlite/src/lib.rs @@ -1,9 +1,10 @@ use serde::Serialize; -use sqlx::{sqlite::SqliteConnectOptions, Pool, Sqlite, SqlitePool}; +use sqlx::{Pool, Sqlite, SqlitePool, sqlite::SqliteConnectOptions}; pub mod addressbook_store; pub mod calendar_store; pub mod error; +pub mod principal_store; pub mod subscription_store; pub use error::Error; diff --git a/crates/store_sqlite/src/principal_store.rs b/crates/store_sqlite/src/principal_store.rs new file mode 100644 index 0000000..abdb893 --- /dev/null +++ b/crates/store_sqlite/src/principal_store.rs @@ -0,0 +1,250 @@ +use async_trait::async_trait; +use derive_more::Constructor; +use password_hash::PasswordHasher; +use pbkdf2::{Params, password_hash::SaltString}; +use rand::rngs::OsRng; +use rustical_store::{ + Error, Secret, + auth::{AuthenticationProvider, User, user::AppToken}, +}; +use sqlx::{SqlitePool, types::Json}; +use tracing::instrument; + +#[derive(Debug, Default, Clone)] +struct PrincipalRow { + id: String, + displayname: Option, + principal_type: String, + password_hash: Option, + memberships: Option>>>, +} + +impl TryFrom for User { + type Error = Error; + + fn try_from(value: PrincipalRow) -> Result { + Ok(User { + id: value.id, + displayname: value.displayname, + password: value.password_hash.map(Secret::from), + principal_type: value.principal_type.as_str().try_into()?, + memberships: value + .memberships + .map(|val| val.0) + .unwrap_or_default() + .into_iter() + .flatten() + .collect(), + }) + } +} + +#[derive(Debug, Constructor)] +pub struct SqlitePrincipalStore { + db: SqlitePool, +} + +#[async_trait] +impl AuthenticationProvider for SqlitePrincipalStore { + #[instrument] + async fn get_principals(&self) -> Result, Error> { + let result: Result, Error> = sqlx::query_as!( + PrincipalRow, + r#" + SELECT id, displayname, principal_type, password_hash, json_group_array(member_of) AS "memberships: Json>>" + FROM principals + LEFT JOIN memberships ON principals.id == memberships.principal + GROUP BY principals.id + "#, + ) + .fetch_all(&self.db) + .await + .map_err(crate::Error::from)? + .into_iter() + .map(User::try_from) + .collect(); + Ok(result?) + } + + #[instrument] + async fn get_principal(&self, id: &str) -> Result, Error> { + let row= sqlx::query_as!( + PrincipalRow, + r#" + SELECT id, displayname, principal_type, password_hash, json_group_array(member_of) AS "memberships: Json>>" + FROM (SELECT * FROM principals WHERE id = ?) AS principals + LEFT JOIN memberships ON principals.id == memberships.principal + GROUP BY principals.id + "#, + id + ) + .fetch_optional(&self.db) + .await + .map_err(crate::Error::from)? + .map(User::try_from); + if let Some(row) = row { + Ok(Some(row?)) + } else { + Ok(None) + } + } + + #[instrument] + async fn remove_principal(&self, id: &str) -> Result<(), Error> { + sqlx::query!(r#"DELETE FROM principals WHERE id = ?"#, id) + .execute(&self.db) + .await + .map_err(crate::Error::from)?; + Ok(()) + } + + #[instrument] + async fn insert_principal( + &self, + user: User, + overwrite: bool, + ) -> Result<(), rustical_store::Error> { + // Would be cleaner to put this into a transaction but for now it will be fine + if !overwrite && self.get_principal(&user.id).await?.is_some() { + return Err(Error::AlreadyExists); + } + let principal_type = user.principal_type.as_str(); + let password = user.password.map(Secret::into_inner); + sqlx::query!( + r#" + REPLACE INTO principals + (id, displayname, principal_type, password_hash) + VALUES (?, ?, ?, ?) + "#, + user.id, + user.displayname, + principal_type, + password + ) + .execute(&self.db) + .await + .map_err(crate::Error::from)?; + Ok(()) + } + + #[instrument] + async fn get_app_tokens(&self, principal: &str) -> Result, Error> { + Ok(sqlx::query_as!( + AppToken, + r#"SELECT id, displayname AS name, token, created_at AS "created_at: _" FROM app_tokens WHERE principal = ?"#, + principal + ) + .fetch_all(&self.db) + .await + .map_err(crate::Error::from)?) + } + + #[instrument(skip(token))] + async fn validate_app_token(&self, user_id: &str, token: &str) -> Result, Error> { + for app_token in &self.get_app_tokens(user_id).await? { + if password_auth::verify_password(token, app_token.token.as_ref()).is_ok() { + return self.get_principal(user_id).await; + } + } + Ok(None) + } + + #[instrument] + async fn remove_app_token(&self, user_id: &str, token_id: &str) -> Result<(), Error> { + sqlx::query!( + r#"DELETE FROM app_tokens WHERE (principal, id) = (?, ?)"#, + user_id, + token_id + ) + .execute(&self.db) + .await + .map_err(crate::Error::from)?; + Ok(()) + } + + #[instrument(skip(password_input))] + async fn validate_password( + &self, + user_id: &str, + password_input: &str, + ) -> Result, Error> { + let user: User = match self.get_principal(user_id).await? { + Some(user) => user, + None => return Ok(None), + }; + let password = match &user.password { + Some(password) => password, + None => return Ok(None), + }; + + if password_auth::verify_password(password_input, password.as_ref()).is_ok() { + return Ok(Some(user)); + } + Ok(None) + } + + #[instrument(skip(token))] + async fn add_app_token( + &self, + user_id: &str, + name: String, + token: String, + ) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let salt = SaltString::generate(OsRng); + let token_hash = pbkdf2::Pbkdf2 + .hash_password_customized( + token.as_bytes(), + None, + None, + Params { + rounds: 1000, + ..Default::default() + }, + &salt, + ) + .map_err(|_| Error::PasswordHash)? + .to_string(); + sqlx::query!( + r#" + INSERT INTO app_tokens + (id, principal, token, displayname) + VALUES (?, ?, ?, ?) + "#, + id, + user_id, + token_hash, + name + ) + .execute(&self.db) + .await + .map_err(crate::Error::from)?; + Ok(id) + } + + #[instrument] + async fn add_membership(&self, principal: &str, member_of: &str) -> Result<(), Error> { + sqlx::query!( + r#"REPLACE INTO memberships (principal, member_of) VALUES (?, ?)"#, + principal, + member_of + ) + .execute(&self.db) + .await + .map_err(crate::Error::from)?; + Ok(()) + } + + #[instrument] + async fn remove_membership(&self, principal: &str, member_of: &str) -> Result<(), Error> { + sqlx::query!( + r#"DELETE FROM memberships WHERE (principal, member_of) = (?, ?)"#, + principal, + member_of + ) + .execute(&self.db) + .await + .map_err(crate::Error::from)?; + Ok(()) + } +} diff --git a/docs/installation.md b/docs/installation.md index c3f0b0f..6b642c7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,7 +12,6 @@ cargo install --locked --git https://github.com/lennart-k/rustical docker run \ -p 4000:4000 \ -v YOUR_DATA_DIR:/var/lib/rustical/ \ - -v YOUR_PRINCIPALS_TOML:/etc/rustical/principals.toml \ -v YOUR_CONFIG_TOML:/etc/rustical/config.toml \ # (1)! -e RUSTICAL__CONFIG_OPTION="asd" \ # (2)! ghcr.io/lennart-k/rustical @@ -55,41 +54,16 @@ Every variable is - Dots become `__` - Arrays are JSON-encoded - ## Users and groups -Next, configure the principals by creating a file specified in `auth.toml.path` (by default `/etc/rustical/principals.toml`) and inserting your principals: +Next, you will want to set up your principals. +Using the `rustical principals` command you can manage principals and passwords. -```toml -[[principals]] -id = "user" -displayname = "User" -password = "$argon2id$......." -app_tokens = [ - {id = "1", name = "Token", token = "$pbkdf2-sha256$........"}, -] -memberships = ["group:amazing_group"] +Groups and rooms are also just principals and you can specify them as such using the `--principal-type` parameter. +To assign a user to a group you can use the `rustical membership` command. Being a member to a principal means that you can completely act on their behalf and see their collections. -[[principals]] -id = "group:amazing_group" -user_type = "group" -displayname = "Amazing group" -``` - -Password hashes can be generated with - -```sh -rustical pwhash -``` - -## Docker - -You can also run the upper commands in Docker with - -```sh -docker run --rm ghcr.io/lennart-k/rustical rustical gen-config -docker run -it --rm ghcr.io/lennart-k/rustical rustical pwhash -``` +You can also completely skip this and instead configure OpenID Connect. +In that case your user will automatically be created when logging in through the frontend. ## Password vs app tokens diff --git a/src/commands/membership.rs b/src/commands/membership.rs index 6226cd5..5447e63 100644 --- a/src/commands/membership.rs +++ b/src/commands/membership.rs @@ -34,7 +34,7 @@ pub struct MembershipArgs { } pub async fn handle_membership_command( - user_store: impl AuthenticationProvider, + user_store: &impl AuthenticationProvider, MembershipArgs { command }: MembershipArgs, ) -> anyhow::Result<()> { let id = match &command { @@ -42,28 +42,22 @@ pub async fn handle_membership_command( MembershipCommand::Remove(RemoveArgs { id, .. }) => id, MembershipCommand::List(ListArgs { id }) => id, }; - let mut principal = user_store - .get_principal(id) - .await? - .unwrap_or_else(|| panic!("Principal {id} does not exist")); - match command { + match &command { MembershipCommand::Assign(AssignArgs { to, .. }) => { - if principal.memberships.contains(&to) { - println!("Principal is already member of {to}"); - return Ok(()); - } - principal.memberships.push(to); - user_store.insert_principal(principal, true).await?; + user_store.add_membership(id, to).await?; println!("Membership assigned"); } MembershipCommand::Remove(RemoveArgs { to, .. }) => { - principal.memberships.retain(|principal| principal != &to); - user_store.insert_principal(principal, true).await?; + user_store.remove_membership(id, to).await?; println!("Membership removed"); } MembershipCommand::List(ListArgs { .. }) => { - for membership in principal.memberships { + let principal = user_store + .get_principal(id) + .await? + .unwrap_or_else(|| panic!("Principal {id} does not exist")); + for membership in principal.memberships() { println!("{membership}"); } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3600920..05a720e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,11 +4,9 @@ use password_hash::PasswordHasher; use pbkdf2::Params; use rand::{RngCore, rngs::OsRng}; use rustical_frontend::FrontendConfig; -use rustical_store::auth::TomlUserStoreConfig; use crate::config::{ - AuthConfig, Config, DataStoreConfig, DavPushConfig, HttpConfig, SqliteDataStoreConfig, - TracingConfig, + Config, DataStoreConfig, DavPushConfig, HttpConfig, SqliteDataStoreConfig, TracingConfig, }; mod membership; @@ -28,9 +26,6 @@ pub fn generate_frontend_secret() -> [u8; 64] { pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> { let config = Config { http: HttpConfig::default(), - auth: AuthConfig::Toml(TomlUserStoreConfig { - path: "/etc/rustical/principals.toml".to_owned(), - }), data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig { db_url: "/var/lib/rustical/db.sqlite3".to_owned(), }), diff --git a/src/commands/principals.rs b/src/commands/principals.rs index d3b398a..08fc593 100644 --- a/src/commands/principals.rs +++ b/src/commands/principals.rs @@ -1,5 +1,5 @@ use super::membership::{MembershipArgs, handle_membership_command}; -use crate::config::{self, Config}; +use crate::{config::Config, get_data_stores}; use clap::{Parser, Subcommand}; use figment::{ Figment, @@ -8,7 +8,7 @@ use figment::{ use password_hash::PasswordHasher; use password_hash::SaltString; use rand::rngs::OsRng; -use rustical_store::auth::{AuthenticationProvider, TomlPrincipalStore, User, user::PrincipalType}; +use rustical_store::auth::{AuthenticationProvider, User, user::PrincipalType}; #[derive(Parser, Debug)] pub struct PrincipalsArgs { @@ -66,13 +66,11 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { .merge(Env::prefixed("RUSTICAL_").split("__")) .extract()?; - let user_store = match config.auth { - config::AuthConfig::Toml(config) => TomlPrincipalStore::new(config)?, - }; + let (_, _, _, principal_store, _) = get_data_stores(true, &config.data_store).await?; match args.command { Command::List => { - for principal in user_store.get_principals().await? { + for principal in principal_store.get_principals().await? { println!( "{} (displayname={}) [{}]", principal.id, @@ -101,13 +99,12 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { } else { None }; - user_store + principal_store .insert_principal( User { id, displayname: name, principal_type: principal_type.unwrap_or_default(), - app_tokens: vec![], password, memberships: vec![], }, @@ -117,7 +114,7 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { println!("Principal created"); } Command::Remove(RemoveArgs { id }) => { - user_store.remove_principal(&id).await?; + principal_store.remove_principal(&id).await?; println!("Principal {id} removed"); } Command::Edit(EditArgs { @@ -127,7 +124,7 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { name, principal_type, }) => { - let mut principal = user_store + let mut principal = principal_store .get_principal(&id) .await? .unwrap_or_else(|| panic!("Principal {id} does not exist")); @@ -153,10 +150,12 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { if let Some(principal_type) = principal_type { principal.principal_type = principal_type; } - user_store.insert_principal(principal, true).await?; + principal_store.insert_principal(principal, true).await?; println!("Principal {id} updated"); } - Command::Membership(args) => handle_membership_command(user_store, args).await?, + Command::Membership(args) => { + handle_membership_command(principal_store.as_ref(), args).await? + } } Ok(()) } diff --git a/src/config.rs b/src/config.rs index 55919b9..a8c454f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,5 @@ use rustical_frontend::FrontendConfig; use rustical_oidc::OidcConfig; -use rustical_store::auth::TomlUserStoreConfig; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize)] @@ -32,13 +31,6 @@ pub enum DataStoreConfig { Sqlite(SqliteDataStoreConfig), } -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -#[serde(deny_unknown_fields)] -pub enum AuthConfig { - Toml(TomlUserStoreConfig), -} - #[derive(Debug, Deserialize, Serialize, Default)] #[serde(deny_unknown_fields, default)] pub struct TracingConfig { @@ -80,7 +72,6 @@ impl Default for NextcloudLoginConfig { #[serde(deny_unknown_fields)] pub struct Config { pub data_store: DataStoreConfig, - pub auth: AuthConfig, #[serde(default)] pub http: HttpConfig, pub frontend: FrontendConfig, diff --git a/src/main.rs b/src/main.rs index 5d842e4..89278e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,10 +11,11 @@ use figment::Figment; use figment::providers::{Env, Format, Toml}; use rustical_dav_push::notifier::push_notifier; use rustical_frontend::nextcloud_login::NextcloudFlows; -use rustical_store::auth::TomlPrincipalStore; +use rustical_store::auth::AuthenticationProvider; use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore}; use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore; use rustical_store_sqlite::calendar_store::SqliteCalendarStore; +use rustical_store_sqlite::principal_store::SqlitePrincipalStore; use rustical_store_sqlite::{SqliteStore, create_db_pool}; use setup_tracing::setup_tracing; use std::sync::Arc; @@ -51,6 +52,7 @@ async fn get_data_stores( Arc, Arc, Arc, + Arc, Receiver, )> { Ok(match &config { @@ -62,7 +64,14 @@ async fn get_data_stores( let addressbook_store = Arc::new(SqliteAddressbookStore::new(db.clone(), send.clone())); let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send)); let subscription_store = Arc::new(SqliteStore::new(db.clone())); - (addressbook_store, cal_store, subscription_store, recv) + let principal_store = Arc::new(SqlitePrincipalStore::new(db.clone())); + ( + addressbook_store, + cal_store, + subscription_store, + principal_store, + recv, + ) } }) } @@ -83,7 +92,7 @@ async fn main() -> Result<()> { setup_tracing(&config.tracing); - let (addr_store, cal_store, subscription_store, update_recv) = + let (addr_store, cal_store, subscription_store, principal_store, update_recv) = get_data_stores(!args.no_migrations, &config.data_store).await?; if config.dav_push.enabled { @@ -94,10 +103,6 @@ async fn main() -> Result<()> { )); } - let user_store = match config.auth { - config::AuthConfig::Toml(config) => Arc::new(TomlPrincipalStore::new(config)?), - }; - let nextcloud_flows = Arc::new(NextcloudFlows::default()); HttpServer::new(move || { @@ -105,7 +110,7 @@ async fn main() -> Result<()> { addr_store.clone(), cal_store.clone(), subscription_store.clone(), - user_store.clone(), + principal_store.clone(), config.frontend.clone(), config.oidc.clone(), config.nextcloud_login.clone(), @@ -131,94 +136,27 @@ mod tests { get_data_stores, }; use actix_web::{http::StatusCode, test::TestRequest}; - use anyhow::anyhow; - use async_trait::async_trait; use rustical_frontend::FrontendConfig; use rustical_frontend::nextcloud_login::NextcloudFlows; - use rustical_store::auth::AuthenticationProvider; use std::sync::Arc; - #[derive(Debug, Clone)] - struct MockUserStore; - - #[async_trait] - impl AuthenticationProvider for MockUserStore { - async fn get_principals( - &self, - ) -> Result, rustical_store::Error> { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - async fn get_principal( - &self, - _id: &str, - ) -> Result, rustical_store::Error> { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - - async fn remove_principal(&self, _id: &str) -> Result<(), rustical_store::Error> { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - - async fn validate_password( - &self, - _user_id: &str, - _password: &str, - ) -> Result, rustical_store::Error> { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - - async fn validate_app_token( - &self, - _user_id: &str, - _token: &str, - ) -> Result, rustical_store::Error> { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - - async fn add_app_token( - &self, - _user_id: &str, - _name: String, - _token: String, - ) -> Result { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - - async fn remove_app_token( - &self, - _user_id: &str, - _token_id: &str, - ) -> Result<(), rustical_store::Error> { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - - async fn insert_principal( - &self, - _user: rustical_store::auth::User, - _overwrite: bool, - ) -> Result<(), rustical_store::Error> { - Err(rustical_store::Error::Other(anyhow!("Not implemented"))) - } - } - #[tokio::test] async fn test_main() { - let (addr_store, cal_store, subscription_store, _update_recv) = get_data_stores( - true, - &crate::config::DataStoreConfig::Sqlite(crate::config::SqliteDataStoreConfig { - db_url: "".to_owned(), - }), - ) - .await - .unwrap(); - - let user_store = Arc::new(MockUserStore); + let (addr_store, cal_store, subscription_store, principal_store, _update_recv) = + get_data_stores( + true, + &crate::config::DataStoreConfig::Sqlite(crate::config::SqliteDataStoreConfig { + db_url: "".to_owned(), + }), + ) + .await + .unwrap(); let app = make_app( addr_store, cal_store, subscription_store, - user_store, + principal_store, FrontendConfig { enabled: false, secret_key: generate_frontend_secret(),