From 23007a3bda62e06511312bea66bc85a2452d9544 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 9 Feb 2025 22:14:55 +0100 Subject: [PATCH] Implement Nextcloud login flow --- Cargo.lock | 23 ++++ Cargo.toml | 4 +- README.md | 1 + crates/store/Cargo.toml | 1 + crates/store/src/auth/mod.rs | 1 + crates/store/src/auth/toml_user_store.rs | 52 ++++++- crates/store/src/error.rs | 7 +- src/app.rs | 10 ++ src/commands/mod.rs | 1 + src/config.rs | 14 ++ src/main.rs | 23 +++- src/nextcloud_login.rs | 164 +++++++++++++++++++++++ 12 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 src/nextcloud_login.rs diff --git a/Cargo.lock b/Cargo.lock index 8c68902..710db74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,6 +934,20 @@ 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.9" @@ -1312,6 +1326,12 @@ 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.2" @@ -2745,6 +2765,7 @@ dependencies = [ "argon2", "async-trait", "clap", + "dashmap", "opentelemetry", "opentelemetry-otlp", "opentelemetry-semantic-conventions", @@ -2769,6 +2790,7 @@ dependencies = [ "tracing-actix-web", "tracing-opentelemetry", "tracing-subscriber", + "uuid", ] [[package]] @@ -2876,6 +2898,7 @@ dependencies = [ "lazy_static", "password-auth", "pbkdf2", + "rand", "regex", "rstest", "rstest_reuse", diff --git a/Cargo.toml b/Cargo.toml index 1e7d245..5bacf2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,7 @@ reqwest = { version = "0.12", features = [ "charset", "http2", ], default-features = false } +dashmap = "6.1" [dependencies] rustical_store = { workspace = true } @@ -131,6 +132,8 @@ clap = { version = "4.5", features = ["derive", "env"] } sqlx = { workspace = true } async-trait = { workspace = true } tracing-actix-web = { workspace = true } +uuid.workspace = true +dashmap.workspace = true opentelemetry = { version = "0.27", optional = true } opentelemetry-otlp = { version = "0.27", optional = true } @@ -139,7 +142,6 @@ opentelemetry_sdk = { version = "0.27", features = [ ], optional = true } opentelemetry-semantic-conventions = { version = "0.27", optional = true } tracing-opentelemetry = { version = "0.28", optional = true } - tracing-subscriber = { version = "0.3", features = [ "env-filter", "fmt", diff --git a/README.md b/README.md index 4376cf7..e362e45 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ a CalDAV/CardDAV server - lightweight (the container image contains only one binary) - adequately fast (I'd say blazingly fastâ„¢ :fire: if I did the benchmarks to back that claim up) - deleted calendars are recoverable +- Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token) ## Installation diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 8f6992f..ab2651b 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -27,6 +27,7 @@ derive_more = { workspace = true } rustical_xml.workspace = true toml.workspace = true tokio.workspace = true +rand.workspace = true [dev-dependencies] rstest = { workspace = true } diff --git a/crates/store/src/auth/mod.rs b/crates/store/src/auth/mod.rs index a68a2d6..3b1774c 100644 --- a/crates/store/src/auth/mod.rs +++ b/crates/store/src/auth/mod.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; pub trait AuthenticationProvider: 'static { async fn get_principal(&self, id: &str) -> Result, crate::Error>; async fn validate_user_token(&self, user_id: &str, token: &str) -> Result, Error>; + async fn add_app_token(&self, user_id: &str, name: String, token: String) -> 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 e849b4a..a790865 100644 --- a/crates/store/src/auth/toml_user_store.rs +++ b/crates/store/src/auth/toml_user_store.rs @@ -1,8 +1,14 @@ -use super::AuthenticationProvider; +use super::{user::AppToken, AuthenticationProvider}; use crate::{auth::User, error::Error}; +use anyhow::anyhow; use async_trait::async_trait; +use password_hash::PasswordHasher; +use pbkdf2::{ + password_hash::{self, rand_core::OsRng, SaltString}, + Params, +}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fs, io}; +use std::{collections::HashMap, fs, io, ops::Deref}; use tokio::sync::RwLock; #[derive(Debug, Clone, Deserialize, Serialize, Default)] @@ -18,6 +24,7 @@ pub struct TomlUserStoreConfig { #[derive(Debug)] pub struct TomlPrincipalStore { pub principals: RwLock>, + config: TomlUserStoreConfig, } #[derive(thiserror::Error, Debug)] @@ -35,8 +42,21 @@ impl TomlPrincipalStore { principals: RwLock::new(HashMap::from_iter( principals.into_iter().map(|user| (user.id.clone(), user)), )), + config, }) } + + fn save(&self, principals: &HashMap) -> Result<(), Error> { + let out = toml::to_string_pretty(&TomlDataModel { + principals: principals + .iter() + .map(|(_, value)| value.to_owned()) + .collect(), + }) + .map_err(|_| anyhow!("Error saving principal database"))?; + fs::write(&self.config.path, out)?; + Ok(()) + } } #[async_trait] @@ -70,4 +90,32 @@ impl AuthenticationProvider for TomlPrincipalStore { Ok(None) } + + async fn add_app_token(&self, user_id: &str, name: String, token: String) -> Result<(), Error> { + let mut principals = self.principals.write().await; + if let Some(principal) = principals.get_mut(user_id) { + let salt = SaltString::generate(OsRng); + let token_hash = pbkdf2::Pbkdf2 + .hash_password_customized( + token.as_bytes(), + None, + None, + Params { + rounds: 1000, + ..Default::default() + }, + &salt, + ) + .map_err(|_| Error::PasswordHash)? + .to_string(); + principal.app_tokens.push(AppToken { + name, + token: token_hash, + }); + self.save(principals.deref())?; + Ok(()) + } else { + Err(Error::NotFound) + } + } } diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs index 445bd54..7ba1864 100644 --- a/crates/store/src/error.rs +++ b/crates/store/src/error.rs @@ -1,7 +1,6 @@ use actix_web::{http::StatusCode, ResponseError}; #[derive(Debug, thiserror::Error)] - pub enum Error { #[error("Not found")] NotFound, @@ -15,6 +14,12 @@ pub enum Error { #[error("Read-only")] ReadOnly, + #[error("Error generating password hash")] + PasswordHash, + + #[error(transparent)] + IO(#[from] std::io::Error), + #[error(transparent)] ParserError(#[from] ical::parser::ParserError), diff --git a/src/app.rs b/src/app.rs index 8a46f2e..32020ae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,12 +10,17 @@ use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore}; use std::sync::Arc; use tracing_actix_web::TracingLogger; +use crate::config::NextcloudLoginConfig; +use crate::nextcloud_login::{configure_nextcloud_login, NextcloudFlows}; + pub fn make_app( addr_store: Arc, cal_store: Arc, subscription_store: Arc, auth_provider: Arc, frontend_config: FrontendConfig, + nextcloud_login_config: NextcloudLoginConfig, + nextcloud_flows_state: Arc, ) -> App< impl ServiceFactory< ServiceRequest, @@ -59,5 +64,10 @@ pub fn make_app( })) .service(web::redirect("/", "/frontend").see_other()); } + if nextcloud_login_config.enabled { + app = app.configure(|cfg| { + configure_nextcloud_login(cfg, nextcloud_flows_state, auth_provider.clone()) + }); + } app } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e1b4bdc..b89a150 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -37,6 +37,7 @@ pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> { enabled: true, }, dav_push: DavPushConfig::default(), + nextcloud_login: Default::default(), }; let generated_config = toml::to_string(&config)?; println!("{generated_config}"); diff --git a/src/config.rs b/src/config.rs index 75ac489..f4cb4c8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,6 +63,18 @@ impl Default for DavPushConfig { } } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields, default)] +pub struct NextcloudLoginConfig { + pub enabled: bool, +} + +impl Default for NextcloudLoginConfig { + fn default() -> Self { + Self { enabled: true } + } +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Config { @@ -75,4 +87,6 @@ pub struct Config { pub tracing: TracingConfig, #[serde(default)] pub dav_push: DavPushConfig, + #[serde(default)] + pub nextcloud_login: NextcloudLoginConfig, } diff --git a/src/main.rs b/src/main.rs index 40db224..a2d5cd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use app::make_app; use clap::{Parser, Subcommand}; use commands::{cmd_gen_config, cmd_pwhash}; use config::{DataStoreConfig, SqliteDataStoreConfig}; +use nextcloud_login::NextcloudFlows; use rustical_dav::push::push_notifier; use rustical_store::auth::TomlPrincipalStore; use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore}; @@ -20,6 +21,7 @@ use tokio::sync::mpsc::Receiver; mod app; mod commands; mod config; +mod nextcloud_login; mod setup_tracing; #[derive(Parser, Debug)] @@ -94,6 +96,8 @@ async fn main() -> Result<()> { config::AuthConfig::Toml(config) => Arc::new(TomlPrincipalStore::new(config)?), }; + let nextcloud_flows = Arc::new(NextcloudFlows::default()); + HttpServer::new(move || { make_app( addr_store.clone(), @@ -101,6 +105,8 @@ async fn main() -> Result<()> { subscription_store.clone(), user_store.clone(), config.frontend.clone(), + config.nextcloud_login.clone(), + nextcloud_flows.clone(), ) }) .bind((config.http.host, config.http.port))? @@ -117,8 +123,12 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { - use crate::{app::make_app, commands::generate_frontend_secret, get_data_stores}; + use crate::{ + app::make_app, commands::generate_frontend_secret, config::NextcloudLoginConfig, + get_data_stores, nextcloud_login::NextcloudFlows, + }; use actix_web::{http::StatusCode, test::TestRequest}; + use anyhow::anyhow; use async_trait::async_trait; use rustical_frontend::FrontendConfig; use rustical_store::auth::AuthenticationProvider; @@ -143,6 +153,15 @@ mod tests { ) -> Result, rustical_store::Error> { Err(rustical_store::Error::NotFound) } + + async fn add_app_token( + &self, + user_id: &str, + name: String, + token: String, + ) -> Result<(), rustical_store::Error> { + Err(rustical_store::Error::Other(anyhow!("Not implemented"))) + } } #[tokio::test] @@ -167,6 +186,8 @@ mod tests { enabled: false, secret_key: generate_frontend_secret(), }, + NextcloudLoginConfig { enabled: false }, + Arc::new(NextcloudFlows::default()), ); let app = actix_web::test::init_service(app).await; let req = TestRequest::get().uri("/").to_request(); diff --git a/src/nextcloud_login.rs b/src/nextcloud_login.rs new file mode 100644 index 0000000..fefb710 --- /dev/null +++ b/src/nextcloud_login.rs @@ -0,0 +1,164 @@ +use actix_web::{ + http::header::{self, ContentType}, + web::{self, Data, Form, Json, Path, ServiceConfig}, + HttpRequest, HttpResponse, Responder, +}; +use dashmap::DashMap; +use rand::{distributions::Alphanumeric, Rng}; +use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider, User}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Debug, Default)] +pub struct NextcloudFlows { + tokens: DashMap, + completed_flows: DashMap, +} + +#[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( + req: HttpRequest, + state: Data, +) -> 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(); + state.tokens.insert(flow_id, token.to_owned()); + Json(NextcloudLoginResponse { + login: flow_url.to_string(), + poll: NextcloudLoginPoll { + token, + endpoint: poll_url.to_string(), + }, + }) +} + +#[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 { + token: String, +} + +async fn post_nextcloud_poll( + state: Data, + form: Form, + path: Path, + auth_provider: Data, + req: HttpRequest, +) -> Result { + let flow = path.into_inner(); + match state.tokens.get(&flow) { + None => return Ok(HttpResponse::Unauthorized().finish()), + Some(dash_ref) if &form.token != dash_ref.value() => { + return Ok(HttpResponse::Unauthorized().finish()) + } + _ => {} + }; + + let app_name = req + .headers() + .get(header::USER_AGENT) + .map(|val| val.to_str().unwrap_or("Client")) + .unwrap_or("Client"); + + if let Some((_, response)) = state.completed_flows.remove(&flow) { + auth_provider + .add_app_token( + &response.login_name, + app_name.to_owned(), + response.app_password.to_owned(), + ) + .await?; + state.tokens.remove(&flow); + Ok(Json(response).respond_to(&req).map_into_boxed_body()) + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + +fn generate_app_token() -> String { + rand::thread_rng() + .sample_iter(Alphanumeric) + .map(char::from) + .take(64) + .collect() +} + +async fn get_nextcloud_flow( + user: User, + state: Data, + path: Path, + req: HttpRequest, +) -> Result { + let flow = path.into_inner(); + if !state.tokens.contains_key(&flow) { + return Ok(HttpResponse::NotFound().body("Login flow not found")); + } + + state.completed_flows.insert( + flow, + NextcloudSuccessResponse { + server: req.full_url().origin().unicode_serialization(), + login_name: user.id.to_owned(), + app_password: generate_app_token(), + }, + ); + Ok(HttpResponse::Ok() + .content_type(ContentType::html()) + .body(format!( + "

Hello {}!

Login completed, you may close this page.

", + user.displayname.unwrap_or(user.id) + ))) +} + +pub fn configure_nextcloud_login( + cfg: &mut ServiceConfig, + nextcloud_flows_state: Arc, + auth_provider: Arc, +) { + cfg.service( + web::scope("") + .wrap(AuthenticationMiddleware::new(auth_provider.clone())) + .app_data(Data::from(nextcloud_flows_state)) + .app_data(Data::from(auth_provider.clone())) + .service(web::resource("/index.php/login/v2").post(post_nextcloud_login)) + .service( + web::resource("/login/v2/poll/{flow}") + .name("nc_login_poll") + .post(post_nextcloud_poll::), + ) + .service( + web::resource("/login/v2/flow/{flow}") + .name("nc_login_flow") + .get(get_nextcloud_flow), + ), + ); +}