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),
- ),
- );
-}