diff --git a/.gitignore b/.gitignore index 112b49d..6749e09 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ crates/*/Cargo.lock db.sqlite3* config.toml +principals.toml /.idea diff --git a/Cargo.lock b/Cargo.lock index 4cdc009..8c68902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2885,6 +2885,7 @@ dependencies = [ "sha2", "thiserror 2.0.11", "tokio", + "toml", "tracing", ] diff --git a/README.md b/README.md index 96ff836..a1a60d1 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,29 @@ rustical gen-config ``` > [!WARNING] -> `rustical gen-config` generates a random `frontend.secret_key`. +> The `rustical gen-config` command generates a random `frontend.secret_key`. > This secret is used to generate session cookies so if it is leaked an attacker could use it to authenticate to against any endpoint (also when the frontend is disabled). You'll have to set your database path to something like `/var/lib/rustical/db.sqlite3`. -There you also set your username, password, and app tokens. + +Next, configure the principals by creating a file specified in `auth.path` (by default `/etc/rustical/principals.toml`) and inserting your principals: + +```toml +[[principals]] +id = "user" +displayname = "User" +password = "$argon2id$......." +app_tokens = [ + "$pbkdf2-sha256$........" +] +memberships = ["group:amazing_group"] + +[[principals]] +id = "group:amazing_group" +user_type = "group" +displayname = "Amazing group" +``` + Password hashes can be generated with ```sh diff --git a/crates/caldav/src/lib.rs b/crates/caldav/src/lib.rs index c832476..cc3aa69 100644 --- a/crates/caldav/src/lib.rs +++ b/crates/caldav/src/lib.rs @@ -10,7 +10,7 @@ use calendar_set::CalendarSetResourceService; use principal::{PrincipalResource, PrincipalResourceService}; use rustical_dav::resource::{NamedRoute, ResourceService, ResourceServiceRoute}; use rustical_dav::resources::RootResourceService; -use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider, UserStore}; +use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider}; use rustical_store::{AddressbookStore, CalendarStore, ContactBirthdayStore, SubscriptionStore}; use std::sync::Arc; use subscription::subscription_resource; @@ -25,13 +25,11 @@ mod subscription; pub use error::Error; pub fn caldav_service< - US: UserStore, AP: AuthenticationProvider, AS: AddressbookStore, C: CalendarStore, S: SubscriptionStore, >( - user_store: Arc, auth_provider: Arc, store: Arc, addr_store: Arc, @@ -40,7 +38,7 @@ pub fn caldav_service< let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store)); web::scope("") - .wrap(AuthenticationMiddleware::new(auth_provider)) + .wrap(AuthenticationMiddleware::new(auth_provider.clone())) .wrap( ErrorHandlers::new().handler(StatusCode::METHOD_NOT_ALLOWED, |res| { Ok(ErrorHandlerResponse::Response( @@ -68,7 +66,7 @@ pub fn caldav_service< .service( web::scope("/principal").service( web::scope("/{principal}") - .service(PrincipalResourceService{store: user_store, home_set: &[ + .service(PrincipalResourceService{auth_provider, home_set: &[ ("calendar", false), ("birthdays", true) ]}.actix_resource().name(PrincipalResource::route_name())) .service(web::scope("/calendar") diff --git a/crates/caldav/src/principal/mod.rs b/crates/caldav/src/principal/mod.rs index 3d48339..8a4337f 100644 --- a/crates/caldav/src/principal/mod.rs +++ b/crates/caldav/src/principal/mod.rs @@ -9,7 +9,7 @@ use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::resource::{NamedRoute, Resource, ResourceService}; use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner}; use rustical_store::auth::user::PrincipalType; -use rustical_store::auth::{User, UserStore}; +use rustical_store::auth::{AuthenticationProvider, User}; use rustical_xml::{EnumUnitVariants, EnumVariants, XmlDeserialize, XmlSerialize}; #[derive(Clone)] @@ -131,13 +131,13 @@ impl Resource for PrincipalResource { } } -pub struct PrincipalResourceService { - pub store: Arc, +pub struct PrincipalResourceService { + pub auth_provider: Arc, pub home_set: &'static [(&'static str, bool)], } #[async_trait(?Send)] -impl ResourceService for PrincipalResourceService { +impl ResourceService for PrincipalResourceService { type PathComponents = (String,); type MemberType = CalendarSetResource; type Resource = PrincipalResource; @@ -148,8 +148,8 @@ impl ResourceService for PrincipalResourceService { (principal,): &Self::PathComponents, ) -> Result { let user = self - .store - .get_user(principal) + .auth_provider + .get_principal(principal) .await? .ok_or(crate::Error::NotFound)?; Ok(PrincipalResource { diff --git a/crates/carddav/src/lib.rs b/crates/carddav/src/lib.rs index e1855fc..87e3197 100644 --- a/crates/carddav/src/lib.rs +++ b/crates/carddav/src/lib.rs @@ -15,7 +15,7 @@ use principal::{PrincipalResource, PrincipalResourceService}; use rustical_dav::resource::{NamedRoute, ResourceService}; use rustical_dav::resources::RootResourceService; use rustical_store::{ - auth::{AuthenticationMiddleware, AuthenticationProvider, UserStore}, + auth::{AuthenticationMiddleware, AuthenticationProvider}, AddressbookStore, SubscriptionStore, }; use std::sync::Arc; @@ -27,12 +27,11 @@ pub mod principal; pub fn carddav_service( auth_provider: Arc, - user_store: Arc, store: Arc, subscription_store: Arc, ) -> impl HttpServiceFactory { web::scope("") - .wrap(AuthenticationMiddleware::new(auth_provider)) + .wrap(AuthenticationMiddleware::new(auth_provider.clone())) .wrap( ErrorHandlers::new().handler(StatusCode::METHOD_NOT_ALLOWED, |res| { Ok(ErrorHandlerResponse::Response( @@ -60,7 +59,7 @@ pub fn carddav_service { +pub struct PrincipalResourceService { addr_store: Arc, - user_store: Arc, + auth_provider: Arc, } -impl PrincipalResourceService { - pub fn new(addr_store: Arc, user_store: Arc) -> Self { +impl PrincipalResourceService { + pub fn new(addr_store: Arc, auth_provider: Arc) -> Self { Self { addr_store, - user_store, + auth_provider, } } } @@ -133,7 +133,9 @@ impl Resource for PrincipalResource { } #[async_trait(?Send)] -impl ResourceService for PrincipalResourceService { +impl ResourceService + for PrincipalResourceService +{ type PathComponents = (String,); type MemberType = AddressbookResource; type Resource = PrincipalResource; @@ -144,8 +146,8 @@ impl ResourceService for PrincipalResourceSe (principal,): &Self::PathComponents, ) -> Result { let user = self - .user_store - .get_user(principal) + .auth_provider + .get_principal(principal) .await? .ok_or(crate::Error::NotFound)?; Ok(PrincipalResource { principal: user }) diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 8a2e8de..8f6992f 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -25,6 +25,8 @@ pbkdf2 = { workspace = true } chrono-tz = { workspace = true } derive_more = { workspace = true } rustical_xml.workspace = true +toml.workspace = true +tokio.workspace = true [dev-dependencies] rstest = { workspace = true } diff --git a/crates/store/src/auth/mod.rs b/crates/store/src/auth/mod.rs index 28a8f6f..a68a2d6 100644 --- a/crates/store/src/auth/mod.rs +++ b/crates/store/src/auth/mod.rs @@ -1,16 +1,15 @@ pub mod middleware; -pub mod static_user_store; +pub mod toml_user_store; pub mod user; -mod user_store; use crate::error::Error; use async_trait::async_trait; #[async_trait] pub trait AuthenticationProvider: 'static { + async fn get_principal(&self, id: &str) -> Result, crate::Error>; async fn validate_user_token(&self, user_id: &str, token: &str) -> Result, Error>; } pub use middleware::AuthenticationMiddleware; -pub use static_user_store::{StaticUserStore, StaticUserStoreConfig}; +pub use toml_user_store::{TomlPrincipalStore, TomlUserStoreConfig}; pub use user::User; -pub use user_store::UserStore; diff --git a/crates/store/src/auth/static_user_store.rs b/crates/store/src/auth/static_user_store.rs deleted file mode 100644 index c24e333..0000000 --- a/crates/store/src/auth/static_user_store.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::{auth::User, error::Error}; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use super::{AuthenticationProvider, UserStore}; - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct StaticUserStoreConfig { - pub users: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct StaticUserStore { - pub users: HashMap, -} - -impl StaticUserStore { - pub fn new(config: StaticUserStoreConfig) -> Self { - Self { - users: HashMap::from_iter(config.users.into_iter().map(|user| (user.id.clone(), user))), - } - } -} - -#[async_trait] -impl UserStore for StaticUserStore { - async fn get_user(&self, id: &str) -> Result, crate::Error> { - Ok(self.users.get(id).cloned()) - } -} - -#[async_trait] -impl AuthenticationProvider for StaticUserStore { - async fn validate_user_token(&self, user_id: &str, token: &str) -> Result, Error> { - let user: User = match self.get_user(user_id).await? { - Some(user) => user, - None => return Ok(None), - }; - - // Try app tokens first since they are cheaper to calculate - // They can afford less iterations since they can be generated with high entropy - for app_token in &user.app_tokens { - if password_auth::verify_password(token, app_token).is_ok() { - return Ok(Some(user)); - } - } - - let password = match &user.password { - Some(password) => password, - None => return Ok(None), - }; - - if password_auth::verify_password(token, password).is_ok() { - return Ok(Some(user)); - } - - Ok(None) - } -} diff --git a/crates/store/src/auth/toml_user_store.rs b/crates/store/src/auth/toml_user_store.rs new file mode 100644 index 0000000..b3004f8 --- /dev/null +++ b/crates/store/src/auth/toml_user_store.rs @@ -0,0 +1,73 @@ +use super::AuthenticationProvider; +use crate::{auth::User, error::Error}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fs, io}; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +struct TomlDataModel { + principals: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TomlUserStoreConfig { + pub path: String, +} + +#[derive(Debug)] +pub struct TomlPrincipalStore { + pub principals: RwLock>, +} + +#[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)), + )), + }) + } +} + +#[async_trait] +impl AuthenticationProvider for TomlPrincipalStore { + async fn get_principal(&self, id: &str) -> Result, crate::Error> { + Ok(self.principals.read().await.get(id).cloned()) + } + + async fn validate_user_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), + }; + + // Try app tokens first since they are cheaper to calculate + // They can afford less iterations since they can be generated with high entropy + for app_token in &user.app_tokens { + if password_auth::verify_password(token, app_token).is_ok() { + return Ok(Some(user)); + } + } + + let password = match &user.password { + Some(password) => password, + None => return Ok(None), + }; + + if password_auth::verify_password(token, password).is_ok() { + return Ok(Some(user)); + } + + Ok(None) + } +} diff --git a/crates/store/src/auth/user_store.rs b/crates/store/src/auth/user_store.rs deleted file mode 100644 index 7c186e1..0000000 --- a/crates/store/src/auth/user_store.rs +++ /dev/null @@ -1,7 +0,0 @@ -use super::User; -use async_trait::async_trait; - -#[async_trait] -pub trait UserStore: 'static { - async fn get_user(&self, id: &str) -> Result, crate::Error>; -} diff --git a/src/app.rs b/src/app.rs index c8f953e..8a46f2e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use actix_web::{web, App}; use rustical_caldav::caldav_service; use rustical_carddav::carddav_service; use rustical_frontend::{configure_frontend, FrontendConfig}; -use rustical_store::auth::{AuthenticationProvider, UserStore}; +use rustical_store::auth::AuthenticationProvider; use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore}; use std::sync::Arc; use tracing_actix_web::TracingLogger; @@ -15,7 +15,6 @@ pub fn make_app( cal_store: Arc, subscription_store: Arc, auth_provider: Arc, - user_store: Arc, frontend_config: FrontendConfig, ) -> App< impl ServiceFactory< @@ -31,7 +30,6 @@ pub fn make_app( .wrap(TracingLogger::default()) .wrap(NormalizePath::trim()) .service(web::scope("/caldav").service(caldav_service( - user_store.clone(), auth_provider.clone(), cal_store.clone(), addr_store.clone(), @@ -39,7 +37,6 @@ pub fn make_app( ))) .service(web::scope("/carddav").service(carddav_service( auth_provider.clone(), - user_store.clone(), addr_store.clone(), subscription_store, ))) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7699900..e1b4bdc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,7 +4,7 @@ use password_hash::PasswordHasher; use pbkdf2::Params; use rand::{rngs::OsRng, RngCore}; use rustical_frontend::FrontendConfig; -use rustical_store::auth::{StaticUserStoreConfig, User}; +use rustical_store::auth::TomlUserStoreConfig; use crate::config::{ AuthConfig, Config, DataStoreConfig, DavPushConfig, HttpConfig, SqliteDataStoreConfig, @@ -25,22 +25,8 @@ pub fn generate_frontend_secret() -> [u8; 64] { pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> { let config = Config { http: HttpConfig::default(), - auth: AuthConfig::Static(StaticUserStoreConfig { - users: vec![User { - id: "default".to_owned(), - displayname: Some("Default user".to_owned()), - user_type: Default::default(), - password: Some( - "generate a password hash with rustical pwhash --algorithm argon2".to_owned(), - ), - app_tokens: vec![ - "generate an app token hash with rustical pwhash --algorithm pbkdf2".to_owned(), - ], - memberships: vec![ - "Here you can specify other principals this principal should be a member of" - .to_owned(), - ], - }], + auth: AuthConfig::Toml(TomlUserStoreConfig { + path: "/etc/rustical/principals.toml".to_owned(), }), data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig { db_url: "".to_owned(), diff --git a/src/config.rs b/src/config.rs index e3f7d46..75ac489 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use rustical_frontend::FrontendConfig; -use rustical_store::auth::StaticUserStoreConfig; +use rustical_store::auth::TomlUserStoreConfig; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize)] @@ -35,7 +35,7 @@ pub enum DataStoreConfig { #[serde(tag = "backend", rename_all = "snake_case")] #[serde(deny_unknown_fields)] pub enum AuthConfig { - Static(StaticUserStoreConfig), + Toml(TomlUserStoreConfig), } #[derive(Debug, Deserialize, Serialize, Default)] diff --git a/src/main.rs b/src/main.rs index 1c0dcd1..40db224 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use clap::{Parser, Subcommand}; use commands::{cmd_gen_config, cmd_pwhash}; use config::{DataStoreConfig, SqliteDataStoreConfig}; use rustical_dav::push::push_notifier; -use rustical_store::auth::StaticUserStore; +use rustical_store::auth::TomlPrincipalStore; use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore}; use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore; use rustical_store_sqlite::calendar_store::SqliteCalendarStore; @@ -90,9 +90,9 @@ async fn main() -> Result<()> { )); } - let user_store = Arc::new(match config.auth { - config::AuthConfig::Static(config) => StaticUserStore::new(config), - }); + let user_store = match config.auth { + config::AuthConfig::Toml(config) => Arc::new(TomlPrincipalStore::new(config)?), + }; HttpServer::new(move || { make_app( @@ -100,7 +100,6 @@ async fn main() -> Result<()> { cal_store.clone(), subscription_store.clone(), user_store.clone(), - user_store.clone(), config.frontend.clone(), ) }) @@ -122,24 +121,21 @@ mod tests { use actix_web::{http::StatusCode, test::TestRequest}; use async_trait::async_trait; use rustical_frontend::FrontendConfig; - use rustical_store::auth::{AuthenticationProvider, UserStore}; + use rustical_store::auth::AuthenticationProvider; use std::sync::Arc; #[derive(Debug, Clone)] struct MockUserStore; #[async_trait] - impl UserStore for MockUserStore { - async fn get_user( + impl AuthenticationProvider for MockUserStore { + async fn get_principal( &self, id: &str, ) -> Result, rustical_store::Error> { Err(rustical_store::Error::NotFound) } - } - #[async_trait] - impl AuthenticationProvider for MockUserStore { async fn validate_user_token( &self, user_id: &str, @@ -151,7 +147,7 @@ mod tests { #[tokio::test] async fn test_main() { - let (addr_store, cal_store, subscription_store, update_recv) = get_data_stores( + let (addr_store, cal_store, subscription_store, _update_recv) = get_data_stores( true, &crate::config::DataStoreConfig::Sqlite(crate::config::SqliteDataStoreConfig { db_url: "".to_owned(), @@ -166,7 +162,6 @@ mod tests { addr_store, cal_store, subscription_store, - user_store.clone(), user_store, FrontendConfig { enabled: false,