mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-13 21:42:34 +00:00
Refactoring of frontend and OIDC
I want to make some code reusable for other projects
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
27
crates/frontend/src/oidc/config.rs
Normal file
27
crates/frontend/src/oidc/config.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -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>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user