From 0ce1bbeddc86563a9eb9922f1cdea3f09152f443 Mon Sep 17 00:00:00 2001 From: Wei Jian Gan Date: Sat, 8 Feb 2025 09:54:41 +0800 Subject: [PATCH] SSH Keys in Credentials Update (#3027) --- Cargo.lock | 2 + server/core/Cargo.toml | 2 + server/core/src/https/views/mod.rs | 8 + server/core/src/https/views/reset.rs | 161 ++++++++++++++++++ server/core/static/style.css | 7 + ...tial_update_add_ssh_publickey_partial.html | 31 ++++ .../templates/credentials_update_partial.html | 65 ++++++- server/core/templates/navbar.html | 4 +- 8 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 server/core/templates/credential_update_add_ssh_publickey_partial.html diff --git a/Cargo.lock b/Cargo.lock index 2bd45e759..6a8c1430b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3175,6 +3175,8 @@ dependencies = [ "serde_json", "serde_with", "sketching", + "sshkey-attest", + "sshkeys", "tempfile", "time", "tokio", diff --git a/server/core/Cargo.toml b/server/core/Cargo.toml index 3b7e6200c..094eec8bb 100644 --- a/server/core/Cargo.toml +++ b/server/core/Cargo.toml @@ -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 } diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index cefb475d6..1c95a2709 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -103,6 +103,10 @@ pub fn view_router() -> Router { .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 { .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")); diff --git a/server/core/src/https/views/reset.rs b/server/core/src/https/views/reset.rs index 1d00e206b..36cea8ffb 100644 --- a/server/core/src/https/views/reset.rs +++ b/server/core/src/https/views/reset.rs @@ -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, +} + #[derive(Template)] #[template(path = "credentials_update_partial.html")] struct CredResetPartialView { @@ -83,6 +93,8 @@ struct CredResetPartialView { primary: Option, unixcred_state: CUCredState, unixcred: Option, + sshkeys_state: CUCredState, + sshkeys: BTreeMap, } #[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, + key_error: Option, +} + #[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, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, + jar: CookieJar, + Form(publickey): Form, +) -> axum::response::Result { + let cu_session_token: CUSessionToken = get_cu_session(&jar).await?; + + let cu_status = state + .qe_r_ref + .handle_idmcredentialupdate( + cu_session_token, + CURequest::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, Extension(kopid): Extension, @@ -805,6 +859,95 @@ pub(crate) async fn view_set_unixcred( .into_response()) } +struct AddSshPublicKeyError { + key: Option, + title: Option, +} + +pub(crate) async fn view_add_ssh_publickey( + State(state): State, + Extension(kopid): Extension, + HxRequest(_hx_request): HxRequest, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + DomainInfo(domain_info): DomainInfo, + jar: CookieJar, + opt_form: Option>, +) -> axum::response::Result { + 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, Extension(kopid): Extension, @@ -910,9 +1053,25 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView { primary, unixcred_state, unixcred, + sshkeys_state, + sshkeys, .. } = cu_status; + let sshkeyss: BTreeMap = 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, } } diff --git a/server/core/static/style.css b/server/core/static/style.css index fe4c1c0b4..345edecb4 100644 --- a/server/core/static/style.css +++ b/server/core/static/style.css @@ -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); +} diff --git a/server/core/templates/credential_update_add_ssh_publickey_partial.html b/server/core/templates/credential_update_add_ssh_publickey_partial.html new file mode 100644 index 000000000..141e19554 --- /dev/null +++ b/server/core/templates/credential_update_add_ssh_publickey_partial.html @@ -0,0 +1,31 @@ +
+
+

Add new SSH Key

+
+
+ + +
+ (% if let Some(title_error) = title_error %)(( title_error ))(% endif %) +
+
+
+ + +
+ (% if let Some(key_error) = key_error %)(( key_error ))(% endif %) +
+
+ +
+ + +
+
+
+ diff --git a/server/core/templates/credentials_update_partial.html b/server/core/templates/credentials_update_partial.html index 277f47583..cb842e29d 100644 --- a/server/core/templates/credentials_update_partial.html +++ b/server/core/templates/credentials_update_partial.html @@ -85,12 +85,14 @@ (% when CUCredState::Modifiable %) (% include "credentials_update_passkeys.html" %) - +
+ +
(% when CUCredState::DeleteOnly %) (% if passkeys.len() > 0 %) @@ -134,6 +136,55 @@ (% when CUCredState::PolicyDeny %) (% endmatch %) + (% match sshkeys_state %) + (% when CUCredState::Modifiable %) +
+

SSH Keys

+ (% if sshkeys.len() > 0 %) +

This is a list of SSH keys associated with your account.

+
    + (% for (keyname, sshkey) in sshkeys %) +
  • +
    + +
    +
    +
    +
    + (( keyname ))(( sshkey.key_type.short_name )) +
    + +
    +
    SHA256:(( sshkey.key ))
    + (% if let Some(comment) = sshkey.comment %) +
    Comment: (( comment ))
    + (% endif %) +
    +
  • + (% endfor %) +
+ (% else %) +

There are no SSH keys associated with your account.

+ (% endif %) +
+ +
+ + (% when CUCredState::DeleteOnly %) + (% when CUCredState::AccessDeny %) + (% when CUCredState::PolicyDeny %) + (% endmatch %) + +
@@ -148,7 +199,7 @@ Careful - Unsaved changes will be lost
-
+