mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-13 20:32:48 +00:00
Implement Nextcloud login flow
This commit is contained in:
10
src/app.rs
10
src/app.rs
@@ -10,12 +10,17 @@ 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>,
|
||||
cal_store: Arc<CS>,
|
||||
subscription_store: Arc<S>,
|
||||
auth_provider: Arc<impl AuthenticationProvider>,
|
||||
frontend_config: FrontendConfig,
|
||||
nextcloud_login_config: NextcloudLoginConfig,
|
||||
nextcloud_flows_state: Arc<NextcloudFlows>,
|
||||
) -> App<
|
||||
impl ServiceFactory<
|
||||
ServiceRequest,
|
||||
@@ -59,5 +64,10 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
|
||||
}))
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> {
|
||||
enabled: true,
|
||||
},
|
||||
dav_push: DavPushConfig::default(),
|
||||
nextcloud_login: Default::default(),
|
||||
};
|
||||
let generated_config = toml::to_string(&config)?;
|
||||
println!("{generated_config}");
|
||||
|
||||
@@ -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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
@@ -75,4 +87,6 @@ pub struct Config {
|
||||
pub tracing: TracingConfig,
|
||||
#[serde(default)]
|
||||
pub dav_push: DavPushConfig,
|
||||
#[serde(default)]
|
||||
pub nextcloud_login: NextcloudLoginConfig,
|
||||
}
|
||||
|
||||
23
src/main.rs
23
src/main.rs
@@ -6,6 +6,7 @@ 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_store::auth::TomlPrincipalStore;
|
||||
use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore};
|
||||
@@ -20,6 +21,7 @@ use tokio::sync::mpsc::Receiver;
|
||||
mod app;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod nextcloud_login;
|
||||
mod setup_tracing;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -94,6 +96,8 @@ async fn main() -> Result<()> {
|
||||
config::AuthConfig::Toml(config) => Arc::new(TomlPrincipalStore::new(config)?),
|
||||
};
|
||||
|
||||
let nextcloud_flows = Arc::new(NextcloudFlows::default());
|
||||
|
||||
HttpServer::new(move || {
|
||||
make_app(
|
||||
addr_store.clone(),
|
||||
@@ -101,6 +105,8 @@ async fn main() -> Result<()> {
|
||||
subscription_store.clone(),
|
||||
user_store.clone(),
|
||||
config.frontend.clone(),
|
||||
config.nextcloud_login.clone(),
|
||||
nextcloud_flows.clone(),
|
||||
)
|
||||
})
|
||||
.bind((config.http.host, config.http.port))?
|
||||
@@ -117,8 +123,12 @@ async fn main() -> Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
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 anyhow::anyhow;
|
||||
use async_trait::async_trait;
|
||||
use rustical_frontend::FrontendConfig;
|
||||
use rustical_store::auth::AuthenticationProvider;
|
||||
@@ -143,6 +153,15 @@ mod tests {
|
||||
) -> Result<Option<rustical_store::auth::User>, rustical_store::Error> {
|
||||
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]
|
||||
@@ -167,6 +186,8 @@ mod tests {
|
||||
enabled: false,
|
||||
secret_key: generate_frontend_secret(),
|
||||
},
|
||||
NextcloudLoginConfig { enabled: false },
|
||||
Arc::new(NextcloudFlows::default()),
|
||||
);
|
||||
let app = actix_web::test::init_service(app).await;
|
||||
let req = TestRequest::get().uri("/").to_request();
|
||||
|
||||
164
src/nextcloud_login.rs
Normal file
164
src/nextcloud_login.rs
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user