[htmx] Credential Update page (#2897)

Implement credential update page in HTMX

---------

Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>
Co-authored-by: Firstyear <william@blackhats.net.au>
This commit is contained in:
Merlijn 2024-08-01 03:17:14 +02:00 committed by GitHub
parent 329750981e
commit f82a52de3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1390 additions and 13 deletions

1
Cargo.lock generated
View file

@ -3444,6 +3444,7 @@ dependencies = [
"libc",
"openssl",
"opentelemetry",
"qrcode",
"rand",
"regex",
"serde",

View file

@ -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 => {

View file

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

View file

@ -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";

View file

@ -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"] }

View file

@ -139,6 +139,7 @@ pub fn get_js_files(role: ServerRole) -> Result<JavaScriptFiles, ()> {
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),

View file

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

View file

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

View file

@ -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<CURegWarning>,
attested_passkeys_state: CUCredState,
passkeys_state: CUCredState,
primary_state: CUCredState,
attested_passkeys: Vec<PasskeyDetail>,
passkeys: Vec<PasskeyDetail>,
primary: Option<CredentialDetail>,
}
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug)]
// Needs to be visible so axum can create this struct
pub(crate) struct ResetTokenParam {
token: Option<String>,
}
#[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<PasswordFeedback>,
},
}
#[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<TotpFeedback>,
},
}
#[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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
) -> axum::response::Result<Response> {
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<CUSessionToken, Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(totp): Form<TOTPRemoveData>,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(passkey): Form<PasskeyRemoveData>,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(passkey_create): Form<PasskeyCreateForm>,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(init_form): Form<PasskeyInitForm>,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
opt_form: Option<Form<NewTotp>>,
) -> axum::response::Result<Response> {
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::<svg::Color>().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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
jar: CookieJar,
opt_form: Option<Form<NewPassword>>,
) -> axum::response::Result<Response> {
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<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
Query(params): Query<ResetTokenParam>,
mut jar: CookieJar,
) -> axum::response::Result<Response> {
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<T: std::fmt::Display>(
implicit_arg: T,
condition: bool,
) -> ::askama::Result<String> {
blank_iff(implicit_arg, &condition)
}
pub fn ternary<T: std::fmt::Display, F: std::fmt::Display>(
implicit_arg: &bool,
true_case: T,
false_case: F,
) -> ::askama::Result<String> {
if *implicit_arg {
Ok(format!("{true_case}"))
} else {
Ok(format!("{false_case}"))
}
}
pub fn blank_iff<T: std::fmt::Display>(
implicit_arg: T,
condition: &bool,
) -> ::askama::Result<String> {
return if *condition {
Ok("".into())
} else {
Ok(format!("{implicit_arg}"))
};
}
}

View file

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

View file

@ -0,0 +1,27 @@
<div>
<div class="row" id="staticPasskeyCreateRow">
<script id="data">(( challenge|safe ))</script>
<!-- Safari requires a human input to start passkey creation -->
<button id="passkeyNamingSafariBtn" class="btn btn-primary">Begin Passkey Enrolment</button>
<form id="passkeyNamingForm" class="g-2 d-none">
<label for="passkey-label" class="form-label">Please name this Passkey</label>
<input type="text" name="name" id="passkey-label" autofocus required class="form-control">
<!-- Hidden inputs to put info passkey into form submission -->
<input hidden type="text" name="creationData" id="passkey-create-data">
<div class="d-flex justify-content-end pt-3 g-3" hx-target="#credentialUpdateDynamicSection">
<button id="password-cancel" type="button" class="btn btn-danger" hx-post="/ui/api/cancel_mfareg">Cancel</button>
<button
type="submit"
hx-post="/ui/api/finish_passkey"
hx-vals='{"class": "(( class ))"}'
id="passkeyNamingSubmitBtn"
class="btn btn-success d-none ms-2" disabled
>Submit</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,60 @@
<div>
<form class="row g-2 pb-3 needs-validation" id="newPasswordForm" novalidate>
<input hidden type="text" autocomplete="username" />
(% 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 %)
<label for="new-password" class="form-label">Enter New Password</label>
<input
aria-describedby="password-validation-feedback"
autocomplete="new-password"
class="form-control ((potentially_invalid_input_class))"
name="new_password"
id="new-password"
placeholder=""
type="password"
required
autofocus
/>
<!-- bootstrap hides the feedback if we remove is-invalid from the input above -->
(% if let PwdCheckResult::Failure with { pwd_equal, warnings } = check_res %)
<div id="password-validation-feedback" class="invalid-feedback d-block">
<ul>
(% for warn in warnings %)
<li>(( warn ))</li>
(% endfor %)
</ul>
</div>
(% endif %)
<label for="new-password-check" class="form-label">Repeat Password</label>
<input
aria-describedby="neq-password-validation-feedback"
autocomplete="new-password"
class="form-control ((potentially_invalid_reinput_class))"
name="new_password_check"
id="new-password-check"
placeholder=""
type="password"
required
/>
<div id="neq-password-validation-feedback" class="invalid-feedback">
<ul><li>Passwords don't match</li></ul>
</div>
</form>
<div class="g-3 d-flex justify-content-end" hx-target="#credentialUpdateDynamicSection">
<button id="password-cancel" type="button" class="btn btn-danger me-2" hx-get="/ui/reset">Cancel</button>
<button id="password-submit" type="button" class="btn btn-primary"
hx-post="/ui/reset/add_password"
hx-include="#newPasswordForm"
>Submit</button>
</div>
</div>

View file

@ -0,0 +1,94 @@
<div>
<div id="totpInfo">
(% if let TotpCheckResult::Init with { secret, qr_code_svg, steps, digits, algo, uri } = check_res %)
<div>((qr_code_svg|safe))
</div>
<code>((uri|safe))</code>
<h3>TOTP details</h3>
<ul>
<li>Secret: (( secret ))</li>
<li>Algorithm: (( algo ))</li>
<li>Time Steps: (( steps )) sec</li>
<li>Code size: (( digits )) digits</li>
</ul>
(% endif %)
</div>
<form class="row g-2 pb-3 needs-validation" id="newTotpForm" novalidate>
(% 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 %)
<label for="new-totp-name" class="form-label">Enter a name for your TOTP</label>
<input
aria-describedby="totp-name-validation-feedback"
class="form-control ((potentially_invalid_name_class))"
name="name"
id="new-totp-name"
required
autofocus
/>
<!-- bootstrap hides the feedback if we remove is-invalid from the input above -->
(% if let TotpCheckResult::Failure with { wrong_code, broken_app, warnings } = check_res %)
<div id="totp-name-validation-feedback" class="invalid-feedback d-block">
<ul>
(% for warn in warnings %)
<li>(( warn ))</li>
(% endfor %)
</ul>
</div>
(% endif %)
<label for="new-totp-check" class="form-label">Enter a TOTP code to confirm it's working</label>
<input
aria-describedby="new-totp-check-feedback"
class="form-control ((potentially_invalid_check_class))"
name="checkTOTPCode"
id="new-totp-check"
type="number"
required
/>
(% if broken_app || wrong_code %)
<div id="neq-totp-validation-feedback" class="invalid-feedback">
<ul>
(% if wrong_code %)
<li>Incorrect TOTP code - Please try again</li>
(% endif %)
(% if broken_app %)
<li>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.</li>
(% endif %)
</ul>
</div>
(% endif %)
</form>
<div class="g-3 d-flex justify-content-end" hx-target="#credentialUpdateDynamicSection">
<button id="totp-cancel" type="button" class="btn btn-danger me-2" hx-post="/ui/api/cancel_mfareg">Cancel</button>
(% if broken_app %)
<button id="totp-submit" type="button" class="btn btn-warning"
hx-post="/ui/reset/add_totp"
hx-target="#newTotpForm"
hx-select="#newTotpForm > *"
hx-vals='{"ignoreBrokenApp": true}'
hx-include="#newTotpForm"
>Accept SHA1</button>
(% else %)
<button id="totp-submit" type="button" class="btn btn-primary"
hx-post="/ui/reset/add_totp"
hx-target="#newTotpForm"
hx-select="#newTotpForm > *"
hx-vals='{"ignoreBrokenApp": false}'
hx-include="#newTotpForm"
>Submit</button>
(% endif %)
</div>
</div>

View file

@ -0,0 +1,20 @@
(% extends "base_htmx.html" %)
(% block title %)Credentials Reset(% endblock %)
(% block head %)
<script src="/pkg/external/cred_update.js"></script>
<script src="/pkg/external/base64.js" async></script>
(% endblock %)
(% block body %)
<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>
(( credentials_update_partial|safe ))
</main>
</div>
(% endblock %)

View file

@ -0,0 +1,49 @@
(% extends "base_htmx.html" %)
(% block title %)Reset Credentials(% endblock %)
(% block head %)
<!-- TODO: janky preloading them here because I assumed htmx swapped new scripts in on boosted requests, we can replace navigation to cred update with a full redirect later, and clean this up then -->
<script src="/pkg/external/cred_update.js"></script>
<script src="/pkg/external/base64.js" async></script>
(% endblock %)
(% block body %)
<main class="flex-shrink-0 container form-signin" id="cred-reset-form">
<center>
<img src="/pkg/img/logo-square.svg" alt="Kanidm" class="kanidm_logo"/>
<h2>Credential Reset</h2>
<h3>(( domain ))</h3>
</center>
<form class="mb-3">
<div>
<label for="token" class="form-label">Enter your credential reset token.</label>
<input
id="token"
name="token"
autofocus
aria-describedby="unknown-reset-token-validation-feedback"
class='form-control (( "is-invalid"|blank_iff(!wrong_code) ))'
>
(% if wrong_code %)
<div id="unknown-reset-token-validation-feedback" class="invalid-feedback">
<ul><li>Unknown reset token.<br>Brand-new tokens might not be synced yet, <br>wait a few minutes before trying again.</li></ul>
</div>
(% endif %)
</div>
</form>
<p class="d-flex flex-row flex-wrap justify-content-between">
<button class="btn btn-secondary" aria-label="Return home"
hx-get="/ui/apps" hx-target="#cred-reset-form" hx-select="#cred-reset-form" hx-swap="outerHTML">
Return to the home page
</button>
<button class="btn btn-primary"
hx-get="/ui/reset"
hx-include="#token"
hx-target="#cred-reset-form" hx-select="#cred-reset-form" hx-swap="outerHTML"
type="submit">
Submit
</button>
</p>
</main>
(% endblock %)

View file

@ -0,0 +1,16 @@
<hr class="my-4" />
<h4>Attested Passkeys</h4>
<p>Passkeys originating from a signed authenticator.</p>
(% for passkey in attested_passkeys %)
<div class="row mb-3">
<div class="col d-flex align-items-center"><span>(( passkey.tag ))</span></div>
<div class="col d-flex justify-content-end">
<button type="button" class="btn btn-danger btn-sml" id="(( passkey.tag ))"
hx-target="#credentialUpdateDynamicSection"
hx-confirm="Are you sure you want to delete attested passkey (( passkey.tag )) ?"
hx-post="/ui/api/remove_passkey" hx-vals='{"uuid": "(( passkey.uuid ))"}'>
Remove
</button>
</div>
</div>
(% endfor %)

View file

@ -0,0 +1,105 @@
<div class="row g-3" id="credentialUpdateDynamicSection" hx-on::before-swap="stillSwapFailureResponse(event)">
<form class="needs-validation" novalidate>
(% match ext_cred_portal %)
(% when CUExtPortal::None %)
(% when CUExtPortal::Hidden %)
<hr class="my-4" />
<p>This account is externally managed. Some features may not be available.</p>
(% when CUExtPortal::Some(url) %)
<hr class="my-4" />
<p>This account is externally managed. Some features may not be available.</p>
<a href="(( url ))">Visit the external account portal</a>
(% endmatch %)
(% if warnings.len() > 0 %)
<hr class="my-4" >
(% for warning in warnings %)
(% let is_danger = [CURegWarning::WebauthnAttestationUnsatisfiable, CURegWarning::Unsatisfiable].contains(warning) %)
<div class='alert alert-(( is_danger|ternary("danger", "warning") ))' role="alert">
(% match warning %)
(% when CURegWarning::MfaRequired %)
Multi-Factor Authentication is required for your account. Either add TOTP or remove your password in favour of passkeys to submit.
(% when CURegWarning::PasskeyRequired %)
Passkeys are required for your account.
(% when CURegWarning::AttestedPasskeyRequired %)
Attested Passkeys are required for your account.
(% when CURegWarning::AttestedResidentKeyRequired %)
Attested Resident Keys are required for your account.
(% when CURegWarning::WebauthnAttestationUnsatisfiable %)
A webauthn attestation policy conflict has occurred and you will not be able to save your credentials
(% when CURegWarning::Unsatisfiable %)
An account policy conflict has occurred and you will not be able to save your credentials
(% endmatch %)
(% if is_danger %)
<br><br>
<b>Contact support IMMEDIATELY.</b>
(% endif %)
</div>
(% endfor %)
(% endif %)
<!-- Attested Passkeys -->
(% match attested_passkeys_state %)
(% when CUCredState::Modifiable %)
(% include "credentials_update_attested_passkeys.html" %)
<button type="button" class="btn btn-primary" hx-post="/ui/reset/add_passkey" hx-vals='{"class": "Attested"}' hx-target="#credentialUpdateDynamicSection">
Add Attested Passkey
</button>
(% when CUCredState::DeleteOnly %)
(% if attested_passkeys.len() > 0 %)
(% include "credentials_update_attested_passkeys.html" %)
(% endif %)
(% when CUCredState::AccessDeny %)
(% when CUCredState::PolicyDeny %)
(% endmatch %)
<!-- Passkeys -->
(% match passkeys_state %)
(% when CUCredState::Modifiable %)
(% include "credentials_update_passkeys.html" %)
<!-- Here we are modifiable so we can render the button to add passkeys -->
<div class="btn-group">
<button type="button" class="btn btn-primary" hx-post="/ui/reset/add_passkey"
hx-vals='{"class": "Any"}'
hx-target="#credentialUpdateDynamicSection">
Add Passkey
</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" hx-post="/ui/api/cancel_mfareg" hx-swap="none">Cancel MFA Registration session</a></li>
</ul>
</div>
(% when CUCredState::DeleteOnly %)
(% if passkeys.len() > 0 %)
(% include "credentials_update_passkeys.html" %)
(% endif %)
(% when CUCredState::AccessDeny %)
(% when CUCredState::PolicyDeny %)
(% endmatch %)
<!-- Password, totp credentials -->
(% let primary_state = primary_state %)
(% include "credentials_update_primary.html" %)
<hr class="my-4"/>
<div class="d-flex flex-row flex-wrap justify-content-between">
<button class="btn btn-danger btn-lg" hx-post="/ui/api/cu_cancel">Cancel</button>
<span class="d-inline-block" tabindex="0" data-bs-toggle="tooltip" data-bs-title="Resolve the warnings at the top.">
<button
class="btn btn-success btn-lg"
type="submit"
hx-post="/ui/api/cu_commit"
hx-boost="false"
(( "disabled"|blank_if(warnings.len() == 0) ))
>Submit Changes</button>
</span>
</div>
</form>
</div>

View file

@ -0,0 +1,23 @@
<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 passkey in passkeys %)
<div class="row mb-3">
<div class="col d-flex align-items-center"><span>(( passkey.tag ))</span></div>
<div class="col d-flex justify-content-end">
<button type="button" class="btn btn-danger btn-sml" id="(( passkey.tag ))"
hx-target="#credentialUpdateDynamicSection"
hx-confirm="Are you sure you want to delete passkey (( passkey.tag )) ?"
hx-post="/ui/api/remove_passkey" hx-vals='{"uuid": "(( passkey.uuid ))"}'>
Remove
</button>
</div>
</div>
(% endfor %)

View file

@ -0,0 +1,93 @@
<div hx-target="#credentialUpdateDynamicSection">
<hr class="my-4" />
<h4>Alternative Authentication Methods</h4>
<p>
(% 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.
<br>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 %)
</p>
(% if matches!(primary_state, CUCredState::Modifiable) %)
(% match primary %)
(% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::Password }) %)
<h6><b>Password</b></h6>
<p>
<button type="button" class="btn btn-primary" hx-post="/ui/reset/change_password">
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>
<button type="button" class="btn btn-success" hx-post="/ui/reset/add_totp">
Add TOTP
</button>
</p>
<br/>
<p>
<button type="button" class="btn btn-danger" hx-post="/ui/api/delete_alt_creds" hx-confirm="Delete your Password and any associated MFA?\nNote: this will not remove Passkeys.">
Delete Alternative Credentials
</button>
</p>
(% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::PasswordMfa(totp_set, _security_key_labels, _backup_codes_remaining)}) %)
<h6><b>Password</b></h6>
<p>
<button type="button" class="btn btn-primary" hx-post="/ui/reset/change_password">
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>
(% for totp in totp_set %)
<button type="button" class="btn btn-warning" hx-post="/ui/api/remove_totp" hx-vals='{"name": "(( totp ))"}'>
Remove totp (( totp ))
</button>
(% endfor %)
<p>
<button type="button" class="btn btn-success" hx-post="/ui/reset/add_totp">
Add TOTP
</button>
</p>
<br/>
<p>
<button type="button" class="btn btn-danger" hx-post="/ui/api/delete_alt_creds" hx-confirm="Delete your Password and any associated MFA?
Note: this will not remove Passkeys.">
Delete Alternative Credentials
</button>
</p>
(% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::GeneratedPassword }) %)
<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" hx-post="/ui/api/delete_alt_creds" >
Delete Generated Password
</button>
(% when Some(CredentialDetail { uuid, type_: kanidm_proto::internal::CredentialDetailType::Passkey(_) }) %)
<p>Webauthn Only - Will migrate to passkeys in a future update</p>
<button type="button" class="btn btn-danger" hx-post="/ui/api/delete_alt_creds" hx-confirm="Delete your Password and any associated MFA?
Note: this will not remove Passkeys.">
Delete Alternative Credentials
</button>
(% when None %)
<button type="button" class="btn btn-warning" hx-post="/ui/reset/add_password">
Add Password
</button>
(% endmatch %)
(% else if matches!(primary_state, CUCredState::DeleteOnly) %)
<p>
<button type="button" class="btn btn-warning" hx-post="/ui/api/delete_alt_creds" hx-confirm="Delete your Password and any associated MFA?
Note: this will not remove Passkeys.">
Delete Legacy Credentials
</button>
</p>
(% endif %)
</div>