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_with",
"sketching",
"sshkey-attest",
"sshkeys",
"tempfile",
"time",
"tokio",

View file

@ -52,6 +52,8 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_with = { workspace = true }
sketching = { workspace = true }
sshkeys = { workspace = true }
sshkey-attest = { workspace = true }
time = { workspace = true, features = ["serde", "std", "local-offset"] }
tokio = { workspace = true, features = ["net", "sync", "io-util", "macros"] }
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/add_passkey", post(reset::view_new_passkey))
.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_unixcred", post(reset::remove_unixcred))
.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/finish_passkey", post(reset::finish_passkey))
.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_commit", post(reset::commit))
.layer(HxRequestGuardLayer::new("/ui"));

View file

@ -14,11 +14,15 @@ use qrcode::render::svg;
use qrcode::QrCode;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use uuid::Uuid;
pub use sshkey_attest::proto::PublicKey as SshPublicKey;
pub use sshkeys::KeyType;
use kanidm_proto::internal::{
CUCredState, CUExtPortal, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus,
CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, UserAuthToken,
@ -69,6 +73,12 @@ struct CredStatusView {
credentials_update_partial: CredResetPartialView,
}
struct SshKey {
key_type: KeyType,
key: String,
comment: Option<String>,
}
#[derive(Template)]
#[template(path = "credentials_update_partial.html")]
struct CredResetPartialView {
@ -83,6 +93,8 @@ struct CredResetPartialView {
primary: Option<CredentialDetail>,
unixcred_state: CUCredState,
unixcred: Option<CredentialDetail>,
sshkeys_state: CUCredState,
sshkeys: BTreeMap<String, SshKey>,
}
#[skip_serializing_none]
@ -104,6 +116,13 @@ struct SetUnixCredPartial {
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)]
enum PwdCheckResult {
Success,
@ -120,6 +139,17 @@ pub(crate) struct NewPassword {
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)]
pub(crate) struct NewTotp {
name: String,
@ -341,6 +371,30 @@ pub(crate) async fn remove_unixcred(
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(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
@ -805,6 +859,95 @@ pub(crate) async fn view_set_unixcred(
.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(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
@ -910,9 +1053,25 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
primary,
unixcred_state,
unixcred,
sshkeys_state,
sshkeys,
..
} = 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 {
ext_cred_portal,
can_commit,
@ -925,6 +1084,8 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
primary,
unixcred_state,
unixcred,
sshkeys_state,
sshkeys: sshkeyss,
}
}

View file

@ -190,3 +190,10 @@ footer {
width: 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 %)
(% include "credentials_update_passkeys.html" %)
<!-- Here we are modifiable so we can render the button to add passkeys -->
<button type="button" class="btn btn-primary"
hx-post="/ui/reset/add_passkey"
hx-vals='{"class": "Any"}'
hx-target="#credentialUpdateDynamicSection">
Add Passkey
</button>
<div class="mt-3">
<button type="button" class="btn btn-primary"
hx-post="/ui/reset/add_passkey"
hx-vals='{"class": "Any"}'
hx-target="#credentialUpdateDynamicSection">
Add Passkey
</button>
</div>
(% when CUCredState::DeleteOnly %)
(% if passkeys.len() > 0 %)
@ -134,6 +136,55 @@
(% when CUCredState::PolicyDeny %)
(% 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" />
<div id="cred-update-commit-bar" class="toast bs-emphasis-color bs-secondary-bg">
<div class="toast-body">
@ -148,7 +199,7 @@
</svg>
<b>Careful</b> - Unsaved changes will be lost</div>
</span>
<div class="mt-2 pt-2 border-top">
<div class="mt-3 d-flex column-gap-2">
<button class="btn btn-danger"
hx-post="/ui/api/cu_cancel"
hx-boost="false"

View file

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