diff --git a/Cargo.lock b/Cargo.lock index 529310a..dac7a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,41 +17,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -337,37 +302,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "axum_session" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cbd59ac11f92412bd86308ce6c9d579e4a6ab627a2c7074afa5e4ef7144900d" -dependencies = [ - "aes-gcm", - "async-trait", - "axum", - "base64 0.22.1", - "bytes", - "chrono", - "cookie", - "dashmap", - "forwarded-header-value", - "futures", - "hmac", - "http", - "http-body", - "rand 0.8.5", - "serde", - "serde_json", - "sha2", - "thiserror 2.0.12", - "tokio", - "tower-layer", - "tower-service", - "tracing", - "uuid", -] - [[package]] name = "backtrace" version = "0.3.75" @@ -533,16 +467,6 @@ dependencies = [ "phf_codegen", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.5.39" @@ -610,11 +534,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "aes-gcm", - "base64 0.22.1", "percent-encoding", - "rand 0.8.5", - "subtle", "time", "version_check", ] @@ -683,19 +603,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -758,20 +668,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "der" version = "0.7.10" @@ -1016,16 +912,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "forwarded-header-value" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" -dependencies = [ - "nonempty", - "thiserror 1.0.69", -] - [[package]] name = "futures" version = "0.3.31" @@ -1034,7 +920,6 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", - "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1120,7 +1005,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1170,16 +1054,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gimli" version = "0.31.1" @@ -1228,12 +1102,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.3" @@ -1616,15 +1484,6 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -1727,6 +1586,7 @@ checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", + "serde", ] [[package]] @@ -1808,12 +1668,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "nonempty" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1918,12 +1772,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openidconnect" version = "4.0.0" @@ -2285,18 +2133,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - [[package]] name = "potential_utf" version = "0.1.2" @@ -2832,6 +2668,9 @@ dependencies = [ "sqlx", "tokio", "toml", + "tower", + "tower-http", + "tower-sessions", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -2863,6 +2702,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tower", + "tower-http", "tracing", "url", "uuid", @@ -2890,6 +2730,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tower", + "tower-http", "tracing", "url", "uuid", @@ -2962,6 +2803,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tower", + "tower-sessions", "tracing", "url", "uuid", @@ -2991,11 +2833,13 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", - "axum_session", + "axum-extra", + "headers", "openidconnect", "reqwest", "serde", "thiserror 2.0.12", + "tower-sessions", ] [[package]] @@ -3028,6 +2872,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tower", + "tower-sessions", "tracing", "uuid", ] @@ -3877,6 +3722,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.6" @@ -3893,6 +3754,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3907,6 +3769,57 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 2.0.12", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.41" @@ -4047,16 +3960,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "untrusted" version = "0.9.0" @@ -4096,7 +3999,6 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "rand 0.9.1", - "serde", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index e5c81eb..10ae36f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ base64 = "0.22" thiserror = "2.0" quick-xml = { version = "0.37" } rust-embed = "8.5" +tower-sessions = "0.14" futures-core = "0.3.31" hex = { version = "0.4.3", features = ["serde"] } mime_guess = "2.0" @@ -92,6 +93,7 @@ sqlx-sqlite = { version = "0.8", features = ["bundled"] } ical = { version = "0.11", features = ["generator", "serde"] } toml = "0.8" tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "normalize-path"] } rustical_dav = { path = "./crates/dav/" } rustical_dav_push = { path = "./crates/dav_push/" } rustical_store = { path = "./crates/store/" } @@ -155,9 +157,10 @@ tracing-subscriber = { version = "0.3", features = [ "registry", ] } figment = { version = "0.10", features = ["env", "toml"] } - +tower-sessions.workspace = true rand.workspace = true rpassword.workspace = true +tower.workspace = true argon2.workspace = true pbkdf2.workspace = true password-hash.workspace = true @@ -166,3 +169,4 @@ rustical_dav.workspace = true rustical_dav_push.workspace = true rustical_oidc.workspace = true quick-xml.workspace = true +tower-http.workspace = true diff --git a/crates/caldav/Cargo.toml b/crates/caldav/Cargo.toml index 1103f03..6d11624 100644 --- a/crates/caldav/Cargo.toml +++ b/crates/caldav/Cargo.toml @@ -31,3 +31,4 @@ rustical_dav_push.workspace = true rustical_ical.workspace = true http.workspace = true headers.workspace = true +tower-http.workspace = true diff --git a/crates/caldav/src/calendar/methods/report/calendar_multiget.rs b/crates/caldav/src/calendar/methods/report/calendar_multiget.rs index 63a9973..e106add 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_multiget.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_multiget.rs @@ -27,6 +27,7 @@ pub async fn get_objects_calendar_multiget( for href in &cal_query.href { if let Some(filename) = href.strip_prefix(path) { + let filename = filename.trim_start_matches("/"); if let Some(object_id) = filename.strip_suffix(".ics") { match store.get_object(principal, cal_id, object_id).await { Ok(object) => result.push(object), diff --git a/crates/caldav/src/calendar_object/methods.rs b/crates/caldav/src/calendar_object/methods.rs index 7caceee..a647e44 100644 --- a/crates/caldav/src/calendar_object/methods.rs +++ b/crates/caldav/src/calendar_object/methods.rs @@ -7,7 +7,7 @@ use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use axum_extra::TypedHeader; use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch}; -use http::StatusCode; +use http::{HeaderMap, StatusCode}; use rustical_ical::CalendarObject; use rustical_store::CalendarStore; use rustical_store::auth::User; @@ -53,13 +53,19 @@ pub async fn put_event( }): Path, State(CalendarObjectResourceService { cal_store }): State>, user: User, - if_none_match: Option>, + mut if_none_match: Option>, + header_map: HeaderMap, body: String, ) -> Result { if !user.is_principal(&principal) { return Err(crate::Error::Unauthorized); } + // https://github.com/hyperium/headers/issues/204 + if !header_map.contains_key("If-None-Match") { + if_none_match = None; + } + let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match { if_none_match == IfNoneMatch::any() } else { diff --git a/crates/caldav/src/calendar_set/mod.rs b/crates/caldav/src/calendar_set/mod.rs index 315ee1f..3461a82 100644 --- a/crates/caldav/src/calendar_set/mod.rs +++ b/crates/caldav/src/calendar_set/mod.rs @@ -95,7 +95,7 @@ impl ResourceService for CalendarSetReso type Principal = User; type PrincipalUri = CalDavPrincipalUri; - const DAV_HEADER: &str = "1, 3, access-control, extended-mkcol"; + const DAV_HEADER: &str = "1, 3, access-control, extended-mkcol, calendar-access"; async fn get_resource( &self, diff --git a/crates/caldav/src/lib.rs b/crates/caldav/src/lib.rs index 7eafff7..99e8e04 100644 --- a/crates/caldav/src/lib.rs +++ b/crates/caldav/src/lib.rs @@ -26,41 +26,10 @@ pub struct CalDavPrincipalUri(&'static str); impl PrincipalUri for CalDavPrincipalUri { fn principal_uri(&self, principal: &str) -> String { - format!("{}/{}", self.0, principal) + format!("{}/principal/{}", self.0, principal) } } -// pub fn caldav_service< -// AP: AuthenticationProvider, -// AS: AddressbookStore, -// C: CalendarStore, -// S: SubscriptionStore, -// >( -// prefix: &'static str, -// auth_provider: Arc, -// store: Arc, -// addr_store: Arc, -// subscription_store: Arc, -// ) -> impl HttpServiceFactory { -// let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store)); -// -// RootResourceService::<_, User, CalDavPrincipalUri>::new(PrincipalResourceService { -// auth_provider: auth_provider.clone(), -// sub_store: subscription_store.clone(), -// birthday_store: birthday_store.clone(), -// cal_store: store.clone(), -// }) -// .actix_scope() -// .wrap(AuthenticationMiddleware::new(auth_provider.clone())) -// .wrap(options_handler()) -// .app_data(Data::from(store.clone())) -// .app_data(Data::from(birthday_store.clone())) -// .app_data(Data::new(CalDavPrincipalUri::new( -// format!("{prefix}/principal").leak(), -// ))) -// .service(subscription_resource(subscription_store)) -// } - pub fn caldav_router< AP: AuthenticationProvider, AS: AddressbookStore, diff --git a/crates/caldav/src/principal/mod.rs b/crates/caldav/src/principal/mod.rs index 828a302..29aea83 100644 --- a/crates/caldav/src/principal/mod.rs +++ b/crates/caldav/src/principal/mod.rs @@ -155,7 +155,7 @@ impl( }): Path, State(AddressObjectResourceService { addr_store }): State>, user: User, - if_none_match: Option>, + mut if_none_match: Option>, + header_map: HeaderMap, body: String, ) -> Result { if !user.is_principal(&principal) { return Err(Error::Unauthorized); } + // https://github.com/hyperium/headers/issues/204 + if !header_map.contains_key("If-None-Match") { + if_none_match = None; + } + let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match { if_none_match == IfNoneMatch::any() } else { diff --git a/crates/carddav/src/address_object/resource.rs b/crates/carddav/src/address_object/resource.rs index 84df360..1d49a9b 100644 --- a/crates/carddav/src/address_object/resource.rs +++ b/crates/carddav/src/address_object/resource.rs @@ -12,7 +12,7 @@ use rustical_dav::{ use rustical_ical::AddressObject; use rustical_store::{AddressbookStore, auth::User}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use std::{convert::Infallible, sync::Arc}; use tower::Service; @@ -108,10 +108,23 @@ impl Resource for AddressObjectResource { } } +fn deserialize_vcf_name<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let name: String = Deserialize::deserialize(deserializer)?; + if let Some(object_id) = name.strip_suffix(".vcf") { + Ok(object_id.to_owned()) + } else { + Err(serde::de::Error::custom("Missing .vcf extension")) + } +} + #[derive(Debug, Clone, Deserialize)] pub struct AddressObjectPathComponents { pub principal: String, pub addressbook_id: String, + #[serde(deserialize_with = "deserialize_vcf_name")] pub object_id: String, } diff --git a/crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs b/crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs index 320f702..222bd68 100644 --- a/crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs +++ b/crates/carddav/src/addressbook/methods/report/addressbook_multiget.rs @@ -35,6 +35,7 @@ pub async fn get_objects_addressbook_multiget( for href in &addressbook_multiget.href { if let Some(filename) = href.strip_prefix(path) { + let filename = filename.trim_start_matches("/"); if let Some(object_id) = filename.strip_suffix(".vcf") { match store .get_object(principal, addressbook_id, object_id, false) diff --git a/crates/carddav/src/lib.rs b/crates/carddav/src/lib.rs index 3b7ef89..9a08b8b 100644 --- a/crates/carddav/src/lib.rs +++ b/crates/carddav/src/lib.rs @@ -23,7 +23,7 @@ pub struct CardDavPrincipalUri(&'static str); impl PrincipalUri for CardDavPrincipalUri { fn principal_uri(&self, principal: &str) -> String { - format!("{}/{}", self.0, principal) + format!("{}/principal/{}", self.0, principal) } } diff --git a/crates/dav/src/resource/methods/delete.rs b/crates/dav/src/resource/methods/delete.rs index 7f31262..a091aa1 100644 --- a/crates/dav/src/resource/methods/delete.rs +++ b/crates/dav/src/resource/methods/delete.rs @@ -11,10 +11,17 @@ pub(crate) async fn axum_route_delete( Path(path): Path, State(resource_service): State, principal: R::Principal, - if_match: Option>, - if_none_match: Option>, + mut if_match: Option>, + mut if_none_match: Option>, header_map: HeaderMap, ) -> Result<(), R::Error> { + // https://github.com/hyperium/headers/issues/204 + if !header_map.contains_key("If-Match") { + if_match = None; + } + if !header_map.contains_key("If-None-Match") { + if_none_match = None; + } let no_trash = header_map .get("X-No-Trashbin") .map(|val| matches!(val.to_str(), Ok("1"))) @@ -46,6 +53,7 @@ pub async fn route_delete( } if let Some(if_match) = if_match { + dbg!(&if_match); if !resource.satisfies_if_match(&if_match) { // Precondition failed return Err(crate::Error::PreconditionFailed.into()); diff --git a/crates/dav/src/resource/methods/propfind.rs b/crates/dav/src/resource/methods/propfind.rs index d34575e..a2b8147 100644 --- a/crates/dav/src/resource/methods/propfind.rs +++ b/crates/dav/src/resource/methods/propfind.rs @@ -10,7 +10,9 @@ use crate::xml::PropfindType; use axum::extract::{Extension, OriginalUri, Path, State}; use rustical_xml::PropName; use rustical_xml::XmlDocument; +use tracing::instrument; +#[instrument(skip(path, resource_service, puri))] pub(crate) async fn axum_route_propfind( Path(path): Path, State(resource_service): State, diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml index 845919e..9ad9f54 100644 --- a/crates/frontend/Cargo.toml +++ b/crates/frontend/Cargo.toml @@ -30,3 +30,4 @@ tracing.workspace = true rustical_oidc.workspace = true axum-extra.workspace= true headers.workspace = true +tower-sessions = "0.14" diff --git a/crates/frontend/src/config.rs b/crates/frontend/src/config.rs index fecdb09..56c5f9c 100644 --- a/crates/frontend/src/config.rs +++ b/crates/frontend/src/config.rs @@ -1,14 +1,5 @@ -use rand::RngCore; use serde::{Deserialize, Serialize}; -pub fn generate_frontend_secret() -> [u8; 64] { - let mut rng = rand::thread_rng(); - - let mut secret = [0u8; 64]; - rng.fill_bytes(&mut secret); - secret -} - fn default_true() -> bool { true } @@ -16,10 +7,6 @@ fn default_true() -> bool { #[derive(Deserialize, Serialize, Clone)] #[serde(deny_unknown_fields)] pub struct FrontendConfig { - #[serde(serialize_with = "hex::serde::serialize")] - #[serde(deserialize_with = "hex::serde::deserialize")] - #[serde(default = "generate_frontend_secret")] - pub secret_key: [u8; 64], #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_true")] diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 04ea169..963b1f1 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -1,357 +1,158 @@ -use askama::Template; -// use askama_web::WebTemplate; -// use assets::{Assets, EmbedService}; -use async_trait::async_trait; -use axum::{Extension, Router, response::IntoResponse, routing::get}; -use http::Uri; -use rand::{Rng, distributions::Alphanumeric}; -use rustical_oidc::OidcConfig; -// use routes::{ -// addressbook::{route_addressbook, route_addressbook_restore}, -// calendar::{route_calendar, route_calendar_restore}, -// login::{route_get_login, route_post_login, route_post_logout}, -// }; -// use rustical_oidc::{OidcConfig, OidcServiceConfig, UserStore, configure_oidc}; -use rustical_store::{ - Addressbook, AddressbookStore, Calendar, CalendarStore, - auth::{ - AuthenticationMiddleware, AuthenticationProvider, User, middleware::AuthenticationLayer, - user::AppToken, - }, +use axum::{ + Extension, RequestExt, Router, + body::Body, + extract::{OriginalUri, Request}, + middleware::{self, Next}, + response::Response, + routing::{get, post}, +}; +use headers::{ContentType, HeaderMapExt}; +use http::{Method, StatusCode}; +use rustical_oidc::{OidcConfig, OidcServiceConfig, route_get_oidc_callback, route_post_oidc}; +use rustical_store::{ + AddressbookStore, CalendarStore, + auth::{AuthenticationProvider, middleware::AuthenticationLayer}, }; -use serde::Deserialize; use std::sync::Arc; -use uuid::Uuid; +use tower_sessions::{ + Expiry, SessionManagerLayer, SessionStore, + cookie::{SameSite, time::Duration}, +}; +use url::Url; mod assets; mod config; pub mod nextcloud_login; +mod oidc_user_store; mod routes; -pub const ROUTE_NAME_HOME: &str = "frontend_home"; -pub const ROUTE_USER_NAMED: &str = "frontend_user_named"; - -pub use config::{FrontendConfig, generate_frontend_secret}; +pub use config::FrontendConfig; +use oidc_user_store::OidcUserStore; use crate::{ assets::{Assets, EmbedService}, - routes::login::{route_get_login, route_post_login}, + routes::{ + addressbook::{route_addressbook, route_addressbook_restore}, + app_token::{route_delete_app_token, route_post_app_token}, + calendar::{route_calendar, route_calendar_restore}, + login::{route_get_login, route_post_login, route_post_logout}, + user::{route_get_home, route_root, route_user_named}, + }, }; -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 { -// pub user: User, -// pub app_tokens: Vec, -// pub calendars: Vec, -// pub deleted_calendars: Vec, -// pub addressbooks: Vec, -// pub deleted_addressbooks: Vec, -// pub is_apple: bool, -// } -// -// async fn route_user_named( -// path: Path, -// cal_store: Data, -// addr_store: Data, -// auth_provider: Data, -// user: User, -// req: HttpRequest, -// ) -> impl Responder { -// let user_id = path.into_inner(); -// if user_id != user.id { -// return actix_web::HttpResponse::Unauthorized().body("Unauthorized"); -// } -// -// let mut calendars = vec![]; -// for group in user.memberships() { -// calendars.extend(cal_store.get_calendars(group).await.unwrap()); -// } -// -// let mut deleted_calendars = vec![]; -// for group in user.memberships() { -// deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap()); -// } -// -// let mut addressbooks = vec![]; -// for group in user.memberships() { -// addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap()); -// } -// -// let mut deleted_addressbooks = vec![]; -// for group in user.memberships() { -// deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap()); -// } -// -// let is_apple = req -// .headers() -// .get(header::USER_AGENT) -// .and_then(|user_agent| user_agent.to_str().ok()) -// .map(|ua| ua.contains("Apple") || ua.contains("Mac OS")) -// .unwrap_or_default(); -// -// UserPage { -// app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(), -// calendars, -// deleted_calendars, -// addressbooks, -// deleted_addressbooks, -// user, -// is_apple, -// } -// .respond_to(&req) -// } -// -// async fn route_get_home(user: User, req: HttpRequest) -> Redirect { -// Redirect::to( -// req.url_for(ROUTE_USER_NAMED, &[user.id]) -// .unwrap() -// .to_string(), -// ) -// .see_other() -// } -// -// async fn route_root(user: Option, req: HttpRequest) -> impl Responder { -// let redirect_url = match user { -// Some(_) => req.url_for_static(ROUTE_NAME_HOME).unwrap(), -// None => req -// .resource_map() -// .url_for::<[_; 0], String>(&req, "frontend_login", []) -// .unwrap(), -// }; -// web::Redirect::to(redirect_url.to_string()).permanent() -// } -// -// #[derive(Template)] -// #[template(path = "apple_configuration/template.xml")] -// pub struct AppleConfig { -// token_name: String, -// account_description: String, -// hostname: String, -// caldav_principal_url: String, -// carddav_principal_url: String, -// user: String, -// token: String, -// caldav_profile_uuid: Uuid, -// carddav_profile_uuid: Uuid, -// plist_uuid: Uuid, -// } -// -// #[derive(Debug, Clone, Deserialize)] -// pub(crate) struct PostAppTokenForm { -// name: String, -// #[serde(default)] -// apple: bool, -// } -// -// async fn route_post_app_token( -// user: User, -// auth_provider: Data, -// path: Path, -// Form(PostAppTokenForm { apple, name }): Form, -// req: HttpRequest, -// ) -> Result { -// assert!(!name.is_empty()); -// assert_eq!(path.into_inner(), user.id); -// let token = generate_app_token(); -// auth_provider -// .add_app_token(&user.id, name.to_owned(), token.clone()) -// .await?; -// if apple { -// let hostname = req.full_url().host_str().unwrap().to_owned(); -// let profile = AppleConfig { -// token_name: name, -// account_description: format!("{}@{}", &user.id, &hostname), -// hostname, -// caldav_principal_url: req -// .url_for("caldav_principal", [&user.id]) -// .unwrap() -// .to_string(), -// carddav_principal_url: req -// .url_for("carddav_principal", [&user.id]) -// .unwrap() -// .to_string(), -// user: user.id.to_owned(), -// token, -// caldav_profile_uuid: Uuid::new_v4(), -// carddav_profile_uuid: Uuid::new_v4(), -// plist_uuid: Uuid::new_v4(), -// } -// .render() -// .unwrap(); -// Ok(HttpResponse::Ok() -// .insert_header(header::ContentDisposition::attachment(format!( -// "rustical-{}.mobileconfig", -// user.id -// ))) -// .insert_header(( -// header::CONTENT_TYPE, -// "application/x-apple-aspen-config; charset=utf-8", -// )) -// .body(profile)) -// } else { -// Ok(HttpResponse::Ok().body(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> { -// let (req, _) = res.into_parts(); -// let redirect_uri = req.uri().to_string(); -// let mut login_url = req.url_for_static("frontend_login").unwrap(); -// login_url -// .query_pairs_mut() -// .append_pair("redirect_uri", &redirect_uri); -// let login_url = login_url.to_string(); -// -// let response = HttpResponse::Unauthorized().body(format!( -// r#" -// -// -// -// -// -// Unauthorized, redirecting to login page -// -// -// "# -// )); -// -// let res = ServiceResponse::new(req, response) -// .map_into_boxed_body() -// .map_into_right_body(); -// -// 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 frontend_router( +pub fn frontend_router< + AP: AuthenticationProvider, + CS: CalendarStore, + AS: AddressbookStore, + S: SessionStore + Clone, +>( auth_provider: Arc, cal_store: Arc, addr_store: Arc, frontend_config: FrontendConfig, oidc_config: Option, + session_store: S, ) -> Router { - let mut router = Router::new().layer(AuthenticationLayer::new(auth_provider.clone())); + let mut router = Router::new(); router = router + .route("/", get(route_root)) + .route("/user", get(route_get_home)) + .route("/user/{user}", get(route_user_named::)) + // App token management + .route("/user/{user}/app_token", post(route_post_app_token::)) + .route( + // POST because HTML5 forms don't support DELETE method + "/user/{user}/app_token/{id}/delete", + post(route_delete_app_token::), + ) + // Calendar + .route( + "/user/{user}/calendar/{calendar}", + get(route_calendar::), + ) + .route( + "/user/{user}/calendar/{calendar}/restore", + post(route_calendar_restore::), + ) + // Addressbook + .route( + "/user/{user}/addressbook/{addressbook}", + get(route_addressbook::), + ) + .route( + "/user/{user}/addressbook/{addressbook}/restore", + post(route_addressbook_restore::), + ) .route("/login", get(route_get_login).post(route_post_login::)) - .route_service("/assets/{*file}", EmbedService::::new()) + .route("/logout", post(route_post_logout)) + .route_service("/assets/{*file}", EmbedService::::new()); + + if let Some(oidc_config) = oidc_config.clone() { + router = router + .route("/login/oidc", post(route_post_oidc)) + .route( + "/login/oidc/callback", + get(route_get_oidc_callback::>), + ) + .layer(Extension(OidcUserStore(auth_provider.clone()))) + .layer(Extension(OidcServiceConfig { + default_redirect_path: "/frontend/user", + session_key_user_id: "user", + })) + .layer(Extension(oidc_config)); + } + + router + .layer(AuthenticationLayer::new(auth_provider.clone())) + .layer( + SessionManagerLayer::new(session_store) + .with_secure(true) + .with_same_site(SameSite::Strict) + .with_expiry(Expiry::OnInactivity(Duration::hours(2))), + ) .layer(Extension(auth_provider.clone())) .layer(Extension(cal_store.clone())) .layer(Extension(addr_store.clone())) .layer(Extension(frontend_config.clone())) - .layer(Extension(oidc_config.clone())); - // .wrap(session_middleware(frontend_config.secret_key)) - // .service(web::resource("").route(web::method(Method::GET).to(route_root))) - // .service( - // web::resource("/user") - // .get(route_get_home) - // .name(ROUTE_NAME_HOME), - // ) - // .service( - // web::resource("/user/{user}") - // .get(route_user_named::) - // .name(ROUTE_USER_NAMED), - // ) - // // App token management - // .service(web::resource("/user/{user}/app_token").post(route_post_app_token::)) - // .service( - // // POST because HTML5 forms don't support DELETE method - // web::resource("/user/{user}/app_token/{id}/delete").post(route_delete_app_token::), - // ) - // // Calendar - // .service(web::resource("/user/{user}/calendar/{calendar}").get(route_calendar::)) - // .service( - // web::resource("/user/{user}/calendar/{calendar}/restore") - // .post(route_calendar_restore::), - // ) - // // Addressbook - // .service( - // web::resource("/user/{user}/addressbook/{addressbook}").get(route_addressbook::), - // ) - // .service( - // web::resource("/user/{user}/addressbook/{addressbook}/restore") - // .post(route_addressbook_restore::), - // ) - // // Login - // .service( - // web::resource("/login") - // .name("frontend_login") - // .get(route_get_login) - // .post(route_post_login::), - // ) - // .service( - // web::resource("/logout") - // .name("frontend_logout") - // .post(route_post_logout), - // ); - - // if let Some(oidc_config) = oidc_config { - // scope = scope.service(web::scope("/login/oidc").configure(|cfg| { - // configure_oidc( - // cfg, - // oidc_config, - // OidcServiceConfig { - // default_redirect_route_name: ROUTE_NAME_HOME, - // session_key_user_id: "user", - // }, - // Arc::new(OidcUserStore(auth_provider.clone())), - // ) - // })); - // } - - router + .layer(Extension(oidc_config.clone())) + .layer(middleware::from_fn(unauthorized_handler)) +} + +async fn unauthorized_handler(mut request: Request, next: Next) -> Response { + let meth = request.method().clone(); + let OriginalUri(uri) = request.extract_parts().await.unwrap(); + let resp = next.run(request).await; + if resp.status() == StatusCode::UNAUTHORIZED { + // This is a dumb hack since parsed Urls cannot be relative + let mut login_url: Url = "http://github.com/frontend/login".parse().unwrap(); + if meth == Method::GET { + login_url + .query_pairs_mut() + .append_pair("redirect_uri", uri.path()); + } + let path = login_url.path(); + let query = login_url + .query() + .map(|query| format!("?{query}")) + .unwrap_or_default(); + let login_url = format!("{path}{query}"); + let mut resp = Response::builder().status(StatusCode::UNAUTHORIZED); + let hdrs = resp.headers_mut().unwrap(); + hdrs.typed_insert(ContentType::html()); + return resp + .body(Body::new(format!( + r#" + + + + + + Unauthorized, redirecting to login page + + + "#, + ))) + .unwrap(); + } + resp } -// -// struct OidcUserStore(Arc); -// -// #[async_trait] -// impl UserStore for OidcUserStore { -// type Error = rustical_store::Error; -// -// async fn user_exists(&self, id: &str) -> Result { -// Ok(self.0.get_principal(id).await?.is_some()) -// } -// -// async fn insert_user(&self, id: &str) -> Result<(), Self::Error> { -// self.0 -// .insert_principal( -// User { -// id: id.to_owned(), -// displayname: None, -// principal_type: Default::default(), -// password: None, -// memberships: vec![], -// }, -// false, -// ) -// .await -// } -// } diff --git a/crates/frontend/src/nextcloud_login/mod.rs b/crates/frontend/src/nextcloud_login/mod.rs index 0900673..d100e2c 100644 --- a/crates/frontend/src/nextcloud_login/mod.rs +++ b/crates/frontend/src/nextcloud_login/mod.rs @@ -1,11 +1,19 @@ +use axum::routing::{get, post}; +use axum::{Extension, Router, middleware}; 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 routes::{get_nextcloud_flow, post_nextcloud_flow, post_nextcloud_login, post_nextcloud_poll}; +use rustical_store::auth::AuthenticationProvider; +use rustical_store::auth::middleware::AuthenticationLayer; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -// mod routes; +use tower_sessions::cookie::SameSite; +use tower_sessions::cookie::time::Duration; +use tower_sessions::{Expiry, SessionManagerLayer, SessionStore}; + +use crate::unauthorized_handler; +mod routes; #[derive(Debug, Clone)] struct NextcloudFlow { @@ -42,32 +50,26 @@ 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), -// ), -// ); -// } +pub fn nextcloud_login_router( + nextcloud_flows_state: Arc, + auth_provider: Arc, + session_store: S, +) -> Router { + Router::new() + .route("/poll/{flow}", post(post_nextcloud_poll::)) + .route( + "/flow/{flow}", + get(get_nextcloud_flow).post(post_nextcloud_flow), + ) + .route("/", post(post_nextcloud_login)) + .layer(Extension(nextcloud_flows_state)) + .layer(Extension(auth_provider.clone())) + .layer(AuthenticationLayer::new(auth_provider.clone())) + .layer( + SessionManagerLayer::new(session_store) + .with_secure(true) + .with_same_site(SameSite::Strict) + .with_expiry(Expiry::OnInactivity(Duration::hours(2))), + ) + .layer(middleware::from_fn(unauthorized_handler)) +} diff --git a/crates/frontend/src/nextcloud_login/routes.rs b/crates/frontend/src/nextcloud_login/routes.rs index 6efee66..991cb0a 100644 --- a/crates/frontend/src/nextcloud_login/routes.rs +++ b/crates/frontend/src/nextcloud_login/routes.rs @@ -1,48 +1,39 @@ -use crate::generate_app_token; - use super::{ NextcloudFlow, NextcloudFlows, NextcloudLoginPoll, NextcloudLoginResponse, NextcloudSuccessResponse, }; -use actix_web::{ - HttpRequest, HttpResponse, Responder, - http::header::{self}, - web::{Data, Form, Html, Json, Path}, -}; +use crate::routes::app_token::generate_app_token; use askama::Template; +use axum::{ + Extension, Form, Json, + extract::Path, + response::{Html, IntoResponse, Response}, +}; +use axum_extra::{TypedHeader, extract::Host}; use chrono::{Duration, Utc}; +use headers::UserAgent; +use http::StatusCode; use rustical_store::auth::{AuthenticationProvider, User}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use tracing::instrument; pub(crate) async fn post_nextcloud_login( - req: HttpRequest, - state: Data, + Extension(state): Extension>, + TypedHeader(user_agent): TypedHeader, + Host(host): Host, ) -> Json { let flow_id = uuid::Uuid::new_v4().to_string(); let token = uuid::Uuid::new_v4().to_string(); - let poll_url = req - .resource_map() - .url_for(&req, "nc_login_poll", [&flow_id]) - .unwrap(); - let flow_url = req - .resource_map() - .url_for(&req, "nc_login_flow", [&flow_id]) - .unwrap(); - - let app_name = req - .headers() - .get(header::USER_AGENT) - .map(|val| val.to_str().unwrap_or("Unknown client")) - .unwrap_or("Unknown client"); + let app_name = user_agent.to_string(); let mut flows = state.flows.write().await; // Flows must not last longer than 10 minutes // We also enforce that condition here to prevent a memory leak where unpolled flows would // never be cleaned up flows.retain(|_, flow| Utc::now() - flow.created_at < Duration::minutes(10)); flows.insert( - flow_id, + flow_id.clone(), NextcloudFlow { app_name: app_name.to_owned(), created_at: Utc::now(), @@ -51,10 +42,10 @@ pub(crate) async fn post_nextcloud_login( }, ); Json(NextcloudLoginResponse { - login: flow_url.to_string(), + login: format!("https://{host}/index.php/login/v2/flow/{flow_id}"), poll: NextcloudLoginPoll { token, - endpoint: poll_url.to_string(), + endpoint: format!("https://{host}/index.php/login/v2/poll/{flow_id}"), }, }) } @@ -66,13 +57,11 @@ pub(crate) struct NextcloudPollForm { } pub(crate) async fn post_nextcloud_poll( - state: Data, - form: Form, - path: Path, - auth_provider: Data, - req: HttpRequest, -) -> Result { - let flow_id = path.into_inner(); + Extension(state): Extension>, + Path(flow_id): Path, + Extension(auth_provider): Extension>, + Form(form): Form, +) -> Result { let mut flows = state.flows.write().await; // Flows must not last longer than 10 minutes @@ -80,7 +69,7 @@ pub(crate) async fn post_nextcloud_poll( if let Some(flow) = flows.get(&flow_id).cloned() { if flow.token != form.token { - return Ok(HttpResponse::Unauthorized().body("Unauthorized")); + return Ok(StatusCode::UNAUTHORIZED.into_response()); } if let Some(response) = &flow.response { auth_provider @@ -91,13 +80,13 @@ pub(crate) async fn post_nextcloud_poll( ) .await?; flows.remove(&flow_id); - Ok(Json(response).respond_to(&req).map_into_boxed_body()) + Ok(Json(response).into_response()) } else { // Not done yet, re-insert flow - Ok(HttpResponse::NotFound().finish()) + Ok(StatusCode::NOT_FOUND.into_response()) } } else { - Ok(HttpResponse::Unauthorized().body("Unauthorized")) + Ok(StatusCode::UNAUTHORIZED.into_response()) } } @@ -108,16 +97,14 @@ struct NextcloudLoginPage { app_name: String, } -#[instrument(skip(state, req))] +#[instrument(skip(state))] pub(crate) async fn get_nextcloud_flow( + Extension(state): Extension>, + Path(flow_id): Path, user: User, - state: Data, - path: Path, - req: HttpRequest, -) -> Result { - let flow_id = path.into_inner(); +) -> Result { if let Some(flow) = state.flows.read().await.get(&flow_id) { - Ok(Html::new( + Ok(Html( NextcloudLoginPage { username: user.displayname.unwrap_or(user.id), app_name: flow.app_name.to_owned(), @@ -125,10 +112,9 @@ pub(crate) async fn get_nextcloud_flow( .render() .unwrap(), ) - .respond_to(&req) - .map_into_boxed_body()) + .into_response()) } else { - Ok(HttpResponse::NotFound().body("Login flow not found")) + Ok((StatusCode::NOT_FOUND, "Login flow not found").into_response()) } } @@ -143,32 +129,30 @@ struct NextcloudLoginSuccessPage { app_name: String, } -#[instrument(skip(state, req))] +#[instrument(skip(state))] pub(crate) async fn post_nextcloud_flow( user: User, - state: Data, - path: Path, - req: HttpRequest, - form: Form, -) -> Result { - let flow_id = path.into_inner(); + Extension(state): Extension>, + Path(flow_id): Path, + Host(host): Host, + Form(form): Form, +) -> Result { if let Some(flow) = state.flows.write().await.get_mut(&flow_id) { - flow.app_name = form.into_inner().app_name; + flow.app_name = form.app_name; flow.response = Some(NextcloudSuccessResponse { - server: req.full_url().origin().unicode_serialization(), + server: format!("https://{host}"), login_name: user.id.to_owned(), app_password: generate_app_token(), }); - Ok(Html::new( + Ok(Html( NextcloudLoginSuccessPage { app_name: flow.app_name.to_owned(), } .render() .unwrap(), ) - .respond_to(&req) - .map_into_boxed_body()) + .into_response()) } else { - Ok(HttpResponse::NotFound().body("Login flow not found")) + Ok((StatusCode::NOT_FOUND, "Login flow not found").into_response()) } } diff --git a/crates/frontend/src/oidc_user_store.rs b/crates/frontend/src/oidc_user_store.rs new file mode 100644 index 0000000..24bcd8f --- /dev/null +++ b/crates/frontend/src/oidc_user_store.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use rustical_oidc::UserStore; +use rustical_store::auth::{AuthenticationProvider, User}; + +pub struct OidcUserStore(pub Arc); + +impl Clone for OidcUserStore { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +#[async_trait] +impl UserStore for OidcUserStore { + type Error = rustical_store::Error; + + async fn user_exists(&self, id: &str) -> Result { + Ok(self.0.get_principal(id).await?.is_some()) + } + + async fn insert_user(&self, id: &str) -> Result<(), Self::Error> { + self.0 + .insert_principal( + User { + id: id.to_owned(), + displayname: None, + principal_type: Default::default(), + password: None, + memberships: vec![], + }, + false, + ) + .await + } +} diff --git a/crates/frontend/src/routes/addressbook.rs b/crates/frontend/src/routes/addressbook.rs index 1dddac4..91f6809 100644 --- a/crates/frontend/src/routes/addressbook.rs +++ b/crates/frontend/src/routes/addressbook.rs @@ -1,10 +1,15 @@ -use actix_web::{ - HttpRequest, HttpResponse, Responder, - http::{StatusCode, header}, - web::{self, Data, Path}, -}; +use std::sync::Arc; + use askama::Template; use askama_web::WebTemplate; +use axum::{ + Extension, + extract::Path, + response::{IntoResponse, Redirect, Response}, +}; +use axum_extra::TypedHeader; +use headers::Referer; +use http::StatusCode; use rustical_store::{Addressbook, AddressbookStore, auth::User}; #[derive(Template, WebTemplate)] @@ -14,37 +19,31 @@ struct AddressbookPage { } pub async fn route_addressbook( - path: Path<(String, String)>, - store: Data, + Path((owner, addrbook_id)): Path<(String, String)>, + Extension(store): Extension>, user: User, - req: HttpRequest, -) -> Result { - let (owner, addrbook_id) = path.into_inner(); +) -> Result { if !user.is_principal(&owner) { - return Ok(HttpResponse::Unauthorized().body("Unauthorized")); + return Ok(StatusCode::UNAUTHORIZED.into_response()); } Ok(AddressbookPage { addressbook: store.get_addressbook(&owner, &addrbook_id, true).await?, } - .respond_to(&req)) + .into_response()) } pub async fn route_addressbook_restore( - path: Path<(String, String)>, - req: HttpRequest, - store: Data, + Path((owner, addressbook_id)): Path<(String, String)>, + Extension(store): Extension>, user: User, -) -> Result { - let (owner, addressbook_id) = path.into_inner(); + referer: Option>, +) -> Result { if !user.is_principal(&owner) { - return Ok(HttpResponse::Unauthorized().body("Unauthorized")); + return Ok(StatusCode::UNAUTHORIZED.into_response()); } store.restore_addressbook(&owner, &addressbook_id).await?; - Ok(match req.headers().get(header::REFERER) { - Some(referer) => web::Redirect::to(referer.to_str().unwrap().to_owned()) - .using_status_code(StatusCode::FOUND) - .respond_to(&req) - .map_into_boxed_body(), - None => HttpResponse::Ok().body("Restored"), + Ok(match referer { + Some(referer) => Redirect::to(&referer.to_string()).into_response(), + None => (StatusCode::CREATED, "Restored").into_response(), }) } diff --git a/crates/frontend/src/routes/app_token.rs b/crates/frontend/src/routes/app_token.rs new file mode 100644 index 0000000..fad316d --- /dev/null +++ b/crates/frontend/src/routes/app_token.rs @@ -0,0 +1,104 @@ +use std::{str::FromStr, sync::Arc}; + +use askama::Template; +use axum::{ + Extension, Form, + body::Body, + extract::Path, + response::{IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::Host; +use headers::{ContentType, HeaderMapExt}; +use http::{HeaderValue, StatusCode, header}; +use rand::{Rng, distributions::Alphanumeric}; +use rustical_store::auth::{AuthenticationProvider, User}; +use serde::Deserialize; +use uuid::Uuid; + +pub fn generate_app_token() -> String { + rand::thread_rng() + .sample_iter(Alphanumeric) + .map(char::from) + .take(64) + .collect() +} + +#[derive(Template)] +#[template(path = "apple_configuration/template.xml")] +pub struct AppleConfig { + token_name: String, + account_description: String, + hostname: String, + caldav_principal_url: String, + carddav_principal_url: String, + user: String, + token: String, + caldav_profile_uuid: Uuid, + carddav_profile_uuid: Uuid, + plist_uuid: Uuid, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct PostAppTokenForm { + name: String, + #[serde(default)] + apple: bool, +} + +pub async fn route_post_app_token( + user: User, + Extension(auth_provider): Extension>, + Path(user_id): Path, + Host(hostname): Host, + Form(PostAppTokenForm { apple, name }): Form, +) -> Result { + assert!(!name.is_empty()); + assert_eq!(user_id, user.id); + let token = generate_app_token(); + auth_provider + .add_app_token(&user.id, name.to_owned(), token.clone()) + .await?; + if apple { + let profile = AppleConfig { + token_name: name, + account_description: format!("{}@{}", &user.id, &hostname), + hostname: hostname.clone(), + caldav_principal_url: format!("https://{hostname}/caldav/principal/{user_id}"), + carddav_principal_url: format!("https://{hostname}/carddav/principal/{user_id}"), + user: user.id.to_owned(), + token, + caldav_profile_uuid: Uuid::new_v4(), + carddav_profile_uuid: Uuid::new_v4(), + plist_uuid: Uuid::new_v4(), + } + .render() + .unwrap(); + let mut res = Response::builder().status(StatusCode::OK); + let hdrs = res.headers_mut().unwrap(); + hdrs.typed_insert( + ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(), + ); + let filename = format!("rustical-{}.mobileconfig", user_id); + hdrs.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!( + "attachement; filename*=UTF-8''{} filename={}", + filename, filename + )) + .unwrap(), + ); + Ok(res.body(Body::new(profile)).unwrap()) + } else { + Ok((StatusCode::OK, token).into_response()) + } +} + +pub async fn route_delete_app_token( + user: User, + Extension(auth_provider): Extension>, + Path((user_id, token_id)): Path<(String, String)>, +) -> Result { + assert_eq!(user_id, user.id); + auth_provider.remove_app_token(&user.id, &token_id).await?; + Ok(Redirect::to("/frontend/user")) +} diff --git a/crates/frontend/src/routes/calendar.rs b/crates/frontend/src/routes/calendar.rs index 335bf1d..b17afc5 100644 --- a/crates/frontend/src/routes/calendar.rs +++ b/crates/frontend/src/routes/calendar.rs @@ -1,10 +1,15 @@ -use actix_web::{ - HttpRequest, HttpResponse, Responder, - http::{StatusCode, header}, - web::{self, Data, Path}, -}; +use std::sync::Arc; + use askama::Template; use askama_web::WebTemplate; +use axum::{ + Extension, + extract::Path, + response::{IntoResponse, Redirect, Response}, +}; +use axum_extra::TypedHeader; +use headers::Referer; +use http::StatusCode; use rustical_store::{Calendar, CalendarStore, auth::User}; #[derive(Template, WebTemplate)] @@ -14,37 +19,31 @@ struct CalendarPage { } pub async fn route_calendar( - path: Path<(String, String)>, - store: Data, + Path((owner, cal_id)): Path<(String, String)>, + Extension(store): Extension>, user: User, - req: HttpRequest, -) -> Result { - let (owner, cal_id) = path.into_inner(); +) -> Result { if !user.is_principal(&owner) { - return Ok(HttpResponse::Unauthorized().body("Unauthorized")); + return Ok(StatusCode::UNAUTHORIZED.into_response()); } Ok(CalendarPage { calendar: store.get_calendar(&owner, &cal_id).await?, } - .respond_to(&req)) + .into_response()) } pub async fn route_calendar_restore( - path: Path<(String, String)>, - req: HttpRequest, - store: Data, + Path((owner, cal_id)): Path<(String, String)>, + Extension(store): Extension>, user: User, -) -> Result { - let (owner, cal_id) = path.into_inner(); + referer: Option>, +) -> Result { if !user.is_principal(&owner) { - return Ok(HttpResponse::Unauthorized().body("Unauthorized")); + return Ok(StatusCode::UNAUTHORIZED.into_response()); } store.restore_calendar(&owner, &cal_id).await?; - Ok(match req.headers().get(header::REFERER) { - Some(referer) => web::Redirect::to(referer.to_str().unwrap().to_owned()) - .using_status_code(StatusCode::FOUND) - .respond_to(&req) - .map_into_boxed_body(), - None => HttpResponse::Created().body("Restored"), + Ok(match referer { + Some(referer) => Redirect::to(&referer.to_string()).into_response(), + None => (StatusCode::CREATED, "Restored").into_response(), }) } diff --git a/crates/frontend/src/routes/login.rs b/crates/frontend/src/routes/login.rs index cb27d80..b06d4d1 100644 --- a/crates/frontend/src/routes/login.rs +++ b/crates/frontend/src/routes/login.rs @@ -5,13 +5,16 @@ use askama::Template; use askama_web::WebTemplate; use axum::{ Extension, Form, - extract::{OriginalUri, Query}, + extract::Query, response::{IntoResponse, Redirect, Response}, }; +use axum_extra::extract::Host; use http::StatusCode; use rustical_store::auth::AuthenticationProvider; use serde::Deserialize; +use tower_sessions::Session; use tracing::instrument; +use url::Url; #[derive(Template, WebTemplate)] #[template(path = "pages/login.html")] @@ -37,20 +40,17 @@ pub async fn route_get_login( Extension(config): Extension, Extension(oidc_config): Extension>, ) -> Response { - // let oidc_data = oidc_config - // .as_ref() - // .as_ref() - // .map(|oidc_config| OidcProviderData { - // name: &oidc_config.name, - // redirect_url: req - // .url_for_static(ROUTE_NAME_OIDC_LOGIN) - // .unwrap() - // .to_string(), - // }); + let oidc_data = oidc_config + .as_ref() + .as_ref() + .map(|oidc_config| OidcProviderData { + name: &oidc_config.name, + redirect_url: "/frontend/login/oidc".to_owned(), + }); LoginPage { redirect_uri, allow_password_login: config.allow_password_login, - oidc_data: None, + oidc_data, } .into_response() } @@ -66,7 +66,8 @@ pub struct PostLoginForm { pub async fn route_post_login( Extension(auth_provider): Extension>, Extension(config): Extension, - OriginalUri(orig_uri): OriginalUri, + session: Session, + Host(host): Host, Form(PostLoginForm { username, password, @@ -76,24 +77,32 @@ pub async fn route_post_login( if !config.allow_password_login { return StatusCode::METHOD_NOT_ALLOWED.into_response(); } - // Ensure that redirect_uri never goes cross-origin let default_redirect = "/frontend/user".to_string(); - let redirect_uri = redirect_uri.unwrap_or(default_redirect.clone()); - // let redirect_uri = orig_uri - // .join(&redirect_uri) - // .ok() - // .and_then(|uri| orig_uri.make_relative(&uri)) - // .unwrap_or(default_redirect); + // Ensure that redirect_uri never goes cross-origin + let base_url: Url = format!("https://{host}").parse().unwrap(); + let redirect_uri = if let Some(redirect_uri) = redirect_uri { + if let Ok(redirect_url) = base_url.join(&redirect_uri) { + if redirect_url.origin() == base_url.origin() { + redirect_url.path().to_owned() + } else { + default_redirect + } + } else { + default_redirect + } + } else { + default_redirect + }; if let Ok(Some(user)) = auth_provider.validate_password(&username, &password).await { - // session.insert("user", user.id).unwrap(); + session.insert("user", user.id).await.unwrap(); Redirect::to(&redirect_uri).into_response() } else { StatusCode::UNAUTHORIZED.into_response() } } -pub async fn route_post_logout() -> Redirect { - // session.remove("user"); +pub async fn route_post_logout(session: Session) -> Redirect { + session.remove_value("user").await.unwrap(); Redirect::to("/") } diff --git a/crates/frontend/src/routes/mod.rs b/crates/frontend/src/routes/mod.rs index 2d914de..e007c7c 100644 --- a/crates/frontend/src/routes/mod.rs +++ b/crates/frontend/src/routes/mod.rs @@ -1,3 +1,5 @@ -// pub mod addressbook; -// pub mod calendar; +pub mod addressbook; +pub mod app_token; +pub mod calendar; pub mod login; +pub mod user; diff --git a/crates/frontend/src/routes/user.rs b/crates/frontend/src/routes/user.rs new file mode 100644 index 0000000..f78c234 --- /dev/null +++ b/crates/frontend/src/routes/user.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use askama::Template; +use askama_web::WebTemplate; +use axum::{ + Extension, + extract::Path, + response::{IntoResponse, Redirect}, +}; +use axum_extra::TypedHeader; +use headers::UserAgent; +use http::StatusCode; +use rustical_store::{ + Addressbook, AddressbookStore, Calendar, CalendarStore, + auth::{AuthenticationProvider, User, user::AppToken}, +}; + +#[derive(Template, WebTemplate)] +#[template(path = "pages/user.html")] +pub struct UserPage { + pub user: User, + pub app_tokens: Vec, + pub calendars: Vec, + pub deleted_calendars: Vec, + pub addressbooks: Vec, + pub deleted_addressbooks: Vec, + pub is_apple: bool, +} + +pub async fn route_user_named< + CS: CalendarStore, + AS: AddressbookStore, + AP: AuthenticationProvider, +>( + Path(user_id): Path, + Extension(cal_store): Extension>, + Extension(addr_store): Extension>, + Extension(auth_provider): Extension>, + TypedHeader(user_agent): TypedHeader, + user: User, +) -> impl IntoResponse { + if user_id != user.id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let mut calendars = vec![]; + for group in user.memberships() { + calendars.extend(cal_store.get_calendars(group).await.unwrap()); + } + + let mut deleted_calendars = vec![]; + for group in user.memberships() { + deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap()); + } + + let mut addressbooks = vec![]; + for group in user.memberships() { + addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap()); + } + + let mut deleted_addressbooks = vec![]; + for group in user.memberships() { + deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap()); + } + + let is_apple = user_agent.as_str().contains("Apple") || user_agent.as_str().contains("Mac OS"); + + UserPage { + app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(), + calendars, + deleted_calendars, + addressbooks, + deleted_addressbooks, + user, + is_apple, + } + .into_response() +} + +pub async fn route_get_home(user: User) -> Redirect { + Redirect::to(&format!("/frontend/user/{}", user.id)) +} + +pub async fn route_root(user: Option) -> Redirect { + match user { + Some(user) => route_get_home(user).await, + None => Redirect::to("/frontend/login"), + } +} diff --git a/crates/ical/src/address_object.rs b/crates/ical/src/address_object.rs index eddf2e5..77e6c0a 100644 --- a/crates/ical/src/address_object.rs +++ b/crates/ical/src/address_object.rs @@ -39,7 +39,7 @@ impl AddressObject { let mut hasher = Sha256::new(); hasher.update(&self.id); hasher.update(self.get_vcf()); - format!("{:x}", hasher.finalize()) + format!("\"{:x}\"", hasher.finalize()) } pub fn get_vcf(&self) -> &str { diff --git a/crates/ical/src/icalendar/object.rs b/crates/ical/src/icalendar/object.rs index 9360561..8b02b4d 100644 --- a/crates/ical/src/icalendar/object.rs +++ b/crates/ical/src/icalendar/object.rs @@ -142,7 +142,7 @@ impl CalendarObject { let mut hasher = Sha256::new(); hasher.update(&self.id); hasher.update(self.get_ics()); - format!("{:x}", hasher.finalize()) + format!("\"{:x}\"", hasher.finalize()) } pub fn get_ics(&self) -> &str { diff --git a/crates/oidc/Cargo.toml b/crates/oidc/Cargo.toml index 1ecef6b..074912f 100644 --- a/crates/oidc/Cargo.toml +++ b/crates/oidc/Cargo.toml @@ -12,4 +12,6 @@ reqwest.workspace = true thiserror.workspace = true async-trait.workspace = true axum.workspace = true -axum_session = "0.16" +tower-sessions = "0.14" +axum-extra.workspace = true +headers.workspace = true diff --git a/crates/oidc/src/config.rs b/crates/oidc/src/config.rs index c01418b..e4953cb 100644 --- a/crates/oidc/src/config.rs +++ b/crates/oidc/src/config.rs @@ -1,4 +1,5 @@ use openidconnect::{ClientId, ClientSecret, IssuerUrl, Scope}; +use reqwest::Url; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize, Clone, Default)] diff --git a/crates/oidc/src/error.rs b/crates/oidc/src/error.rs index 43e06c4..58fe7ff 100644 --- a/crates/oidc/src/error.rs +++ b/crates/oidc/src/error.rs @@ -13,6 +13,9 @@ pub enum OidcError { #[error(transparent)] OidcClaimsVerificationError(#[from] ClaimsVerificationError), + #[error(transparent)] + SessionError(#[from] tower_sessions::session::Error), + #[error("{0}")] Other(&'static str), } diff --git a/crates/oidc/src/lib.rs b/crates/oidc/src/lib.rs index 2cb64a0..8c04f3a 100644 --- a/crates/oidc/src/lib.rs +++ b/crates/oidc/src/lib.rs @@ -1,8 +1,9 @@ use axum::{ Extension, Form, - extract::{Query, Request, State}, + extract::Query, response::{IntoResponse, Redirect, Response}, }; +use axum_extra::extract::Host; pub use config::OidcConfig; use config::UserIdClaim; use error::OidcError; @@ -12,21 +13,20 @@ use openidconnect::{ RedirectUrl, TokenResponse, UserInfoClaims, core::{CoreClient, CoreGenderClaim, CoreProviderMetadata, CoreResponseType}, }; +use reqwest::{StatusCode, Url}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use tower_sessions::Session; pub use user_store::UserStore; mod config; mod error; mod user_store; -pub const ROUTE_NAME_OIDC_LOGIN: &str = "oidc_login"; -const ROUTE_NAME_OIDC_CALLBACK: &str = "oidc_callback"; const SESSION_KEY_OIDC_STATE: &str = "oidc_state"; #[derive(Debug, Clone)] pub struct OidcServiceConfig { - pub default_redirect_route_name: &'static str, + pub default_redirect_path: &'static str, pub session_key_user_id: &'static str, } @@ -92,44 +92,48 @@ pub struct GetOidcForm { } /// Endpoint that redirects to the authorize endpoint of the OIDC service -// pub async fn route_post_oidc( -// Form(GetOidcForm { redirect_uri }): Form, -// State(oidc_config): State, -// // session: Session, -// req: Request, -// ) -> Result { -// let http_client = get_http_client(); -// let oidc_client = get_oidc_client( -// oidc_config.clone(), -// &http_client, -// RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?, -// ) -// .await?; -// -// let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); -// -// let (auth_url, csrf_token, nonce) = oidc_client -// .authorize_url( -// AuthenticationFlow::::AuthorizationCode, -// CsrfToken::new_random, -// Nonce::new_random, -// ) -// .add_scopes(oidc_config.scopes.clone()) -// .set_pkce_challenge(pkce_challenge) -// .url(); -// -// // session.insert( -// // SESSION_KEY_OIDC_STATE, -// // OidcState { -// // state: csrf_token, -// // nonce, -// // pkce_verifier, -// // redirect_uri, -// // }, -// // )?; -// -// Ok(Redirect::to(auth_url.as_str()).into_response()) -// } +pub async fn route_post_oidc( + Extension(oidc_config): Extension, + session: Session, + Host(host): Host, + Form(GetOidcForm { redirect_uri }): Form, +) -> Result { + let callback_uri = format!("https://{host}/frontend/login/oidc/callback"); + + let http_client = get_http_client(); + let oidc_client = get_oidc_client( + oidc_config.clone(), + &http_client, + RedirectUrl::new(callback_uri)?, + ) + .await?; + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (auth_url, csrf_token, nonce) = oidc_client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scopes(oidc_config.scopes.clone()) + .set_pkce_challenge(pkce_challenge) + .url(); + + session + .insert( + SESSION_KEY_OIDC_STATE, + OidcState { + state: csrf_token, + nonce, + pkce_verifier, + redirect_uri, + }, + ) + .await?; + + Ok(Redirect::to(auth_url.as_str()).into_response()) +} #[derive(Debug, Clone, Deserialize)] pub struct AuthCallbackQuery { @@ -139,123 +143,111 @@ pub struct AuthCallbackQuery { } // Handle callback from IdP page -// pub async fn route_get_oidc_callback( -// Extension(oidc_config): Extension, -// session: Session, -// Extension(user_store): Extension, -// Query(AuthCallbackQuery { code, iss, state }): Query, -// State(service_config): State, -// req: Request, -// ) -> Result { -// assert_eq!(iss, oidc_config.issuer); -// let oidc_state = session -// .remove_as::(SESSION_KEY_OIDC_STATE) -// .ok_or(OidcError::Other("No local OIDC state"))? -// .map_err(|_| OidcError::Other("Error parsing OIDC state"))?; -// -// assert_eq!(oidc_state.state.secret(), &state); -// -// let http_client = get_http_client(); -// let oidc_client = get_oidc_client( -// oidc_config.clone(), -// &http_client, -// RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?, -// ) -// .await?; -// -// let token_response = oidc_client -// .exchange_code(code)? -// .set_pkce_verifier(oidc_state.pkce_verifier) -// .request_async(&http_client) -// .await -// .map_err(|_| OidcError::Other("Error requesting token"))?; -// let id_claims = token_response -// .id_token() -// .ok_or(OidcError::Other("OIDC provider did not return an ID token"))? -// .claims(&oidc_client.id_token_verifier(), &oidc_state.nonce)?; -// -// let user_info_claims: UserInfoClaims = oidc_client -// .user_info( -// token_response.access_token().clone(), -// Some(id_claims.subject().clone()), -// )? -// .request_async(&http_client) -// .await -// .map_err(|_| OidcError::Other("Error fetching user info"))?; -// -// if let Some(require_group) = &oidc_config.require_group { -// if !user_info_claims -// .additional_claims() -// .groups -// .contains(require_group) -// { -// return Ok(HttpResponse::build(StatusCode::UNAUTHORIZED) -// .body("User is not in an authorized group to use RustiCal")); -// } -// } -// -// let user_id = match oidc_config.claim_userid { -// UserIdClaim::Sub => user_info_claims.subject().to_string(), -// UserIdClaim::PreferredUsername => user_info_claims -// .preferred_username() -// .ok_or(OidcError::Other("Missing preferred_username claim"))? -// .to_string(), -// }; -// -// match user_store.user_exists(&user_id).await { -// Ok(false) => { -// // User does not exist -// if !oidc_config.allow_sign_up { -// return Ok(HttpResponse::Unauthorized().body("User sign up disabled")); -// } -// // Create new user -// if let Err(err) = user_store.insert_user(&user_id).await { -// return Ok(err.error_response()); -// } -// } -// Ok(true) => {} -// Err(err) => { -// return Ok(err.error_response()); -// } -// } -// -// let default_redirect = req -// .url_for_static(service_config.default_redirect_route_name)? -// .to_string(); -// let redirect_uri = oidc_state.redirect_uri.unwrap_or(default_redirect.clone()); -// let redirect_uri = req -// .full_url() -// .join(&redirect_uri) -// .ok() -// .and_then(|uri| req.full_url().make_relative(&uri)) -// .unwrap_or(default_redirect); -// -// // Complete login flow -// session.insert(service_config.session_key_user_id, user_id.clone())?; -// -// Ok(Redirect::to(redirect_uri) -// .temporary() -// .respond_to(&req) -// .map_into_boxed_body()) -// } +pub async fn route_get_oidc_callback( + Extension(oidc_config): Extension, + Extension(user_store): Extension, + Extension(service_config): Extension, + session: Session, + Query(AuthCallbackQuery { code, iss, state }): Query, + Host(host): Host, +) -> Result { + let callback_uri = format!("https://{host}/frontend/login/oidc/callback"); -// pub fn configure_oidc( -// cfg: &mut ServiceConfig, -// oidc_config: OidcConfig, -// service_config: OidcServiceConfig, -// user_store: Arc, -// ) { -// cfg.app_data(Data::new(oidc_config)) -// .app_data(Data::new(service_config)) -// .app_data(Data::from(user_store)) -// .service( -// web::resource("") -// .name(ROUTE_NAME_OIDC_LOGIN) -// .post(route_post_oidc), -// ) -// .service( -// web::resource("/callback") -// .name(ROUTE_NAME_OIDC_CALLBACK) -// .get(route_get_oidc_callback::), -// ); -// } + assert_eq!(iss, oidc_config.issuer); + let oidc_state = session + .remove::(SESSION_KEY_OIDC_STATE) + .await? + .ok_or(OidcError::Other("No local OIDC state"))?; + + assert_eq!(oidc_state.state.secret(), &state); + + let http_client = get_http_client(); + let oidc_client = get_oidc_client( + oidc_config.clone(), + &http_client, + RedirectUrl::new(callback_uri)?, + ) + .await?; + + let token_response = oidc_client + .exchange_code(code)? + .set_pkce_verifier(oidc_state.pkce_verifier) + .request_async(&http_client) + .await + .map_err(|_| OidcError::Other("Error requesting token"))?; + let id_claims = token_response + .id_token() + .ok_or(OidcError::Other("OIDC provider did not return an ID token"))? + .claims(&oidc_client.id_token_verifier(), &oidc_state.nonce)?; + + let user_info_claims: UserInfoClaims = oidc_client + .user_info( + token_response.access_token().clone(), + Some(id_claims.subject().clone()), + )? + .request_async(&http_client) + .await + .map_err(|_| OidcError::Other("Error fetching user info"))?; + + if let Some(require_group) = &oidc_config.require_group { + if !user_info_claims + .additional_claims() + .groups + .contains(require_group) + { + return Ok(( + StatusCode::UNAUTHORIZED, + "User is not in an authorized group to use RustiCal", + ) + .into_response()); + } + } + + let user_id = match oidc_config.claim_userid { + UserIdClaim::Sub => user_info_claims.subject().to_string(), + UserIdClaim::PreferredUsername => user_info_claims + .preferred_username() + .ok_or(OidcError::Other("Missing preferred_username claim"))? + .to_string(), + }; + + match user_store.user_exists(&user_id).await { + Ok(false) => { + // User does not exist + if !oidc_config.allow_sign_up { + return Ok((StatusCode::UNAUTHORIZED, "User signup is disabled").into_response()); + } + // Create new user + if let Err(err) = user_store.insert_user(&user_id).await { + return Ok(err.into_response()); + } + } + Ok(true) => {} + Err(err) => { + return Ok(err.into_response()); + } + } + + let default_redirect = service_config.default_redirect_path.to_owned(); + let base_url: Url = format!("https://{host}").parse().unwrap(); + let redirect_uri = if let Some(redirect_uri) = oidc_state.redirect_uri { + if let Ok(redirect_url) = base_url.join(&redirect_uri) { + if redirect_url.origin() == base_url.origin() { + redirect_url.path().to_owned() + } else { + default_redirect + } + } else { + default_redirect + } + } else { + default_redirect + }; + + // Complete login flow + session + .insert(service_config.session_key_user_id, user_id.clone()) + .await?; + + Ok(Redirect::to(&redirect_uri).into_response()) +} diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 7669edb..0c18e8d 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -32,6 +32,7 @@ rrule.workspace = true headers.workspace = true tower.workspace = true futures-core.workspace = true +tower-sessions.workspace = true [dev-dependencies] rstest = { workspace = true } diff --git a/crates/store/src/auth/middleware.rs b/crates/store/src/auth/middleware.rs index 628f774..f8bc7d6 100644 --- a/crates/store/src/auth/middleware.rs +++ b/crates/store/src/auth/middleware.rs @@ -1,3 +1,5 @@ +use crate::auth::User; + use super::AuthenticationProvider; use axum::{extract::Request, response::Response}; use futures_core::future::BoxFuture; @@ -8,6 +10,7 @@ use std::{ task::{Context, Poll}, }; use tower::{Layer, Service}; +use tower_sessions::Session; use tracing::{Instrument, info_span}; pub struct AuthenticationLayer { @@ -71,8 +74,15 @@ where let ap = self.auth_provider.clone(); let mut inner = self.inner.clone(); - // request.extensions_mut(); Box::pin(async move { + if let Some(session) = request.extensions().get::() { + if let Ok(Some(user_id)) = session.get::("user").await { + if let Ok(Some(user)) = ap.get_principal(&user_id).await { + request.extensions_mut().insert(user); + } + } + } + if let Some(auth) = auth_header { let user_id = auth.username(); let password = auth.password(); @@ -84,6 +94,7 @@ where request.extensions_mut().insert(user); } } + let response = inner.call(request).await?; Ok(response) }) diff --git a/crates/store/src/auth/user.rs b/crates/store/src/auth/user.rs index 87b089b..e7710a8 100644 --- a/crates/store/src/auth/user.rs +++ b/crates/store/src/auth/user.rs @@ -1,6 +1,6 @@ use axum::{ body::Body, - extract::FromRequestParts, + extract::{FromRequestParts, OptionalFromRequestParts}, response::{IntoResponse, Response}, }; use chrono::{DateTime, Utc}; @@ -8,7 +8,7 @@ use derive_more::Display; use http::{HeaderValue, StatusCode, header}; use rustical_xml::ValueSerialize; use serde::{Deserialize, Serialize}; -use std::fmt::Display; +use std::{convert::Infallible, fmt::Display}; use crate::Secret; @@ -144,3 +144,14 @@ impl FromRequestParts for User { .ok_or(UnauthorizedError) } } + +impl OptionalFromRequestParts for User { + type Rejection = Infallible; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs index 8820a6a..2255164 100644 --- a/crates/store/src/error.rs +++ b/crates/store/src/error.rs @@ -1,3 +1,4 @@ +use axum::response::IntoResponse; use http::StatusCode; #[derive(Debug, thiserror::Error)] @@ -39,3 +40,9 @@ impl Error { } } } + +impl IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + (self.status_code(), self.to_string()).into_response() + } +} diff --git a/crates/xml/src/error.rs b/crates/xml/src/error.rs index c147975..5812733 100644 --- a/crates/xml/src/error.rs +++ b/crates/xml/src/error.rs @@ -4,7 +4,7 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum XmlError { - // Syntactix errors + // Syntactic errors #[error(transparent)] QuickXmlError(#[from] quick_xml::Error), #[error(transparent)] diff --git a/src/app.rs b/src/app.rs index 10a1050..0c13c52 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,20 @@ use axum::Router; -use axum::response::Redirect; -use axum::routing::get; +use axum::extract::Request; +use axum::response::{Redirect, Response}; +use axum::routing::{any, get}; use rustical_caldav::caldav_router; use rustical_carddav::carddav_router; -use rustical_frontend::nextcloud_login::NextcloudFlows; +use rustical_frontend::nextcloud_login::{NextcloudFlows, nextcloud_login_router}; use rustical_frontend::{FrontendConfig, frontend_router}; use rustical_oidc::OidcConfig; use rustical_store::auth::AuthenticationProvider; use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore}; use std::sync::Arc; +use std::time::Duration; +use tower_http::classify::ServerErrorsFailureClass; +use tower_http::trace::TraceLayer; +use tower_sessions::MemoryStore; +use tracing::Span; use crate::config::NextcloudLoginConfig; @@ -45,13 +51,14 @@ pub fn make_app( ) .route( "/.well-known/caldav", - get(async || Redirect::permanent("/caldav")), + any(async || Redirect::permanent("/caldav")), ) .route( "/.well-known/carddav", - get(async || Redirect::permanent("/caldav")), + any(async || Redirect::permanent("/caldav")), ); + let session_store = MemoryStore::default(); if frontend_config.enabled { router = router .nest( @@ -62,21 +69,42 @@ pub fn make_app( addr_store.clone(), frontend_config, oidc_config, + session_store.clone(), ), ) .route("/", get(async || Redirect::to("/frontend"))); } - router + if nextcloud_login_config.enabled { + router = router.nest( + "/index.php/login/v2", + nextcloud_login_router( + nextcloud_flows_state, + auth_provider.clone(), + session_store.clone(), + ), + ); + } + router.layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request| { + tracing::debug_span!( + "http-request", + status_code = tracing::field::Empty, + otel.name = + tracing::field::display(format!("{} {}", request.method(), request.uri())), + ) + }) + .on_request(|_req: &Request, _span: &Span| {}) + .on_response(|response: &Response, _latency: Duration, span: &Span| { + span.record("status_code", tracing::field::display(response.status())); - // if nextcloud_login_config.enabled { - // app = app.configure(|cfg| { - // configure_nextcloud_login( - // cfg, - // nextcloud_flows_state, - // auth_provider.clone(), - // frontend_config.secret_key, - // ) - // }); - // } + tracing::debug!("response generated") + }) + .on_failure( + |_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| { + tracing::error!("something went wrong") + }, + ), + ) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d0c49ef..ab03f1b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,7 +3,7 @@ use clap::{Parser, ValueEnum}; use password_hash::PasswordHasher; use pbkdf2::Params; use rand::rngs::OsRng; -use rustical_frontend::{FrontendConfig, generate_frontend_secret}; +use rustical_frontend::FrontendConfig; use crate::config::{ Config, DataStoreConfig, DavPushConfig, HttpConfig, SqliteDataStoreConfig, TracingConfig, @@ -23,7 +23,6 @@ pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> { }), tracing: TracingConfig::default(), frontend: FrontendConfig { - secret_key: generate_frontend_secret(), enabled: true, allow_password_login: true, }, diff --git a/src/main.rs b/src/main.rs index fc533bd..08c26ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ use crate::config::Config; use anyhow::Result; use app::make_app; +use axum::ServiceExt; +use axum::extract::Request; use clap::{Parser, Subcommand}; use commands::principals::{PrincipalsArgs, cmd_principals}; use commands::{cmd_gen_config, cmd_pwhash}; @@ -18,6 +20,8 @@ use rustical_store_sqlite::{SqliteStore, create_db_pool}; use setup_tracing::setup_tracing; use std::sync::Arc; use tokio::sync::mpsc::Receiver; +use tower::Layer; +use tower_http::normalize_path::NormalizePathLayer; mod app; mod commands; @@ -113,6 +117,9 @@ async fn main() -> Result<()> { config.nextcloud_login.clone(), nextcloud_flows.clone(), ); + let app = ServiceExt::::into_make_service( + NormalizePathLayer::trim_trailing_slash().layer(app), + ); let listener = tokio::net::TcpListener::bind(&format!( "{}:{}",