mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-13 22:52:22 +00:00
Fix Nextcloud login flaws
This commit is contained in:
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -2780,6 +2780,7 @@ dependencies = [
|
|||||||
"rustical_carddav",
|
"rustical_carddav",
|
||||||
"rustical_dav",
|
"rustical_dav",
|
||||||
"rustical_frontend",
|
"rustical_frontend",
|
||||||
|
"rustical_nextcloud_login",
|
||||||
"rustical_store",
|
"rustical_store",
|
||||||
"rustical_store_sqlite",
|
"rustical_store_sqlite",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2882,6 +2883,28 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "rustical_store"
|
name = "rustical_store"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ rustical_caldav = { path = "./crates/caldav/" }
|
|||||||
rustical_carddav = { path = "./crates/carddav/" }
|
rustical_carddav = { path = "./crates/carddav/" }
|
||||||
rustical_frontend = { path = "./crates/frontend/" }
|
rustical_frontend = { path = "./crates/frontend/" }
|
||||||
rustical_xml = { path = "./crates/xml/" }
|
rustical_xml = { path = "./crates/xml/" }
|
||||||
|
rustical_nextcloud_login = { path = "./crates/nextcloud_login/" }
|
||||||
chrono-tz = "0.10"
|
chrono-tz = "0.10"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
@@ -155,4 +156,5 @@ pbkdf2.workspace = true
|
|||||||
password-hash.workspace = true
|
password-hash.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
rustical_dav.workspace = true
|
rustical_dav.workspace = true
|
||||||
|
rustical_nextcloud_login.workspace = true
|
||||||
quick-xml.workspace = true
|
quick-xml.workspace = true
|
||||||
|
|||||||
25
crates/nextcloud_login/Cargo.toml
Normal file
25
crates/nextcloud_login/Cargo.toml
Normal 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
|
||||||
2
crates/nextcloud_login/askama.toml
Normal file
2
crates/nextcloud_login/askama.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[general]
|
||||||
|
dirs = ["public/templates"]
|
||||||
7
crates/nextcloud_login/public/assets/style.css
Normal file
7
crates/nextcloud_login/public/assets/style.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
17
crates/nextcloud_login/public/templates/layouts/default.html
Normal file
17
crates/nextcloud_login/public/templates/layouts/default.html
Normal 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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
113
crates/nextcloud_login/src/assets.rs
Normal file
113
crates/nextcloud_login/src/assets.rs
Normal 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())),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
229
crates/nextcloud_login/src/lib.rs
Normal file
229
crates/nextcloud_login/src/lib.rs
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ use actix_web::{web, App};
|
|||||||
use rustical_caldav::caldav_service;
|
use rustical_caldav::caldav_service;
|
||||||
use rustical_carddav::carddav_service;
|
use rustical_carddav::carddav_service;
|
||||||
use rustical_frontend::{configure_frontend, FrontendConfig};
|
use rustical_frontend::{configure_frontend, FrontendConfig};
|
||||||
|
use rustical_nextcloud_login::{configure_nextcloud_login, NextcloudFlows};
|
||||||
use rustical_store::auth::AuthenticationProvider;
|
use rustical_store::auth::AuthenticationProvider;
|
||||||
use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore};
|
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::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>,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ 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_nextcloud_login::NextcloudFlows;
|
||||||
use rustical_store::auth::TomlPrincipalStore;
|
use rustical_store::auth::TomlPrincipalStore;
|
||||||
use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore};
|
use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore};
|
||||||
use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore;
|
use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore;
|
||||||
@@ -21,7 +21,6 @@ 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)]
|
||||||
@@ -125,12 +124,13 @@ async fn main() -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::{
|
use crate::{
|
||||||
app::make_app, commands::generate_frontend_secret, config::NextcloudLoginConfig,
|
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 actix_web::{http::StatusCode, test::TestRequest};
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use rustical_frontend::FrontendConfig;
|
use rustical_frontend::FrontendConfig;
|
||||||
|
use rustical_nextcloud_login::NextcloudFlows;
|
||||||
use rustical_store::auth::AuthenticationProvider;
|
use rustical_store::auth::AuthenticationProvider;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user