kanidm/server/web_ui/user/src/credential/reset.rs
Merlijn eddca4fc86
Feature object graph ()
* Refactor: move the object graph ui to admin web ui
* Add dynamic js loading support
Load viz.js dynamically
* Add some js docs
* chore: cleanup imports
* chore: remove unused clipboard feature
chore: remove unused mermaid.sh
* Messing with the profile.release settings and reverting the changes I tried has now made the build much smaller yay :D
* Refactor: user raw search requests
Assert service-accounts properly
* refactor: new v1 proto structure
* Add self to CONTRIBUTORS.md

---------

Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>
2024-02-29 02:25:40 +00:00

863 lines
32 KiB
Rust

use gloo::console;
use kanidm_proto::internal::{
CUCredState, CUExtPortal, CUIntentToken, CURegWarning, CUSessionToken, CUStatus,
CredentialDetail, CredentialDetailType, PasskeyDetail,
};
use kanidmd_web_ui_shared::constants::URL_USER_HOME;
use kanidmd_web_ui_shared::models::{get_cred_update_session, pop_return_location};
use kanidmd_web_ui_shared::utils::{autofocus, do_footer};
use kanidmd_web_ui_shared::{add_body_form_classes, logo_img, remove_body_form_classes};
use serde::Serialize;
use uuid::Uuid;
use wasm_bindgen::UnwrapThrowExt;
use yew::prelude::*;
use yew_router::prelude::*;
use super::delete::DeleteApp;
use super::passkey::PasskeyModalApp;
use super::passkeyremove::PasskeyRemoveModalApp;
use super::pwmodal::PwModalApp;
use super::totpmodal::TotpModalApp;
use super::totpremove::TotpRemoveComp;
use kanidmd_web_ui_shared::ui::error_page;
use kanidmd_web_ui_shared::{do_request, error::FetchError, utils, RequestMethod};
// use std::rc::Rc;
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub enum EventBusMsg {
UpdateStatus { status: CUStatus },
Error { emsg: String, kopid: Option<String> },
}
#[derive(PartialEq, Properties)]
pub struct ModalProps {
pub token: CUSessionToken,
pub cb: Callback<EventBusMsg>,
}
#[derive(PartialEq, Properties)]
pub struct TotpRemoveProps {
pub token: CUSessionToken,
pub label: String,
pub cb: Callback<EventBusMsg>,
}
#[derive(PartialEq)]
pub enum PasskeyClass {
Any,
Attested,
}
#[derive(PartialEq, Properties)]
pub struct PasskeyModalProps {
pub token: CUSessionToken,
pub class: PasskeyClass,
pub allowed_devices: Option<Vec<String>>,
pub cb: Callback<EventBusMsg>,
}
#[derive(PartialEq, Properties)]
pub struct PasskeyRemoveModalProps {
pub token: CUSessionToken,
pub class: PasskeyClass,
pub tag: String,
pub uuid: Uuid,
pub cb: Callback<EventBusMsg>,
}
pub enum Msg {
TokenSubmit,
BeginSession {
token: CUSessionToken,
status: CUStatus,
},
UpdateSession {
status: CUStatus,
},
Cancel,
Commit,
Success,
Error {
emsg: String,
kopid: Option<String>,
},
// TODO: use this? :)
#[allow(dead_code)]
Ignore,
}
impl From<FetchError> for Msg {
fn from(fe: FetchError) -> Self {
Msg::Error {
emsg: fe.as_string(),
kopid: None,
}
}
}
#[allow(clippy::large_enum_variant)]
//Page state
enum State {
TokenInput,
WaitingForStatus,
Main {
token: CUSessionToken,
status: CUStatus,
},
WaitingForCommit,
#[allow(clippy::large_enum_variant)]
Error {
emsg: String,
kopid: Option<String>,
},
}
pub struct CredentialResetApp {
state: State,
cb: Callback<EventBusMsg>,
}
impl Component for CredentialResetApp {
type Message = Msg;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
#[cfg(debug_assertions)]
console::debug!("credential::reset::create");
// On a page refresh/reload, should we restart a session that *may* have existed?
// This could be achieved with local storage
// Where did we come from?
add_body_form_classes!();
// Can we pre-load in a session token? This occurs when we are sent a
// credential reset from the views UI.
/* Were we given a token for the reset? */
let location = ctx
.link()
.location()
.expect_throw("Can't access current location");
// TODO: the error here ... isn't always an error, when a user comes from the dashboard they don't set a cred token in the URL, probably should handle this with a *slightly* nicer error
let query: Option<CUIntentToken> = location
.query()
.map_err(|e| {
let e_msg = format!("error decoding URL parameters -> {:?}", e);
console::error!(e_msg.as_str());
})
.ok();
let m_session = get_cred_update_session();
let state = match (query, m_session) {
(Some(cu_intent), None) => {
// Go straight to go! Collect 200!
ctx.link().send_future(async {
match Self::exchange_intent_token(cu_intent.token).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
State::WaitingForStatus
}
(None, Some((token, status))) => State::Main { token, status },
(None, None) => State::TokenInput,
(Some(_), Some(_)) => State::Error {
emsg: "Invalid State - Reset link and memory session both are available!"
.to_string(),
kopid: None,
},
};
let cb = Callback::from({
let link = ctx.link().clone();
move |emsg| {
let msg = match emsg {
EventBusMsg::UpdateStatus { status } => Msg::UpdateSession { status },
EventBusMsg::Error { emsg, kopid } => Msg::Error { emsg, kopid },
};
link.send_message(msg);
}
});
CredentialResetApp { state, cb }
}
fn changed(&mut self, _ctx: &Context<Self>, _props: &Self::Properties) -> bool {
#[cfg(debug_assertions)]
console::debug!("credential::reset::change");
false
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
#[cfg(debug_assertions)]
console::debug!("credential::reset::update");
let next_state = match (msg, &self.state) {
(Msg::Ignore, _) => None,
(Msg::TokenSubmit, State::TokenInput) => {
#[allow(clippy::expect_used)]
let token = utils::get_value_from_element_id("token")
.expect("Unable to find an input with id=token");
ctx.link().send_future(async {
match Self::exchange_intent_token(token).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
Some(State::WaitingForStatus)
}
(Msg::BeginSession { token, status }, State::WaitingForStatus) => {
#[cfg(debug_assertions)]
console::debug!(format!("begin session {:?}", status).as_str());
Some(State::Main { token, status })
}
(Msg::UpdateSession { status }, State::Main { token, status: _ }) => {
#[cfg(debug_assertions)]
console::debug!(format!("{:?}", status).as_str());
Some(State::Main {
token: token.clone(),
status,
})
}
(Msg::Commit, State::Main { token, status }) => {
console::debug!(format!("{:?}", status).as_str());
let token_c = token.clone();
ctx.link().send_future(async {
match Self::commit_session(token_c).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
Some(State::WaitingForCommit)
}
(Msg::Cancel, State::Main { token, status: _ }) => {
#[cfg(debug_assertions)]
console::debug!("msg::cancel");
let token_c = token.clone();
ctx.link().send_future(async {
match Self::cancel_session(token_c).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
Some(State::WaitingForCommit)
}
(Msg::Success, State::WaitingForCommit) => {
let loc = pop_return_location();
#[cfg(debug_assertions)]
console::debug!(["Successful, redirecting to -> ", &loc].concat());
let window = gloo_utils::window();
window.location().set_href(&loc.to_string()).unwrap_throw();
None
}
(Msg::Error { emsg, kopid }, _) => Some(State::Error { emsg, kopid }),
(_, _) => {
console::error!("CredentialResetApp state match fail on update!");
None
}
};
if let Some(mut next_state) = next_state {
std::mem::swap(&mut self.state, &mut next_state);
true
} else {
false
}
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
#[cfg(debug_assertions)]
console::debug!("credential::reset::rendered");
// because sometimes bootstrap doesn't catch it, which is annoying.
autofocus("token");
}
fn view(&self, ctx: &Context<Self>) -> Html {
#[cfg(debug_assertions)]
console::debug!("credential::reset::view");
match &self.state {
State::TokenInput => self.view_token_input(ctx),
State::WaitingForStatus | State::WaitingForCommit => self.view_waiting(ctx),
State::Main { token, status } => self.view_main(ctx, token, status),
State::Error { emsg, kopid } => self.view_error(ctx, emsg, kopid.as_deref()),
}
}
fn destroy(&mut self, _ctx: &Context<Self>) {
#[cfg(debug_assertions)]
console::debug!("credential::reset::destroy");
remove_body_form_classes!();
}
}
impl CredentialResetApp {
fn view_token_input(&self, ctx: &Context<Self>) -> Html {
html! {
<main class="flex-shrink-0 form-signin">
<center>
{logo_img()}
<h2>{ "Credential Reset" } </h2>
// TODO: replace this with a call to domain info
// <h3>{ "idm.example.com" } </h3>
</center>
<form
onsubmit={ ctx.link().callback(|e: SubmitEvent| {
console::debug!("credential::reset::view_token_input -> TokenInput - prevent_default()");
e.prevent_default();
Msg::TokenSubmit
} ) }
action="javascript:void(0);">
<p class="text-center">
<label for="token" class="form-label">
{"Enter your credential reset token."}
</label>
<input
id="token"
name="token"
autofocus=true
type="text"
class="form-control"
value=""
/>
</p>
<p class="text-center">
<button type="submit" class="btn btn-primary">{" Submit "}</button><br />
</p>
</form>
<p class="text-center">
<a href={URL_USER_HOME}><button href={URL_USER_HOME} class="btn btn-secondary" aria-label="Return home">{"Return to the home page"}</button></a>
</p>
</main>
}
}
fn view_waiting(&self, _ctx: &Context<Self>) -> Html {
html! {
<main class="text-center form-signin h-100">
<div class="vert-center">
<div class="spinner-border text-dark" role="status">
<span class="visually-hidden">{ "Loading..." }</span>
</div>
</div>
</main>
}
}
fn view_main(&self, ctx: &Context<Self>, token: &CUSessionToken, status: &CUStatus) -> Html {
remove_body_form_classes!();
let CUStatus {
spn,
displayname,
ext_cred_portal,
mfaregstate: _,
can_commit,
warnings,
primary,
primary_state,
passkeys,
passkeys_state,
attested_passkeys,
attested_passkeys_state,
attested_passkeys_allowed_devices,
} = status;
let (username, domain) = spn.split_once('@').unwrap_or(("", &spn));
let names = format!("{} ({})", displayname, username);
let cb = self.cb.clone();
let ext_cred_portal_html = match ext_cred_portal {
CUExtPortal::None => html! { <></> },
CUExtPortal::Hidden => html! {
<>
<hr class="my-4" />
<p>{ "This account is externally managed. Some features may not be available." }</p>
</>
},
CUExtPortal::Some(url) => {
let url_str = url.as_str().to_string();
html! {
<>
<hr class="my-4" />
<p>{ "This account is externally managed. Some features may not be available." }</p>
<a href={ url_str } >{ "Visit the external account portal" }</a>
</>
}
}
};
let pw_html = self.view_primary(token, primary, *primary_state);
let passkey_html = self.view_passkeys(token, passkeys, *passkeys_state);
let attested_passkey_html = self.view_attested_passkeys(
token,
attested_passkeys,
*attested_passkeys_state,
attested_passkeys_allowed_devices.as_slice(),
);
let warnings_html = if warnings.is_empty() {
html! { <></> }
} else {
html! {
<>
<hr class="my-4" />
{ for warnings.iter()
.map(|warning|
match warning {
CURegWarning::MfaRequired => html! {
<div class="alert alert-warning" role="alert">
<p>{ "Multi-Factor Authentication is required for your account. Either add TOTP or remove your password in favour of passkeys to submit." }</p>
</div>
},
CURegWarning::PasskeyRequired => html! {
<div class="alert alert-warning" role="alert">
<p>{ "Passkeys are required for your account." }</p>
</div>
},
CURegWarning::AttestedPasskeyRequired => html! {
<div class="alert alert-warning" role="alert">
<p>{ "Attested Passkeys are required for your account." }</p>
</div>
},
CURegWarning::AttestedResidentKeyRequired => html! {
<div class="alert alert-warning" role="alert">
<p>{ "Attested Resident Keys are required for your account." }</p>
</div>
},
CURegWarning::WebauthnAttestationUnsatisfiable => html! {
<div class="alert alert-danger" role="alert">
<p>{ "A webauthn attestation policy conflict has occurred and you will not be able to save your credentials" }</p>
<p>{ "Contact support IMMEDIATELY." }</p>
</div>
},
CURegWarning::Unsatisfiable => html! {
<div class="alert alert-danger" role="alert">
<p>{ "An account policy conflict has occurred and you will not be able to save your credentials" }</p>
<p>{ "Contact support IMMEDIATELY." }</p>
</div>
},
}
)
}
</>
}
};
html! {
<>
<div class="d-flex align-items-start form-cred-reset-body">
<main class="w-100">
<div class="py-3 text-center">
<h3>{ "Updating Credentials" }</h3>
<p>{ names }</p>
<p>{ domain }</p>
</div>
<div class="row g-3">
<form class="needs-validation" novalidate=true>
{ ext_cred_portal_html }
{ warnings_html }
{ attested_passkey_html }
{ passkey_html }
{ pw_html }
<hr class="my-4" />
<button class="w-50 btn btn-danger btn-lg"
disabled=false
onclick={
ctx.link()
.callback(move |_| {
Msg::Cancel
})
}
>{ "Cancel" }</button>
<button
class="w-50 btn btn-success btn-lg"
disabled={ !can_commit }
onclick={
ctx.link()
.callback(move |_| {
Msg::Commit
})
}
type="submit"
>{ "Submit Changes" }</button>
</form>
</div>
</main>
<DeleteApp token= { token.clone() } cb={ cb.clone() }/>
</div>
{ do_footer() }
</>
}
}
fn view_primary(
&self,
token: &CUSessionToken,
primary: &Option<CredentialDetail>,
primary_state: CUCredState,
) -> Html {
let cb = self.cb.clone();
// match on primary, get type_.
let alt_auth_method_inner = if matches!(primary_state, CUCredState::Modifiable) {
match primary {
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::Password,
}) => {
html! {
<>
<h6> <b>{ "Password" }</b> </h6>
<p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticPassword">
{ "Change Password" }
</button>
</p>
<h6> <b>{ "Time-based One Time Password (TOTP)" }</b> </h6>
<p>{ "TOTPs are 6 digit codes generated on-demand as a second authentication factor."}</p>
<p>
<TotpModalApp token={ token.clone() } cb={ cb.clone() }/>
</p>
<br/>
<p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete Alternative Credentials" }
</button>
</p>
</>
}
}
Some(CredentialDetail {
uuid: _,
type_:
CredentialDetailType::PasswordMfa(
// Used for what TOTP the user has.
totp_set,
// Being deprecated.
_security_key_labels,
// Need to wire in backup codes.
_backup_codes_remaining,
),
}) => {
html! {
<>
<h6> <b>{ "Password" }</b> </h6>
<p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticPassword">
{ "Change Password" }
</button>
</p>
<br/>
<h6> <b>{ "Time-based One Time Password (TOTP)" }</b></h6>
<p>{ "TOTPs are 6 digit codes generated on-demand as a second authentication factor."}</p>
<>
{ for totp_set.iter()
.map(|detail| html! { <TotpRemoveComp token={ token.clone() } label={ detail.clone() } cb={ cb.clone() } /> })
}
</>
<p>
<TotpModalApp token={ token.clone() } cb={ cb.clone() }/>
</p>
<br/>
<br/>
<p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete Alternative Credentials" }
</button>
</p>
</>
}
}
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::GeneratedPassword,
}) => {
html! {
<>
<h6> <b>{ "Password" }</b> </h6>
<p>{ "In order to set up alternative authentication methods, you must delete the generated password." }</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete Generated Password" }
</button>
</>
}
}
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::Passkey(_),
}) => {
html! {
<>
<p>{ "Webauthn Only - Will migrate to passkeys in a future update" }</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete Alternative Credentials" }
</button>
</>
}
}
None => {
html! {
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticPassword">
{ "Add Password" }
</button>
}
}
}
} else if matches!(primary_state, CUCredState::DeleteOnly) {
html! {
<p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete Legacy Credentials" }
</button>
</p>
}
} else {
html! {<></>}
};
let alt_auth_method_warning = match primary_state {
CUCredState::Modifiable => {
html! {
<>
<p>{ "If possible, passkeys should be used instead, as they are phishing and exploit resistant." }</p>
</>
}
}
CUCredState::DeleteOnly => {
html! {
<>
<p>{ "If possible, passkeys should be used instead, as they are phishing and exploit resistant." }</p>
<p>{ "Account policy prevents you modifying these credentials, but you may remove them." }</p>
</>
}
}
CUCredState::AccessDeny => {
html! {
<>
<p>{ "You do not have access to modify these credentials." }</p>
</>
}
}
CUCredState::PolicyDeny => {
html! {
<>
<p>{ "Account policy prevents you from setting these credentials" }</p>
</>
}
}
};
html! {
<>
<hr class="my-4" />
<h4>{"Alternative Authentication Methods" }</h4>
{ alt_auth_method_warning }
{ alt_auth_method_inner }
<PwModalApp token={ token.clone() } cb={ cb } />
</>
}
}
fn view_passkeys(
&self,
token: &CUSessionToken,
passkeys: &[PasskeyDetail],
passkeys_state: CUCredState,
) -> Html {
let cb = self.cb.clone();
match passkeys_state {
CUCredState::DeleteOnly | CUCredState::Modifiable => {
html! {
<>
<hr class="my-4" />
<h4>{"Passkeys"}</h4>
<p>{ "Easy to use digital credentials with self-contained multi-factor authentication designed to replace passwords." }</p>
<p>
<a target="_blank" href="https://support.microsoft.com/en-us/windows/passkeys-in-windows-301c8944-5ea2-452b-9886-97e4d2ef4422">{ "Windows" }</a>
{ ", " }
<a target="_blank" href="https://support.apple.com/guide/mac-help/create-a-passkey-mchl4af65d1a/mac">{ "MacOS" }</a>
{ ", " }
<a target="_blank" href="https://support.google.com/android/answer/14124480?hl=en">{ "Android" }</a>
{ ", and " }
<a target="_blank" href="https://support.apple.com/guide/iphone/use-passkeys-to-sign-in-to-apps-and-websites-iphf538ea8d0/ios">{ "iOS" }</a>
{ " have built-in support for passkeys."}
</p>
{ for passkeys.iter()
.map(|detail|
PasskeyRemoveModalApp::render_button(&detail.tag, detail.uuid)
)
}
{ for passkeys.iter()
.map(|detail|
html! { <PasskeyRemoveModalApp token={ token.clone() } tag={ detail.tag.clone() } uuid={ detail.uuid } cb={ cb.clone() } class={ PasskeyClass::Any } /> }
)
}
{ if passkeys_state == CUCredState::Modifiable {
html! { <PasskeyModalApp token={ token.clone() } cb={ cb.clone() } class={ PasskeyClass::Any } /> }
} else {
html! { <></> }
}}
</>
}
}
CUCredState::AccessDeny => {
html! { <></> }
}
CUCredState::PolicyDeny => {
html! { <></> }
}
}
}
fn view_attested_passkeys(
&self,
token: &CUSessionToken,
attested_passkeys: &[PasskeyDetail],
attested_passkeys_state: CUCredState,
attested_passkeys_allowed_devices: &[String],
) -> Html {
let cb = self.cb.clone();
match attested_passkeys_state {
CUCredState::Modifiable => {
html! {
<>
<hr class="my-4" />
<h4>{"Attested Passkeys"}</h4>
{ for attested_passkeys.iter()
.map(|detail|
PasskeyRemoveModalApp::render_button(&detail.tag, detail.uuid)
)
}
{ for attested_passkeys.iter()
.map(|detail|
html! { <PasskeyRemoveModalApp token={ token.clone() } tag={ detail.tag.clone() } uuid={ detail.uuid } cb={ cb.clone() } class={ PasskeyClass::Attested } /> }
)
}
<PasskeyModalApp token={ token.clone() } cb={ cb } class={ PasskeyClass::Attested } allowed_devices={ Some(attested_passkeys_allowed_devices.to_vec()) } />
</>
}
}
CUCredState::DeleteOnly => {
if attested_passkeys.is_empty() {
html! { <></> }
} else {
html! {
<>
<hr class="my-4" />
<h4>{"Attested Passkeys"}</h4>
{ for attested_passkeys.iter()
.map(|detail|
PasskeyRemoveModalApp::render_button(&detail.tag, detail.uuid)
)
}
{ for attested_passkeys.iter()
.map(|detail|
html! { <PasskeyRemoveModalApp token={ token.clone() } tag={ detail.tag.clone() } uuid={ detail.uuid } cb={ cb.clone() } class={ PasskeyClass::Attested } /> }
)
}
</>
}
}
}
CUCredState::AccessDeny | CUCredState::PolicyDeny => {
// Don't display anything.
html! { <></> }
}
}
}
fn view_error(&self, _ctx: &Context<Self>, msg: &str, kopid: Option<&str>) -> Html {
html! {
<main class="form-signin">
{ error_page(msg, kopid) }
</main>
}
}
async fn exchange_intent_token(token: String) -> Result<Msg, FetchError> {
let request = CUIntentToken { token };
let req_jsvalue = request
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
.expect("Failed to serialise request");
let req_jsvalue = js_sys::JSON::stringify(&req_jsvalue).expect_throw("failed to stringify");
let (kopid, status, value, _) = do_request(
"/v1/credential/_exchange_intent",
RequestMethod::POST,
Some(req_jsvalue),
)
.await?;
if status == 200 {
let (token, status): (CUSessionToken, CUStatus) =
serde_wasm_bindgen::from_value(value).expect_throw("Invalid response type");
Ok(Msg::BeginSession { token, status })
} else {
let emsg = value.as_string().unwrap_or_default();
Ok(Msg::Error { emsg, kopid })
}
}
async fn end_session(token: CUSessionToken, url: &str) -> Result<Msg, FetchError> {
let req_jsvalue = token
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
.expect("Failed to serialise request");
let req_jsvalue = js_sys::JSON::stringify(&req_jsvalue).expect_throw("failed to stringify");
let (kopid, status, value, _) =
do_request(url, RequestMethod::POST, Some(req_jsvalue)).await?;
if status == 200 {
Ok(Msg::Success)
} else {
let emsg = value.as_string().unwrap_or_default();
Ok(Msg::Error { emsg, kopid })
}
}
async fn cancel_session(token: CUSessionToken) -> Result<Msg, FetchError> {
Self::end_session(token, "/v1/credential/_cancel").await
}
async fn commit_session(token: CUSessionToken) -> Result<Msg, FetchError> {
Self::end_session(token, "/v1/credential/_commit").await
}
}