Refactoring of frontend and OIDC

I want to make some code reusable for other projects
This commit is contained in:
Lennart
2025-04-20 21:23:52 +02:00
parent 678d2291e0
commit 2c74d56f50
6 changed files with 109 additions and 87 deletions

View File

@@ -1,35 +1,10 @@
use openidconnect::{ClientId, ClientSecret, IssuerUrl, Scope}; pub use crate::oidc::OidcConfig;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
fn default_true() -> bool { fn default_true() -> bool {
true 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<ClientSecret>,
pub scopes: Vec<Scope>,
#[serde(default)]
pub allow_sign_up: bool,
pub require_group: Option<String>,
#[serde(default)]
pub claim_userid: UserIdClaim,
}
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct FrontendConfig { pub struct FrontendConfig {

View File

@@ -14,7 +14,7 @@ use actix_web::{
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use assets::{Assets, EmbedService}; use assets::{Assets, EmbedService};
use oidc::{route_get_oidc_callback, route_post_oidc}; use oidc::configure_oidc;
use rand::{Rng, distributions::Alphanumeric}; use rand::{Rng, distributions::Alphanumeric};
use routes::{ use routes::{
addressbook::{route_addressbook, route_addressbook_restore}, addressbook::{route_addressbook, route_addressbook_restore},
@@ -34,6 +34,9 @@ pub mod nextcloud_login;
mod oidc; mod oidc;
mod routes; 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 use config::{FrontendConfig, OidcConfig};
pub fn generate_app_token() -> String { pub fn generate_app_token() -> String {
@@ -54,15 +57,6 @@ struct UserPage {
pub deleted_addressbooks: Vec<Addressbook>, pub deleted_addressbooks: Vec<Addressbook>,
} }
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<CS: CalendarStore, AS: AddressbookStore>( async fn route_user_named<CS: CalendarStore, AS: AddressbookStore>(
path: Path<String>, path: Path<String>,
cal_store: Data<CS>, cal_store: Data<CS>,
@@ -106,9 +100,18 @@ async fn route_user_named<CS: CalendarStore, AS: AddressbookStore>(
.respond_to(&req) .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 { async fn route_root(user: Option<User>, req: HttpRequest) -> impl Responder {
let redirect_url = match user { let redirect_url = match user {
Some(_) => req.url_for_static("frontend_user").unwrap(), Some(_) => req.url_for_static(ROUTE_NAME_HOME).unwrap(),
None => req None => req
.resource_map() .resource_map()
.url_for::<[_; 0], String>(&req, "frontend_login", []) .url_for::<[_; 0], String>(&req, "frontend_login", [])
@@ -208,63 +211,52 @@ pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: Add
.service(web::resource("").route(web::method(Method::GET).to(route_root))) .service(web::resource("").route(web::method(Method::GET).to(route_root)))
.service( .service(
web::resource("/user") web::resource("/user")
.route(web::method(Method::GET).to(route_user)) .get(route_get_home)
.name("frontend_user"), .name(ROUTE_NAME_HOME),
) )
.service( .service(
web::resource("/user/{user}") web::resource("/user/{user}")
.route(web::method(Method::GET).to(route_user_named::<CS, AS>)) .get(route_user_named::<CS, AS>)
.name("frontend_user_named"), .name(ROUTE_USER_NAMED),
) )
// App token management
.service(web::resource("/user/{user}/app_token").post(route_post_app_token::<AP>))
.service( .service(
web::resource("/user/{user}/app_token") // POST because HTML5 forms don't support DELETE method
.route(web::method(Method::POST).to(route_post_app_token::<AP>)), web::resource("/user/{user}/app_token/{id}/delete").post(route_delete_app_token::<AP>),
)
.service(
web::resource("/user/{user}/app_token/{id}/delete")
.route(web::method(Method::POST).to(route_delete_app_token::<AP>)),
)
.service(
web::resource("/user/{user}/calendar/{calendar}")
.route(web::method(Method::GET).to(route_calendar::<CS>)),
) )
// Calendar
.service(web::resource("/user/{user}/calendar/{calendar}").get(route_calendar::<CS>))
.service( .service(
web::resource("/user/{user}/calendar/{calendar}/restore") web::resource("/user/{user}/calendar/{calendar}/restore")
.route(web::method(Method::POST).to(route_calendar_restore::<CS>)), .post(route_calendar_restore::<CS>),
) )
// Addressbook
.service( .service(
web::resource("/user/{user}/addressbook/{addressbook}") web::resource("/user/{user}/addressbook/{addressbook}").get(route_addressbook::<AS>),
.route(web::method(Method::GET).to(route_addressbook::<AS>)),
) )
.service( .service(
web::resource("/user/{user}/addressbook/{addressbook}/restore") web::resource("/user/{user}/addressbook/{addressbook}/restore")
.route(web::method(Method::POST).to(route_addressbook_restore::<AS>)), .post(route_addressbook_restore::<AS>),
) )
// Login
.service( .service(
web::resource("/login") web::resource("/login")
.name("frontend_login") .name("frontend_login")
.route(web::method(Method::GET).to(route_get_login)) .get(route_get_login)
.route(web::method(Method::POST).to(route_post_login::<AP>)), .post(route_post_login::<AP>),
) )
.service( .service(
web::resource("/logout") web::resource("/logout")
.name("frontend_logout") .name("frontend_logout")
.route(web::method(Method::POST).to(route_post_logout)), .post(route_post_logout),
); );
if let Some(oidc_config) = oidc_config { if let Some(oidc_config) = oidc_config {
scope = scope scope = scope.service(
.app_data(Data::new(oidc_config)) web::scope("/login/oidc")
.service( .configure(|cfg| configure_oidc::<AP>(cfg, oidc_config, ROUTE_NAME_HOME)),
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::<AP>)),
);
} }
cfg.service(scope); cfg.service(scope);

View File

@@ -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<ClientSecret>,
pub scopes: Vec<Scope>,
#[serde(default)]
pub allow_sign_up: bool,
pub require_group: Option<String>,
#[serde(default)]
pub claim_userid: UserIdClaim,
}

View File

@@ -1,10 +1,11 @@
use crate::config::{OidcConfig, UserIdClaim};
use actix_session::Session; use actix_session::Session;
use actix_web::{ use actix_web::{
HttpRequest, HttpResponse, Responder, HttpRequest, HttpResponse, Responder,
http::StatusCode, 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 error::OidcError;
use openidconnect::{ use openidconnect::{
AuthenticationFlow, AuthorizationCode, CsrfToken, EndpointMaybeSet, EndpointNotSet, AuthenticationFlow, AuthorizationCode, CsrfToken, EndpointMaybeSet, EndpointNotSet,
@@ -15,15 +16,16 @@ use openidconnect::{
use rustical_store::auth::{AuthenticationProvider, User, user::PrincipalType::Individual}; use rustical_store::auth::{AuthenticationProvider, User, user::PrincipalType::Individual};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
mod config;
mod error; mod error;
pub(crate) struct OidcProviderData<'a> { pub const ROUTE_NAME_OIDC_LOGIN: &str = "oidc_login";
pub name: &'a str, const ROUTE_NAME_OIDC_CALLBACK: &str = "oidc_callback";
pub redirect_url: String,
}
const SESSION_KEY_OIDC_STATE: &str = "oidc_state"; const SESSION_KEY_OIDC_STATE: &str = "oidc_state";
#[derive(Debug)]
pub struct DefaultRedirectRouteName(pub &'static str);
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct OidcState { struct OidcState {
state: CsrfToken, state: CsrfToken,
@@ -96,7 +98,7 @@ pub async fn route_post_oidc(
let oidc_client = get_oidc_client( let oidc_client = get_oidc_client(
oidc_config.as_ref().clone(), oidc_config.as_ref().clone(),
&http_client, &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?; .await?;
@@ -138,6 +140,7 @@ pub async fn route_get_oidc_callback<AP: AuthenticationProvider>(
session: Session, session: Session,
auth_provider: Data<AP>, auth_provider: Data<AP>,
Query(AuthCallbackQuery { code, iss }): Query<AuthCallbackQuery>, Query(AuthCallbackQuery { code, iss }): Query<AuthCallbackQuery>,
default_redirect_name: Data<DefaultRedirectRouteName>,
) -> Result<impl Responder, OidcError> { ) -> Result<impl Responder, OidcError> {
assert_eq!(iss, oidc_config.issuer); assert_eq!(iss, oidc_config.issuer);
let oidc_state = session let oidc_state = session
@@ -149,7 +152,7 @@ pub async fn route_get_oidc_callback<AP: AuthenticationProvider>(
let oidc_client = get_oidc_client( let oidc_client = get_oidc_client(
oidc_config.get_ref().clone(), oidc_config.get_ref().clone(),
&http_client, &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?; .await?;
@@ -207,7 +210,9 @@ pub async fn route_get_oidc_callback<AP: AuthenticationProvider>(
user = Some(new_user); 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 = oidc_state.redirect_uri.unwrap_or(default_redirect.clone());
let redirect_uri = req let redirect_uri = req
.full_url() .full_url()
@@ -225,7 +230,25 @@ pub async fn route_get_oidc_callback<AP: AuthenticationProvider>(
.respond_to(&req) .respond_to(&req)
.map_into_boxed_body()) .map_into_boxed_body())
} else { } else {
// Add user provisioning
Ok(HttpResponse::build(StatusCode::UNAUTHORIZED).body("User does not exist")) Ok(HttpResponse::build(StatusCode::UNAUTHORIZED).body("User does not exist"))
} }
} }
pub fn configure_oidc<AP: AuthenticationProvider>(
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::<AP>),
);
}

View File

@@ -18,7 +18,7 @@ pub async fn route_calendar<C: CalendarStore>(
store: Data<C>, store: Data<C>,
user: User, user: User,
req: HttpRequest, req: HttpRequest,
) -> Result<impl Responder, rustical_store::Error> { ) -> Result<HttpResponse, rustical_store::Error> {
let (owner, cal_id) = path.into_inner(); let (owner, cal_id) = path.into_inner();
if !user.is_principal(&owner) { if !user.is_principal(&owner) {
return Ok(HttpResponse::Unauthorized().body("Unauthorized")); return Ok(HttpResponse::Unauthorized().body("Unauthorized"));
@@ -34,7 +34,7 @@ pub async fn route_calendar_restore<CS: CalendarStore>(
req: HttpRequest, req: HttpRequest,
store: Data<CS>, store: Data<CS>,
user: User, user: User,
) -> Result<impl Responder, rustical_store::Error> { ) -> Result<HttpResponse, rustical_store::Error> {
let (owner, cal_id) = path.into_inner(); let (owner, cal_id) = path.into_inner();
if !user.is_principal(&owner) { if !user.is_principal(&owner) {
return Ok(HttpResponse::Unauthorized().body("Unauthorized")); return Ok(HttpResponse::Unauthorized().body("Unauthorized"));
@@ -45,6 +45,6 @@ pub async fn route_calendar_restore<CS: CalendarStore>(
.using_status_code(StatusCode::FOUND) .using_status_code(StatusCode::FOUND)
.respond_to(&req) .respond_to(&req)
.map_into_boxed_body(), .map_into_boxed_body(),
None => HttpResponse::Ok().body("Restored"), None => HttpResponse::Created().body("Restored"),
}) })
} }

View File

@@ -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_session::Session;
use actix_web::{ use actix_web::{
HttpRequest, HttpResponse, Responder, HttpRequest, HttpResponse, Responder,
@@ -19,6 +19,11 @@ struct LoginPage<'a> {
allow_password_login: bool, allow_password_login: bool,
} }
struct OidcProviderData<'a> {
pub name: &'a str,
pub redirect_url: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct GetLoginQuery { pub struct GetLoginQuery {
redirect_uri: Option<String>, redirect_uri: Option<String>,
@@ -30,14 +35,14 @@ pub async fn route_get_login(
req: HttpRequest, req: HttpRequest,
config: Data<FrontendConfig>, config: Data<FrontendConfig>,
oidc_config: Data<Option<OidcConfig>>, oidc_config: Data<Option<OidcConfig>>,
) -> impl Responder { ) -> HttpResponse {
let oidc_data = oidc_config let oidc_data = oidc_config
.as_ref() .as_ref()
.as_ref() .as_ref()
.map(|oidc_config| OidcProviderData { .map(|oidc_config| OidcProviderData {
name: &oidc_config.name, name: &oidc_config.name,
redirect_url: req redirect_url: req
.url_for_static("frontend_login_oidc") .url_for_static(ROUTE_NAME_OIDC_LOGIN)
.unwrap() .unwrap()
.to_string(), .to_string(),
}); });