mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 04:27:02 +01:00
[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:
parent
329750981e
commit
f82a52de3b
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -3444,6 +3444,7 @@ dependencies = [
|
|||
"libc",
|
||||
"openssl",
|
||||
"opentelemetry",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"regex",
|
||||
"serde",
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
736
server/core/src/https/views/reset.rs
Normal file
736
server/core/src/https/views/reset.rs
Normal 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}"))
|
||||
};
|
||||
}
|
||||
}
|
127
server/core/static/external/cred_update.js
vendored
Normal file
127
server/core/static/external/cred_update.js
vendored
Normal 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();
|
||||
});
|
||||
}
|
27
server/core/templates/cred_update/add_passkey_partial.html
Normal file
27
server/core/templates/cred_update/add_passkey_partial.html
Normal 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>
|
60
server/core/templates/cred_update/add_password_partial.html
Normal file
60
server/core/templates/cred_update/add_password_partial.html
Normal 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>
|
||||
|
94
server/core/templates/cred_update/add_totp_partial.html
Normal file
94
server/core/templates/cred_update/add_totp_partial.html
Normal 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>
|
||||
|
20
server/core/templates/credentials_reset.html
Normal file
20
server/core/templates/credentials_reset.html
Normal 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 %)
|
49
server/core/templates/credentials_reset_form.html
Normal file
49
server/core/templates/credentials_reset_form.html
Normal 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 %)
|
|
@ -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 %)
|
105
server/core/templates/credentials_update_partial.html
Normal file
105
server/core/templates/credentials_update_partial.html
Normal 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>
|
23
server/core/templates/credentials_update_passkeys.html
Normal file
23
server/core/templates/credentials_update_passkeys.html
Normal 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 %)
|
93
server/core/templates/credentials_update_primary.html
Normal file
93
server/core/templates/credentials_update_primary.html
Normal 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>
|
Loading…
Reference in a new issue