Fix Nextcloud login flaws

This commit is contained in:
Lennart
2025-02-10 16:42:48 +01:00
parent 7e8a246084
commit 01049bad18
13 changed files with 443 additions and 168 deletions

23
Cargo.lock generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
[general]
dirs = ["public/templates"]

View File

@@ -0,0 +1,7 @@
body {
font-family: sans-serif;
}
* {
box-sizing: border-box;
}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}RustiCal{% endblock %}</title>
<link rel="stylesheet" href="/frontend/assets/style.css" />
{% block imports %}{% endblock %}
</head>
<body>
<div id="app">
{% block content %}<p>Placeholder</p>{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{% extends "layouts/default.html" %}
{% block imports %}
{% endblock %}
{% block content %}
<p>Authorize application to act on your behalf, {{ username }}?</p>
<form method="POST">
<label for="app_name">App name</label>
<input type="text" value="{{ app_name }}" name="app_name"/>
<button type="submit">Authorize</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/default.html" %}
{% block imports %}
{% endblock %}
{% block content %}
<p>Authorized app {{ app_name }}, you may now close this page.</p>
{% endblock %}

View File

@@ -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<E>
where
E: 'static + RustEmbed,
{
_embed: PhantomData<E>,
prefix: String,
}
impl<E> EmbedService<E>
where
E: 'static + RustEmbed,
{
pub fn new(prefix: String) -> Self {
Self {
prefix,
_embed: PhantomData,
}
}
}
impl<E> HttpServiceFactory for EmbedService<E>
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<E> ServiceFactory<ServiceRequest> for EmbedService<E>
where
E: 'static + RustEmbed,
{
type Response = ServiceResponse;
type Error = actix_web::Error;
type Config = ();
type Service = EmbedService<E>;
type InitError = ();
type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
fn new_service(&self, _: ()) -> Self::Future {
let prefix = self.prefix.clone();
Box::pin(async move {
Ok(Self {
prefix,
_embed: PhantomData,
})
})
}
}
impl<E> Service<ServiceRequest> for EmbedService<E>
where
E: 'static + RustEmbed,
{
type Response = ServiceResponse<BoxBody>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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())),
}
})
}
}

View File

@@ -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<Utc>,
token: String,
response: Option<NextcloudSuccessResponse>,
}
#[derive(Debug, Default)]
pub struct NextcloudFlows {
flows: RwLock<HashMap<String, NextcloudFlow>>,
}
#[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();
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<AP: AuthenticationProvider>(
state: Data<NextcloudFlows>,
form: Form<NextcloudPollForm>,
path: Path<String>,
auth_provider: Data<AP>,
req: HttpRequest,
) -> Result<HttpResponse, rustical_store::Error> {
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<NextcloudFlows>,
path: Path<String>,
req: HttpRequest,
) -> Result<impl Responder, rustical_store::Error> {
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<NextcloudFlows>,
path: Path<String>,
req: HttpRequest,
form: Form<NextcloudAuthorizeForm>,
) -> Result<impl Responder, rustical_store::Error> {
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<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(EmbedService::<Assets>::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::<AP>),
)
.service(
web::resource("/login/v2/flow/{flow}")
.name("nc_login_flow")
.get(get_nextcloud_flow)
.post(post_nextcloud_flow),
),
);
}

View File

@@ -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<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
addr_store: Arc<AS>,

View File

@@ -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;

View File

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