Re-add enrol another device flow

This was a commonly requested re-addition to the new webui. This
adds the ability for someone to scan a qr code or follow a link
to enrol another device to their account.
This commit is contained in:
William Brown 2024-12-17 16:25:19 +10:00 committed by Firstyear
parent 11438a9dd5
commit c59f560e50
9 changed files with 162 additions and 11 deletions

View file

@ -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(),
};

View file

@ -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<str> 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",

View file

@ -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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
) -> axum::response::Result<Response> {
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::<svg::Color>().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())
}

View file

@ -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,

View file

@ -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<ServerState> {
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))

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-phone-flip" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11 1H5a1 1 0 0 0-1 1v6a.5.5 0 0 1-1 0V2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1m1 13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a.5.5 0 0 0-1 0v2a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-2a.5.5 0 0 0-1 0zM1.713 7.954a.5.5 0 1 0-.419-.908c-.347.16-.654.348-.882.57C.184 7.842 0 8.139 0 8.5c0 .546.408.94.823 1.201.44.278 1.043.51 1.745.696C3.978 10.773 5.898 11 8 11q.148 0 .294-.002l-1.148 1.148a.5.5 0 0 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2a.5.5 0 1 0-.708.708l1.145 1.144L8 10c-2.04 0-3.87-.221-5.174-.569-.656-.175-1.151-.374-1.47-.575C1.012 8.639 1 8.506 1 8.5c0-.003 0-.059.112-.17.115-.112.31-.242.6-.376Zm12.993-.908a.5.5 0 0 0-.419.908c.292.134.486.264.6.377.113.11.113.166.113.169s0 .065-.13.187c-.132.122-.352.26-.677.4-.645.28-1.596.523-2.763.687a.5.5 0 0 0 .14.99c1.212-.17 2.26-.43 3.02-.758.38-.164.713-.357.96-.587.246-.229.45-.537.45-.919 0-.362-.184-.66-.412-.883s-.535-.411-.882-.571M7.5 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,8 +1,7 @@
<div>
<div id="totpInfo">
(% if let Some(TotpInit with { secret, qr_code_svg, steps, digits, algo, uri }) = totp_init %)
<div>((qr_code_svg|safe))
</div>
<div>((qr_code_svg|safe))</div>
<code>((uri|safe))</code>
<h3>TOTP details</h3>

View file

@ -0,0 +1,17 @@
(% extends "user_settings_partial_base.html" %)
(% block selected_setting_group %)
Enrol Another Device
(% endblock %)
(% block settings_window %)
<p>You can enrol another device to your account by scanning the QR code or following the link below.</p>
<div id="intentInfo">
<div>((qr_code_svg|safe))</div>
<ul>
<li>Url: <code>((uri|safe))</code></li>
<li>Secret: <code>(( secret ))</code></li>
</ul>
</div>
(% endblock %)

View file

@ -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") %)
</ul>
<div id="settings-window" class="flex-grow-1 ps-sm-4 ps-md-5 pt-sm-0 pt-4">
<div>