SSH Keys in Credentials Update (#3027)

This commit is contained in:
Wei Jian Gan 2025-02-08 09:54:41 +08:00 committed by GitHub
parent ad3cf8828f
commit 0ce1bbeddc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 271 additions and 9 deletions

2
Cargo.lock generated
View file

@ -3175,6 +3175,8 @@ dependencies = [
"serde_json", "serde_json",
"serde_with", "serde_with",
"sketching", "sketching",
"sshkey-attest",
"sshkeys",
"tempfile", "tempfile",
"time", "time",
"tokio", "tokio",

View file

@ -52,6 +52,8 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_with = { workspace = true } serde_with = { workspace = true }
sketching = { workspace = true } sketching = { workspace = true }
sshkeys = { workspace = true }
sshkey-attest = { workspace = true }
time = { workspace = true, features = ["serde", "std", "local-offset"] } time = { workspace = true, features = ["serde", "std", "local-offset"] }
tokio = { workspace = true, features = ["net", "sync", "io-util", "macros"] } tokio = { workspace = true, features = ["net", "sync", "io-util", "macros"] }
tokio-openssl = { workspace = true } tokio-openssl = { workspace = true }

View file

@ -103,6 +103,10 @@ pub fn view_router() -> Router<ServerState> {
.route("/reset/change_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("/reset/add_passkey", post(reset::view_new_passkey))
.route("/reset/set_unixcred", post(reset::view_set_unixcred)) .route("/reset/set_unixcred", post(reset::view_set_unixcred))
.route(
"/reset/add_ssh_publickey",
post(reset::view_add_ssh_publickey),
)
.route("/api/delete_alt_creds", post(reset::remove_alt_creds)) .route("/api/delete_alt_creds", post(reset::remove_alt_creds))
.route("/api/delete_unixcred", post(reset::remove_unixcred)) .route("/api/delete_unixcred", post(reset::remove_unixcred))
.route("/api/add_totp", post(reset::add_totp)) .route("/api/add_totp", post(reset::add_totp))
@ -110,6 +114,10 @@ pub fn view_router() -> Router<ServerState> {
.route("/api/remove_passkey", post(reset::remove_passkey)) .route("/api/remove_passkey", post(reset::remove_passkey))
.route("/api/finish_passkey", post(reset::finish_passkey)) .route("/api/finish_passkey", post(reset::finish_passkey))
.route("/api/cancel_mfareg", post(reset::cancel_mfareg)) .route("/api/cancel_mfareg", post(reset::cancel_mfareg))
.route(
"/api/remove_ssh_publickey",
post(reset::remove_ssh_publickey),
)
.route("/api/cu_cancel", post(reset::cancel_cred_update)) .route("/api/cu_cancel", post(reset::cancel_cred_update))
.route("/api/cu_commit", post(reset::commit)) .route("/api/cu_commit", post(reset::commit))
.layer(HxRequestGuardLayer::new("/ui")); .layer(HxRequestGuardLayer::new("/ui"));

View file

@ -14,11 +14,15 @@ use qrcode::render::svg;
use qrcode::QrCode; use qrcode::QrCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::str::FromStr; use std::str::FromStr;
use uuid::Uuid; use uuid::Uuid;
pub use sshkey_attest::proto::PublicKey as SshPublicKey;
pub use sshkeys::KeyType;
use kanidm_proto::internal::{ use kanidm_proto::internal::{
CUCredState, CUExtPortal, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus, CUCredState, CUExtPortal, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus,
CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, UserAuthToken, CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, UserAuthToken,
@ -69,6 +73,12 @@ struct CredStatusView {
credentials_update_partial: CredResetPartialView, credentials_update_partial: CredResetPartialView,
} }
struct SshKey {
key_type: KeyType,
key: String,
comment: Option<String>,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "credentials_update_partial.html")] #[template(path = "credentials_update_partial.html")]
struct CredResetPartialView { struct CredResetPartialView {
@ -83,6 +93,8 @@ struct CredResetPartialView {
primary: Option<CredentialDetail>, primary: Option<CredentialDetail>,
unixcred_state: CUCredState, unixcred_state: CUCredState,
unixcred: Option<CredentialDetail>, unixcred: Option<CredentialDetail>,
sshkeys_state: CUCredState,
sshkeys: BTreeMap<String, SshKey>,
} }
#[skip_serializing_none] #[skip_serializing_none]
@ -104,6 +116,13 @@ struct SetUnixCredPartial {
check_res: PwdCheckResult, check_res: PwdCheckResult,
} }
#[derive(Template)]
#[template(path = "credential_update_add_ssh_publickey_partial.html")]
struct AddSshPublicKeyPartial {
title_error: Option<String>,
key_error: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
enum PwdCheckResult { enum PwdCheckResult {
Success, Success,
@ -120,6 +139,17 @@ pub(crate) struct NewPassword {
new_password_check: String, new_password_check: String,
} }
#[derive(Deserialize, Debug)]
pub(crate) struct NewPublicKey {
title: String,
key: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct PublicKeyRemoveData {
name: String,
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub(crate) struct NewTotp { pub(crate) struct NewTotp {
name: String, name: String,
@ -341,6 +371,30 @@ pub(crate) async fn remove_unixcred(
Ok(get_cu_partial_response(cu_status)) Ok(get_cu_partial_response(cu_status))
} }
pub(crate) async fn remove_ssh_publickey(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
Form(publickey): Form<PublicKeyRemoveData>,
) -> 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::SshPublicKeyRemove(publickey.name),
kopid.eventid,
)
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
.await?;
Ok(get_cu_partial_response(cu_status))
}
pub(crate) async fn remove_totp( pub(crate) async fn remove_totp(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
@ -805,6 +859,95 @@ pub(crate) async fn view_set_unixcred(
.into_response()) .into_response())
} }
struct AddSshPublicKeyError {
key: Option<String>,
title: Option<String>,
}
pub(crate) async fn view_add_ssh_publickey(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo,
jar: CookieJar,
opt_form: Option<Form<NewPublicKey>>,
) -> axum::response::Result<Response> {
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
let new_key = match opt_form {
None => {
return Ok((AddSshPublicKeyPartial {
title_error: None,
key_error: None,
},)
.into_response());
}
Some(Form(new_key)) => new_key,
};
let (
AddSshPublicKeyError {
key: key_error,
title: title_error,
},
status,
) = {
let publickey = match SshPublicKey::from_string(&new_key.key) {
Err(_) => {
return Ok((AddSshPublicKeyPartial {
title_error: None,
key_error: Some("Key cannot be parsed".to_string()),
},)
.into_response());
}
Ok(publickey) => publickey,
};
let res = state
.qe_r_ref
.handle_idmcredentialupdate(
cu_session_token,
CURequest::SshPublicKey(new_key.title, publickey),
kopid.eventid,
)
.await;
match res {
Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
Err(e @ (OperationError::InvalidLabel | OperationError::DuplicateLabel)) => (
AddSshPublicKeyError {
title: Some(e.to_string()),
key: None,
},
StatusCode::UNPROCESSABLE_ENTITY,
),
Err(e @ OperationError::DuplicateKey) => (
AddSshPublicKeyError {
key: Some(e.to_string()),
title: None,
},
StatusCode::UNPROCESSABLE_ENTITY,
),
Err(operr) => {
return Err(ErrorResponse::from(HtmxError::new(
&kopid,
operr,
domain_info,
)))
}
}
};
Ok((
status,
HxPushUrl(Uri::from_static("/ui/reset/add_ssh_publickey")),
AddSshPublicKeyPartial {
title_error,
key_error,
},
)
.into_response())
}
pub(crate) async fn view_reset_get( pub(crate) async fn view_reset_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
@ -910,9 +1053,25 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
primary, primary,
unixcred_state, unixcred_state,
unixcred, unixcred,
sshkeys_state,
sshkeys,
.. ..
} = cu_status; } = cu_status;
let sshkeyss: BTreeMap<String, SshKey> = sshkeys
.iter()
.map(|(k, v)| {
(
k.clone(),
SshKey {
key_type: v.clone().key_type,
key: v.fingerprint().hash,
comment: v.comment.clone(),
},
)
})
.collect();
CredResetPartialView { CredResetPartialView {
ext_cred_portal, ext_cred_portal,
can_commit, can_commit,
@ -925,6 +1084,8 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
primary, primary,
unixcred_state, unixcred_state,
unixcred, unixcred,
sshkeys_state,
sshkeys: sshkeyss,
} }
} }

View file

@ -190,3 +190,10 @@ footer {
width: var(--icon-size); width: var(--icon-size);
height: var(--icon-size); height: var(--icon-size);
} }
.ssh-list-icon {
--icon-size: 32px;
width: var(--icon-size);
height: var(--icon-size);
transform: rotate(35deg);
}

View file

@ -0,0 +1,31 @@
<hr>
<div class="d-flex flex-column row-gap-4">
<h4>Add new SSH Key</h4>
<form class="row-gap-3 d-flex flex-column needs-validation"
hx-target="#credentialUpdateDynamicSection"
hx-post="/ui/reset/add_ssh_publickey">
<div>
<label for="key-title" class="form-label">Title</label>
<input type="text" class="form-control(% if let Some(_) = title_error %) is-invalid(% endif %)" id="key-title" name="title" aria-describedby="title-validation-feedback">
<div id="title-validation-feedback" class="invalid-feedback">
(% if let Some(title_error) = title_error %)(( title_error ))(% endif %)
</div>
</div>
<div>
<label for="key-content" class="form-label">Key</label>
<textarea class="form-control(% if let Some(_) = key_error %) is-invalid(% endif %)" id="key-content" rows="5" name="key"
aria-describedby="key-validation-feedback"
placeholder="Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'"
></textarea>
<div id="key-validation-feedback" class="invalid-feedback">
(% if let Some(key_error) = key_error %)(( key_error ))(% endif %)
</div>
</div>
<div class="column-gap-2 d-flex justify-content-end mt-2" hx-target="#credentialUpdateDynamicSection">
<button type="button" class="btn btn-danger" hx-get=((Urls::CredReset)) hx-target="body">Cancel</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>

View file

@ -85,12 +85,14 @@
(% when CUCredState::Modifiable %) (% when CUCredState::Modifiable %)
(% include "credentials_update_passkeys.html" %) (% include "credentials_update_passkeys.html" %)
<!-- Here we are modifiable so we can render the button to add passkeys --> <!-- Here we are modifiable so we can render the button to add passkeys -->
<div class="mt-3">
<button type="button" class="btn btn-primary" <button type="button" class="btn btn-primary"
hx-post="/ui/reset/add_passkey" hx-post="/ui/reset/add_passkey"
hx-vals='{"class": "Any"}' hx-vals='{"class": "Any"}'
hx-target="#credentialUpdateDynamicSection"> hx-target="#credentialUpdateDynamicSection">
Add Passkey Add Passkey
</button> </button>
</div>
(% when CUCredState::DeleteOnly %) (% when CUCredState::DeleteOnly %)
(% if passkeys.len() > 0 %) (% if passkeys.len() > 0 %)
@ -134,6 +136,55 @@
(% when CUCredState::PolicyDeny %) (% when CUCredState::PolicyDeny %)
(% endmatch %) (% endmatch %)
(% match sshkeys_state %)
(% when CUCredState::Modifiable %)
<hr class="my-4" />
<h4>SSH Keys</h4>
(% if sshkeys.len() > 0 %)
<p>This is a list of SSH keys associated with your account.</p>
<ul class="list-group">
(% for (keyname, sshkey) in sshkeys %)
<li class="list-group-item d-flex column-gap-3 py-3">
<div>
<img class="ssh-list-icon" src="/pkg/img/icons/key.svg" alt="" />
</div>
<div class="d-flex flex-column row-gap-2 flex-grow-1">
<div class="d-flex justify-content-between">
<div class="fw-bold column-gap-2">
(( keyname ))<span class="badge rounded-pill text-bg-dark ms-2">(( sshkey.key_type.short_name ))</span>
</div>
<button class="btn btn-tiny btn-danger"
hx-post="/ui/api/remove_ssh_publickey"
hx-vals='{"name": "(( keyname ))"}'
hx-target="#credentialUpdateDynamicSection">
Remove
</button>
</div>
<div><span class="font-monospace text-break">SHA256:(( sshkey.key ))</span></div>
(% if let Some(comment) = sshkey.comment %)
<div class="rounded bg-body-tertiary border border-light-subtle text-body-secondary px-2 py-1 align-self-stretch">Comment: (( comment ))</div>
(% endif %)
</div>
</li>
(% endfor %)
</ul>
(% else %)
<p>There are no SSH keys associated with your account.</p>
(% endif %)
<div class="mt-3">
<button class="btn btn-primary" type="button"
hx-post="/ui/reset/add_ssh_publickey"
hx-target="#credentialUpdateDynamicSection">
Add SSH Key
</button>
</div>
(% when CUCredState::DeleteOnly %)
(% when CUCredState::AccessDeny %)
(% when CUCredState::PolicyDeny %)
(% endmatch %)
<hr class="my-4" /> <hr class="my-4" />
<div id="cred-update-commit-bar" class="toast bs-emphasis-color bs-secondary-bg"> <div id="cred-update-commit-bar" class="toast bs-emphasis-color bs-secondary-bg">
<div class="toast-body"> <div class="toast-body">
@ -148,7 +199,7 @@
</svg> </svg>
<b>Careful</b> - Unsaved changes will be lost</div> <b>Careful</b> - Unsaved changes will be lost</div>
</span> </span>
<div class="mt-2 pt-2 border-top"> <div class="mt-3 d-flex column-gap-2">
<button class="btn btn-danger" <button class="btn btn-danger"
hx-post="/ui/api/cu_cancel" hx-post="/ui/api/cu_cancel"
hx-boost="false" hx-boost="false"

View file

@ -21,7 +21,7 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarCollapse"> <div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav me-auto mb-2 mb-md-0"> <ul class="navbar-nav">
<li> <li>
<a class="nav-link" href=((Urls::Apps))> <a class="nav-link" href=((Urls::Apps))>
<span data-feather="file"></span>Applications</a> <span data-feather="file"></span>Applications</a>
@ -31,7 +31,7 @@
<span data-feather="file"></span>Profile</a> <span data-feather="file"></span>Profile</a>
</li> </li>
</ul> </ul>
<ul class="navbar-nav me-auto mb-2 mb-md-0 ms-md-auto"> <ul class="navbar-nav ms-md-auto">
<li> <li>
<a class="nav-link" href="#" data-bs-toggle="modal" <a class="nav-link" href="#" data-bs-toggle="modal"
data-bs-target="#signoutModal">Sign out</a> data-bs-target="#signoutModal">Sign out</a>