From 01049bad18d8e2e1b61f2e5685d14f57ce141bb5 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:42:48 +0100 Subject: [PATCH] Fix Nextcloud login flaws --- Cargo.lock | 23 ++ Cargo.toml | 2 + crates/nextcloud_login/Cargo.toml | 25 ++ crates/nextcloud_login/askama.toml | 2 + .../nextcloud_login/public/assets/style.css | 7 + .../public/templates/layouts/default.html | 17 ++ .../templates/pages/nextcloud_login_form.html | 13 + .../pages/nextcloud_login_success.html | 8 + crates/nextcloud_login/src/assets.rs | 113 +++++++++ crates/nextcloud_login/src/lib.rs | 229 ++++++++++++++++++ src/app.rs | 2 +- src/main.rs | 6 +- src/nextcloud_login.rs | 164 ------------- 13 files changed, 443 insertions(+), 168 deletions(-) create mode 100644 crates/nextcloud_login/Cargo.toml create mode 100644 crates/nextcloud_login/askama.toml create mode 100644 crates/nextcloud_login/public/assets/style.css create mode 100644 crates/nextcloud_login/public/templates/layouts/default.html create mode 100644 crates/nextcloud_login/public/templates/pages/nextcloud_login_form.html create mode 100644 crates/nextcloud_login/public/templates/pages/nextcloud_login_success.html create mode 100644 crates/nextcloud_login/src/assets.rs create mode 100644 crates/nextcloud_login/src/lib.rs delete mode 100644 src/nextcloud_login.rs diff --git a/Cargo.lock b/Cargo.lock index 710db74..c5ae83c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,6 +2780,7 @@ dependencies = [ "rustical_carddav", "rustical_dav", "rustical_frontend", + "rustical_nextcloud_login", "rustical_store", "rustical_store_sqlite", "serde", @@ -2882,6 +2883,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "rustical_nextcloud_login" +version = "0.1.0" +dependencies = [ + "actix-session", + "actix-web", + "askama", + "askama_actix", + "chrono", + "dashmap", + "futures-core", + "hex", + "mime_guess", + "rand", + "rust-embed", + "rustical_store", + "serde", + "thiserror 2.0.11", + "tokio", + "uuid", +] + [[package]] name = "rustical_store" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5bacf2e..3865b80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,7 @@ rustical_caldav = { path = "./crates/caldav/" } rustical_carddav = { path = "./crates/carddav/" } rustical_frontend = { path = "./crates/frontend/" } rustical_xml = { path = "./crates/xml/" } +rustical_nextcloud_login = { path = "./crates/nextcloud_login/" } chrono-tz = "0.10" rand = "0.8" argon2 = "0.5" @@ -155,4 +156,5 @@ pbkdf2.workspace = true password-hash.workspace = true reqwest.workspace = true rustical_dav.workspace = true +rustical_nextcloud_login.workspace = true quick-xml.workspace = true diff --git a/crates/nextcloud_login/Cargo.toml b/crates/nextcloud_login/Cargo.toml new file mode 100644 index 0000000..d642b1c --- /dev/null +++ b/crates/nextcloud_login/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rustical_nextcloud_login" +version.workspace = true +edition.workspace = true +description.workspace = true +repository.workspace = true +publish = false + +[dependencies] +askama.workspace = true +askama_actix = { workspace = true } +actix-session = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +actix-web = { workspace = true } +rustical_store = { workspace = true } +rust-embed.workspace = true +futures-core.workspace = true +hex.workspace = true +mime_guess.workspace = true +rand.workspace = true +dashmap.workspace = true +uuid.workspace = true +chrono.workspace = true diff --git a/crates/nextcloud_login/askama.toml b/crates/nextcloud_login/askama.toml new file mode 100644 index 0000000..893df5b --- /dev/null +++ b/crates/nextcloud_login/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["public/templates"] diff --git a/crates/nextcloud_login/public/assets/style.css b/crates/nextcloud_login/public/assets/style.css new file mode 100644 index 0000000..2876913 --- /dev/null +++ b/crates/nextcloud_login/public/assets/style.css @@ -0,0 +1,7 @@ +body { + font-family: sans-serif; +} + +* { + box-sizing: border-box; +} diff --git a/crates/nextcloud_login/public/templates/layouts/default.html b/crates/nextcloud_login/public/templates/layouts/default.html new file mode 100644 index 0000000..32dd48a --- /dev/null +++ b/crates/nextcloud_login/public/templates/layouts/default.html @@ -0,0 +1,17 @@ + + + + + + + {% block title %}RustiCal{% endblock %} + + {% block imports %}{% endblock %} + + + +
+ {% block content %}

Placeholder

{% endblock %} +
+ + diff --git a/crates/nextcloud_login/public/templates/pages/nextcloud_login_form.html b/crates/nextcloud_login/public/templates/pages/nextcloud_login_form.html new file mode 100644 index 0000000..52c3072 --- /dev/null +++ b/crates/nextcloud_login/public/templates/pages/nextcloud_login_form.html @@ -0,0 +1,13 @@ +{% extends "layouts/default.html" %} + +{% block imports %} +{% endblock %} + +{% block content %} +

Authorize application to act on your behalf, {{ username }}?

+
+ + + +
+{% endblock %} diff --git a/crates/nextcloud_login/public/templates/pages/nextcloud_login_success.html b/crates/nextcloud_login/public/templates/pages/nextcloud_login_success.html new file mode 100644 index 0000000..78bd859 --- /dev/null +++ b/crates/nextcloud_login/public/templates/pages/nextcloud_login_success.html @@ -0,0 +1,8 @@ +{% extends "layouts/default.html" %} + +{% block imports %} +{% endblock %} + +{% block content %} +

Authorized app {{ app_name }}, you may now close this page.

+{% endblock %} diff --git a/crates/nextcloud_login/src/assets.rs b/crates/nextcloud_login/src/assets.rs new file mode 100644 index 0000000..6e99886 --- /dev/null +++ b/crates/nextcloud_login/src/assets.rs @@ -0,0 +1,113 @@ +use std::marker::PhantomData; + +use actix_web::{ + body::BoxBody, + dev::{ + HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest, ServiceResponse, + }, + http::{header, Method}, + HttpResponse, +}; +use futures_core::future::LocalBoxFuture; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "public/assets"] +pub struct Assets; + +pub struct EmbedService +where + E: 'static + RustEmbed, +{ + _embed: PhantomData, + prefix: String, +} + +impl EmbedService +where + E: 'static + RustEmbed, +{ + pub fn new(prefix: String) -> Self { + Self { + prefix, + _embed: PhantomData, + } + } +} + +impl HttpServiceFactory for EmbedService +where + E: 'static + RustEmbed, +{ + fn register(self, config: &mut actix_web::dev::AppService) { + let resource_def = if config.is_root() { + ResourceDef::root_prefix(&self.prefix) + } else { + ResourceDef::prefix(&self.prefix) + }; + config.register_service(resource_def, None, self, None); + } +} + +impl ServiceFactory for EmbedService +where + E: 'static + RustEmbed, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Config = (); + type Service = EmbedService; + type InitError = (); + type Future = LocalBoxFuture<'static, Result>; + + fn new_service(&self, _: ()) -> Self::Future { + let prefix = self.prefix.clone(); + Box::pin(async move { + Ok(Self { + prefix, + _embed: PhantomData, + }) + }) + } +} + +impl Service for EmbedService +where + E: 'static + RustEmbed, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Future = LocalBoxFuture<'static, Result>; + + actix_web::dev::always_ready!(); + + fn call(&self, req: ServiceRequest) -> Self::Future { + Box::pin(async move { + if req.method() != Method::GET && req.method() != Method::HEAD { + return Ok(req.into_response(HttpResponse::MethodNotAllowed())); + } + let path = req.match_info().unprocessed().trim_start_matches('/'); + + match E::get(path) { + Some(file) => { + let data = file.data; + let hash = hex::encode(file.metadata.sha256_hash()); + let mime = mime_guess::from_path(path).first_or_octet_stream(); + + let body = if req.method() == Method::HEAD { + Default::default() + } else { + data + }; + Ok(req.into_response( + HttpResponse::Ok() + .content_type(mime) + .insert_header((header::ETAG, hash)) + .body(body), + )) + } + None => Ok(req.into_response(HttpResponse::NotFound())), + } + }) + } +} diff --git a/crates/nextcloud_login/src/lib.rs b/crates/nextcloud_login/src/lib.rs new file mode 100644 index 0000000..959b12e --- /dev/null +++ b/crates/nextcloud_login/src/lib.rs @@ -0,0 +1,229 @@ +use actix_web::{ + http::header::{self}, + web::{self, Data, Form, Json, Path, ServiceConfig}, + HttpRequest, HttpResponse, Responder, +}; +use askama::Template; +use assets::{Assets, EmbedService}; +use chrono::{DateTime, Duration, Utc}; +use rand::{distributions::Alphanumeric, Rng}; +use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider, User}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::RwLock; +mod assets; + +#[derive(Debug, Clone)] +struct NextcloudFlow { + app_name: String, + created_at: DateTime, + token: String, + response: Option, +} + +#[derive(Debug, Default)] +pub struct NextcloudFlows { + flows: RwLock>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct NextcloudLoginPoll { + token: String, + endpoint: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct NextcloudLoginResponse { + poll: NextcloudLoginPoll, + login: String, +} + +async fn post_nextcloud_login( + 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(); + + let app_name = req + .headers() + .get(header::USER_AGENT) + .map(|val| val.to_str().unwrap_or("Unknown client")) + .unwrap_or("Unknown client"); + + 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, + NextcloudFlow { + app_name: app_name.to_owned(), + created_at: Utc::now(), + token: token.to_owned(), + response: None, + }, + ); + 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_id = path.into_inner(); + let mut flows = state.flows.write().await; + + // Flows must not last longer than 10 minutes + flows.retain(|_, flow| Utc::now() - flow.created_at < Duration::minutes(10)); + + if let Some(flow) = flows.get(&flow_id).cloned() { + if flow.token != form.token { + return Ok(HttpResponse::Unauthorized().body("Unauthorized")); + } + if let Some(response) = &flow.response { + auth_provider + .add_app_token( + &response.login_name, + flow.app_name.to_owned(), + response.app_password.to_owned(), + ) + .await?; + flows.remove(&flow_id); + Ok(Json(response).respond_to(&req).map_into_boxed_body()) + } else { + // Not done yet, re-insert flow + Ok(HttpResponse::NotFound().finish()) + } + } else { + Ok(HttpResponse::Unauthorized().body("Unauthorized")) + } +} + +fn generate_app_token() -> String { + rand::thread_rng() + .sample_iter(Alphanumeric) + .map(char::from) + .take(64) + .collect() +} + +#[derive(Template)] +#[template(path = "pages/nextcloud_login_form.html")] +struct NextcloudLoginPage { + username: String, + app_name: String, +} + +async fn get_nextcloud_flow( + user: User, + state: Data, + path: Path, + req: HttpRequest, +) -> Result { + let flow_id = path.into_inner(); + if let Some(flow) = state.flows.read().await.get(&flow_id) { + Ok(NextcloudLoginPage { + username: user.displayname.unwrap_or(user.id), + app_name: flow.app_name.to_owned(), + } + .respond_to(&req)) + } else { + Ok(HttpResponse::NotFound().body("Login flow not found")) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct NextcloudAuthorizeForm { + app_name: String, +} + +#[derive(Template)] +#[template(path = "pages/nextcloud_login_success.html")] +struct NextcloudLoginSuccessPage { + app_name: String, +} + +async fn post_nextcloud_flow( + user: User, + state: Data, + path: Path, + req: HttpRequest, + form: Form, +) -> Result { + let flow_id = path.into_inner(); + if let Some(flow) = state.flows.write().await.get_mut(&flow_id) { + flow.app_name = form.into_inner().app_name; + flow.response = Some(NextcloudSuccessResponse { + server: req.full_url().origin().unicode_serialization(), + login_name: user.id.to_owned(), + app_password: generate_app_token(), + }); + Ok(NextcloudLoginSuccessPage { + app_name: flow.app_name.to_owned(), + } + .respond_to(&req)) + } else { + Ok(HttpResponse::NotFound().body("Login flow not found")) + } +} + +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(EmbedService::::new("/assets".to_owned())) + .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) + .post(post_nextcloud_flow), + ), + ); +} diff --git a/src/app.rs b/src/app.rs index 32020ae..cce3157 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,13 +5,13 @@ use actix_web::{web, App}; use rustical_caldav::caldav_service; use rustical_carddav::carddav_service; use rustical_frontend::{configure_frontend, FrontendConfig}; +use rustical_nextcloud_login::{configure_nextcloud_login, NextcloudFlows}; use rustical_store::auth::AuthenticationProvider; 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, diff --git a/src/main.rs b/src/main.rs index a2d5cd8..4b6a541 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ 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_nextcloud_login::NextcloudFlows; use rustical_store::auth::TomlPrincipalStore; use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore}; use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore; @@ -21,7 +21,6 @@ use tokio::sync::mpsc::Receiver; mod app; mod commands; mod config; -mod nextcloud_login; mod setup_tracing; #[derive(Parser, Debug)] @@ -125,12 +124,13 @@ async fn main() -> Result<()> { mod tests { use crate::{ app::make_app, commands::generate_frontend_secret, config::NextcloudLoginConfig, - get_data_stores, nextcloud_login::NextcloudFlows, + get_data_stores, }; use actix_web::{http::StatusCode, test::TestRequest}; use anyhow::anyhow; use async_trait::async_trait; use rustical_frontend::FrontendConfig; + use rustical_nextcloud_login::NextcloudFlows; use rustical_store::auth::AuthenticationProvider; use std::sync::Arc; diff --git a/src/nextcloud_login.rs b/src/nextcloud_login.rs deleted file mode 100644 index fefb710..0000000 --- a/src/nextcloud_login.rs +++ /dev/null @@ -1,164 +0,0 @@ -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), - ), - ); -}