diff --git a/Cargo.lock b/Cargo.lock
index b9b71bd..9e9fb5e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3164,6 +3164,7 @@ dependencies = [
"tokio",
"toml",
"tracing",
+ "uuid",
]
[[package]]
diff --git a/README.md b/README.md
index bee79c5..7a158b6 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,7 @@ docker run \
-p 4000:4000 \
-v YOUR_DATA_DIR:/var/lib/rustical/ \
-v YOUR_CONFIG_TOML:/etc/rustical/config.toml \
+ -v YOUR_PRINCIPALS_TOML:/etc/rustical/principals.toml \
ghcr.io/lennart-k/rustical
```
@@ -77,7 +78,7 @@ id = "user"
displayname = "User"
password = "$argon2id$......."
app_tokens = [
- {name = "Token", token = "$pbkdf2-sha256$........"},
+ {id = "1", name = "Token", token = "$pbkdf2-sha256$........"},
]
memberships = ["group:amazing_group"]
@@ -107,8 +108,9 @@ docker run -it --rm ghcr.io/lennart-k/rustical rustical pwhash
The password is meant as a password you use to log in to the frontend.
Since it's sensitive information,
the secure but slow hash algorithm `argon2` is chosen.
+If you've configured OpenID Connect you can also completely omit the password.
-I recommend to generate random app tokens for each CalDAV/CardDAV client.
+I recommend to generate random app tokens for each CalDAV/CardDAV client (which can also be done through the frontend).
These can use the faster `pbkdf2` algorithm.
### WebDAV Push
diff --git a/crates/frontend/public/templates/pages/calendar.html b/crates/frontend/public/templates/pages/calendar.html
index c94f57b..455ba81 100644
--- a/crates/frontend/public/templates/pages/calendar.html
+++ b/crates/frontend/public/templates/pages/calendar.html
@@ -5,8 +5,30 @@
{% block content %}
{% let name = calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) %}
-
{{ name }}
+{{ calendar.principal }}/{{ name }}
{% if let Some(description) = calendar.description %}{{ description }}
{% endif%}
+{% if let Some(subscription_url) = calendar.subscription_url %}
+Subscription URL
+{{ subscription_url }}
+{% endif %}
+
+Components
+
+ {% for comp in calendar.components %}
+ {{ comp.as_str() }}
+ {% endfor %}
+
+
+Timezone
+
+{% if let Some(timezone_id) = calendar.timezone_id %}
+{{ timezone_id }}
+{% endif %}
+{% if let Some(timezone) = calendar.timezone %}
+{{ timezone }}
+{% endif %}
+
+
{{ calendar|json }}
-{% endblock %}
+{%endblock %}
diff --git a/crates/frontend/public/templates/pages/user.html b/crates/frontend/public/templates/pages/user.html
index 480c41b..0e8794f 100644
--- a/crates/frontend/public/templates/pages/user.html
+++ b/crates/frontend/public/templates/pages/user.html
@@ -72,24 +72,47 @@ li.collection-list-item {
App tokens
-
+
+
+ Name
+ Created at
+
+
{% for app_token in user.app_tokens %}
-
- {{ app_token.name }}
- {% if let Some(created_at) = app_token.created_at %}
- {{ created_at.to_rfc3339() }}
- {% endif %}
-
+
+ {{ app_token.name }}
+
+ {% if let Some(created_at) = app_token.created_at %}
+ {{ created_at.to_rfc3339() }}
+ {% endif %}
+
+
+
+
+
{% endfor %}
-
-
+
+
+
+
Calendars
{% for calendar in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("red".to_owned()) %}
-
+
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs
index d4fafb3..04a4f86 100644
--- a/crates/frontend/src/lib.rs
+++ b/crates/frontend/src/lib.rs
@@ -1,5 +1,7 @@
use actix_session::{
- SessionMiddleware, config::CookieContentSecurity, storage::CookieSessionStore,
+ SessionMiddleware,
+ config::CookieContentSecurity,
+ storage::{CookieSessionStore, SessionStore},
};
use actix_web::{
HttpRequest, HttpResponse, Responder,
@@ -7,12 +9,13 @@ use actix_web::{
dev::ServiceResponse,
http::{Method, StatusCode},
middleware::{ErrorHandlerResponse, ErrorHandlers},
- web::{self, Data, Path, Redirect},
+ web::{self, Data, Form, Path, Redirect},
};
use askama::Template;
use askama_web::WebTemplate;
use assets::{Assets, EmbedService};
use oidc::{route_get_oidc_callback, route_post_oidc};
+use rand::{Rng, distributions::Alphanumeric};
use routes::{
addressbook::{route_addressbook, route_addressbook_restore},
calendar::{route_calendar, route_calendar_restore},
@@ -22,6 +25,7 @@ use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarStore,
auth::{AuthenticationMiddleware, AuthenticationProvider, User},
};
+use serde::Deserialize;
use std::sync::Arc;
mod assets;
@@ -32,6 +36,14 @@ mod routes;
pub use config::FrontendConfig;
+pub fn generate_app_token() -> String {
+ rand::thread_rng()
+ .sample_iter(Alphanumeric)
+ .map(char::from)
+ .take(64)
+ .collect()
+}
+
#[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")]
struct UserPage {
@@ -105,6 +117,36 @@ async fn route_root(user: Option, req: HttpRequest) -> impl Responder {
web::Redirect::to(redirect_url.to_string()).permanent()
}
+#[derive(Debug, Clone, Deserialize)]
+pub(crate) struct PostAppTokenForm {
+ name: String,
+}
+
+async fn route_post_app_token(
+ user: User,
+ auth_provider: Data,
+ path: Path,
+ Form(PostAppTokenForm { name }): Form,
+) -> Result {
+ assert_eq!(path.into_inner(), user.id);
+ let token = generate_app_token();
+ auth_provider
+ .add_app_token(&user.id, name, token.clone())
+ .await?;
+ Ok(token)
+}
+
+async fn route_delete_app_token(
+ user: User,
+ auth_provider: Data,
+ path: Path<(String, String)>,
+) -> Result {
+ let (path_user, token_id) = path.into_inner();
+ assert_eq!(path_user, user.id);
+ auth_provider.remove_app_token(&user.id, &token_id).await?;
+ Ok(Redirect::to("/frontend/user").see_other())
+}
+
pub(crate) fn unauthorized_handler(
res: ServiceResponse,
) -> actix_web::Result> {
@@ -136,6 +178,14 @@ pub(crate) fn unauthorized_handler(
Ok(ErrorHandlerResponse::Response(res))
}
+pub fn session_middleware(frontend_secret: [u8; 64]) -> SessionMiddleware {
+ SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&frontend_secret))
+ .cookie_secure(true)
+ .cookie_same_site(SameSite::Strict)
+ .cookie_content_security(CookieContentSecurity::Private)
+ .build()
+}
+
pub fn configure_frontend(
cfg: &mut web::ServiceConfig,
auth_provider: Arc,
@@ -147,16 +197,7 @@ pub fn configure_frontend))
.name("frontend_user_named"),
)
+ .service(
+ web::resource("/user/{user}/app_token")
+ .route(web::method(Method::POST).to(route_post_app_token::)),
+ )
+ .service(
+ web::resource("/user/{user}/app_token/{id}/delete")
+ .route(web::method(Method::POST).to(route_delete_app_token::)),
+ )
.service(
web::resource("/user/{user}/calendar/{calendar}")
.route(web::method(Method::GET).to(route_calendar::)),
diff --git a/crates/frontend/src/nextcloud_login/mod.rs b/crates/frontend/src/nextcloud_login/mod.rs
new file mode 100644
index 0000000..065db12
--- /dev/null
+++ b/crates/frontend/src/nextcloud_login/mod.rs
@@ -0,0 +1,78 @@
+use actix_web::{
+ http::StatusCode,
+ middleware::ErrorHandlers,
+ web::{self, Data, ServiceConfig},
+};
+use chrono::{DateTime, Utc};
+use routes::{get_nextcloud_flow, post_nextcloud_flow, post_nextcloud_login, post_nextcloud_poll};
+use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::sync::Arc;
+use tokio::sync::RwLock;
+mod routes;
+
+#[derive(Debug, Clone)]
+struct NextcloudFlow {
+ app_name: String,
+ created_at: DateTime,
+ token: String,
+ response: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NextcloudSuccessResponse {
+ server: String,
+ login_name: String,
+ app_password: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NextcloudLoginResponse {
+ poll: NextcloudLoginPoll,
+ login: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NextcloudLoginPoll {
+ token: String,
+ endpoint: String,
+}
+
+#[derive(Debug, Default)]
+pub struct NextcloudFlows {
+ flows: RwLock>,
+}
+
+use crate::{session_middleware, unauthorized_handler};
+
+pub fn configure_nextcloud_login(
+ cfg: &mut ServiceConfig,
+ nextcloud_flows_state: Arc,
+ auth_provider: Arc,
+ frontend_secret: [u8; 64],
+) {
+ cfg.service(
+ web::scope("/index.php/login/v2")
+ .wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler))
+ .wrap(AuthenticationMiddleware::new(auth_provider.clone()))
+ .wrap(session_middleware(frontend_secret))
+ .app_data(Data::from(nextcloud_flows_state))
+ .app_data(Data::from(auth_provider.clone()))
+ .service(web::resource("").post(post_nextcloud_login))
+ .service(
+ web::resource("/poll/{flow}")
+ .name("nc_login_poll")
+ .post(post_nextcloud_poll::),
+ )
+ .service(
+ web::resource("/flow/{flow}")
+ .name("nc_login_flow")
+ .get(get_nextcloud_flow)
+ .post(post_nextcloud_flow),
+ ),
+ );
+}
diff --git a/crates/frontend/src/nextcloud_login.rs b/crates/frontend/src/nextcloud_login/routes.rs
similarity index 60%
rename from crates/frontend/src/nextcloud_login.rs
rename to crates/frontend/src/nextcloud_login/routes.rs
index ab3cc6e..6efee66 100644
--- a/crates/frontend/src/nextcloud_login.rs
+++ b/crates/frontend/src/nextcloud_login/routes.rs
@@ -1,55 +1,21 @@
-use actix_session::{
- SessionMiddleware, config::CookieContentSecurity, storage::CookieSessionStore,
+use crate::generate_app_token;
+
+use super::{
+ NextcloudFlow, NextcloudFlows, NextcloudLoginPoll, NextcloudLoginResponse,
+ NextcloudSuccessResponse,
};
use actix_web::{
HttpRequest, HttpResponse, Responder,
- cookie::{Key, SameSite},
- http::{
- StatusCode,
- header::{self},
- },
- middleware::ErrorHandlers,
- web::{self, Data, Form, Html, Json, Path, ServiceConfig},
+ http::header::{self},
+ web::{Data, Form, Html, Json, Path},
};
use askama::Template;
-use chrono::{DateTime, Duration, Utc};
-use rand::{Rng, distributions::Alphanumeric};
-use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider, User};
+use chrono::{Duration, Utc};
+use rustical_store::auth::{AuthenticationProvider, User};
use serde::{Deserialize, Serialize};
-use std::{collections::HashMap, sync::Arc};
-use tokio::sync::RwLock;
use tracing::instrument;
-use crate::unauthorized_handler;
-
-#[derive(Debug, Clone)]
-struct NextcloudFlow {
- app_name: String,
- created_at: DateTime,
- token: String,
- response: Option,
-}
-
-#[derive(Debug, Default)]
-pub struct NextcloudFlows {
- flows: RwLock>,
-}
-
-#[derive(Debug, Clone, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct NextcloudLoginPoll {
- token: String,
- endpoint: String,
-}
-
-#[derive(Debug, Clone, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct NextcloudLoginResponse {
- poll: NextcloudLoginPoll,
- login: String,
-}
-
-async fn post_nextcloud_login(
+pub(crate) async fn post_nextcloud_login(
req: HttpRequest,
state: Data,
) -> Json {
@@ -95,19 +61,11 @@ async fn post_nextcloud_login(
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
-struct NextcloudSuccessResponse {
- server: String,
- login_name: String,
- app_password: String,
-}
-
-#[derive(Debug, Clone, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct NextcloudPollForm {
+pub(crate) struct NextcloudPollForm {
token: String,
}
-async fn post_nextcloud_poll(
+pub(crate) async fn post_nextcloud_poll(
state: Data,
form: Form,
path: Path,
@@ -143,14 +101,6 @@ async fn post_nextcloud_poll(
}
}
-fn generate_app_token() -> String {
- rand::thread_rng()
- .sample_iter(Alphanumeric)
- .map(char::from)
- .take(64)
- .collect()
-}
-
#[derive(Template)]
#[template(path = "pages/nextcloud_login/form.html")]
struct NextcloudLoginPage {
@@ -159,7 +109,7 @@ struct NextcloudLoginPage {
}
#[instrument(skip(state, req))]
-async fn get_nextcloud_flow(
+pub(crate) async fn get_nextcloud_flow(
user: User,
state: Data,
path: Path,
@@ -183,7 +133,7 @@ async fn get_nextcloud_flow(
}
#[derive(Debug, Clone, Deserialize, Serialize)]
-struct NextcloudAuthorizeForm {
+pub(crate) struct NextcloudAuthorizeForm {
app_name: String,
}
@@ -194,7 +144,7 @@ struct NextcloudLoginSuccessPage {
}
#[instrument(skip(state, req))]
-async fn post_nextcloud_flow(
+pub(crate) async fn post_nextcloud_flow(
user: User,
state: Data,
path: Path,
@@ -222,40 +172,3 @@ async fn post_nextcloud_flow(
Ok(HttpResponse::NotFound().body("Login flow not found"))
}
}
-
-pub fn configure_nextcloud_login(
- cfg: &mut ServiceConfig,
- nextcloud_flows_state: Arc,
- auth_provider: Arc,
- frontend_secret: [u8; 64],
-) {
- cfg.service(
- web::scope("/index.php/login/v2")
- .wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler))
- .wrap(AuthenticationMiddleware::new(auth_provider.clone()))
- .wrap(
- SessionMiddleware::builder(
- CookieSessionStore::default(),
- Key::from(&frontend_secret),
- )
- .cookie_secure(true)
- .cookie_same_site(SameSite::Strict)
- .cookie_content_security(CookieContentSecurity::Private)
- .build(),
- )
- .app_data(Data::from(nextcloud_flows_state))
- .app_data(Data::from(auth_provider.clone()))
- .service(web::resource("").post(post_nextcloud_login))
- .service(
- web::resource("/poll/{flow}")
- .name("nc_login_poll")
- .post(post_nextcloud_poll::),
- )
- .service(
- web::resource("/flow/{flow}")
- .name("nc_login_flow")
- .get(get_nextcloud_flow)
- .post(post_nextcloud_flow),
- ),
- );
-}
diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml
index ab2651b..28c77f3 100644
--- a/crates/store/Cargo.toml
+++ b/crates/store/Cargo.toml
@@ -28,6 +28,7 @@ rustical_xml.workspace = true
toml.workspace = true
tokio.workspace = true
rand.workspace = true
+uuid.workspace = true
[dev-dependencies]
rstest = { workspace = true }
diff --git a/crates/store/src/auth/mod.rs b/crates/store/src/auth/mod.rs
index 87dc280..f3c8a66 100644
--- a/crates/store/src/auth/mod.rs
+++ b/crates/store/src/auth/mod.rs
@@ -9,7 +9,14 @@ pub trait AuthenticationProvider: 'static {
async fn get_principal(&self, id: &str) -> Result, crate::Error>;
async fn insert_principal(&self, user: User) -> Result<(), crate::Error>;
async fn validate_user_token(&self, user_id: &str, token: &str) -> Result , Error>;
- async fn add_app_token(&self, user_id: &str, name: String, token: String) -> Result<(), Error>;
+ /// Returns a token identifier
+ async fn add_app_token(
+ &self,
+ user_id: &str,
+ name: String,
+ token: String,
+ ) -> Result;
+ async fn remove_app_token(&self, user_id: &str, token_id: &str) -> Result<(), Error>;
}
pub use middleware::AuthenticationMiddleware;
diff --git a/crates/store/src/auth/toml_user_store.rs b/crates/store/src/auth/toml_user_store.rs
index de3ebd1..ca0780f 100644
--- a/crates/store/src/auth/toml_user_store.rs
+++ b/crates/store/src/auth/toml_user_store.rs
@@ -101,9 +101,16 @@ impl AuthenticationProvider for TomlPrincipalStore {
Ok(None)
}
- async fn add_app_token(&self, user_id: &str, name: String, token: String) -> Result<(), Error> {
+ /// 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(
@@ -122,11 +129,23 @@ impl AuthenticationProvider for TomlPrincipalStore {
name,
token: token_hash,
created_at: Some(chrono::Utc::now()),
+ id: id.clone(),
});
self.save(principals.deref())?;
- Ok(())
+ 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 e332406..7c34bdc 100644
--- a/crates/store/src/auth/user.rs
+++ b/crates/store/src/auth/user.rs
@@ -37,6 +37,7 @@ impl ValueSerialize for PrincipalType {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppToken {
+ pub id: String,
pub name: String,
pub token: String,
pub created_at: Option>,
diff --git a/crates/store/src/calendar/object.rs b/crates/store/src/calendar/object.rs
index 9e23f85..0b1cbd4 100644
--- a/crates/store/src/calendar/object.rs
+++ b/crates/store/src/calendar/object.rs
@@ -1,6 +1,6 @@
use super::{CalDateTime, EventObject, JournalObject, TodoObject};
use crate::Error;
-use ical::parser::{ical::component::IcalTimeZone, Component};
+use ical::parser::{Component, ical::component::IcalTimeZone};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
@@ -13,14 +13,19 @@ pub enum CalendarObjectType {
Journal = 2,
}
-impl rustical_xml::ValueSerialize for CalendarObjectType {
- fn serialize(&self) -> String {
+impl CalendarObjectType {
+ pub fn as_str(&self) -> &'static str {
match self {
CalendarObjectType::Event => "VEVENT",
CalendarObjectType::Todo => "VTODO",
CalendarObjectType::Journal => "VJOURNAL",
}
- .to_owned()
+ }
+}
+
+impl rustical_xml::ValueSerialize for CalendarObjectType {
+ fn serialize(&self) -> String {
+ self.as_str().to_owned()
}
}