Implement Nextcloud login flow

This commit is contained in:
Lennart
2025-02-09 22:14:55 +01:00
parent 1d103ea312
commit 23007a3bda
12 changed files with 296 additions and 5 deletions

23
Cargo.lock generated
View File

@@ -934,6 +934,20 @@ dependencies = [
"syn", "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]] [[package]]
name = "der" name = "der"
version = "0.7.9" version = "0.7.9"
@@ -1312,6 +1326,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.2" version = "0.15.2"
@@ -2745,6 +2765,7 @@ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
"clap", "clap",
"dashmap",
"opentelemetry", "opentelemetry",
"opentelemetry-otlp", "opentelemetry-otlp",
"opentelemetry-semantic-conventions", "opentelemetry-semantic-conventions",
@@ -2769,6 +2790,7 @@ dependencies = [
"tracing-actix-web", "tracing-actix-web",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"uuid",
] ]
[[package]] [[package]]
@@ -2876,6 +2898,7 @@ dependencies = [
"lazy_static", "lazy_static",
"password-auth", "password-auth",
"pbkdf2", "pbkdf2",
"rand",
"regex", "regex",
"rstest", "rstest",
"rstest_reuse", "rstest_reuse",

View File

@@ -114,6 +114,7 @@ reqwest = { version = "0.12", features = [
"charset", "charset",
"http2", "http2",
], default-features = false } ], default-features = false }
dashmap = "6.1"
[dependencies] [dependencies]
rustical_store = { workspace = true } rustical_store = { workspace = true }
@@ -131,6 +132,8 @@ clap = { version = "4.5", features = ["derive", "env"] }
sqlx = { workspace = true } sqlx = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tracing-actix-web = { workspace = true } tracing-actix-web = { workspace = true }
uuid.workspace = true
dashmap.workspace = true
opentelemetry = { version = "0.27", optional = true } opentelemetry = { version = "0.27", optional = true }
opentelemetry-otlp = { version = "0.27", optional = true } opentelemetry-otlp = { version = "0.27", optional = true }
@@ -139,7 +142,6 @@ opentelemetry_sdk = { version = "0.27", features = [
], optional = true } ], optional = true }
opentelemetry-semantic-conventions = { version = "0.27", optional = true } opentelemetry-semantic-conventions = { version = "0.27", optional = true }
tracing-opentelemetry = { version = "0.28", optional = true } tracing-opentelemetry = { version = "0.28", optional = true }
tracing-subscriber = { version = "0.3", features = [ tracing-subscriber = { version = "0.3", features = [
"env-filter", "env-filter",
"fmt", "fmt",

View File

@@ -14,6 +14,7 @@ a CalDAV/CardDAV server
- lightweight (the container image contains only one binary) - 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) - adequately fast (I'd say blazingly fast™ :fire: if I did the benchmarks to back that claim up)
- deleted calendars are recoverable - deleted calendars are recoverable
- Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token)
## Installation ## Installation

View File

@@ -27,6 +27,7 @@ derive_more = { workspace = true }
rustical_xml.workspace = true rustical_xml.workspace = true
toml.workspace = true toml.workspace = true
tokio.workspace = true tokio.workspace = true
rand.workspace = true
[dev-dependencies] [dev-dependencies]
rstest = { workspace = true } rstest = { workspace = true }

View File

@@ -8,6 +8,7 @@ use async_trait::async_trait;
pub trait AuthenticationProvider: 'static { pub trait AuthenticationProvider: 'static {
async fn get_principal(&self, id: &str) -> Result<Option<User>, crate::Error>; async fn get_principal(&self, id: &str) -> Result<Option<User>, crate::Error>;
async fn validate_user_token(&self, user_id: &str, token: &str) -> Result<Option<User>, Error>; async fn validate_user_token(&self, user_id: &str, token: &str) -> Result<Option<User>, Error>;
async fn add_app_token(&self, user_id: &str, name: String, token: String) -> Result<(), Error>;
} }
pub use middleware::AuthenticationMiddleware; pub use middleware::AuthenticationMiddleware;

View File

@@ -1,8 +1,14 @@
use super::AuthenticationProvider; use super::{user::AppToken, AuthenticationProvider};
use crate::{auth::User, error::Error}; use crate::{auth::User, error::Error};
use anyhow::anyhow;
use async_trait::async_trait; use async_trait::async_trait;
use password_hash::PasswordHasher;
use pbkdf2::{
password_hash::{self, rand_core::OsRng, SaltString},
Params,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, io}; use std::{collections::HashMap, fs, io, ops::Deref};
use tokio::sync::RwLock; use tokio::sync::RwLock;
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
@@ -18,6 +24,7 @@ pub struct TomlUserStoreConfig {
#[derive(Debug)] #[derive(Debug)]
pub struct TomlPrincipalStore { pub struct TomlPrincipalStore {
pub principals: RwLock<HashMap<String, User>>, pub principals: RwLock<HashMap<String, User>>,
config: TomlUserStoreConfig,
} }
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
@@ -35,8 +42,21 @@ impl TomlPrincipalStore {
principals: RwLock::new(HashMap::from_iter( principals: RwLock::new(HashMap::from_iter(
principals.into_iter().map(|user| (user.id.clone(), user)), principals.into_iter().map(|user| (user.id.clone(), user)),
)), )),
config,
}) })
} }
fn save(&self, principals: &HashMap<String, User>) -> 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] #[async_trait]
@@ -70,4 +90,32 @@ impl AuthenticationProvider for TomlPrincipalStore {
Ok(None) 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)
}
}
} }

View File

@@ -1,7 +1,6 @@
use actix_web::{http::StatusCode, ResponseError}; use actix_web::{http::StatusCode, ResponseError};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Not found")] #[error("Not found")]
NotFound, NotFound,
@@ -15,6 +14,12 @@ pub enum Error {
#[error("Read-only")] #[error("Read-only")]
ReadOnly, ReadOnly,
#[error("Error generating password hash")]
PasswordHash,
#[error(transparent)]
IO(#[from] std::io::Error),
#[error(transparent)] #[error(transparent)]
ParserError(#[from] ical::parser::ParserError), ParserError(#[from] ical::parser::ParserError),

View File

@@ -10,12 +10,17 @@ use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore};
use std::sync::Arc; use std::sync::Arc;
use tracing_actix_web::TracingLogger; use tracing_actix_web::TracingLogger;
use crate::config::NextcloudLoginConfig;
use crate::nextcloud_login::{configure_nextcloud_login, NextcloudFlows};
pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>( pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
addr_store: Arc<AS>, addr_store: Arc<AS>,
cal_store: Arc<CS>, cal_store: Arc<CS>,
subscription_store: Arc<S>, subscription_store: Arc<S>,
auth_provider: Arc<impl AuthenticationProvider>, auth_provider: Arc<impl AuthenticationProvider>,
frontend_config: FrontendConfig, frontend_config: FrontendConfig,
nextcloud_login_config: NextcloudLoginConfig,
nextcloud_flows_state: Arc<NextcloudFlows>,
) -> App< ) -> App<
impl ServiceFactory< impl ServiceFactory<
ServiceRequest, ServiceRequest,
@@ -59,5 +64,10 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
})) }))
.service(web::redirect("/", "/frontend").see_other()); .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 app
} }

View File

@@ -37,6 +37,7 @@ pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> {
enabled: true, enabled: true,
}, },
dav_push: DavPushConfig::default(), dav_push: DavPushConfig::default(),
nextcloud_login: Default::default(),
}; };
let generated_config = toml::to_string(&config)?; let generated_config = toml::to_string(&config)?;
println!("{generated_config}"); println!("{generated_config}");

View File

@@ -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)] #[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Config { pub struct Config {
@@ -75,4 +87,6 @@ pub struct Config {
pub tracing: TracingConfig, pub tracing: TracingConfig,
#[serde(default)] #[serde(default)]
pub dav_push: DavPushConfig, pub dav_push: DavPushConfig,
#[serde(default)]
pub nextcloud_login: NextcloudLoginConfig,
} }

View File

@@ -6,6 +6,7 @@ use app::make_app;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use commands::{cmd_gen_config, cmd_pwhash}; use commands::{cmd_gen_config, cmd_pwhash};
use config::{DataStoreConfig, SqliteDataStoreConfig}; use config::{DataStoreConfig, SqliteDataStoreConfig};
use nextcloud_login::NextcloudFlows;
use rustical_dav::push::push_notifier; use rustical_dav::push::push_notifier;
use rustical_store::auth::TomlPrincipalStore; use rustical_store::auth::TomlPrincipalStore;
use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore}; use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore};
@@ -20,6 +21,7 @@ use tokio::sync::mpsc::Receiver;
mod app; mod app;
mod commands; mod commands;
mod config; mod config;
mod nextcloud_login;
mod setup_tracing; mod setup_tracing;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -94,6 +96,8 @@ async fn main() -> Result<()> {
config::AuthConfig::Toml(config) => Arc::new(TomlPrincipalStore::new(config)?), config::AuthConfig::Toml(config) => Arc::new(TomlPrincipalStore::new(config)?),
}; };
let nextcloud_flows = Arc::new(NextcloudFlows::default());
HttpServer::new(move || { HttpServer::new(move || {
make_app( make_app(
addr_store.clone(), addr_store.clone(),
@@ -101,6 +105,8 @@ async fn main() -> Result<()> {
subscription_store.clone(), subscription_store.clone(),
user_store.clone(), user_store.clone(),
config.frontend.clone(), config.frontend.clone(),
config.nextcloud_login.clone(),
nextcloud_flows.clone(),
) )
}) })
.bind((config.http.host, config.http.port))? .bind((config.http.host, config.http.port))?
@@ -117,8 +123,12 @@ async fn main() -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { 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 actix_web::{http::StatusCode, test::TestRequest};
use anyhow::anyhow;
use async_trait::async_trait; use async_trait::async_trait;
use rustical_frontend::FrontendConfig; use rustical_frontend::FrontendConfig;
use rustical_store::auth::AuthenticationProvider; use rustical_store::auth::AuthenticationProvider;
@@ -143,6 +153,15 @@ mod tests {
) -> Result<Option<rustical_store::auth::User>, rustical_store::Error> { ) -> Result<Option<rustical_store::auth::User>, rustical_store::Error> {
Err(rustical_store::Error::NotFound) 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] #[tokio::test]
@@ -167,6 +186,8 @@ mod tests {
enabled: false, enabled: false,
secret_key: generate_frontend_secret(), secret_key: generate_frontend_secret(),
}, },
NextcloudLoginConfig { enabled: false },
Arc::new(NextcloudFlows::default()),
); );
let app = actix_web::test::init_service(app).await; let app = actix_web::test::init_service(app).await;
let req = TestRequest::get().uri("/").to_request(); let req = TestRequest::get().uri("/").to_request();

164
src/nextcloud_login.rs Normal file
View File

@@ -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<String, String>,
completed_flows: DashMap<String, NextcloudSuccessResponse>,
}
#[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<NextcloudFlows>,
) -> Json<NextcloudLoginResponse> {
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<AP: AuthenticationProvider>(
state: Data<NextcloudFlows>,
form: Form<NextcloudPollForm>,
path: Path<String>,
auth_provider: Data<AP>,
req: HttpRequest,
) -> Result<HttpResponse, rustical_store::Error> {
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<NextcloudFlows>,
path: Path<String>,
req: HttpRequest,
) -> Result<impl Responder, rustical_store::Error> {
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!(
"<!Doctype html><html><body><h1>Hello {}!</h1><p>Login completed, you may close this page.</p></body></html>",
user.displayname.unwrap_or(user.id)
)))
}
pub fn configure_nextcloud_login<AP: AuthenticationProvider>(
cfg: &mut ServiceConfig,
nextcloud_flows_state: Arc<NextcloudFlows>,
auth_provider: Arc<AP>,
) {
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::<AP>),
)
.service(
web::resource("/login/v2/flow/{flow}")
.name("nc_login_flow")
.get(get_nextcloud_flow),
),
);
}