Breaking changes to auth provider, principal store outsourced to new config file

This commit is contained in:
Lennart
2025-02-09 17:19:08 +01:00
parent a71000ccf7
commit 8948589b09
16 changed files with 137 additions and 133 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ crates/*/Cargo.lock
db.sqlite3*
config.toml
principals.toml
/.idea

1
Cargo.lock generated
View File

@@ -2885,6 +2885,7 @@ dependencies = [
"sha2",
"thiserror 2.0.11",
"tokio",
"toml",
"tracing",
]

View File

@@ -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

View File

@@ -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<US>,
auth_provider: Arc<AP>,
store: Arc<C>,
addr_store: Arc<AS>,
@@ -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")

View File

@@ -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<US: UserStore> {
pub store: Arc<US>,
pub struct PrincipalResourceService<AP: AuthenticationProvider> {
pub auth_provider: Arc<AP>,
pub home_set: &'static [(&'static str, bool)],
}
#[async_trait(?Send)]
impl<US: UserStore> ResourceService for PrincipalResourceService<US> {
impl<AP: AuthenticationProvider> ResourceService for PrincipalResourceService<AP> {
type PathComponents = (String,);
type MemberType = CalendarSetResource;
type Resource = PrincipalResource;
@@ -148,8 +148,8 @@ impl<US: UserStore> ResourceService for PrincipalResourceService<US> {
(principal,): &Self::PathComponents,
) -> Result<Self::Resource, Self::Error> {
let user = self
.store
.get_user(principal)
.auth_provider
.get_principal(principal)
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource {

View File

@@ -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<AP: AuthenticationProvider, A: AddressbookStore, S: SubscriptionStore>(
auth_provider: Arc<AP>,
user_store: Arc<impl UserStore>,
store: Arc<A>,
subscription_store: Arc<S>,
) -> 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<AP: AuthenticationProvider, A: AddressbookStore, S: Subsc
web::scope("/principal").service(
web::scope("/{principal}")
.service(
PrincipalResourceService::new(store.clone(), user_store)
PrincipalResourceService::new(store.clone(), auth_provider)
.actix_resource()
.name(PrincipalResource::route_name()),
)

View File

@@ -6,21 +6,21 @@ use rustical_dav::extensions::{CommonPropertiesExtension, CommonPropertiesProp};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{NamedRoute, Resource, ResourceService};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_store::auth::{User, UserStore};
use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::AddressbookStore;
use rustical_xml::{EnumUnitVariants, EnumVariants, XmlDeserialize, XmlSerialize};
use std::sync::Arc;
pub struct PrincipalResourceService<A: AddressbookStore, US: UserStore> {
pub struct PrincipalResourceService<A: AddressbookStore, AP: AuthenticationProvider> {
addr_store: Arc<A>,
user_store: Arc<US>,
auth_provider: Arc<AP>,
}
impl<A: AddressbookStore, US: UserStore> PrincipalResourceService<A, US> {
pub fn new(addr_store: Arc<A>, user_store: Arc<US>) -> Self {
impl<A: AddressbookStore, AP: AuthenticationProvider> PrincipalResourceService<A, AP> {
pub fn new(addr_store: Arc<A>, auth_provider: Arc<AP>) -> Self {
Self {
addr_store,
user_store,
auth_provider,
}
}
}
@@ -133,7 +133,9 @@ impl Resource for PrincipalResource {
}
#[async_trait(?Send)]
impl<A: AddressbookStore, US: UserStore> ResourceService for PrincipalResourceService<A, US> {
impl<A: AddressbookStore, AP: AuthenticationProvider> ResourceService
for PrincipalResourceService<A, AP>
{
type PathComponents = (String,);
type MemberType = AddressbookResource;
type Resource = PrincipalResource;
@@ -144,8 +146,8 @@ impl<A: AddressbookStore, US: UserStore> ResourceService for PrincipalResourceSe
(principal,): &Self::PathComponents,
) -> Result<Self::Resource, Self::Error> {
let user = self
.user_store
.get_user(principal)
.auth_provider
.get_principal(principal)
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource { principal: user })

View File

@@ -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 }

View File

@@ -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<Option<User>, crate::Error>;
async fn validate_user_token(&self, user_id: &str, token: &str) -> Result<Option<User>, 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;

View File

@@ -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<User>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StaticUserStore {
pub users: HashMap<String, User>,
}
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<Option<User>, 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<Option<User>, 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)
}
}

View File

@@ -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<User>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TomlUserStoreConfig {
pub path: String,
}
#[derive(Debug)]
pub struct TomlPrincipalStore {
pub principals: RwLock<HashMap<String, User>>,
}
#[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<Self, TomlStoreError> {
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<Option<User>, crate::Error> {
Ok(self.principals.read().await.get(id).cloned())
}
async fn validate_user_token(&self, user_id: &str, token: &str) -> Result<Option<User>, 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)
}
}

View File

@@ -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<Option<User>, crate::Error>;
}

View File

@@ -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<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
cal_store: Arc<CS>,
subscription_store: Arc<S>,
auth_provider: Arc<impl AuthenticationProvider>,
user_store: Arc<impl UserStore>,
frontend_config: FrontendConfig,
) -> App<
impl ServiceFactory<
@@ -31,7 +30,6 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
.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<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
)))
.service(web::scope("/carddav").service(carddav_service(
auth_provider.clone(),
user_store.clone(),
addr_store.clone(),
subscription_store,
)))

View File

@@ -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(),

View File

@@ -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)]

View File

@@ -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<Option<rustical_store::auth::User>, 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,