diff --git a/server/core/src/https/mod.rs b/server/core/src/https/mod.rs index d3cd43f5a..ae48307fa 100644 --- a/server/core/src/https/mod.rs +++ b/server/core/src/https/mod.rs @@ -53,6 +53,7 @@ use tokio::{ use tokio_openssl::SslStream; use tower::Service; use tower_http::{services::ServeDir, trace::TraceLayer}; +use url::Url; use uuid::Uuid; use std::io::ErrorKind; @@ -62,16 +63,17 @@ use std::{net::SocketAddr, str::FromStr}; #[derive(Clone)] pub struct ServerState { - pub status_ref: &'static StatusActor, - pub qe_w_ref: &'static QueryServerWriteV1, - pub qe_r_ref: &'static QueryServerReadV1, + pub(crate) status_ref: &'static StatusActor, + pub(crate) qe_w_ref: &'static QueryServerWriteV1, + pub(crate) qe_r_ref: &'static QueryServerReadV1, // Store the token management parts. - pub jws_signer: JwsHs256Signer, + pub(crate) jws_signer: JwsHs256Signer, pub(crate) trust_x_forward_for: bool, - pub csp_header: HeaderValue, - pub domain: String, + pub(crate) csp_header: HeaderValue, + pub(crate) origin: Url, + pub(crate) domain: String, // This is set to true by default, and is only false on integration tests. - pub secure_cookies: bool, + pub(crate) secure_cookies: bool, } impl ServerState { @@ -209,6 +211,12 @@ pub async fn create_https_server( let trust_x_forward_for = config.trust_x_forward_for; + let origin = Url::parse(&config.origin) + // Should be impossible! + .map_err(|err| { + error!(?err, "Unable to parse origin URL - refusing to start. You must correct the value for origin. {:?}", config.origin); + })?; + let state = ServerState { status_ref, qe_w_ref, @@ -216,6 +224,7 @@ pub async fn create_https_server( jws_signer, trust_x_forward_for, csp_header, + origin, domain: config.domain.clone(), secure_cookies: config.integration_test_config.is_none(), }; diff --git a/server/core/src/https/views/constants.rs b/server/core/src/https/views/constants.rs index 3d3811015..55c5f84aa 100644 --- a/server/core/src/https/views/constants.rs +++ b/server/core/src/https/views/constants.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub(crate) enum ProfileMenuItems { UserProfile, Credentials, + EnrolDevice, UnixPassword, } @@ -25,6 +26,7 @@ pub(crate) enum Urls { Apps, CredReset, CredResetError, + EnrolDevice, Profile, UpdateCredentials, Oauth2Resume, @@ -38,6 +40,7 @@ impl AsRef for Urls { Self::Apps => "/ui/apps", Self::CredReset => "/ui/reset", Self::CredResetError => "/ui/reset/err", + Self::EnrolDevice => "/ui/enrol", Self::Profile => "/ui/profile", Self::UpdateCredentials => "/ui/update_credentials", Self::Oauth2Resume => "/ui/oauth2/resume", diff --git a/server/core/src/https/views/enrol.rs b/server/core/src/https/views/enrol.rs new file mode 100644 index 000000000..abe35acab --- /dev/null +++ b/server/core/src/https/views/enrol.rs @@ -0,0 +1,116 @@ +use askama::Template; +use askama_axum::IntoResponse; + +use axum::extract::State; +use axum::response::Response; +use axum::Extension; + +use axum_extra::extract::CookieJar; +use kanidm_proto::internal::UserAuthToken; + +use qrcode::render::svg; +use qrcode::QrCode; +use url::Url; + +use std::time::Duration; + +use super::constants::Urls; +use super::navbar::NavbarCtx; +use crate::https::extractors::{DomainInfo, VerifiedClientInformation}; +use crate::https::middleware::KOpId; +use crate::https::views::constants::ProfileMenuItems; +use crate::https::views::errors::HtmxError; +use crate::https::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose}; +use crate::https::ServerState; + +#[derive(Template)] +#[template(path = "user_settings.html")] +struct ProfileView { + navbar_ctx: NavbarCtx, + profile_partial: EnrolDeviceView, +} + +#[derive(Template)] +#[template(path = "enrol_device.html")] +pub(crate) struct EnrolDeviceView { + menu_active_item: ProfileMenuItems, + secret: String, + qr_code_svg: String, + uri: Url, +} + +pub(crate) async fn view_enrol_get( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, + jar: CookieJar, +) -> axum::response::Result { + let uat: UserAuthToken = state + .qe_r_ref + .handle_whoami_uat(client_auth_info.clone(), kopid.eventid) + .await + .map_err(|op_err| HtmxError::new(&kopid, op_err))?; + + let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0); + let can_rw = uat.purpose_readwrite_active(time); + + // The user lacks an elevated session, request a re-auth. + if !can_rw { + let display_ctx = LoginDisplayCtx { + domain_info, + oauth2: None, + reauth: Some(Reauth { + username: uat.spn, + purpose: ReauthPurpose::ProfileSettings, + }), + error: None, + }; + + return Ok(super::login::view_reauth_get( + state, + client_auth_info, + kopid, + jar, + Urls::EnrolDevice.as_ref(), + display_ctx, + ) + .await); + } + + let cu_intent = state + .qe_w_ref + .handle_idmcredentialupdateintent( + client_auth_info, + uat.spn, + Some(Duration::from_secs(900)), + kopid.eventid, + ) + .await + .map_err(|op_err| HtmxError::new(&kopid, op_err))?; + + let secret = cu_intent.token; + + let mut uri = state.origin.clone(); + uri.set_path(Urls::CredReset.as_ref()); + uri.set_query(Some(format!("token={secret}").as_str())); + + let qr_code_svg = match QrCode::new(uri.as_str()) { + Ok(qr) => qr.render::().build(), + Err(qr_err) => { + error!("Failed to create TOTP QR code: {qr_err}"); + "QR Code Generation Failed".to_string() + } + }; + + Ok(ProfileView { + navbar_ctx: NavbarCtx { domain_info }, + profile_partial: EnrolDeviceView { + menu_active_item: ProfileMenuItems::EnrolDevice, + qr_code_svg, + secret, + uri, + }, + } + .into_response()) +} diff --git a/server/core/src/https/views/login.rs b/server/core/src/https/views/login.rs index 7a54f1a0d..ec6b7e38b 100644 --- a/server/core/src/https/views/login.rs +++ b/server/core/src/https/views/login.rs @@ -14,8 +14,8 @@ use axum::{ }; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use kanidm_proto::internal::{ - COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_OAUTH2_REQ, COOKIE_USERNAME, - COOKIE_CU_SESSION_TOKEN + COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_CU_SESSION_TOKEN, COOKIE_OAUTH2_REQ, + COOKIE_USERNAME, }; use kanidm_proto::v1::{ AuthAllowed, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthStep, diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index 7fb53d472..a2d2da324 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -16,6 +16,7 @@ use crate::https::ServerState; mod apps; mod constants; mod cookies; +mod enrol; mod errors; mod login; mod navbar; @@ -37,6 +38,7 @@ pub fn view_router() -> Router { get(|| async { Redirect::permanent(Urls::Login.as_ref()) }), ) .route("/apps", get(apps::view_apps_get)) + .route("/enrol", get(enrol::view_enrol_get)) .route("/reset", get(reset::view_reset_get)) .route("/update_credentials", get(reset::view_self_reset_get)) .route("/profile", get(profile::view_profile_get)) diff --git a/server/core/static/img/icons/phone-flip.svg b/server/core/static/img/icons/phone-flip.svg new file mode 100644 index 000000000..1a144f27a --- /dev/null +++ b/server/core/static/img/icons/phone-flip.svg @@ -0,0 +1,3 @@ + + + diff --git a/server/core/templates/credential_update_add_totp_partial.html b/server/core/templates/credential_update_add_totp_partial.html index f9b1ec729..e9df734e6 100644 --- a/server/core/templates/credential_update_add_totp_partial.html +++ b/server/core/templates/credential_update_add_totp_partial.html @@ -1,8 +1,7 @@
(% if let Some(TotpInit with { secret, qr_code_svg, steps, digits, algo, uri }) = totp_init %) -
((qr_code_svg|safe)) -
+
((qr_code_svg|safe))
((uri|safe))

TOTP details

diff --git a/server/core/templates/enrol_device.html b/server/core/templates/enrol_device.html new file mode 100644 index 000000000..c314bdfdd --- /dev/null +++ b/server/core/templates/enrol_device.html @@ -0,0 +1,17 @@ +(% extends "user_settings_partial_base.html" %) + +(% block selected_setting_group %) +Enrol Another Device +(% endblock %) + +(% block settings_window %) +

You can enrol another device to your account by scanning the QR code or following the link below.

+ +
+
((qr_code_svg|safe))
+
    +
  • Url: ((uri|safe))
  • +
  • Secret: (( secret ))
  • +
+
+(% endblock %) diff --git a/server/core/templates/user_settings_partial_base.html b/server/core/templates/user_settings_partial_base.html index f091f4c31..b0490d456 100644 --- a/server/core/templates/user_settings_partial_base.html +++ b/server/core/templates/user_settings_partial_base.html @@ -20,6 +20,8 @@ ProfileMenuItems::UserProfile, "person") %) (% call side_menu_item("Credentials", (Urls::UpdateCredentials), ProfileMenuItems::Credentials, "shield-lock") %) + (% call side_menu_item("Enrol Device", (Urls::EnrolDevice), + ProfileMenuItems::EnrolDevice, "phone-flip") %)