From 2c74d56f503e547f88c82e4c0fed5d3c1fb8f818 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:23:52 +0200 Subject: [PATCH] Refactoring of frontend and OIDC I want to make some code reusable for other projects --- crates/frontend/src/config.rs | 27 +-------- crates/frontend/src/lib.rs | 80 ++++++++++++-------------- crates/frontend/src/oidc/config.rs | 27 +++++++++ crates/frontend/src/oidc/mod.rs | 45 +++++++++++---- crates/frontend/src/routes/calendar.rs | 6 +- crates/frontend/src/routes/login.rs | 11 +++- 6 files changed, 109 insertions(+), 87 deletions(-) create mode 100644 crates/frontend/src/oidc/config.rs diff --git a/crates/frontend/src/config.rs b/crates/frontend/src/config.rs index bbc6155..8cfb66b 100644 --- a/crates/frontend/src/config.rs +++ b/crates/frontend/src/config.rs @@ -1,35 +1,10 @@ -use openidconnect::{ClientId, ClientSecret, IssuerUrl, Scope}; +pub use crate::oidc::OidcConfig; use serde::{Deserialize, Serialize}; fn default_true() -> bool { true } -#[derive(Deserialize, Serialize, Clone, Default)] -#[serde(rename_all = "snake_case")] -pub enum UserIdClaim { - // The correct option - Sub, - // The more ergonomic option if you know what you're doing - #[default] - PreferredUsername, -} - -#[derive(Deserialize, Serialize, Clone)] -#[serde(deny_unknown_fields)] -pub struct OidcConfig { - pub name: String, - pub issuer: IssuerUrl, - pub client_id: ClientId, - pub client_secret: Option, - pub scopes: Vec, - #[serde(default)] - pub allow_sign_up: bool, - pub require_group: Option, - #[serde(default)] - pub claim_userid: UserIdClaim, -} - #[derive(Deserialize, Serialize, Clone)] #[serde(deny_unknown_fields)] pub struct FrontendConfig { diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index f736429..13aef2f 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -14,7 +14,7 @@ use actix_web::{ use askama::Template; use askama_web::WebTemplate; use assets::{Assets, EmbedService}; -use oidc::{route_get_oidc_callback, route_post_oidc}; +use oidc::configure_oidc; use rand::{Rng, distributions::Alphanumeric}; use routes::{ addressbook::{route_addressbook, route_addressbook_restore}, @@ -34,6 +34,9 @@ pub mod nextcloud_login; mod oidc; mod routes; +pub const ROUTE_NAME_HOME: &str = "frontend_home"; +pub const ROUTE_USER_NAMED: &str = "frontend_user_named"; + pub use config::{FrontendConfig, OidcConfig}; pub fn generate_app_token() -> String { @@ -54,15 +57,6 @@ struct UserPage { pub deleted_addressbooks: Vec, } -async fn route_user(user: User, req: HttpRequest) -> Redirect { - Redirect::to( - req.url_for("frontend_user_named", &[user.id]) - .unwrap() - .to_string(), - ) - .see_other() -} - async fn route_user_named( path: Path, cal_store: Data, @@ -106,9 +100,18 @@ async fn route_user_named( .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, req: HttpRequest) -> impl Responder { let redirect_url = match user { - Some(_) => req.url_for_static("frontend_user").unwrap(), + Some(_) => req.url_for_static(ROUTE_NAME_HOME).unwrap(), None => req .resource_map() .url_for::<[_; 0], String>(&req, "frontend_login", []) @@ -208,63 +211,52 @@ pub fn configure_frontend)) - .name("frontend_user_named"), + .get(route_user_named::) + .name(ROUTE_USER_NAMED), ) + // App token management + .service(web::resource("/user/{user}/app_token").post(route_post_app_token::)) .service( - web::resource("/user/{user}/app_token") - .route(web::method(Method::POST).to(route_post_app_token::)), - ) - .service( - web::resource("/user/{user}/app_token/{id}/delete") - .route(web::method(Method::POST).to(route_delete_app_token::)), - ) - .service( - web::resource("/user/{user}/calendar/{calendar}") - .route(web::method(Method::GET).to(route_calendar::)), + // POST because HTML5 forms don't support DELETE method + web::resource("/user/{user}/app_token/{id}/delete").post(route_delete_app_token::), ) + // Calendar + .service(web::resource("/user/{user}/calendar/{calendar}").get(route_calendar::)) .service( web::resource("/user/{user}/calendar/{calendar}/restore") - .route(web::method(Method::POST).to(route_calendar_restore::)), + .post(route_calendar_restore::), ) + // Addressbook .service( - web::resource("/user/{user}/addressbook/{addressbook}") - .route(web::method(Method::GET).to(route_addressbook::)), + web::resource("/user/{user}/addressbook/{addressbook}").get(route_addressbook::), ) .service( web::resource("/user/{user}/addressbook/{addressbook}/restore") - .route(web::method(Method::POST).to(route_addressbook_restore::)), + .post(route_addressbook_restore::), ) + // Login .service( web::resource("/login") .name("frontend_login") - .route(web::method(Method::GET).to(route_get_login)) - .route(web::method(Method::POST).to(route_post_login::)), + .get(route_get_login) + .post(route_post_login::), ) .service( web::resource("/logout") .name("frontend_logout") - .route(web::method(Method::POST).to(route_post_logout)), + .post(route_post_logout), ); if let Some(oidc_config) = oidc_config { - scope = scope - .app_data(Data::new(oidc_config)) - .service( - web::resource("/login/oidc") - .name("frontend_login_oidc") - .route(web::method(Method::POST).to(route_post_oidc)), - ) - .service( - web::resource("/login/oidc/callback") - .name("frontend_oidc_callback") - .route(web::method(Method::GET).to(route_get_oidc_callback::)), - ); + scope = scope.service( + web::scope("/login/oidc") + .configure(|cfg| configure_oidc::(cfg, oidc_config, ROUTE_NAME_HOME)), + ); } cfg.service(scope); diff --git a/crates/frontend/src/oidc/config.rs b/crates/frontend/src/oidc/config.rs new file mode 100644 index 0000000..c01418b --- /dev/null +++ b/crates/frontend/src/oidc/config.rs @@ -0,0 +1,27 @@ +use openidconnect::{ClientId, ClientSecret, IssuerUrl, Scope}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum UserIdClaim { + // The correct option + Sub, + // The more ergonomic option if you know what you're doing + #[default] + PreferredUsername, +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct OidcConfig { + pub name: String, + pub issuer: IssuerUrl, + pub client_id: ClientId, + pub client_secret: Option, + pub scopes: Vec, + #[serde(default)] + pub allow_sign_up: bool, + pub require_group: Option, + #[serde(default)] + pub claim_userid: UserIdClaim, +} diff --git a/crates/frontend/src/oidc/mod.rs b/crates/frontend/src/oidc/mod.rs index e2f4e27..fc976b5 100644 --- a/crates/frontend/src/oidc/mod.rs +++ b/crates/frontend/src/oidc/mod.rs @@ -1,10 +1,11 @@ -use crate::config::{OidcConfig, UserIdClaim}; use actix_session::Session; use actix_web::{ HttpRequest, HttpResponse, Responder, http::StatusCode, - web::{Data, Form, Query, Redirect}, + web::{self, Data, Form, Query, Redirect, ServiceConfig}, }; +pub use config::OidcConfig; +use config::UserIdClaim; use error::OidcError; use openidconnect::{ AuthenticationFlow, AuthorizationCode, CsrfToken, EndpointMaybeSet, EndpointNotSet, @@ -15,15 +16,16 @@ use openidconnect::{ use rustical_store::auth::{AuthenticationProvider, User, user::PrincipalType::Individual}; use serde::{Deserialize, Serialize}; +mod config; mod error; -pub(crate) struct OidcProviderData<'a> { - pub name: &'a str, - pub redirect_url: String, -} - +pub const ROUTE_NAME_OIDC_LOGIN: &str = "oidc_login"; +const ROUTE_NAME_OIDC_CALLBACK: &str = "oidc_callback"; const SESSION_KEY_OIDC_STATE: &str = "oidc_state"; +#[derive(Debug)] +pub struct DefaultRedirectRouteName(pub &'static str); + #[derive(Debug, Deserialize, Serialize)] struct OidcState { state: CsrfToken, @@ -96,7 +98,7 @@ pub async fn route_post_oidc( let oidc_client = get_oidc_client( oidc_config.as_ref().clone(), &http_client, - RedirectUrl::new(req.url_for_static("frontend_oidc_callback")?.to_string())?, + RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?, ) .await?; @@ -138,6 +140,7 @@ pub async fn route_get_oidc_callback( session: Session, auth_provider: Data, Query(AuthCallbackQuery { code, iss }): Query, + default_redirect_name: Data, ) -> Result { assert_eq!(iss, oidc_config.issuer); let oidc_state = session @@ -149,7 +152,7 @@ pub async fn route_get_oidc_callback( let oidc_client = get_oidc_client( oidc_config.get_ref().clone(), &http_client, - RedirectUrl::new(req.url_for_static("frontend_oidc_callback")?.to_string())?, + RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?, ) .await?; @@ -207,7 +210,9 @@ pub async fn route_get_oidc_callback( user = Some(new_user); } - let default_redirect = req.url_for_static("frontend_user")?.to_string(); + let default_redirect = req + .url_for_static(default_redirect_name.as_ref().0)? + .to_string(); let redirect_uri = oidc_state.redirect_uri.unwrap_or(default_redirect.clone()); let redirect_uri = req .full_url() @@ -225,7 +230,25 @@ pub async fn route_get_oidc_callback( .respond_to(&req) .map_into_boxed_body()) } else { - // Add user provisioning Ok(HttpResponse::build(StatusCode::UNAUTHORIZED).body("User does not exist")) } } + +pub fn configure_oidc( + cfg: &mut ServiceConfig, + oidc_config: OidcConfig, + default_redirect_name: &'static str, +) { + cfg.app_data(Data::new(oidc_config)) + .app_data(Data::new(DefaultRedirectRouteName(default_redirect_name))) + .service( + web::resource("") + .name(ROUTE_NAME_OIDC_LOGIN) + .post(route_post_oidc), + ) + .service( + web::resource("/callback") + .name(ROUTE_NAME_OIDC_CALLBACK) + .get(route_get_oidc_callback::), + ); +} diff --git a/crates/frontend/src/routes/calendar.rs b/crates/frontend/src/routes/calendar.rs index fd604f7..335bf1d 100644 --- a/crates/frontend/src/routes/calendar.rs +++ b/crates/frontend/src/routes/calendar.rs @@ -18,7 +18,7 @@ pub async fn route_calendar( store: Data, user: User, req: HttpRequest, -) -> Result { +) -> Result { let (owner, cal_id) = path.into_inner(); if !user.is_principal(&owner) { return Ok(HttpResponse::Unauthorized().body("Unauthorized")); @@ -34,7 +34,7 @@ pub async fn route_calendar_restore( req: HttpRequest, store: Data, user: User, -) -> Result { +) -> Result { let (owner, cal_id) = path.into_inner(); if !user.is_principal(&owner) { return Ok(HttpResponse::Unauthorized().body("Unauthorized")); @@ -45,6 +45,6 @@ pub async fn route_calendar_restore( .using_status_code(StatusCode::FOUND) .respond_to(&req) .map_into_boxed_body(), - None => HttpResponse::Ok().body("Restored"), + None => HttpResponse::Created().body("Restored"), }) } diff --git a/crates/frontend/src/routes/login.rs b/crates/frontend/src/routes/login.rs index 462f639..74b0fee 100644 --- a/crates/frontend/src/routes/login.rs +++ b/crates/frontend/src/routes/login.rs @@ -1,4 +1,4 @@ -use crate::{FrontendConfig, OidcConfig, oidc::OidcProviderData}; +use crate::{FrontendConfig, OidcConfig, oidc::ROUTE_NAME_OIDC_LOGIN}; use actix_session::Session; use actix_web::{ HttpRequest, HttpResponse, Responder, @@ -19,6 +19,11 @@ struct LoginPage<'a> { allow_password_login: bool, } +struct OidcProviderData<'a> { + pub name: &'a str, + pub redirect_url: String, +} + #[derive(Debug, Deserialize)] pub struct GetLoginQuery { redirect_uri: Option, @@ -30,14 +35,14 @@ pub async fn route_get_login( req: HttpRequest, config: Data, oidc_config: Data>, -) -> impl Responder { +) -> HttpResponse { let oidc_data = oidc_config .as_ref() .as_ref() .map(|oidc_config| OidcProviderData { name: &oidc_config.name, redirect_url: req - .url_for_static("frontend_login_oidc") + .url_for_static(ROUTE_NAME_OIDC_LOGIN) .unwrap() .to_string(), });