Implement almost all previous features

This commit is contained in:
Lennart
2025-06-08 19:38:33 +02:00
parent 95889e3df1
commit 00eb43f048
41 changed files with 906 additions and 916 deletions

View File

@@ -30,3 +30,4 @@ tracing.workspace = true
rustical_oidc.workspace = true
axum-extra.workspace= true
headers.workspace = true
tower-sessions = "0.14"

View File

@@ -1,14 +1,5 @@
use rand::RngCore;
use serde::{Deserialize, Serialize};
pub fn generate_frontend_secret() -> [u8; 64] {
let mut rng = rand::thread_rng();
let mut secret = [0u8; 64];
rng.fill_bytes(&mut secret);
secret
}
fn default_true() -> bool {
true
}
@@ -16,10 +7,6 @@ fn default_true() -> bool {
#[derive(Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct FrontendConfig {
#[serde(serialize_with = "hex::serde::serialize")]
#[serde(deserialize_with = "hex::serde::deserialize")]
#[serde(default = "generate_frontend_secret")]
pub secret_key: [u8; 64],
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]

View File

@@ -1,357 +1,158 @@
use askama::Template;
// use askama_web::WebTemplate;
// use assets::{Assets, EmbedService};
use async_trait::async_trait;
use axum::{Extension, Router, response::IntoResponse, routing::get};
use http::Uri;
use rand::{Rng, distributions::Alphanumeric};
use rustical_oidc::OidcConfig;
// use routes::{
// addressbook::{route_addressbook, route_addressbook_restore},
// calendar::{route_calendar, route_calendar_restore},
// login::{route_get_login, route_post_login, route_post_logout},
// };
// use rustical_oidc::{OidcConfig, OidcServiceConfig, UserStore, configure_oidc};
use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarStore,
auth::{
AuthenticationMiddleware, AuthenticationProvider, User, middleware::AuthenticationLayer,
user::AppToken,
},
use axum::{
Extension, RequestExt, Router,
body::Body,
extract::{OriginalUri, Request},
middleware::{self, Next},
response::Response,
routing::{get, post},
};
use headers::{ContentType, HeaderMapExt};
use http::{Method, StatusCode};
use rustical_oidc::{OidcConfig, OidcServiceConfig, route_get_oidc_callback, route_post_oidc};
use rustical_store::{
AddressbookStore, CalendarStore,
auth::{AuthenticationProvider, middleware::AuthenticationLayer},
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use tower_sessions::{
Expiry, SessionManagerLayer, SessionStore,
cookie::{SameSite, time::Duration},
};
use url::Url;
mod assets;
mod config;
pub mod nextcloud_login;
mod oidc_user_store;
mod routes;
pub const ROUTE_NAME_HOME: &str = "frontend_home";
pub const ROUTE_USER_NAMED: &str = "frontend_user_named";
pub use config::{FrontendConfig, generate_frontend_secret};
pub use config::FrontendConfig;
use oidc_user_store::OidcUserStore;
use crate::{
assets::{Assets, EmbedService},
routes::login::{route_get_login, route_post_login},
routes::{
addressbook::{route_addressbook, route_addressbook_restore},
app_token::{route_delete_app_token, route_post_app_token},
calendar::{route_calendar, route_calendar_restore},
login::{route_get_login, route_post_login, route_post_logout},
user::{route_get_home, route_root, route_user_named},
},
};
pub fn generate_app_token() -> String {
rand::thread_rng()
.sample_iter(Alphanumeric)
.map(char::from)
.take(64)
.collect()
}
// #[derive(Template, WebTemplate)]
// #[template(path = "pages/user.html")]
// struct UserPage {
// pub user: User,
// pub app_tokens: Vec<AppToken>,
// pub calendars: Vec<Calendar>,
// pub deleted_calendars: Vec<Calendar>,
// pub addressbooks: Vec<Addressbook>,
// pub deleted_addressbooks: Vec<Addressbook>,
// pub is_apple: bool,
// }
//
// async fn route_user_named<CS: CalendarStore, AS: AddressbookStore, AP: AuthenticationProvider>(
// path: Path<String>,
// cal_store: Data<CS>,
// addr_store: Data<AS>,
// auth_provider: Data<AP>,
// user: User,
// req: HttpRequest,
// ) -> impl Responder {
// let user_id = path.into_inner();
// if user_id != user.id {
// return actix_web::HttpResponse::Unauthorized().body("Unauthorized");
// }
//
// let mut calendars = vec![];
// for group in user.memberships() {
// calendars.extend(cal_store.get_calendars(group).await.unwrap());
// }
//
// let mut deleted_calendars = vec![];
// for group in user.memberships() {
// deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap());
// }
//
// let mut addressbooks = vec![];
// for group in user.memberships() {
// addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap());
// }
//
// let mut deleted_addressbooks = vec![];
// for group in user.memberships() {
// deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap());
// }
//
// let is_apple = req
// .headers()
// .get(header::USER_AGENT)
// .and_then(|user_agent| user_agent.to_str().ok())
// .map(|ua| ua.contains("Apple") || ua.contains("Mac OS"))
// .unwrap_or_default();
//
// UserPage {
// app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
// calendars,
// deleted_calendars,
// addressbooks,
// deleted_addressbooks,
// user,
// is_apple,
// }
// .respond_to(&req)
// }
//
// async fn route_get_home(user: User, req: HttpRequest) -> Redirect {
// Redirect::to(
// req.url_for(ROUTE_USER_NAMED, &[user.id])
// .unwrap()
// .to_string(),
// )
// .see_other()
// }
//
// async fn route_root(user: Option<User>, req: HttpRequest) -> impl Responder {
// let redirect_url = match user {
// Some(_) => req.url_for_static(ROUTE_NAME_HOME).unwrap(),
// None => req
// .resource_map()
// .url_for::<[_; 0], String>(&req, "frontend_login", [])
// .unwrap(),
// };
// web::Redirect::to(redirect_url.to_string()).permanent()
// }
//
// #[derive(Template)]
// #[template(path = "apple_configuration/template.xml")]
// pub struct AppleConfig {
// token_name: String,
// account_description: String,
// hostname: String,
// caldav_principal_url: String,
// carddav_principal_url: String,
// user: String,
// token: String,
// caldav_profile_uuid: Uuid,
// carddav_profile_uuid: Uuid,
// plist_uuid: Uuid,
// }
//
// #[derive(Debug, Clone, Deserialize)]
// pub(crate) struct PostAppTokenForm {
// name: String,
// #[serde(default)]
// apple: bool,
// }
//
// async fn route_post_app_token<AP: AuthenticationProvider>(
// user: User,
// auth_provider: Data<AP>,
// path: Path<String>,
// Form(PostAppTokenForm { apple, name }): Form<PostAppTokenForm>,
// req: HttpRequest,
// ) -> Result<HttpResponse, rustical_store::Error> {
// assert!(!name.is_empty());
// assert_eq!(path.into_inner(), user.id);
// let token = generate_app_token();
// auth_provider
// .add_app_token(&user.id, name.to_owned(), token.clone())
// .await?;
// if apple {
// let hostname = req.full_url().host_str().unwrap().to_owned();
// let profile = AppleConfig {
// token_name: name,
// account_description: format!("{}@{}", &user.id, &hostname),
// hostname,
// caldav_principal_url: req
// .url_for("caldav_principal", [&user.id])
// .unwrap()
// .to_string(),
// carddav_principal_url: req
// .url_for("carddav_principal", [&user.id])
// .unwrap()
// .to_string(),
// user: user.id.to_owned(),
// token,
// caldav_profile_uuid: Uuid::new_v4(),
// carddav_profile_uuid: Uuid::new_v4(),
// plist_uuid: Uuid::new_v4(),
// }
// .render()
// .unwrap();
// Ok(HttpResponse::Ok()
// .insert_header(header::ContentDisposition::attachment(format!(
// "rustical-{}.mobileconfig",
// user.id
// )))
// .insert_header((
// header::CONTENT_TYPE,
// "application/x-apple-aspen-config; charset=utf-8",
// ))
// .body(profile))
// } else {
// Ok(HttpResponse::Ok().body(token))
// }
// }
//
// async fn route_delete_app_token<AP: AuthenticationProvider>(
// user: User,
// auth_provider: Data<AP>,
// path: Path<(String, String)>,
// ) -> Result<Redirect, rustical_store::Error> {
// let (path_user, token_id) = path.into_inner();
// assert_eq!(path_user, user.id);
// auth_provider.remove_app_token(&user.id, &token_id).await?;
// Ok(Redirect::to("/frontend/user").see_other())
// }
//
// pub(crate) fn unauthorized_handler<B>(
// res: ServiceResponse<B>,
// ) -> actix_web::Result<ErrorHandlerResponse<B>> {
// let (req, _) = res.into_parts();
// let redirect_uri = req.uri().to_string();
// let mut login_url = req.url_for_static("frontend_login").unwrap();
// login_url
// .query_pairs_mut()
// .append_pair("redirect_uri", &redirect_uri);
// let login_url = login_url.to_string();
//
// let response = HttpResponse::Unauthorized().body(format!(
// r#"<!Doctype html>
// <html>
// <head>
// <meta http-equiv="refresh" content="1; url={login_url}" />
// </head>
// <body>
// Unauthorized, redirecting to <a href="{login_url}">login page</a>
// </body>
// <html>
// "#
// ));
//
// let res = ServiceResponse::new(req, response)
// .map_into_boxed_body()
// .map_into_right_body();
//
// Ok(ErrorHandlerResponse::Response(res))
// }
//
// pub fn session_middleware(frontend_secret: [u8; 64]) -> SessionMiddleware<impl SessionStore> {
// SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&frontend_secret))
// .cookie_secure(true)
// .cookie_same_site(SameSite::Strict)
// .cookie_content_security(CookieContentSecurity::Private)
// .build()
// }
pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: AddressbookStore>(
pub fn frontend_router<
AP: AuthenticationProvider,
CS: CalendarStore,
AS: AddressbookStore,
S: SessionStore + Clone,
>(
auth_provider: Arc<AP>,
cal_store: Arc<CS>,
addr_store: Arc<AS>,
frontend_config: FrontendConfig,
oidc_config: Option<OidcConfig>,
session_store: S,
) -> Router {
let mut router = Router::new().layer(AuthenticationLayer::new(auth_provider.clone()));
let mut router = Router::new();
router = router
.route("/", get(route_root))
.route("/user", get(route_get_home))
.route("/user/{user}", get(route_user_named::<CS, AS, AP>))
// App token management
.route("/user/{user}/app_token", post(route_post_app_token::<AP>))
.route(
// POST because HTML5 forms don't support DELETE method
"/user/{user}/app_token/{id}/delete",
post(route_delete_app_token::<AP>),
)
// Calendar
.route(
"/user/{user}/calendar/{calendar}",
get(route_calendar::<CS>),
)
.route(
"/user/{user}/calendar/{calendar}/restore",
post(route_calendar_restore::<CS>),
)
// Addressbook
.route(
"/user/{user}/addressbook/{addressbook}",
get(route_addressbook::<AS>),
)
.route(
"/user/{user}/addressbook/{addressbook}/restore",
post(route_addressbook_restore::<AS>),
)
.route("/login", get(route_get_login).post(route_post_login::<AP>))
.route_service("/assets/{*file}", EmbedService::<Assets>::new())
.route("/logout", post(route_post_logout))
.route_service("/assets/{*file}", EmbedService::<Assets>::new());
if let Some(oidc_config) = oidc_config.clone() {
router = router
.route("/login/oidc", post(route_post_oidc))
.route(
"/login/oidc/callback",
get(route_get_oidc_callback::<OidcUserStore<AP>>),
)
.layer(Extension(OidcUserStore(auth_provider.clone())))
.layer(Extension(OidcServiceConfig {
default_redirect_path: "/frontend/user",
session_key_user_id: "user",
}))
.layer(Extension(oidc_config));
}
router
.layer(AuthenticationLayer::new(auth_provider.clone()))
.layer(
SessionManagerLayer::new(session_store)
.with_secure(true)
.with_same_site(SameSite::Strict)
.with_expiry(Expiry::OnInactivity(Duration::hours(2))),
)
.layer(Extension(auth_provider.clone()))
.layer(Extension(cal_store.clone()))
.layer(Extension(addr_store.clone()))
.layer(Extension(frontend_config.clone()))
.layer(Extension(oidc_config.clone()));
// .wrap(session_middleware(frontend_config.secret_key))
// .service(web::resource("").route(web::method(Method::GET).to(route_root)))
// .service(
// web::resource("/user")
// .get(route_get_home)
// .name(ROUTE_NAME_HOME),
// )
// .service(
// web::resource("/user/{user}")
// .get(route_user_named::<CS, AS, AP>)
// .name(ROUTE_USER_NAMED),
// )
// // App token management
// .service(web::resource("/user/{user}/app_token").post(route_post_app_token::<AP>))
// .service(
// // POST because HTML5 forms don't support DELETE method
// web::resource("/user/{user}/app_token/{id}/delete").post(route_delete_app_token::<AP>),
// )
// // Calendar
// .service(web::resource("/user/{user}/calendar/{calendar}").get(route_calendar::<CS>))
// .service(
// web::resource("/user/{user}/calendar/{calendar}/restore")
// .post(route_calendar_restore::<CS>),
// )
// // Addressbook
// .service(
// web::resource("/user/{user}/addressbook/{addressbook}").get(route_addressbook::<AS>),
// )
// .service(
// web::resource("/user/{user}/addressbook/{addressbook}/restore")
// .post(route_addressbook_restore::<AS>),
// )
// // Login
// .service(
// web::resource("/login")
// .name("frontend_login")
// .get(route_get_login)
// .post(route_post_login::<AP>),
// )
// .service(
// web::resource("/logout")
// .name("frontend_logout")
// .post(route_post_logout),
// );
// if let Some(oidc_config) = oidc_config {
// scope = scope.service(web::scope("/login/oidc").configure(|cfg| {
// configure_oidc(
// cfg,
// oidc_config,
// OidcServiceConfig {
// default_redirect_route_name: ROUTE_NAME_HOME,
// session_key_user_id: "user",
// },
// Arc::new(OidcUserStore(auth_provider.clone())),
// )
// }));
// }
router
.layer(Extension(oidc_config.clone()))
.layer(middleware::from_fn(unauthorized_handler))
}
async fn unauthorized_handler(mut request: Request, next: Next) -> Response {
let meth = request.method().clone();
let OriginalUri(uri) = request.extract_parts().await.unwrap();
let resp = next.run(request).await;
if resp.status() == StatusCode::UNAUTHORIZED {
// This is a dumb hack since parsed Urls cannot be relative
let mut login_url: Url = "http://github.com/frontend/login".parse().unwrap();
if meth == Method::GET {
login_url
.query_pairs_mut()
.append_pair("redirect_uri", uri.path());
}
let path = login_url.path();
let query = login_url
.query()
.map(|query| format!("?{query}"))
.unwrap_or_default();
let login_url = format!("{path}{query}");
let mut resp = Response::builder().status(StatusCode::UNAUTHORIZED);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::html());
return resp
.body(Body::new(format!(
r#"<!Doctype html>
<html>
<head>
<meta http-equiv="refresh" content="1; url={login_url}" />
</head>
<body>
Unauthorized, redirecting to <a href="{login_url}">login page</a>
</body>
<html>
"#,
)))
.unwrap();
}
resp
}
//
// struct OidcUserStore<AP: AuthenticationProvider>(Arc<AP>);
//
// #[async_trait]
// impl<AP: AuthenticationProvider> UserStore for OidcUserStore<AP> {
// type Error = rustical_store::Error;
//
// async fn user_exists(&self, id: &str) -> Result<bool, Self::Error> {
// Ok(self.0.get_principal(id).await?.is_some())
// }
//
// async fn insert_user(&self, id: &str) -> Result<(), Self::Error> {
// self.0
// .insert_principal(
// User {
// id: id.to_owned(),
// displayname: None,
// principal_type: Default::default(),
// password: None,
// memberships: vec![],
// },
// false,
// )
// .await
// }
// }

View File

@@ -1,11 +1,19 @@
use axum::routing::{get, post};
use axum::{Extension, Router, middleware};
use chrono::{DateTime, Utc};
// use routes::{get_nextcloud_flow, post_nextcloud_flow, post_nextcloud_login, post_nextcloud_poll};
use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider};
use routes::{get_nextcloud_flow, post_nextcloud_flow, post_nextcloud_login, post_nextcloud_poll};
use rustical_store::auth::AuthenticationProvider;
use rustical_store::auth::middleware::AuthenticationLayer;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
// mod routes;
use tower_sessions::cookie::SameSite;
use tower_sessions::cookie::time::Duration;
use tower_sessions::{Expiry, SessionManagerLayer, SessionStore};
use crate::unauthorized_handler;
mod routes;
#[derive(Debug, Clone)]
struct NextcloudFlow {
@@ -42,32 +50,26 @@ pub struct NextcloudFlows {
flows: RwLock<HashMap<String, NextcloudFlow>>,
}
// use crate::{session_middleware, unauthorized_handler};
//
// pub fn configure_nextcloud_login<AP: AuthenticationProvider>(
// cfg: &mut ServiceConfig,
// nextcloud_flows_state: Arc<NextcloudFlows>,
// auth_provider: Arc<AP>,
// frontend_secret: [u8; 64],
// ) {
// cfg.service(
// web::scope("/index.php/login/v2")
// .wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler))
// .wrap(AuthenticationMiddleware::new(auth_provider.clone()))
// .wrap(session_middleware(frontend_secret))
// .app_data(Data::from(nextcloud_flows_state))
// .app_data(Data::from(auth_provider.clone()))
// .service(web::resource("").post(post_nextcloud_login))
// .service(
// web::resource("/poll/{flow}")
// .name("nc_login_poll")
// .post(post_nextcloud_poll::<AP>),
// )
// .service(
// web::resource("/flow/{flow}")
// .name("nc_login_flow")
// .get(get_nextcloud_flow)
// .post(post_nextcloud_flow),
// ),
// );
// }
pub fn nextcloud_login_router<AP: AuthenticationProvider, S: SessionStore + Clone>(
nextcloud_flows_state: Arc<NextcloudFlows>,
auth_provider: Arc<AP>,
session_store: S,
) -> Router {
Router::new()
.route("/poll/{flow}", post(post_nextcloud_poll::<AP>))
.route(
"/flow/{flow}",
get(get_nextcloud_flow).post(post_nextcloud_flow),
)
.route("/", post(post_nextcloud_login))
.layer(Extension(nextcloud_flows_state))
.layer(Extension(auth_provider.clone()))
.layer(AuthenticationLayer::new(auth_provider.clone()))
.layer(
SessionManagerLayer::new(session_store)
.with_secure(true)
.with_same_site(SameSite::Strict)
.with_expiry(Expiry::OnInactivity(Duration::hours(2))),
)
.layer(middleware::from_fn(unauthorized_handler))
}

View File

@@ -1,48 +1,39 @@
use crate::generate_app_token;
use super::{
NextcloudFlow, NextcloudFlows, NextcloudLoginPoll, NextcloudLoginResponse,
NextcloudSuccessResponse,
};
use actix_web::{
HttpRequest, HttpResponse, Responder,
http::header::{self},
web::{Data, Form, Html, Json, Path},
};
use crate::routes::app_token::generate_app_token;
use askama::Template;
use axum::{
Extension, Form, Json,
extract::Path,
response::{Html, IntoResponse, Response},
};
use axum_extra::{TypedHeader, extract::Host};
use chrono::{Duration, Utc};
use headers::UserAgent;
use http::StatusCode;
use rustical_store::auth::{AuthenticationProvider, User};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::instrument;
pub(crate) async fn post_nextcloud_login(
req: HttpRequest,
state: Data<NextcloudFlows>,
Extension(state): Extension<Arc<NextcloudFlows>>,
TypedHeader(user_agent): TypedHeader<UserAgent>,
Host(host): Host,
) -> 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 app_name = user_agent.to_string();
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,
flow_id.clone(),
NextcloudFlow {
app_name: app_name.to_owned(),
created_at: Utc::now(),
@@ -51,10 +42,10 @@ pub(crate) async fn post_nextcloud_login(
},
);
Json(NextcloudLoginResponse {
login: flow_url.to_string(),
login: format!("https://{host}/index.php/login/v2/flow/{flow_id}"),
poll: NextcloudLoginPoll {
token,
endpoint: poll_url.to_string(),
endpoint: format!("https://{host}/index.php/login/v2/poll/{flow_id}"),
},
})
}
@@ -66,13 +57,11 @@ pub(crate) struct NextcloudPollForm {
}
pub(crate) 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();
Extension(state): Extension<Arc<NextcloudFlows>>,
Path(flow_id): Path<String>,
Extension(auth_provider): Extension<Arc<AP>>,
Form(form): Form<NextcloudPollForm>,
) -> Result<Response, rustical_store::Error> {
let mut flows = state.flows.write().await;
// Flows must not last longer than 10 minutes
@@ -80,7 +69,7 @@ pub(crate) async fn post_nextcloud_poll<AP: AuthenticationProvider>(
if let Some(flow) = flows.get(&flow_id).cloned() {
if flow.token != form.token {
return Ok(HttpResponse::Unauthorized().body("Unauthorized"));
return Ok(StatusCode::UNAUTHORIZED.into_response());
}
if let Some(response) = &flow.response {
auth_provider
@@ -91,13 +80,13 @@ pub(crate) async fn post_nextcloud_poll<AP: AuthenticationProvider>(
)
.await?;
flows.remove(&flow_id);
Ok(Json(response).respond_to(&req).map_into_boxed_body())
Ok(Json(response).into_response())
} else {
// Not done yet, re-insert flow
Ok(HttpResponse::NotFound().finish())
Ok(StatusCode::NOT_FOUND.into_response())
}
} else {
Ok(HttpResponse::Unauthorized().body("Unauthorized"))
Ok(StatusCode::UNAUTHORIZED.into_response())
}
}
@@ -108,16 +97,14 @@ struct NextcloudLoginPage {
app_name: String,
}
#[instrument(skip(state, req))]
#[instrument(skip(state))]
pub(crate) async fn get_nextcloud_flow(
Extension(state): Extension<Arc<NextcloudFlows>>,
Path(flow_id): Path<String>,
user: User,
state: Data<NextcloudFlows>,
path: Path<String>,
req: HttpRequest,
) -> Result<impl Responder, rustical_store::Error> {
let flow_id = path.into_inner();
) -> Result<Response, rustical_store::Error> {
if let Some(flow) = state.flows.read().await.get(&flow_id) {
Ok(Html::new(
Ok(Html(
NextcloudLoginPage {
username: user.displayname.unwrap_or(user.id),
app_name: flow.app_name.to_owned(),
@@ -125,10 +112,9 @@ pub(crate) async fn get_nextcloud_flow(
.render()
.unwrap(),
)
.respond_to(&req)
.map_into_boxed_body())
.into_response())
} else {
Ok(HttpResponse::NotFound().body("Login flow not found"))
Ok((StatusCode::NOT_FOUND, "Login flow not found").into_response())
}
}
@@ -143,32 +129,30 @@ struct NextcloudLoginSuccessPage {
app_name: String,
}
#[instrument(skip(state, req))]
#[instrument(skip(state))]
pub(crate) 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();
Extension(state): Extension<Arc<NextcloudFlows>>,
Path(flow_id): Path<String>,
Host(host): Host,
Form(form): Form<NextcloudAuthorizeForm>,
) -> Result<Response, rustical_store::Error> {
if let Some(flow) = state.flows.write().await.get_mut(&flow_id) {
flow.app_name = form.into_inner().app_name;
flow.app_name = form.app_name;
flow.response = Some(NextcloudSuccessResponse {
server: req.full_url().origin().unicode_serialization(),
server: format!("https://{host}"),
login_name: user.id.to_owned(),
app_password: generate_app_token(),
});
Ok(Html::new(
Ok(Html(
NextcloudLoginSuccessPage {
app_name: flow.app_name.to_owned(),
}
.render()
.unwrap(),
)
.respond_to(&req)
.map_into_boxed_body())
.into_response())
} else {
Ok(HttpResponse::NotFound().body("Login flow not found"))
Ok((StatusCode::NOT_FOUND, "Login flow not found").into_response())
}
}

View File

@@ -0,0 +1,37 @@
use std::sync::Arc;
use async_trait::async_trait;
use rustical_oidc::UserStore;
use rustical_store::auth::{AuthenticationProvider, User};
pub struct OidcUserStore<AP: AuthenticationProvider>(pub Arc<AP>);
impl<AP: AuthenticationProvider> Clone for OidcUserStore<AP> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
#[async_trait]
impl<AP: AuthenticationProvider> UserStore for OidcUserStore<AP> {
type Error = rustical_store::Error;
async fn user_exists(&self, id: &str) -> Result<bool, Self::Error> {
Ok(self.0.get_principal(id).await?.is_some())
}
async fn insert_user(&self, id: &str) -> Result<(), Self::Error> {
self.0
.insert_principal(
User {
id: id.to_owned(),
displayname: None,
principal_type: Default::default(),
password: None,
memberships: vec![],
},
false,
)
.await
}
}

View File

@@ -1,10 +1,15 @@
use actix_web::{
HttpRequest, HttpResponse, Responder,
http::{StatusCode, header},
web::{self, Data, Path},
};
use std::sync::Arc;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Extension,
extract::Path,
response::{IntoResponse, Redirect, Response},
};
use axum_extra::TypedHeader;
use headers::Referer;
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, auth::User};
#[derive(Template, WebTemplate)]
@@ -14,37 +19,31 @@ struct AddressbookPage {
}
pub async fn route_addressbook<AS: AddressbookStore>(
path: Path<(String, String)>,
store: Data<AS>,
Path((owner, addrbook_id)): Path<(String, String)>,
Extension(store): Extension<Arc<AS>>,
user: User,
req: HttpRequest,
) -> Result<impl Responder, rustical_store::Error> {
let (owner, addrbook_id) = path.into_inner();
) -> Result<Response, rustical_store::Error> {
if !user.is_principal(&owner) {
return Ok(HttpResponse::Unauthorized().body("Unauthorized"));
return Ok(StatusCode::UNAUTHORIZED.into_response());
}
Ok(AddressbookPage {
addressbook: store.get_addressbook(&owner, &addrbook_id, true).await?,
}
.respond_to(&req))
.into_response())
}
pub async fn route_addressbook_restore<AS: AddressbookStore>(
path: Path<(String, String)>,
req: HttpRequest,
store: Data<AS>,
Path((owner, addressbook_id)): Path<(String, String)>,
Extension(store): Extension<Arc<AS>>,
user: User,
) -> Result<impl Responder, rustical_store::Error> {
let (owner, addressbook_id) = path.into_inner();
referer: Option<TypedHeader<Referer>>,
) -> Result<Response, rustical_store::Error> {
if !user.is_principal(&owner) {
return Ok(HttpResponse::Unauthorized().body("Unauthorized"));
return Ok(StatusCode::UNAUTHORIZED.into_response());
}
store.restore_addressbook(&owner, &addressbook_id).await?;
Ok(match req.headers().get(header::REFERER) {
Some(referer) => web::Redirect::to(referer.to_str().unwrap().to_owned())
.using_status_code(StatusCode::FOUND)
.respond_to(&req)
.map_into_boxed_body(),
None => HttpResponse::Ok().body("Restored"),
Ok(match referer {
Some(referer) => Redirect::to(&referer.to_string()).into_response(),
None => (StatusCode::CREATED, "Restored").into_response(),
})
}

View File

@@ -0,0 +1,104 @@
use std::{str::FromStr, sync::Arc};
use askama::Template;
use axum::{
Extension, Form,
body::Body,
extract::Path,
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::Host;
use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, StatusCode, header};
use rand::{Rng, distributions::Alphanumeric};
use rustical_store::auth::{AuthenticationProvider, User};
use serde::Deserialize;
use uuid::Uuid;
pub fn generate_app_token() -> String {
rand::thread_rng()
.sample_iter(Alphanumeric)
.map(char::from)
.take(64)
.collect()
}
#[derive(Template)]
#[template(path = "apple_configuration/template.xml")]
pub struct AppleConfig {
token_name: String,
account_description: String,
hostname: String,
caldav_principal_url: String,
carddav_principal_url: String,
user: String,
token: String,
caldav_profile_uuid: Uuid,
carddav_profile_uuid: Uuid,
plist_uuid: Uuid,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PostAppTokenForm {
name: String,
#[serde(default)]
apple: bool,
}
pub async fn route_post_app_token<AP: AuthenticationProvider>(
user: User,
Extension(auth_provider): Extension<Arc<AP>>,
Path(user_id): Path<String>,
Host(hostname): Host,
Form(PostAppTokenForm { apple, name }): Form<PostAppTokenForm>,
) -> Result<Response, rustical_store::Error> {
assert!(!name.is_empty());
assert_eq!(user_id, user.id);
let token = generate_app_token();
auth_provider
.add_app_token(&user.id, name.to_owned(), token.clone())
.await?;
if apple {
let profile = AppleConfig {
token_name: name,
account_description: format!("{}@{}", &user.id, &hostname),
hostname: hostname.clone(),
caldav_principal_url: format!("https://{hostname}/caldav/principal/{user_id}"),
carddav_principal_url: format!("https://{hostname}/carddav/principal/{user_id}"),
user: user.id.to_owned(),
token,
caldav_profile_uuid: Uuid::new_v4(),
carddav_profile_uuid: Uuid::new_v4(),
plist_uuid: Uuid::new_v4(),
}
.render()
.unwrap();
let mut res = Response::builder().status(StatusCode::OK);
let hdrs = res.headers_mut().unwrap();
hdrs.typed_insert(
ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(),
);
let filename = format!("rustical-{}.mobileconfig", user_id);
hdrs.insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachement; filename*=UTF-8''{} filename={}",
filename, filename
))
.unwrap(),
);
Ok(res.body(Body::new(profile)).unwrap())
} else {
Ok((StatusCode::OK, token).into_response())
}
}
pub async fn route_delete_app_token<AP: AuthenticationProvider>(
user: User,
Extension(auth_provider): Extension<Arc<AP>>,
Path((user_id, token_id)): Path<(String, String)>,
) -> Result<Redirect, rustical_store::Error> {
assert_eq!(user_id, user.id);
auth_provider.remove_app_token(&user.id, &token_id).await?;
Ok(Redirect::to("/frontend/user"))
}

View File

@@ -1,10 +1,15 @@
use actix_web::{
HttpRequest, HttpResponse, Responder,
http::{StatusCode, header},
web::{self, Data, Path},
};
use std::sync::Arc;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Extension,
extract::Path,
response::{IntoResponse, Redirect, Response},
};
use axum_extra::TypedHeader;
use headers::Referer;
use http::StatusCode;
use rustical_store::{Calendar, CalendarStore, auth::User};
#[derive(Template, WebTemplate)]
@@ -14,37 +19,31 @@ struct CalendarPage {
}
pub async fn route_calendar<C: CalendarStore>(
path: Path<(String, String)>,
store: Data<C>,
Path((owner, cal_id)): Path<(String, String)>,
Extension(store): Extension<Arc<C>>,
user: User,
req: HttpRequest,
) -> Result<HttpResponse, rustical_store::Error> {
let (owner, cal_id) = path.into_inner();
) -> Result<Response, rustical_store::Error> {
if !user.is_principal(&owner) {
return Ok(HttpResponse::Unauthorized().body("Unauthorized"));
return Ok(StatusCode::UNAUTHORIZED.into_response());
}
Ok(CalendarPage {
calendar: store.get_calendar(&owner, &cal_id).await?,
}
.respond_to(&req))
.into_response())
}
pub async fn route_calendar_restore<CS: CalendarStore>(
path: Path<(String, String)>,
req: HttpRequest,
store: Data<CS>,
Path((owner, cal_id)): Path<(String, String)>,
Extension(store): Extension<Arc<CS>>,
user: User,
) -> Result<HttpResponse, rustical_store::Error> {
let (owner, cal_id) = path.into_inner();
referer: Option<TypedHeader<Referer>>,
) -> Result<Response, rustical_store::Error> {
if !user.is_principal(&owner) {
return Ok(HttpResponse::Unauthorized().body("Unauthorized"));
return Ok(StatusCode::UNAUTHORIZED.into_response());
}
store.restore_calendar(&owner, &cal_id).await?;
Ok(match req.headers().get(header::REFERER) {
Some(referer) => web::Redirect::to(referer.to_str().unwrap().to_owned())
.using_status_code(StatusCode::FOUND)
.respond_to(&req)
.map_into_boxed_body(),
None => HttpResponse::Created().body("Restored"),
Ok(match referer {
Some(referer) => Redirect::to(&referer.to_string()).into_response(),
None => (StatusCode::CREATED, "Restored").into_response(),
})
}

View File

@@ -5,13 +5,16 @@ use askama::Template;
use askama_web::WebTemplate;
use axum::{
Extension, Form,
extract::{OriginalUri, Query},
extract::Query,
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::Host;
use http::StatusCode;
use rustical_store::auth::AuthenticationProvider;
use serde::Deserialize;
use tower_sessions::Session;
use tracing::instrument;
use url::Url;
#[derive(Template, WebTemplate)]
#[template(path = "pages/login.html")]
@@ -37,20 +40,17 @@ pub async fn route_get_login(
Extension(config): Extension<FrontendConfig>,
Extension(oidc_config): Extension<Option<OidcConfig>>,
) -> Response {
// let oidc_data = oidc_config
// .as_ref()
// .as_ref()
// .map(|oidc_config| OidcProviderData {
// name: &oidc_config.name,
// redirect_url: req
// .url_for_static(ROUTE_NAME_OIDC_LOGIN)
// .unwrap()
// .to_string(),
// });
let oidc_data = oidc_config
.as_ref()
.as_ref()
.map(|oidc_config| OidcProviderData {
name: &oidc_config.name,
redirect_url: "/frontend/login/oidc".to_owned(),
});
LoginPage {
redirect_uri,
allow_password_login: config.allow_password_login,
oidc_data: None,
oidc_data,
}
.into_response()
}
@@ -66,7 +66,8 @@ pub struct PostLoginForm {
pub async fn route_post_login<AP: AuthenticationProvider>(
Extension(auth_provider): Extension<Arc<AP>>,
Extension(config): Extension<FrontendConfig>,
OriginalUri(orig_uri): OriginalUri,
session: Session,
Host(host): Host,
Form(PostLoginForm {
username,
password,
@@ -76,24 +77,32 @@ pub async fn route_post_login<AP: AuthenticationProvider>(
if !config.allow_password_login {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
// Ensure that redirect_uri never goes cross-origin
let default_redirect = "/frontend/user".to_string();
let redirect_uri = redirect_uri.unwrap_or(default_redirect.clone());
// let redirect_uri = orig_uri
// .join(&redirect_uri)
// .ok()
// .and_then(|uri| orig_uri.make_relative(&uri))
// .unwrap_or(default_redirect);
// Ensure that redirect_uri never goes cross-origin
let base_url: Url = format!("https://{host}").parse().unwrap();
let redirect_uri = if let Some(redirect_uri) = redirect_uri {
if let Ok(redirect_url) = base_url.join(&redirect_uri) {
if redirect_url.origin() == base_url.origin() {
redirect_url.path().to_owned()
} else {
default_redirect
}
} else {
default_redirect
}
} else {
default_redirect
};
if let Ok(Some(user)) = auth_provider.validate_password(&username, &password).await {
// session.insert("user", user.id).unwrap();
session.insert("user", user.id).await.unwrap();
Redirect::to(&redirect_uri).into_response()
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
pub async fn route_post_logout() -> Redirect {
// session.remove("user");
pub async fn route_post_logout(session: Session) -> Redirect {
session.remove_value("user").await.unwrap();
Redirect::to("/")
}

View File

@@ -1,3 +1,5 @@
// pub mod addressbook;
// pub mod calendar;
pub mod addressbook;
pub mod app_token;
pub mod calendar;
pub mod login;
pub mod user;

View File

@@ -0,0 +1,89 @@
use std::sync::Arc;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Extension,
extract::Path,
response::{IntoResponse, Redirect},
};
use axum_extra::TypedHeader;
use headers::UserAgent;
use http::StatusCode;
use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarStore,
auth::{AuthenticationProvider, User, user::AppToken},
};
#[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")]
pub struct UserPage {
pub user: User,
pub app_tokens: Vec<AppToken>,
pub calendars: Vec<Calendar>,
pub deleted_calendars: Vec<Calendar>,
pub addressbooks: Vec<Addressbook>,
pub deleted_addressbooks: Vec<Addressbook>,
pub is_apple: bool,
}
pub async fn route_user_named<
CS: CalendarStore,
AS: AddressbookStore,
AP: AuthenticationProvider,
>(
Path(user_id): Path<String>,
Extension(cal_store): Extension<Arc<CS>>,
Extension(addr_store): Extension<Arc<AS>>,
Extension(auth_provider): Extension<Arc<AP>>,
TypedHeader(user_agent): TypedHeader<UserAgent>,
user: User,
) -> impl IntoResponse {
if user_id != user.id {
return StatusCode::UNAUTHORIZED.into_response();
}
let mut calendars = vec![];
for group in user.memberships() {
calendars.extend(cal_store.get_calendars(group).await.unwrap());
}
let mut deleted_calendars = vec![];
for group in user.memberships() {
deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap());
}
let mut addressbooks = vec![];
for group in user.memberships() {
addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap());
}
let mut deleted_addressbooks = vec![];
for group in user.memberships() {
deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap());
}
let is_apple = user_agent.as_str().contains("Apple") || user_agent.as_str().contains("Mac OS");
UserPage {
app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
calendars,
deleted_calendars,
addressbooks,
deleted_addressbooks,
user,
is_apple,
}
.into_response()
}
pub async fn route_get_home(user: User) -> Redirect {
Redirect::to(&format!("/frontend/user/{}", user.id))
}
pub async fn route_root(user: Option<User>) -> Redirect {
match user {
Some(user) => route_get_home(user).await,
None => Redirect::to("/frontend/login"),
}
}