From f82a52de3b97cbc0cc83af3eada13a488dab5861 Mon Sep 17 00:00:00 2001 From: Merlijn <32853531+ToxicMushroom@users.noreply.github.com> Date: Thu, 1 Aug 2024 03:17:14 +0200 Subject: [PATCH] [htmx] Credential Update page (#2897) Implement credential update page in HTMX --------- Co-authored-by: James Hodgkinson Co-authored-by: Firstyear --- Cargo.lock | 1 + proto/src/internal/credupdate.rs | 4 +- proto/src/internal/error.rs | 2 + proto/src/internal/mod.rs | 1 + server/core/Cargo.toml | 1 + server/core/src/https/mod.rs | 1 + server/core/src/https/views/errors.rs | 26 +- server/core/src/https/views/mod.rs | 17 +- server/core/src/https/views/reset.rs | 736 ++++++++++++++++++ server/core/static/external/cred_update.js | 127 +++ .../cred_update/add_passkey_partial.html | 27 + .../cred_update/add_password_partial.html | 60 ++ .../cred_update/add_totp_partial.html | 94 +++ server/core/templates/credentials_reset.html | 20 + .../templates/credentials_reset_form.html | 49 ++ .../credentials_update_attested_passkeys.html | 16 + .../templates/credentials_update_partial.html | 105 +++ .../credentials_update_passkeys.html | 23 + .../templates/credentials_update_primary.html | 93 +++ 19 files changed, 1390 insertions(+), 13 deletions(-) create mode 100644 server/core/src/https/views/reset.rs create mode 100644 server/core/static/external/cred_update.js create mode 100644 server/core/templates/cred_update/add_passkey_partial.html create mode 100644 server/core/templates/cred_update/add_password_partial.html create mode 100644 server/core/templates/cred_update/add_totp_partial.html create mode 100644 server/core/templates/credentials_reset.html create mode 100644 server/core/templates/credentials_reset_form.html create mode 100644 server/core/templates/credentials_update_attested_passkeys.html create mode 100644 server/core/templates/credentials_update_partial.html create mode 100644 server/core/templates/credentials_update_passkeys.html create mode 100644 server/core/templates/credentials_update_primary.html diff --git a/Cargo.lock b/Cargo.lock index 19b4241b6..8035457ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3444,6 +3444,7 @@ dependencies = [ "libc", "openssl", "opentelemetry", + "qrcode", "rand", "regex", "serde", diff --git a/proto/src/internal/credupdate.rs b/proto/src/internal/credupdate.rs index 0f6de0bdf..0621de92e 100644 --- a/proto/src/internal/credupdate.rs +++ b/proto/src/internal/credupdate.rs @@ -139,7 +139,7 @@ pub enum CUCredState { // Disabled, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub enum CURegWarning { MfaRequired, PasskeyRequired, @@ -388,7 +388,7 @@ impl fmt::Display for PasswordFeedback { } PasswordFeedback::TooShort(minlength) => write!( f, - "Password too was short, needs to be at least {} characters long.", + "Password was too short, needs to be at least {} characters long.", minlength ), PasswordFeedback::UseAFewWordsAvoidCommonPhrases => { diff --git a/proto/src/internal/error.rs b/proto/src/internal/error.rs index b5ebe868d..634046f85 100644 --- a/proto/src/internal/error.rs +++ b/proto/src/internal/error.rs @@ -122,6 +122,7 @@ pub enum OperationError { ReplDomainUuidMismatch, ReplServerUuidSplitDataState, TransactionAlreadyCommitted, + CannotStartMFADuringOngoingMFASession, /// when you ask for a gid that overlaps a system reserved range /// When a name is denied by the system config ValueDenyName, @@ -280,6 +281,7 @@ impl OperationError { Self::QueueDisconnected => None, Self::Webauthn => None, Self::Wait(_) => None, + Self::CannotStartMFADuringOngoingMFASession => Some("Cannot start a new MFA authentication flow when there already is one active."), Self::ReplReplayFailure => None, Self::ReplEntryNotChanged => None, Self::ReplInvalidRUVState => None, diff --git a/proto/src/internal/mod.rs b/proto/src/internal/mod.rs index 5657cb262..51e14d8d6 100644 --- a/proto/src/internal/mod.rs +++ b/proto/src/internal/mod.rs @@ -26,6 +26,7 @@ pub use self::token::*; pub const COOKIE_AUTH_SESSION_ID: &str = "auth-session-id"; pub const COOKIE_BEARER_TOKEN: &str = "bearer"; +pub const COOKIE_CU_SESSION_TOKEN: &str = "cu-session-token"; pub const COOKIE_USERNAME: &str = "username"; pub const COOKIE_OAUTH2_REQ: &str = "o2-authreq"; diff --git a/server/core/Cargo.toml b/server/core/Cargo.toml index 35cf99844..9ddc6667e 100644 --- a/server/core/Cargo.toml +++ b/server/core/Cargo.toml @@ -47,6 +47,7 @@ libc = { workspace = true } openssl = { workspace = true } opentelemetry = { workspace = true, features = ["logs"] } # opentelemetry_api = { workspace = true, features = ["logs"] } +qrcode = { workspace = true, features = ["svg"] } rand = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/server/core/src/https/mod.rs b/server/core/src/https/mod.rs index 2d2c575f8..61cb4524f 100644 --- a/server/core/src/https/mod.rs +++ b/server/core/src/https/mod.rs @@ -139,6 +139,7 @@ pub fn get_js_files(role: ServerRole) -> Result { vec![ ("external/bootstrap.bundle.min.js", None, false, false), ("external/htmx.min.1.9.12.js", None, false, false), + ("external/cred_update.js", None, false, false), ("external/confetti.js", None, false, false), ("external/base64.js", None, false, false), ("pkhtml.js", None, false, false), diff --git a/server/core/src/https/views/errors.rs b/server/core/src/https/views/errors.rs index bffe86ac8..4a025c40a 100644 --- a/server/core/src/https/views/errors.rs +++ b/server/core/src/https/views/errors.rs @@ -1,12 +1,13 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; +use axum_htmx::{HxReswap, HxRetarget, SwapOption}; use utoipa::ToSchema; use uuid::Uuid; use kanidm_proto::internal::OperationError; use crate::https::middleware::KOpId; - +use crate::https::views::{HtmlTemplate, UnrecoverableErrorView}; // #[derive(Template)] // #[template(path = "recoverable_error_partial.html")] // struct ErrorPartialView { @@ -21,7 +22,6 @@ use crate::https::middleware::KOpId; pub(crate) enum HtmxError { /// Something went wrong when doing things. OperationError(Uuid, OperationError), - // InternalServerError(Uuid, String), } impl HtmxError { @@ -33,15 +33,12 @@ impl HtmxError { impl IntoResponse for HtmxError { fn into_response(self) -> Response { match self { - // HtmxError::InternalServerError(_kopid, inner) => { - // (StatusCode::INTERNAL_SERVER_ERROR, inner).into_response() - // } - HtmxError::OperationError(_kopid, inner) => { + HtmxError::OperationError(kopid, inner) => { let body = serde_json::to_string(&inner).unwrap_or(inner.to_string()); match &inner { - OperationError::NotAuthenticated | OperationError::SessionExpired => { - Redirect::to("/ui").into_response() - } + OperationError::NotAuthenticated + | OperationError::SessionExpired + | OperationError::InvalidSessionState => Redirect::to("/ui").into_response(), OperationError::SystemProtectedObject | OperationError::AccessDenied => { (StatusCode::FORBIDDEN, body).into_response() } @@ -54,7 +51,16 @@ impl IntoResponse for HtmxError { | OperationError::CU0003WebauthnUserNotVerified => { (StatusCode::BAD_REQUEST, body).into_response() } - _ => (StatusCode::INTERNAL_SERVER_ERROR, body).into_response(), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + HxRetarget("body".to_string()), + HxReswap(SwapOption::OuterHtml), + HtmlTemplate(UnrecoverableErrorView { + err_code: inner, + operation_id: kopid, + }) + ) + .into_response(), } } } diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index c3f2a6941..87becd200 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -19,6 +19,7 @@ use crate::https::{ mod apps; mod errors; mod login; +mod reset; mod oauth2; #[derive(Template)] @@ -32,10 +33,12 @@ pub fn view_router() -> Router { let unguarded_router = Router::new() .route("/", get(|| async { Redirect::permanent("/ui/login") })) .route("/apps", get(apps::view_apps_get)) + .route("/reset", get(reset::view_reset_get)) .route("/logout", get(login::view_logout_get)) .route("/oauth2", get(oauth2::view_index_get)) .route("/oauth2/resume", get(oauth2::view_resume_get)) .route("/oauth2/consent", post(oauth2::view_consent_post)) + // The login routes are htmx-free to make them simpler, which means // they need manual guarding for direct get requests which can occur // if a user attempts to reload the page. @@ -72,7 +75,19 @@ pub fn view_router() -> Router { // The webauthn post is unguarded because it's not a htmx event. // Anything that is a partial only works if triggered from htmx - let guarded_router = Router::new().layer(HxRequestGuardLayer::new("/ui")); + let guarded_router = Router::new() + .route("/reset/add_totp", post(reset::view_new_totp)) + .route("/reset/add_password", post(reset::view_new_pwd)) + .route("/reset/change_password", post(reset::view_new_pwd)) + .route("/reset/add_passkey", post(reset::view_new_passkey)) + .route("/api/delete_alt_creds", post(reset::remove_alt_creds)) + .route("/api/remove_totp", post(reset::remove_totp)) + .route("/api/remove_passkey", post(reset::remove_passkey)) + .route("/api/finish_passkey", post(reset::finish_passkey)) + .route("/api/cancel_mfareg", post(reset::cancel_mfareg)) + .route("/api/cu_cancel", post(reset::cancel)) + .route("/api/cu_commit", post(reset::commit)) + .layer(HxRequestGuardLayer::new("/ui")); Router::new().merge(unguarded_router).merge(guarded_router) } diff --git a/server/core/src/https/views/reset.rs b/server/core/src/https/views/reset.rs new file mode 100644 index 000000000..e1cf1d7b7 --- /dev/null +++ b/server/core/src/https/views/reset.rs @@ -0,0 +1,736 @@ +use askama::Template; +use axum::extract::{Query, State}; +use axum::http::{StatusCode, Uri}; +use axum::response::{ErrorResponse, IntoResponse, Redirect, Response}; +use axum::{Extension, Form}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use axum_extra::extract::CookieJar; +use axum_htmx::{HxEvent, HxLocation, HxPushUrl, HxRequest, HxReselect, HxResponseTrigger, HxReswap, HxRetarget, SwapOption}; +use futures_util::TryFutureExt; +use qrcode::render::svg; +use qrcode::QrCode; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::fmt; +use std::fmt::{Display, Formatter}; +use uuid::Uuid; + +use kanidm_proto::internal::{ + CUCredState, CUExtPortal, CUIntentToken, CURegState, CURegWarning, CURequest, CUSessionToken, + CUStatus, CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, + COOKIE_CU_SESSION_TOKEN, +}; + +use crate::https::extractors::VerifiedClientInformation; +use crate::https::middleware::KOpId; +use crate::https::views::errors::HtmxError; +use crate::https::views::HtmlTemplate; +use crate::https::ServerState; + +#[derive(Template)] +#[template(path = "credentials_reset_form.html")] +struct ResetCredFormView { + domain: String, + wrong_code: bool, +} + +#[derive(Template)] +#[template(path = "credentials_reset.html")] +struct CredResetView { + domain: String, + names: String, + credentials_update_partial: CredResetPartialView, +} + +#[derive(Template)] +#[template(path = "credentials_update_partial.html")] +struct CredResetPartialView { + ext_cred_portal: CUExtPortal, + warnings: Vec, + attested_passkeys_state: CUCredState, + passkeys_state: CUCredState, + primary_state: CUCredState, + attested_passkeys: Vec, + passkeys: Vec, + primary: Option, +} + +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug)] +// Needs to be visible so axum can create this struct +pub(crate) struct ResetTokenParam { + token: Option, +} + +#[derive(Template)] +#[template(path = "cred_update/add_password_partial.html")] +struct AddPasswordPartial { + check_res: PwdCheckResult, +} + +#[derive(Serialize, Deserialize, Debug)] +enum PwdCheckResult { + Success, + Init, + Failure { + pwd_equal: bool, + warnings: Vec, + }, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct NewPassword { + new_password: String, + new_password_check: String, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct NewTotp { + name: String, + #[serde(rename = "checkTOTPCode")] + check_totpcode: u32, + #[serde(rename = "ignoreBrokenApp")] + ignore_broken_app: bool, +} + +#[derive(Template)] +#[template(path = "cred_update/add_passkey_partial.html")] +struct AddPasskeyPartial { + // Passkey challenge for adding a new passkey + challenge: String, + class: PasskeyClass, +} + +#[derive(Deserialize, Debug)] +struct PasskeyCreateResponse {} + +#[derive(Deserialize, Debug)] +struct PasskeyCreateExtensions {} + +#[derive(Deserialize, Debug)] +pub(crate) struct PasskeyInitForm { + class: PasskeyClass, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct PasskeyCreateForm { + name: String, + class: PasskeyClass, + #[serde(rename = "creationData")] + creation_data: String, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct PasskeyRemoveData { + uuid: Uuid, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct TOTPRemoveData { + name: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub(crate) enum TotpCheckResult { + Init { + secret: String, + qr_code_svg: String, + steps: u64, + digits: u8, + algo: TotpAlgo, + uri: String, + }, + Failure { + wrong_code: bool, + broken_app: bool, + warnings: Vec, + }, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) enum TotpFeedback { + BlankName, + DuplicateName, +} + +impl Display for TotpFeedback { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + TotpFeedback::BlankName => write!(f, "Please enter a name."), + TotpFeedback::DuplicateName => write!( + f, + "This name already exists, choose another or remove the existing one." + ), + } + } +} + +#[derive(Template)] +#[template(path = "cred_update/add_totp_partial.html")] +struct AddTotpPartial { + check_res: TotpCheckResult, +} + +#[derive(PartialEq, Debug, Serialize, Deserialize)] +pub enum PasskeyClass { + Any, + Attested, +} + +impl Display for PasskeyClass { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + PasskeyClass::Any => write!(f, "Any"), + PasskeyClass::Attested => write!(f, "Attested"), + } + } +} + +pub(crate) async fn commit( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(jar).await?; + + state + .qe_w_ref + .handle_idmcredentialupdatecommit(cu_session_token, kopid.eventid) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + Ok((HxLocation::from(Uri::from_static("/ui")), "").into_response()) +} + +pub(crate) async fn cancel( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(jar).await?; + + state + .qe_w_ref + .handle_idmcredentialupdatecancel(cu_session_token, kopid.eventid) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + Ok((HxLocation::from(Uri::from_static("/ui")), "").into_response()) +} + +pub(crate) async fn cancel_mfareg( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(jar).await?; + + let cu_status = state + .qe_r_ref + .handle_idmcredentialupdate(cu_session_token, CURequest::CancelMFAReg, kopid.eventid) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + Ok(get_cu_partial_response(cu_status)) +} + +async fn get_cu_session(jar: CookieJar) -> Result { + let cookie = jar.get(COOKIE_CU_SESSION_TOKEN); + return if let Some(cookie) = cookie { + let cu_session_token = cookie.value(); + let cu_session_token = CUSessionToken { + token: cu_session_token.into(), + }; + Ok(cu_session_token) + } else { + Err((StatusCode::FORBIDDEN, Redirect::to("/ui/reset")).into_response()) + }; +} + +pub(crate) async fn remove_alt_creds( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(jar).await?; + + let cu_status = state + .qe_r_ref + .handle_idmcredentialupdate(cu_session_token, CURequest::PrimaryRemove, kopid.eventid) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + Ok(get_cu_partial_response(cu_status)) +} + +pub(crate) async fn remove_totp( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Form(totp): Form, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(jar).await?; + + let cu_status = state + .qe_r_ref + .handle_idmcredentialupdate( + cu_session_token, + CURequest::TotpRemove(totp.name), + kopid.eventid, + ) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + Ok(get_cu_partial_response(cu_status)) +} + +pub(crate) async fn remove_passkey( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Form(passkey): Form, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(jar).await?; + + let cu_status = state + .qe_r_ref + .handle_idmcredentialupdate( + cu_session_token, + CURequest::PasskeyRemove(passkey.uuid), + kopid.eventid, + ) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + Ok(get_cu_partial_response(cu_status)) +} + +pub(crate) async fn finish_passkey( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Form(passkey_create): Form, +) -> axum::response::Result { + let cu_session_token = get_cu_session(jar).await?; + + match serde_json::from_str(passkey_create.creation_data.as_str()) { + Ok(creation_data) => { + let cu_request = match passkey_create.class { + PasskeyClass::Any => CURequest::PasskeyFinish(passkey_create.name, creation_data), + PasskeyClass::Attested => { + CURequest::AttestedPasskeyFinish(passkey_create.name, creation_data) + } + }; + + let cu_status = state + .qe_r_ref + .handle_idmcredentialupdate(cu_session_token, cu_request, kopid.eventid) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + Ok(get_cu_partial_response(cu_status)) + } + Err(e) => { + error!("Bad request for passkey creation: {e}"); + Ok(( + StatusCode::UNPROCESSABLE_ENTITY, + HtmxError::new(&kopid, OperationError::Backend).into_response(), + ) + .into_response()) + } + } +} + +pub(crate) async fn view_new_passkey( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Form(init_form): Form, +) -> axum::response::Result { + let cu_session_token = get_cu_session(jar).await?; + let cu_req = match init_form.class { + PasskeyClass::Any => CURequest::PasskeyInit, + PasskeyClass::Attested => CURequest::AttestedPasskeyInit, + }; + + let cu_status: CUStatus = state + .qe_r_ref + .handle_idmcredentialupdate(cu_session_token, cu_req, kopid.eventid) + .map_err(|op_err| HtmxError::new(&kopid, op_err)) + .await?; + + let response = match cu_status.mfaregstate { + CURegState::Passkey(chal) | CURegState::AttestedPasskey(chal) => { + HtmlTemplate(AddPasskeyPartial { + challenge: serde_json::to_string(&chal).unwrap(), + class: init_form.class, + }) + .into_response() + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + HtmxError::new(&kopid, OperationError::Backend).into_response(), + ) + .into_response(), + }; + + let passkey_init_trigger = + HxResponseTrigger::after_swap([HxEvent::new("addPasskeySwapped".to_string())]); + Ok(( + passkey_init_trigger, + HxPushUrl(Uri::from_static("/ui/reset/add_passkey")), + response, + ) + .into_response()) +} + +pub(crate) async fn view_new_totp( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, + opt_form: Option>, +) -> axum::response::Result { + let cu_session_token = get_cu_session(jar).await?; + let push_url = HxPushUrl(Uri::from_static("/ui/reset/add_totp")); + let swapped_handler_trigger = + HxResponseTrigger::after_swap([HxEvent::new("addTotpSwapped".to_string())]); + + let new_totp = match opt_form { + // Initial response handling, user is entering the form for first time + None => { + let cu_status = state + .qe_r_ref + .handle_idmcredentialupdate( + cu_session_token, + CURequest::TotpGenerate, + kopid.eventid, + ) + .await + // TODO: better handling for invalid mfaregstate state, can be invalid if certain mfa flows were interrupted + // TODO: We should maybe automatically cancel the other MFA reg + .map_err(|op_err| HtmxError::new(&kopid, op_err))?; + + let partial = if let CURegState::TotpCheck(secret) = cu_status.mfaregstate { + let uri = secret.to_uri(); + let 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() + } + }; + + AddTotpPartial { + check_res: TotpCheckResult::Init { + secret: secret.get_secret(), + qr_code_svg: svg, + steps: secret.step, + digits: secret.digits, + algo: secret.algo, + uri, + }, + } + } else { + return Err(ErrorResponse::from(HtmxError::new( + &kopid, + OperationError::CannotStartMFADuringOngoingMFASession, + ))); + }; + + return Ok((swapped_handler_trigger, push_url, HtmlTemplate(partial)).into_response()); + } + + // User has submitted a totp code + Some(Form(new_totp)) => new_totp, + }; + + let cu_status = if new_totp.ignore_broken_app { + // Cope with SHA1 apps because the user has intended to do so, their totp code was already verified + state.qe_r_ref.handle_idmcredentialupdate( + cu_session_token, + CURequest::TotpAcceptSha1, + kopid.eventid, + ) + } else { + // Validate totp code example + state.qe_r_ref.handle_idmcredentialupdate( + cu_session_token, + CURequest::TotpVerify(new_totp.check_totpcode, new_totp.name), + kopid.eventid, + ) + } + .await + .map_err(|op_err| HtmxError::new(&kopid, op_err))?; + + let warnings = vec![]; + let check_res = match &cu_status.mfaregstate { + CURegState::None => return Ok(get_cu_partial_response(cu_status)), + CURegState::TotpTryAgain => TotpCheckResult::Failure { + wrong_code: true, + broken_app: false, + warnings, + }, + CURegState::TotpInvalidSha1 => TotpCheckResult::Failure { + wrong_code: false, + broken_app: true, + warnings, + }, + CURegState::TotpCheck(_) + | CURegState::BackupCodes(_) + | CURegState::Passkey(_) + | CURegState::AttestedPasskey(_) => { + return Err(ErrorResponse::from(HtmxError::new( + &kopid, + OperationError::InvalidState, + ))) + } + }; + + let template = HtmlTemplate(AddTotpPartial { check_res }); + Ok((swapped_handler_trigger, push_url, template).into_response()) +} + +pub(crate) async fn view_new_pwd( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + jar: CookieJar, + opt_form: Option>, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(jar).await?; + let swapped_handler_trigger = + HxResponseTrigger::after_swap([HxEvent::new("addPasswordSwapped".to_string())]); + + let new_passwords = match opt_form { + None => { + let partial = AddPasswordPartial { + check_res: PwdCheckResult::Init, + }; + return Ok((swapped_handler_trigger, HtmlTemplate(partial)).into_response()); + } + Some(Form(new_passwords)) => new_passwords, + }; + + let pwd_equal = new_passwords.new_password == new_passwords.new_password_check; + let (warnings, status) = if pwd_equal { + let res = state + .qe_r_ref + .handle_idmcredentialupdate( + cu_session_token, + CURequest::Password(new_passwords.new_password), + kopid.eventid, + ) + .await; + match res { + Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)), + Err(OperationError::PasswordQuality(password_feedback)) => { + (password_feedback, StatusCode::UNPROCESSABLE_ENTITY) + } + Err(operr) => return Err(ErrorResponse::from(HtmxError::new(&kopid, operr))), + } + } else { + (vec![], StatusCode::UNPROCESSABLE_ENTITY) + }; + + let check_res = PwdCheckResult::Failure { + pwd_equal, + warnings, + }; + let template = HtmlTemplate(AddPasswordPartial { check_res }); + + Ok(( + status, + swapped_handler_trigger, + HxPushUrl(Uri::from_static("/ui/reset/change_password")), + template, + ) + .into_response()) +} + +pub(crate) async fn view_reset_get( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + Query(params): Query, + mut jar: CookieJar, +) -> axum::response::Result { + let domain_display_name = state.qe_r_ref.get_domain_display_name(kopid.eventid).await; + let push_url = HxPushUrl(Uri::from_static("/ui/reset")); + let cookie = jar.get(COOKIE_CU_SESSION_TOKEN); + if let Some(cookie) = cookie { + // We already have a session + let cu_session_token = cookie.value(); + let cu_session_token = CUSessionToken { + token: cu_session_token.into(), + }; + let cu_status = match state + .qe_r_ref + .handle_idmcredentialupdatestatus(cu_session_token, kopid.eventid) + .await + { + Ok(cu_status) => cu_status, + Err( + OperationError::SessionExpired + | OperationError::InvalidSessionState + | OperationError::InvalidState, + ) => { + // If our previous credential update session expired we want to see the reset form again. + jar = jar.remove(Cookie::from(COOKIE_CU_SESSION_TOKEN)); + + if let Some(token) = params.token { + let token_uri_string = format!("/ui/reset?token={token}"); + return Ok((jar, Redirect::to(token_uri_string.as_str())).into_response()); + } + return Ok((jar, Redirect::to("/ui/reset")).into_response()); + } + Err(op_err) => return Ok(HtmxError::new(&kopid, op_err).into_response()), + }; + + // CU Session cookie is okay + let cu_resp = get_cu_response(domain_display_name, cu_status); + Ok(cu_resp) + } else if let Some(token) = params.token { + // We have a reset token and want to create a new session + match state + .qe_w_ref + .handle_idmcredentialexchangeintent(CUIntentToken { token }, kopid.eventid) + .await + { + Ok((cu_session_token, cu_status)) => { + let cu_resp = get_cu_response(domain_display_name, cu_status); + + let mut token_cookie = Cookie::new(COOKIE_CU_SESSION_TOKEN, cu_session_token.token); + token_cookie.set_secure(state.secure_cookies); + token_cookie.set_same_site(SameSite::Strict); + token_cookie.set_http_only(true); + jar = jar.add(token_cookie); + + Ok((jar, cu_resp).into_response()) + } + Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => { + let cred_form_view = ResetCredFormView { + domain: domain_display_name.clone(), + wrong_code: true, + }; + + // Reset code expired + Ok((push_url, HtmlTemplate(cred_form_view)).into_response()) + } + Err(op_err) => Err(ErrorResponse::from( + HtmxError::new(&kopid, op_err).into_response(), + )), + } + } else { + let cred_form_view = ResetCredFormView { + domain: domain_display_name.clone(), + wrong_code: false, + }; + // We don't have any credential, show reset token input form + Ok((push_url, HtmlTemplate(cred_form_view)).into_response()) + } +} + +fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView { + let CUStatus { + ext_cred_portal, + warnings, + passkeys_state, + attested_passkeys_state, + attested_passkeys, + passkeys, + primary_state, + primary, + .. + } = cu_status; + + return CredResetPartialView { + ext_cred_portal, + warnings, + attested_passkeys_state, + passkeys_state, + attested_passkeys, + passkeys, + primary_state, + primary, + }; +} + +fn get_cu_partial_response(cu_status: CUStatus) -> Response { + let credentials_update_partial = get_cu_partial(cu_status); + return ( + HxPushUrl(Uri::from_static("/ui/reset")), + HxRetarget("#credentialUpdateDynamicSection".to_string()), + HxReselect("#credentialUpdateDynamicSection".to_string()), + HxReswap(SwapOption::OuterHtml), + HtmlTemplate(credentials_update_partial), + ) + .into_response(); +} + +fn get_cu_response(domain: String, cu_status: CUStatus) -> Response { + let spn = cu_status.spn.clone(); + let displayname = cu_status.displayname.clone(); + let (username, _domain) = spn.split_once('@').unwrap_or(("", &spn)); + let names = format!("{} ({})", displayname, username); + let credentials_update_partial = get_cu_partial(cu_status); + ( + HxPushUrl(Uri::from_static("/ui/reset")), + HtmlTemplate(CredResetView { + domain, + names, + credentials_update_partial, + }), + ) + .into_response() +} + +// Any filter defined in the module `filters` is accessible in your template. +mod filters { + pub fn blank_if( + implicit_arg: T, + condition: bool, + ) -> ::askama::Result { + blank_iff(implicit_arg, &condition) + } + pub fn ternary( + implicit_arg: &bool, + true_case: T, + false_case: F, + ) -> ::askama::Result { + if *implicit_arg { + Ok(format!("{true_case}")) + } else { + Ok(format!("{false_case}")) + } + } + pub fn blank_iff( + implicit_arg: T, + condition: &bool, + ) -> ::askama::Result { + return if *condition { + Ok("".into()) + } else { + Ok(format!("{implicit_arg}")) + }; + } +} diff --git a/server/core/static/external/cred_update.js b/server/core/static/external/cred_update.js new file mode 100644 index 000000000..44f1d7ab4 --- /dev/null +++ b/server/core/static/external/cred_update.js @@ -0,0 +1,127 @@ +// Makes the password form interactive (e.g. shows when passwords don't match) +function setupInteractivePwdFormListeners() { + const new_pwd = document.getElementById("new-password"); + const new_pwd_check = document.getElementById("new-password-check"); + const pwd_submit = document.getElementById("password-submit"); + + function markPwdCheckValid() { + new_pwd_check.classList.remove("is-invalid"); + new_pwd_check.classList.add("is-valid"); + pwd_submit.disabled = false; + } + + function markPwdCheckInvalid() { + new_pwd_check.classList.add("is-invalid"); + new_pwd_check.classList.remove("is-valid"); + pwd_submit.disabled = true; + } + + new_pwd.addEventListener("input", (_) => { + // Don't mark invalid if user didn't fill in the confirmation box yet + // Also my password manager (keepassxc with autocomplete) + // likes to fire off input events when both inputs were empty. + if (new_pwd_check.value !== "") { + if (new_pwd.value === new_pwd_check.value) { + markPwdCheckValid(); + } else { + markPwdCheckInvalid(); + } + } + new_pwd.classList.remove("is-invalid"); + }); + + new_pwd_check.addEventListener("input", (_) => { + // No point in updating the status if confirmation box is empty + if (new_pwd_check.value === "") return; + if (new_pwd_check.value === new_pwd.value) { + markPwdCheckValid(); + } else { + markPwdCheckInvalid(); + } + }); +} + +function stillSwapFailureResponse(event) { + if (event.detail.xhr.status === 422 || event.detail.xhr.status === 500) { + console.log("Still swapping failure response") + event.detail.shouldSwap = true; + event.detail.isError = false; + } +} + +function onPasskeyCreated(assertion) { + try { + console.log(assertion) + let creationData = {}; + + creationData.id = assertion.id; + creationData.rawId = Base64.fromUint8Array(new Uint8Array(assertion.rawId)) + creationData.response = {}; + creationData.response.attestationObject = Base64.fromUint8Array(new Uint8Array(assertion.response.attestationObject)) + creationData.response.clientDataJSON = Base64.fromUint8Array(new Uint8Array(assertion.response.clientDataJSON)) + creationData.type = assertion.type + creationData.extensions = assertion.getClientExtensionResults() + creationData.extensions.uvm = undefined + + // Put the passkey creation data into the form for submission + document.getElementById("passkey-create-data").value = JSON.stringify(creationData) + + // Make the name input visible and hide the "Begin Passkey Enrollment" button + document.getElementById("passkeyNamingSafariBtn").classList.add("d-none") + document.getElementById("passkeyNamingForm").classList.remove("d-none") + document.getElementById("passkeyNamingSubmitBtn").classList.remove("d-none") + } catch (e) { + console.log(e) + if (confirm("Failed to encode your new passkey's data for transmission, confirm to reload this page.\nReport this issue if it keeps occurring.")) { + window.location.reload(); + } + } +} + +function startPasskeyEnrollment() { + try { + const data_elem = document.getElementById('data'); + const credentialRequestOptions = JSON.parse(data_elem.textContent); + credentialRequestOptions.publicKey.challenge = Base64.toUint8Array(credentialRequestOptions.publicKey.challenge); + credentialRequestOptions.publicKey.user.id = Base64.toUint8Array(credentialRequestOptions.publicKey.user.id); + + console.log(credentialRequestOptions) + navigator.credentials + .create({publicKey: credentialRequestOptions.publicKey}) + .then((assertion) => { + onPasskeyCreated(assertion); + }, (reason) => { + alert("Passkey creation failed " + reason.toString()) + console.log("Passkey creation failed: " + reason.toString()) + }); + } catch (e) { + console.log(e) + if (confirm("Failed to initialize passkey creation, confirm to reload this page.\nReport this issue if it keeps occurring.")) { + window.location.reload(); + } + } +} + +function setupPasskeyNamingSafariButton() { + document.getElementById("passkeyNamingSafariBtn") + .addEventListener("click", startPasskeyEnrollment) +} + +function setupSubmitBtnVisibility() { + document.getElementById("passkey-label") + ?.addEventListener("input", updateSubmitButtonVisibility) +} + +function updateSubmitButtonVisibility(event) { + const submitButton = document.getElementById("passkeyNamingSubmitBtn"); + submitButton.disabled = event.value === ""; +} + +window.onload = function () { + document.body.addEventListener("addPasswordSwapped", () => { setupInteractivePwdFormListeners() }); + document.body.addEventListener("addPasskeySwapped", () => { + setupPasskeyNamingSafariButton(); + startPasskeyEnrollment(); + setupSubmitBtnVisibility(); + }); +} diff --git a/server/core/templates/cred_update/add_passkey_partial.html b/server/core/templates/cred_update/add_passkey_partial.html new file mode 100644 index 000000000..a6f46286b --- /dev/null +++ b/server/core/templates/cred_update/add_passkey_partial.html @@ -0,0 +1,27 @@ +
+
+ + + + + +
+ + + + + + +
+ + +
+
+
+
\ No newline at end of file diff --git a/server/core/templates/cred_update/add_password_partial.html b/server/core/templates/cred_update/add_password_partial.html new file mode 100644 index 000000000..2c76f9785 --- /dev/null +++ b/server/core/templates/cred_update/add_password_partial.html @@ -0,0 +1,60 @@ +
+
+ + (% let potentially_invalid_input_class = "" %) + (% let potentially_invalid_reinput_class = "" %) + (% let pwd_equal = true %) + + (% if let PwdCheckResult::Failure with { pwd_equal, warnings } = check_res %) + (% let pwd_equal = pwd_equal.clone() %) + (% let potentially_invalid_input_class = "is-invalid"|blank_if(warnings.len() == 0) %) + (% let potentially_invalid_reinput_class = "is-invalid"|blank_iff(pwd_equal) %) + (% endif %) + + + + + (% if let PwdCheckResult::Failure with { pwd_equal, warnings } = check_res %) +
+
    + (% for warn in warnings %) +
  • (( warn ))
  • + (% endfor %) +
+
+ (% endif %) + + + +
+
  • Passwords don't match
+
+
+
+ + +
+
+ diff --git a/server/core/templates/cred_update/add_totp_partial.html b/server/core/templates/cred_update/add_totp_partial.html new file mode 100644 index 000000000..05dd0e286 --- /dev/null +++ b/server/core/templates/cred_update/add_totp_partial.html @@ -0,0 +1,94 @@ +
+
+ (% if let TotpCheckResult::Init with { secret, qr_code_svg, steps, digits, algo, uri } = check_res %) +
((qr_code_svg|safe)) +
+ ((uri|safe)) + +

TOTP details

+
    +
  • Secret: (( secret ))
  • +
  • Algorithm: (( algo ))
  • +
  • Time Steps: (( steps )) sec
  • +
  • Code size: (( digits )) digits
  • +
+ (% endif %) + +
+
+ (% let potentially_invalid_name_class = "" %) + (% let potentially_invalid_check_class = "" %) + (% let wrong_code = false %) + (% let broken_app = false %) + (% if let TotpCheckResult::Failure with { wrong_code, broken_app, warnings } = check_res %) + (% let wrong_code = wrong_code.clone() %) + (% let broken_app = broken_app.clone() %) + (% let potentially_invalid_name_class = "is-invalid"|blank_if(warnings.len() == 0) %) + (% let potentially_invalid_check_class = "is-invalid"|blank_iff(wrong_code) %) + (% endif %) + + + + + (% if let TotpCheckResult::Failure with { wrong_code, broken_app, warnings } = check_res %) +
+
    + (% for warn in warnings %) +
  • (( warn ))
  • + (% endfor %) +
+
+ (% endif %) + + + + (% if broken_app || wrong_code %) +
+
    + (% if wrong_code %) +
  • Incorrect TOTP code - Please try again
  • + (% endif %) + (% if broken_app %) +
  • Your authenticator appears to be implemented in a way that uses SHA1, rather than SHA256. Are you sure you want to proceed? If you want to try with a new authenticator, enter a new code.
  • + (% endif %) +
+
+ (% endif %) + +
+
+ + (% if broken_app %) + + (% else %) + + (% endif %) +
+
+ diff --git a/server/core/templates/credentials_reset.html b/server/core/templates/credentials_reset.html new file mode 100644 index 000000000..be81660c8 --- /dev/null +++ b/server/core/templates/credentials_reset.html @@ -0,0 +1,20 @@ +(% extends "base_htmx.html" %) +(% block title %)Credentials Reset(% endblock %) + +(% block head %) + + +(% endblock %) + +(% block body %) +
+
+
+

Updating Credentials

+

(( names ))

+

(( domain ))

+
+ (( credentials_update_partial|safe )) +
+
+(% endblock %) \ No newline at end of file diff --git a/server/core/templates/credentials_reset_form.html b/server/core/templates/credentials_reset_form.html new file mode 100644 index 000000000..6bcf2e244 --- /dev/null +++ b/server/core/templates/credentials_reset_form.html @@ -0,0 +1,49 @@ +(% extends "base_htmx.html" %) + +(% block title %)Reset Credentials(% endblock %) + +(% block head %) + + + +(% endblock %) + +(% block body %) +
+
+ +

Credential Reset

+

(( domain ))

+
+
+
+ + + (% if wrong_code %) +
+
  • Unknown reset token.
    Brand-new tokens might not be synced yet,
    wait a few minutes before trying again.
+
+ (% endif %) +
+
+

+ + +

+
+(% endblock %) \ No newline at end of file diff --git a/server/core/templates/credentials_update_attested_passkeys.html b/server/core/templates/credentials_update_attested_passkeys.html new file mode 100644 index 000000000..7e7834d54 --- /dev/null +++ b/server/core/templates/credentials_update_attested_passkeys.html @@ -0,0 +1,16 @@ +
+

Attested Passkeys

+

Passkeys originating from a signed authenticator.

+(% for passkey in attested_passkeys %) +
+
(( passkey.tag ))
+
+ +
+
+(% endfor %) \ No newline at end of file diff --git a/server/core/templates/credentials_update_partial.html b/server/core/templates/credentials_update_partial.html new file mode 100644 index 000000000..458014f1a --- /dev/null +++ b/server/core/templates/credentials_update_partial.html @@ -0,0 +1,105 @@ +
+
+ (% match ext_cred_portal %) + (% when CUExtPortal::None %) + (% when CUExtPortal::Hidden %) +
+

This account is externally managed. Some features may not be available.

+ (% when CUExtPortal::Some(url) %) +
+

This account is externally managed. Some features may not be available.

+ Visit the external account portal + (% endmatch %) + + (% if warnings.len() > 0 %) +
+ (% for warning in warnings %) + (% let is_danger = [CURegWarning::WebauthnAttestationUnsatisfiable, CURegWarning::Unsatisfiable].contains(warning) %) + + + (% endfor %) + (% endif %) + + + (% match attested_passkeys_state %) + (% when CUCredState::Modifiable %) + (% include "credentials_update_attested_passkeys.html" %) + + (% when CUCredState::DeleteOnly %) + (% if attested_passkeys.len() > 0 %) + (% include "credentials_update_attested_passkeys.html" %) + (% endif %) + (% when CUCredState::AccessDeny %) + (% when CUCredState::PolicyDeny %) + (% endmatch %) + + + (% match passkeys_state %) + (% when CUCredState::Modifiable %) + (% include "credentials_update_passkeys.html" %) + +
+ + + +
+ + + (% when CUCredState::DeleteOnly %) + (% if passkeys.len() > 0 %) + (% include "credentials_update_passkeys.html" %) + (% endif %) + (% when CUCredState::AccessDeny %) + (% when CUCredState::PolicyDeny %) + (% endmatch %) + + + (% let primary_state = primary_state %) + (% include "credentials_update_primary.html" %) + +
+ +
+ + + + +
+
+
diff --git a/server/core/templates/credentials_update_passkeys.html b/server/core/templates/credentials_update_passkeys.html new file mode 100644 index 000000000..a820a22cb --- /dev/null +++ b/server/core/templates/credentials_update_passkeys.html @@ -0,0 +1,23 @@ +
+

Passkeys

+

Easy to use digital credentials with self-contained multi-factor authentication designed to replace passwords.

+

+ Windows, + MacOS, + Android, and + iOS + have built-in support for passkeys. +

+(% for passkey in passkeys %) +
+
(( passkey.tag ))
+
+ +
+
+(% endfor %) \ No newline at end of file diff --git a/server/core/templates/credentials_update_primary.html b/server/core/templates/credentials_update_primary.html new file mode 100644 index 000000000..07b16113e --- /dev/null +++ b/server/core/templates/credentials_update_primary.html @@ -0,0 +1,93 @@ +
+
+

Alternative Authentication Methods

+ +

+ (% match primary_state %) + (% when CUCredState::Modifiable %) + If possible, passkeys should be used instead, as they are phishing and exploit resistant. + (% when CUCredState::DeleteOnly %) + If possible, passkeys should be used instead, as they are phishing and exploit resistant. +
Account policy prevents you modifying these credentials, but you may remove them. + (% when CUCredState::AccessDeny %) + You do not have access to modify these credentials. + (% when CUCredState::PolicyDeny %) + Account policy prevents you from setting these credentials + (% endmatch %) +

+ + (% if matches!(primary_state, CUCredState::Modifiable) %) + (% match primary %) + (% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::Password }) %) +
Password
+

+ +

+
Time-based One Time Password (TOTP)
+

TOTPs are 6 digit codes generated on-demand as a second authentication factor.

+

+ +

+
+

+ +

+ (% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::PasswordMfa(totp_set, _security_key_labels, _backup_codes_remaining)}) %) +
Password
+

+ +

+
Time-based One Time Password (TOTP)
+

TOTPs are 6 digit codes generated on-demand as a second authentication factor.

+ (% for totp in totp_set %) + + (% endfor %) + +

+ +

+
+

+ +

+ (% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::GeneratedPassword }) %) +
Password
+

In order to set up alternative authentication methods, you must delete the generated password.

+ + (% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::Passkey(_) }) %) +

Webauthn Only - Will migrate to passkeys in a future update

+ + (% when None %) + + (% endmatch %) + (% else if matches!(primary_state, CUCredState::DeleteOnly) %) +

+ +

+ (% endif %) +
\ No newline at end of file