diff --git a/Cargo.lock b/Cargo.lock index 61bf8214f..07b8db93e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" dependencies = [ "cfg-if", + "getrandom", "once_cell", "version_check", ] @@ -973,6 +974,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot 0.12.1", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1004,6 +1030,45 @@ dependencies = [ "memchr", ] +[[package]] +name = "cursive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5438eb16bdd8af51b31e74764fef5d0a9260227a5ec82ba75c9d11ce46595839" +dependencies = [ + "ahash 0.8.3", + "cfg-if", + "crossbeam-channel", + "crossterm", + "cursive_core", + "lazy_static", + "libc", + "log", + "signal-hook", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "cursive_core" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4db3b58161228d0dcb45c7968c5e74c3f03ad39e8983e58ad7d57061aa2cd94d" +dependencies = [ + "ahash 0.8.3", + "crossbeam-channel", + "enum-map", + "enumset", + "lazy_static", + "log", + "num", + "owning_ref", + "time 0.3.25", + "unicode-segmentation", + "unicode-width", + "xi-unicode", +] + [[package]] name = "daemon" version = "1.1.0-rc.14-dev" @@ -1285,6 +1350,26 @@ dependencies = [ "syn 2.0.29", ] +[[package]] +name = "enum-map" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9705d8de4776df900a4a0b2384f8b0ab42f775e93b083b42f8ce71bdc32a47e3" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccb14d927583dd5c2eac0f2cf264fc4762aefe1ae14c47a8a20fc1939d3a5fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + [[package]] name = "enumflags2" version = "0.7.7" @@ -1305,6 +1390,27 @@ dependencies = [ "syn 2.0.29", ] +[[package]] +name = "enumset" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e875f1719c16de097dee81ed675e2d9bb63096823ed3f0ca827b7dea3028bbbb" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08b6c6ab82d70f08844964ba10c7babb716de2ecaeab9be5717918a5177d3af" +dependencies = [ + "darling 0.20.3", + "proc-macro2", + "quote", + "syn 2.0.29", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -2080,7 +2186,7 @@ dependencies = [ "byteorder", "color_quant", "num-iter", - "num-rational", + "num-rational 0.3.2", "num-traits", ] @@ -2322,13 +2428,16 @@ dependencies = [ "clap", "clap_complete", "compact_jwt", + "cursive", "dialoguer", "futures-concurrency", "kanidm_build_profiles", "kanidm_client", "kanidm_proto", + "lazy_static", "libc", "qrcode", + "regex", "rpassword 7.2.0", "serde", "serde_json", @@ -2963,6 +3072,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational 0.4.1", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.3" @@ -2974,6 +3096,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.3.3" @@ -3017,6 +3148,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.16" @@ -3209,6 +3351,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owning_ref" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff55baddef9e4ad00f88b6c743a2a8062d4c6ade126c2a528644b8e444d52ce" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "pam_kanidm" version = "1.1.0-rc.14-dev" @@ -4197,6 +4348,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -5446,6 +5618,12 @@ dependencies = [ "time 0.3.25", ] +[[package]] +name = "xi-unicode" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" + [[package]] name = "yew" version = "0.20.0" diff --git a/book/src/developers/designs/diagrams/idv_api_diagram.drawio b/book/src/developers/designs/diagrams/idv_api_diagram.drawio new file mode 100644 index 000000000..0355709c2 --- /dev/null +++ b/book/src/developers/designs/diagrams/idv_api_diagram.drawio @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/book/src/developers/designs/diagrams/idv_api_diagram.drawio.svg b/book/src/developers/designs/diagrams/idv_api_diagram.drawio.svg new file mode 100644 index 000000000..ffbbe25e8 --- /dev/null +++ b/book/src/developers/designs/diagrams/idv_api_diagram.drawio.svg @@ -0,0 +1,4 @@ + + + +
The user with the lowest uuid is the first 
to ask the other user for their code
The user with the lowest uuid is the first...
The user with the highest
uuid is the first to provide 
their code to the other user
The user with the highest...
Start
Start
WaitForCode
WaitForCode
ProvideCode {totp: u32, step: u32}
ProvideCode {totp: u32, step:...
This allows to refetch the 
totp once it expires
This allows to refetch the...
DisplayCode
DisplayCode
if the code provided was correct and we have the lowest
 uuid it means we just successfully received the other
user's code and now we have to provide it 
if the code provided was correct and we have the lowest...
if the provided code didn't match 
the other user's code you get CodeFailure,
if it was correct refer to the other two 
responses of SubmitCode
if the provided code didn't match...
if the code provided was correct and we have the highest uuid it means we already provided our code 
and we also successfully just received the other user code, so the flow terminates with success
if the code provided was correct and we have the highest uuid it means we already provided our code...
SubmitCode
SubmitCode
CodeFailure
CodeFailure
Success
Success
Text is not SVG - cannot display
\ No newline at end of file diff --git a/book/src/developers/designs/diagrams/idv_generic_responses.drawio b/book/src/developers/designs/diagrams/idv_generic_responses.drawio new file mode 100644 index 000000000..e59c365ab --- /dev/null +++ b/book/src/developers/designs/diagrams/idv_generic_responses.drawio @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/book/src/developers/designs/diagrams/idv_generic_responses.drawio.svg b/book/src/developers/designs/diagrams/idv_generic_responses.drawio.svg new file mode 100644 index 000000000..f4dae0956 --- /dev/null +++ b/book/src/developers/designs/diagrams/idv_generic_responses.drawio.svg @@ -0,0 +1,4 @@ + + + +
IdentityVerificationUnavailable 
IdentityVerificationUnavailabl...
IdentityVerificationAvailable 
IdentityVerificationAvailable 
InvalidUserId
InvalidUserId
if you send a request with your own id you will always get this response, regardless of what IdentifyUserRequest was sent.
Its purpose is to just check if the idv is available
if you send a request with your own id you will always get this response, regardless of what...
If the user cannot access the idv this will be the ONLY IdentityVerificationrResponse
they will ever get, regardless of the IdentifyUserRequest or the id* sent.
*there is a cavet about what id you send, as providing a non existing id will make the server throw a 404 http error instead of returning an idv response at all. Although this doesn’t contradict what previously stated, as an http error is not an IdentityVerificationResponse instance, don’t rely on
the api always returning IdentityVerificationUnavailable if the user cannot access the idv.
If the user cannot access the idv this will be the ONLY IdentityVerificationrResponse...
This response is issued only when the provided
userId exists but cannot access the idv feature. If a non existing user id is provided then a http 404 error is returned.
This response is issued only when the provided...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/book/src/developers/designs/diagrams/idv_state_machine.drawio b/book/src/developers/designs/diagrams/idv_state_machine.drawio new file mode 100644 index 000000000..e432e6024 --- /dev/null +++ b/book/src/developers/designs/diagrams/idv_state_machine.drawio @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/book/src/developers/designs/diagrams/idv_state_machine.drawio.svg b/book/src/developers/designs/diagrams/idv_state_machine.drawio.svg new file mode 100644 index 000000000..ad3055a3f --- /dev/null +++ b/book/src/developers/designs/diagrams/idv_state_machine.drawio.svg @@ -0,0 +1,4 @@ + + + +
Start
Start
WaitForCode
WaitForCode
ProvideCode {totp: u32, step: u32}
ProvideCode {totp: u32, step:...
DisplayCode
DisplayCode
SubmitCode
SubmitCode
IdentityVerificationUnavailable 
IdentityVerificationUnavailabl...
IdentityVerificationAvailable 
IdentityVerificationAvailable 
Start
Start
InvalidUserId
InvalidUserId
SubmitCode
SubmitCode
CodeFailure
CodeFailure
ProvideCode {totp: u32, step: u32}
ProvideCode {totp: u32, step:...
Success
Success
The other user didn't confirm the 
code we provided was correct
The other user didn't confirm...
The other user didn't confirm the 
code we provided was correct
The other user didn't confirm...
WaitForCode
WaitForCode
DisplayCode
DisplayCode
Success
Success
CodeFailure
CodeFailure
Text is not SVG - cannot display
\ No newline at end of file diff --git a/book/src/developers/designs/identity_verification_feature.md b/book/src/developers/designs/identity_verification_feature.md new file mode 100644 index 000000000..83e85879e --- /dev/null +++ b/book/src/developers/designs/identity_verification_feature.md @@ -0,0 +1,39 @@ +# The identity verification API + +The following diagram describes the api request/response of the identity verification feature (from here on referred as “idv”). The api takes an _IdentifyUserRequest_ instance as input, which in the diagram is represented by a circle shape, and it returns an _IdentifyUserResponse_, which is represented by a rectangle. +The response rectangles are colored with green or red, and although all responses belong to the same enum, the colors are meant to provide additional information. A green response means that the input was valid and therefore it contains the next step in the identity verification flow, while a red response means the input was invalid and the flow terminates there. Note that the protocol is completely stateless, so the following diagram is not to be intended as a state machine, for the idv state machine go [here](#the-identity-verification-state-machine-idv). + +![idv api diagram](diagrams/idv_api_diagram.drawio.svg) + +Note that the endpoint path is _/v1/person/:id/\_identify_user_, therefore every request is made up by the _IdentifyUserRequest_ and an Id. Furthermore to use the api a user needs to be authenticated, so we link their userid to all their idv requests. Since all requests contains this additional information, there is a subset of responses that solely depend on it and therefore can **always** be returned regardless of what _IdentifyUserRequest_ what provided. Below you can find said subset along with an explanation for every response. + +![generic api responses](diagrams/idv_generic_responses.drawio.svg) + +Here are the _IdentifyUserRequest_ and _IdentifyUserResponse_ enums just described as found inside the [source code](https://github.com/kanidm/kanidm/blob/05b35df413e017ca44cc4410cc255b63728ef373/proto/src/internal.rs#L32) : + +```rust +pub enum IdentifyUserRequest { + Start, + SubmitCode { other_totp: u32 }, + DisplayCode, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum IdentifyUserResponse { + IdentityVerificationUnavailable, + IdentityVerificationAvailable, + ProvideCode { step: u32, totp: u32 }, + WaitForCode, + Success, + CodeFailure, + InvalidUserId, +} + +``` + +## The identity verification state machine + +Here is the idv state machine, built on top of the idv endpoint request/response types previously described. +Since the protocol provided by kanidm is completely stateless and doesn’t involve any online communication, some extra work is needed on the ui side to make things work. Specifically on the diagram you will notice some black arrows: they represent all the state transitions entirely driven by the ui without requiring any api call. You’ll also notice some empty rectangles with a red border: they represent the scenario in which the other user tells us that the code provided doesn’t match. This makes the idv fail, and it’s the only case in which the failure is entirely driven by the ui. + +![idv state machine](diagrams/idv_state_machine.drawio.svg) diff --git a/server/web_ui/pkg/kanidmd_web_ui.js b/server/web_ui/pkg/kanidmd_web_ui.js index e72a7fc8f..e65e0910f 100644 --- a/server/web_ui/pkg/kanidmd_web_ui.js +++ b/server/web_ui/pkg/kanidmd_web_ui.js @@ -225,7 +225,7 @@ function makeMutClosure(arg0, arg1, dtor, f) { return real; } function __wbg_adapter_48(arg0, arg1) { - wasm._dyn_core__ops__function__FnMut_____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h0b7f7280fe11554e(arg0, arg1); + wasm._dyn_core__ops__function__FnMut_____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hceb9878d1477b426(arg0, arg1); } let stack_pointer = 128; @@ -237,7 +237,7 @@ function addBorrowedObject(obj) { } function __wbg_adapter_51(arg0, arg1, arg2) { try { - wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h575e9f970b01eb58(arg0, arg1, addBorrowedObject(arg2)); + wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h3278f9f07368de56(arg0, arg1, addBorrowedObject(arg2)); } finally { heap[stack_pointer++] = undefined; } @@ -245,14 +245,14 @@ function __wbg_adapter_51(arg0, arg1, arg2) { function __wbg_adapter_54(arg0, arg1, arg2) { try { - wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hb3e22b40c036b75b(arg0, arg1, addBorrowedObject(arg2)); + wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hc8ade5491b0f90f8(arg0, arg1, addBorrowedObject(arg2)); } finally { heap[stack_pointer++] = undefined; } } function __wbg_adapter_57(arg0, arg1, arg2) { - wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hc278baeffa5598ad(arg0, arg1, addHeapObject(arg2)); + wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h4bea8770a6d5511b(arg0, arg1, addHeapObject(arg2)); } /** @@ -1146,7 +1146,7 @@ function __wbg_get_imports() { const ret = wasm.memory; return addHeapObject(ret); }; - imports.wbg.__wbindgen_closure_wrapper1505 = function(arg0, arg1, arg2) { + imports.wbg.__wbindgen_closure_wrapper1506 = function(arg0, arg1, arg2) { const ret = makeMutClosure(arg0, arg1, 752, __wbg_adapter_48); return addHeapObject(ret); }; @@ -1154,7 +1154,7 @@ function __wbg_get_imports() { const ret = makeMutClosure(arg0, arg1, 1975, __wbg_adapter_51); return addHeapObject(ret); }; - imports.wbg.__wbindgen_closure_wrapper4367 = function(arg0, arg1, arg2) { + imports.wbg.__wbindgen_closure_wrapper4369 = function(arg0, arg1, arg2) { const ret = makeMutClosure(arg0, arg1, 2069, __wbg_adapter_54); return addHeapObject(ret); }; diff --git a/server/web_ui/pkg/kanidmd_web_ui_bg.wasm b/server/web_ui/pkg/kanidmd_web_ui_bg.wasm index 20081d415..052c99c65 100644 Binary files a/server/web_ui/pkg/kanidmd_web_ui_bg.wasm and b/server/web_ui/pkg/kanidmd_web_ui_bg.wasm differ diff --git a/server/web_ui/pkg/kanidmd_web_ui_bg.wasm.br b/server/web_ui/pkg/kanidmd_web_ui_bg.wasm.br index d95ab3ed8..f629a77e7 100644 Binary files a/server/web_ui/pkg/kanidmd_web_ui_bg.wasm.br and b/server/web_ui/pkg/kanidmd_web_ui_bg.wasm.br differ diff --git a/server/web_ui/src/views/identityverification.rs b/server/web_ui/src/views/identityverification.rs index b9033bb02..3177b24d2 100644 --- a/server/web_ui/src/views/identityverification.rs +++ b/server/web_ui/src/views/identityverification.rs @@ -60,10 +60,20 @@ static INVALID_USERID_ERROR: &str = "The provided UserID is invalid!"; lazy_static::lazy_static! { pub static ref VALIDATE_TOTP_RE: Regex = { #[allow(clippy::expect_used)] - Regex::new(r"^\d{6}$").expect("Invalid singleline regex found") + Regex::new(r"^\d{5,6}$").expect("Failed to parse VALIDATE_TOTP_RE") // TODO: add an error ID (internal error, restart) }; } +#[test] +fn totp_regex_test() { + assert!(VALIDATE_TOTP_RE.is_match("123456")); + assert!(VALIDATE_TOTP_RE.is_match("12345")); + assert!(!VALIDATE_TOTP_RE.is_match("1234567")); + assert!(!VALIDATE_TOTP_RE.is_match("1234")); + assert!(!VALIDATE_TOTP_RE.is_match("12345a")); + assert!(!VALIDATE_TOTP_RE.is_match("def not a totp")); +} + #[derive(Clone)] pub struct IdentityVerificationApp { other_id: String, diff --git a/tools/cli/Cargo.toml b/tools/cli/Cargo.toml index ec4d43543..5b7c5a9e9 100644 --- a/tools/cli/Cargo.toml +++ b/tools/cli/Cargo.toml @@ -14,6 +14,7 @@ repository = { workspace=true } [features] default = ["unix"] +idv-tui = ["dep:cursive"] unix = [] [lib] @@ -50,6 +51,14 @@ tokio = { workspace = true, features = ["rt", "macros"] } url = { workspace = true, features = ["serde"] } uuid = { workspace=true } zxcvbn = { workspace=true } +lazy_static.workspace = true +regex.workspace = true + +[dependencies.cursive] +version = "0.20.0" +optional = true +default-features = false +features = ["crossterm-backend"] [build-dependencies] clap = { workspace = true, features = ["derive"] } diff --git a/tools/cli/src/cli/identify_user_tui.rs b/tools/cli/src/cli/identify_user_tui.rs new file mode 100644 index 000000000..4af2cef25 --- /dev/null +++ b/tools/cli/src/cli/identify_user_tui.rs @@ -0,0 +1,603 @@ +use cursive::{ + align::HAlign, + crossterm, + view::{Nameable, Resizable}, + views::{Dialog, DummyView, EditView, LinearLayout, TextArea, TextView}, + CbSink, Cursive, CursiveRunnable, View, +}; +use kanidm_client::KanidmClient; +use kanidm_proto::internal::{IdentifyUserRequest, IdentifyUserResponse}; +use std::{cell::RefCell, sync::Arc, time::SystemTime}; +use tokio::sync::{ + mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, + oneshot::{self, Receiver}, +}; + +// here I used a simple function instead of a struct because all the channel stuff requires ownership, so if we were to use a struct with a `run` method, it would have to take ownership of everything +// so might as well just use a function +pub async fn run_identity_verification_tui(self_id: &str, client: KanidmClient) { + //unbounded channel to send messages to the controller from the ui + let (controller_tx, controller_rx) = unbounded_channel::(); + // unbounded channel to send messages to the ui from the controller + let (ui_tx, ui_rx) = unbounded_channel::(); + + // we manually send the initial start message + if controller_tx.send(IdentifyUserMsg::Start).is_err() { + eprint!("Failed to send the initial start message to the controller! Aborting..."); // TODO: add an error ID (internal error, restart) + return; + }; + + // oneshot channel to get the callback sink from the ui + let (cb_tx, cb_rx) = oneshot::channel::(); + // we start the ui in its own thread + let gui_handle = std::thread::spawn(move || { + let mut ui = Ui::new(controller_tx, ui_rx); + if cb_tx.send(ui.get_cb()).is_err() { + eprintln!("Internal callback error in the CLI's TUI, please restart or log an issue with the Kanidm project if it continues to occur."); // TODO: add an error ID (internal error, restart) + return; + }; + ui.0.run(); + }); + + start_business_logic_loop(controller_rx, cb_rx, ui_tx, self_id, client).await; + + if let Err(e) = gui_handle.join() { + eprintln!( + "The UI thread returned an error, please restart the program. Error was: {:?}", + e + ); // TODO: add an error ID (internal error, restart) + }; +} + +async fn start_business_logic_loop( + mut controller_rx: UnboundedReceiver, + cb_rx: Receiver, + ui_tx: UnboundedSender, + self_id: &str, + client: KanidmClient, +) { + let Ok(cb) = cb_rx.await else { + eprintln!("Internal callback error in the CLI's logic loop, please restart or log an issue with the Kanidm project if it continues to occur."); // TODO: add an error ID (internal error, restart) + return; + }; + + let send_msg_and_call_callback = |msg: IdentifyUserState| { + if ui_tx.send(msg).is_err() { + eprintln!("The UI thread returned an error, please restart the program."); + // TODO: add an error ID (internal error, restart) + } + if cb.send(Box::new(Ui::update_state_callback)).is_err() { + eprintln!("The UI thread returned an error, please restart the program."); + // TODO: add an error ID (internal error, restart) + }; + }; + let self_id = Arc::new(self_id.to_string()); + // conveniently when the `quit()` is called on the ui it also drops the controller_tx since it's stored in the `user_data` so as per the doc `controller_rx.recv()` will return None and therefore the loop will exit + while let Some(msg) = controller_rx.recv().await { + // ** NEVER EVER CALL `break` inside the loop as it will drop the mpsc receiver and sender and the ui won't be able to process whatever message is sent to it + // ** instead use `continue` so that the loop will only exit when the ui drops its controllers + let (id, req) = match &msg { + IdentifyUserMsg::Start => (&self_id, IdentifyUserRequest::Start), + IdentifyUserMsg::SubmitOtherId { other_id } => (other_id, IdentifyUserRequest::Start), + IdentifyUserMsg::SubmitCode { + code: totp, + other_id, + } => ( + other_id, + IdentifyUserRequest::SubmitCode { other_totp: *totp }, + ), + IdentifyUserMsg::CodeConfirmedFirst { other_id } => { + send_msg_and_call_callback(IdentifyUserState::WaitForCode { + other_id: other_id.clone(), + }); + continue; + } + IdentifyUserMsg::CodeConfirmedSecond { other_id } => { + send_msg_and_call_callback(IdentifyUserState::Success { + other_id: other_id.clone(), + }); + continue; + } + IdentifyUserMsg::ReDisplayCodeFirst { other_id } + | IdentifyUserMsg::ReDisplayCodeSecond { other_id } => { + (other_id, IdentifyUserRequest::DisplayCode) + } + }; + let res = match client.idm_person_identify_user(id, req).await { + Ok(res) => res, + Err(e) => { + let err = IdentifyUserState::Error { + error_title: "Server error!".to_string(), + error_msg: format!("{:?}", e), + }; + send_msg_and_call_callback(err); + continue; + } + }; + let state = match res { + IdentifyUserResponse::IdentityVerificationUnavailable => IdentifyUserState::Error { + error_title: "Feature unavailable".to_string(), + error_msg: IDENTITY_UNAVAILABLE_ERROR_MESSAGE.to_string(), + }, + IdentifyUserResponse::IdentityVerificationAvailable => { + IdentifyUserState::IdDisplayAndSubmit { + self_id: self_id.clone(), + } + } + IdentifyUserResponse::ProvideCode { step, totp } => match msg { + IdentifyUserMsg::SubmitOtherId { other_id } + | IdentifyUserMsg::ReDisplayCodeFirst { other_id } => { + IdentifyUserState::DisplayCodeFirst { + self_totp: totp, + step, + other_id, + } + } + IdentifyUserMsg::SubmitCode { other_id, .. } + | IdentifyUserMsg::ReDisplayCodeSecond { other_id } => { + IdentifyUserState::DisplayCodeSecond { + self_totp: totp, + step, + other_id, + } + } + _ => IdentifyUserState::invalid_state_error(), + }, + IdentifyUserResponse::WaitForCode => match msg { + IdentifyUserMsg::SubmitOtherId { other_id } + | IdentifyUserMsg::SubmitCode { other_id, .. } => { + IdentifyUserState::WaitForCode { other_id } + } + _ => IdentifyUserState::invalid_state_error(), + }, + IdentifyUserResponse::Success => match msg { + IdentifyUserMsg::SubmitCode { other_id, .. } => { + IdentifyUserState::Success { other_id } + } + _ => IdentifyUserState::invalid_state_error(), + }, + IdentifyUserResponse::CodeFailure => match msg { + IdentifyUserMsg::SubmitCode { .. } => IdentifyUserState::Error { + error_title: "🚨 Identity verification failed 🚨".to_string(), + error_msg: CODE_FAILURE_ERROR_MESSAGE.to_string(), + }, + _ => IdentifyUserState::invalid_state_error(), + }, + IdentifyUserResponse::InvalidUserId => IdentifyUserState::Error { + error_msg: format!("{id} {INVALID_USER_ID_ERROR_MESSAGE}"), + error_title: "Invalid ID error".to_string(), + }, + }; + send_msg_and_call_callback(state); + } +} + +// this is kind of awkward but Cursive doesn't allow us to store data in the `user_data` without having to clone it every time we access it, +// so since all the Strings will never change during the execution of the program, we can just use Arcs to avoid cloning them every time +#[derive(Debug, Clone, PartialEq)] +enum IdentifyUserState { + IdDisplayAndSubmit { + self_id: Arc, + }, + WaitForCode { + other_id: Arc, + }, + DisplayCodeFirst { + self_totp: u32, + step: u32, + other_id: Arc, + }, + DisplayCodeSecond { + self_totp: u32, + step: u32, + other_id: Arc, + }, + Success { + other_id: Arc, + }, + Error { + error_msg: String, + error_title: String, + }, +} + +impl IdentifyUserState { + pub fn invalid_state_error() -> Self { + IdentifyUserState::Error { + error_msg: INVALID_STATE_ERROR_MESSAGE.to_string(), // TODO: add an error ID (internal error, restart) + error_title: "Invalid flow detected!".to_string(), + } + } +} + +#[derive(Debug, Clone)] +enum IdentifyUserMsg { + Start, + SubmitOtherId { other_id: Arc }, + SubmitCode { code: u32, other_id: Arc }, + CodeConfirmedFirst { other_id: Arc }, + CodeConfirmedSecond { other_id: Arc }, + ReDisplayCodeFirst { other_id: Arc }, + ReDisplayCodeSecond { other_id: Arc }, +} + +struct Ui(CursiveRunnable); + +struct UiUserData { + controller_tx: UnboundedSender, + ui_rx: UnboundedReceiver, +} + +impl Ui { + fn new( + controller_tx: UnboundedSender, + ui_rx: UnboundedReceiver, + ) -> Self { + let mut cursive = crossterm(); + cursive.add_global_callback('q', |s| { + s.quit(); + }); + cursive.set_autorefresh(true); + cursive.set_user_data(UiUserData { + controller_tx, + ui_rx, + }); + + Ui(cursive) + } + + fn get_cb(&self) -> CbSink { + self.0.cb_sink().clone() + } + + fn render_state( + s: &mut Cursive, + state: IdentifyUserState, + controller_tx: UnboundedSender, + ) { + match state { + IdentifyUserState::IdDisplayAndSubmit { self_id } => { + let controller_tx_clone = controller_tx.clone(); + let layout = LinearLayout::vertical() + .child(DummyView.fixed_height(1)) + .child( + TextView::new(format!( + "When asked for your ID, provide the following: {}", + self_id + )) + .h_align(HAlign::Center), + ) + .child(DummyView.fixed_height(1)) + .child( + TextView::new(" ---------------------------------------------- ") + .h_align(HAlign::Center), + ) + .child(DummyView.fixed_height(1)) + .child( + TextView::new("Ask for the other person's ID, and insert it here!") + .h_align(HAlign::Center), + ) + .child(DummyView.fixed_height(1)) + .child( + EditView::new() + .on_submit(move |s, user_id: &str| { + let send_outcome = + controller_tx.send(IdentifyUserMsg::SubmitOtherId { + other_id: Arc::new(user_id.to_string()), + }); + if send_outcome.is_err() { + s.quit(); + }; + Self::loading_view(s); + }) + .with_name("id-user-input"), + ); + // we have to redeclare this because we consumed it in the prev closure + s.add_layer( + Dialog::around(layout) + .button("Quit", |s| { + s.quit(); + }) + .button("Continue", move |s| { + let user_id = match s + .call_on_name("id-user-input", |view: &mut EditView| { + view.get_content() + }) { + Some(user_id) => user_id, + None => { + return Self::error_state_view( + s, + "Internal error, couldn't get the 'id-user-input' view, please restart the program.", // TODO: add an error ID (internal error, restart) + None, + ); + } + }; + + let send_outcome = + controller_tx_clone.send(IdentifyUserMsg::SubmitOtherId { + other_id: Arc::new(user_id.to_string()), + }); + if send_outcome.is_err() { + s.quit(); + }; + Self::loading_view(s); + }), + ); + } + IdentifyUserState::WaitForCode { other_id } => { + s.pop_layer(); + let other_id_clone = other_id.clone(); + let controller_tx_clone = controller_tx.clone(); + let layout = LinearLayout::vertical() + .child(TextView::new(format!( + "Ask for {}'s code, and insert it here!", + &other_id + ))) + .child(DummyView.fixed_height(1)) + .child( + EditView::new() + .on_submit(move |s, code: &str| { + let code_u32 = + match Self::parse_totp_code_and_display_popup(s, code) { + Some(code) => code, + None => return, + }; + + let send_outcome = + controller_tx.send(IdentifyUserMsg::SubmitCode { + code: code_u32, + other_id: other_id_clone.clone(), + }); + if send_outcome.is_err() { + s.quit(); + }; + Self::loading_view(s); + }) + .with_name("totp-input"), + ); + s.add_layer( + Dialog::around(layout) + .button("Quit", |s| { + s.quit(); + }) + .button("Continue", move |s| { + let code = match s.call_on_name("totp-input", |view: &mut EditView| { + view.get_content() + }) { + Some(code) => code, + None => { + return Self::error_state_view( + s, + "Internal error, couldn't get the 'totp-input' view, please restart the program.", // TODO: add an error ID (internal error, restart) + None, + ); + } + }; + + let code_u32 = + match Self::parse_totp_code_and_display_popup(s, code.as_str()) { + Some(code) => code, + None => return, + }; + + let send_outcome = + controller_tx_clone.send(IdentifyUserMsg::SubmitCode { + code: code_u32, + other_id: other_id.clone(), + }); + if send_outcome.is_err() { + s.quit(); + }; + Self::loading_view(s); + }), + ); + } + IdentifyUserState::DisplayCodeFirst { + self_totp, + step, + other_id, + } => { + s.pop_layer(); + let layout = LinearLayout::vertical() + .child(TextView::new(format!( + "Provide the following code when asked: {self_totp}" + ))) + .child(DummyView.fixed_height(1)) + .child(TotpCountdownView::new( + step as u64, + controller_tx.clone(), + IdentifyUserMsg::ReDisplayCodeFirst { + other_id: other_id.clone(), + }, + )); + s.add_layer(Dialog::around(layout).button("Continue", move |s| { + Self::confirmation_view( + s, + &other_id, + controller_tx.clone(), + IdentifyUserMsg::CodeConfirmedFirst { + other_id: other_id.clone(), + }, + ); + })); + } + IdentifyUserState::DisplayCodeSecond { + self_totp, + step, + other_id, + } => { + s.pop_layer(); + let layout = LinearLayout::vertical() + .child(TextView::new(format!( + "Provide the following code when asked: {self_totp}" + ))) + .child(DummyView.fixed_height(1)) + .child(TotpCountdownView::new( + step as u64, + controller_tx.clone(), + IdentifyUserMsg::ReDisplayCodeSecond { + other_id: other_id.clone(), + }, + )); + s.add_layer(Dialog::around(layout).button("Continue", move |s| { + Self::confirmation_view( + s, + &other_id, + controller_tx.clone(), + IdentifyUserMsg::CodeConfirmedSecond { + other_id: other_id.clone(), + }, + ); + })); + } + IdentifyUserState::Success { other_id } => { + s.pop_layer(); + let layout = LinearLayout::vertical().child(TextView::new(format!( + "{other_id}'s identity has been successfully verified!" + ))); + + s.add_layer( + Dialog::around(layout) + .padding_lrtb(1, 1, 1, 0) + .title("Success 🎉🎉") + .button("Quit", |s| { + s.quit(); + }), + ); + } + IdentifyUserState::Error { + error_msg: msg, + error_title: title, + } => Self::error_state_view(s, &msg, Some(&title)), + }; + } + + fn update_state_callback(s: &mut Cursive) { + let user_data = match s.user_data::() { + Some(data) => data, + None => { + return Self::error_state_view( + s, + "Failed to parse server response, please start again.", // TODO: add error ID (internal error, restart) + None, + ); + } + }; + if let Some(state) = user_data.ui_rx.blocking_recv() { + let controller_rx = user_data.controller_tx.to_owned(); // we have to take ownership so the mut borrow `s` can be passed to `render_state` + Ui::render_state(s, state, controller_rx); + } + } + + fn confirmation_view( + s: &mut Cursive, + other_id: &Arc, + controller_tx: UnboundedSender, + msg: IdentifyUserMsg, + ) { + let textarea = TextArea::new().content(format!("Did you confirm that {other_id} correctly verified your code? If you proceed, you won't be able to go back.")).disabled().fixed_width(57); + s.add_layer( + Dialog::around(textarea) + .padding_lrtb(1, 1, 0, 1) + .title("Warning!") + .button("Continue", move |s| { + s.pop_layer(); + s.pop_layer(); + let send_outcome = controller_tx.send(msg.to_owned()); + if send_outcome.is_err() { + s.quit(); + }; + }) + .dismiss_button("Cancel"), + ); + } + + fn error_state_view(s: &mut Cursive, msg: &str, error_title: Option<&str>) { + s.pop_layer(); + let layout = LinearLayout::vertical() + .child(DummyView.fixed_height(1)) + .child(TextView::new(msg)); + + s.add_layer( + Dialog::around(layout) + .title(error_title.unwrap_or("An error occurred!")) + .button("Quit", |s| { + s.quit(); + }), + ); + } + + fn parse_totp_code_and_display_popup(s: &mut Cursive, code: &str) -> Option { + let code_u32 = match code.parse::() { + Ok(code_u32) => code_u32, + Err(_) => { + Self::disposable_warning_view(s, "The code you provided is not a number!"); + return None; + } + }; + if code.len() < 5 || code.len() > 6 { + Self::disposable_warning_view(s, "The code should be a 5 or 6 digit number!"); + return None; + }; + Some(code_u32) + } + + fn disposable_warning_view(s: &mut Cursive, msg: &str) { + let dialog = Dialog::text(msg).dismiss_button("Ok"); + s.add_layer(dialog); + } + + fn loading_view(s: &mut Cursive) { + s.pop_layer(); + s.add_layer(TextView::new("Loading, please wait...")); + } +} + +struct TotpCountdownView { + msg: IdentifyUserMsg, + step: u64, + controller_tx: UnboundedSender, + should_call_callback: RefCell, // we need to use a refcell since we need to mutate this data inside the `draw` method which has a `&self` reference +} + +impl TotpCountdownView { + fn new( + step: u64, + controller_tx: UnboundedSender, + msg: IdentifyUserMsg, + ) -> Self { + Self { + should_call_callback: RefCell::new(true), + msg, + step, + controller_tx, + } + } + + fn get_ticks_left_from_now(&self, step: u64) -> u64 { + #[allow(clippy::expect_used)] + let dur = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("invalid duration from epoch now"); + step - dur.as_secs() % (step) + } +} + +impl View for TotpCountdownView { + fn draw(&self, printer: &cursive::Printer) { + let ticks_left_from_now = self.get_ticks_left_from_now(self.step); + // basically whenever the ticks_left reset to step, i.e. the first time this function has been called after we got to a new totp window, then we + // call the callback to fetch a new code which will be displayed at best in the next tick. On very slow connections the user might see the old + // code for a bit. If we want to get rid of this we would need to pass to the struct a callback to show a loading screen + if ticks_left_from_now == self.step && *self.should_call_callback.borrow() { + self.controller_tx + .send(self.msg.to_owned()) + .expect("TOTP countdown view failed to send msg to controller"); // TODO: add an error ID (internal error, restart) + *self.should_call_callback.borrow_mut() = false; + }; + printer.print( + (0, 0), + &format!(" {}s left", ticks_left_from_now), + ); + } +} diff --git a/tools/cli/src/cli/lib.rs b/tools/cli/src/cli/lib.rs index d7f08ce7c..1a0c0ad06 100644 --- a/tools/cli/src/cli/lib.rs +++ b/tools/cli/src/cli/lib.rs @@ -10,12 +10,17 @@ #![deny(clippy::trivially_copy_pass_by_ref)] // We allow expect since it forces good error messages at the least. #![allow(clippy::expect_used)] - #[macro_use] extern crate tracing; use crate::common::OpType; use std::path::PathBuf; + +#[cfg(not(feature = "idv-tui"))] +use identify_user_no_tui::{run_identity_verification_no_tui, IdentifyUserState}; +#[cfg(feature = "idv-tui")] +use identify_user_tui::run_identity_verification_tui; + use url::Url; use uuid::Uuid; @@ -25,6 +30,8 @@ pub mod badlist; pub mod common; pub mod domain; pub mod group; +#[cfg(feature = "idv-tui")] +mod identify_user_tui; pub mod oauth2; pub mod person; pub mod raw; @@ -39,6 +46,7 @@ impl SelfOpt { pub fn debug(&self) -> bool { match self { SelfOpt::Whoami(copt) => copt.debug, + SelfOpt::IdentifyUser(copt) => copt.debug, } } @@ -62,6 +70,38 @@ impl SelfOpt { Err(e) => println!("Error: {:?}", e), } } + SelfOpt::IdentifyUser(copt) => { + let client = copt.to_client(OpType::Write).await; + let whoami_response = match client.whoami().await { + Ok(o_ent) => { + match o_ent { + Some(ent) => ent, + None => { + eprintln!("Authentication with cached token failed, can't query information."); // TODO: add an error ID (login, or clear token cache) + return; + } + } + } + Err(e) => { + println!("Error querying whoami endpoint: {:?}", e); // TODO: add an error ID (internal/web response error, restart or check connectivity) + return; + } + }; + + let spn = + match whoami_response.attrs.get("spn").and_then(|v| v.first()) { + Some(spn) => spn, + None => { + eprintln!("Failed to parse your SPN from the system's whoami endpoint, exiting!"); // TODO: add an error ID (internal/web response error, restart) + return; + } + }; + + #[cfg(feature = "idv-tui")] + run_identity_verification_tui(spn, client).await; + #[cfg(not(feature = "idv-tui"))] + run_identity_verification_no_tui(IdentifyUserState::Start, client, spn, None).await; + } // end PersonOpt::Validity } } } @@ -144,3 +184,254 @@ pub(crate) fn password_prompt(prompt: &str) -> Option { } None } + +pub const IDENTITY_UNAVAILABLE_ERROR_MESSAGE: &str = "The identity verification feature is not enabled for your account, please contact an administrator."; +pub const CODE_FAILURE_ERROR_MESSAGE: &str = "The provided code doesn't match, please try again."; +pub const INVALID_USER_ID_ERROR_MESSAGE: &str = + "account exists but cannot access the identity verification feature 😕"; +pub const INVALID_STATE_ERROR_MESSAGE: &str = + "The user identification flow is in an invalid state 😵😵"; + +#[cfg(not(feature = "idv-tui"))] +mod identify_user_no_tui { + use crate::{ + CODE_FAILURE_ERROR_MESSAGE, IDENTITY_UNAVAILABLE_ERROR_MESSAGE, + INVALID_STATE_ERROR_MESSAGE, INVALID_USER_ID_ERROR_MESSAGE, + }; + + use kanidm_client::{ClientError, KanidmClient}; + use kanidm_proto::internal::{IdentifyUserRequest, IdentifyUserResponse}; + + use dialoguer::{Confirm, Input}; + use regex::Regex; + use std::{ + io::{stdout, Write}, + time::SystemTime, + }; + + lazy_static::lazy_static! { + pub static ref VALIDATE_TOTP_RE: Regex = { + #[allow(clippy::expect_used)] + Regex::new(r"^\d{5,6}$").expect("Failed to parse VALIDATE_TOTP_RE") // TODO: add an error ID (internal error, restart) + }; + } + + pub(super) enum IdentifyUserState { + Start, + IdDisplayAndSubmit, + SubmitCode, + DisplayCodeFirst { self_totp: u32, step: u32 }, + DisplayCodeSecond { self_totp: u32, step: u32 }, + } + + fn server_error(e: &ClientError) { + eprintln!("Server error!"); // TODO: add an error ID (internal error, restart) + eprintln!("{:?}", e); + println!("Exiting..."); + } + + pub(super) async fn run_identity_verification_no_tui<'a>( + mut state: IdentifyUserState, + client: KanidmClient, + self_id: &'a str, + mut other_id: Option, + ) { + loop { + match state { + IdentifyUserState::Start => { + let res = match &client + .idm_person_identify_user(self_id, IdentifyUserRequest::Start) + .await + { + Ok(res) => res.clone(), + Err(e) => { + return server_error(e); + } + }; + match res { + IdentifyUserResponse::IdentityVerificationUnavailable => { + println!("{IDENTITY_UNAVAILABLE_ERROR_MESSAGE}"); + return; + } + IdentifyUserResponse::IdentityVerificationAvailable => { + state = IdentifyUserState::IdDisplayAndSubmit; + } + _ => { + eprintln!("{INVALID_STATE_ERROR_MESSAGE}"); + return; + } + } + } + IdentifyUserState::IdDisplayAndSubmit => { + println!("When asked for your ID, provide the following: {self_id}"); + + // Display Prompt + let other_user_id: String = Input::new() + .with_prompt("Ask for the other person's ID, and insert it here") + .interact_text() + .expect("Failed to interact with interactive session"); + let _ = stdout().flush(); + + let res = match &client + .idm_person_identify_user(&other_user_id, IdentifyUserRequest::Start) + .await + { + Ok(res) => res.clone(), + Err(e) => { + return server_error(e); + } + }; + match res { + IdentifyUserResponse::WaitForCode => { + state = IdentifyUserState::SubmitCode; + + other_id = Some(other_user_id); + } + IdentifyUserResponse::ProvideCode { step, totp } => { + state = IdentifyUserState::DisplayCodeFirst { + self_totp: totp, + step, + }; + + other_id = Some(other_user_id); + } + IdentifyUserResponse::InvalidUserId => { + eprintln!("{other_user_id} {INVALID_USER_ID_ERROR_MESSAGE}"); + return; + } + _ => { + eprintln!("{INVALID_STATE_ERROR_MESSAGE}"); + return; + } + } + } + IdentifyUserState::SubmitCode => { + // Display Prompt + let other_totp: String = Input::new() + .with_prompt("Insert here the other person code") + .validate_with(|s: &String| -> Result<(), &str> { + if VALIDATE_TOTP_RE.is_match(s) { + Ok(()) + } else { + Err("The code should be a 5 or 6 digit number") + } + }) + .interact_text() + .expect("Failed to interact with interactive session"); + + let res = match &client + .idm_person_identify_user( + other_id.as_deref().unwrap_or_default(), + IdentifyUserRequest::SubmitCode { + other_totp: other_totp.parse().unwrap_or_default(), + }, + ) + .await + { + Ok(res) => res.clone(), + Err(e) => { + return server_error(e); + } + }; + match res { + IdentifyUserResponse::CodeFailure => { + eprintln!("{CODE_FAILURE_ERROR_MESSAGE}"); + return; + } + IdentifyUserResponse::Success => { + println!( + "{}'s identity has been successfully verified 🎉🎉", + other_id.as_deref().unwrap_or_default() + ); + return; + } + IdentifyUserResponse::InvalidUserId => { + eprintln!( + "{} {INVALID_USER_ID_ERROR_MESSAGE}", + other_id.as_deref().unwrap_or_default() + ); + return; + } + IdentifyUserResponse::ProvideCode { step, totp } => { + // since we have already inserted the code, we have to go to display code second, + state = IdentifyUserState::DisplayCodeSecond { + self_totp: totp, + step, + }; + } + + _ => { + eprintln!("{INVALID_STATE_ERROR_MESSAGE}"); + return; + } + } + } + IdentifyUserState::DisplayCodeFirst { self_totp, step } => { + println!("Provide the following code when asked: {}", self_totp); + let seconds_left = get_ms_left_from_now(step as u128) / 1000; + println!("This codes expires in {seconds_left} seconds"); + let _ = stdout().flush(); + if !matches!(Confirm::new().with_prompt("Continue?").interact(), Ok(true)) { + println!("Identity verification failed. Exiting..."); + return; + } + match Confirm::new() + .with_prompt(format!("Did you confirm that {} correctly verified your code? If you proceed, you won't be able to go back.", other_id.as_deref().unwrap_or_default())) + .interact() { + Ok(true) => {println!("Code confirmed, continuing...")} + Ok(false) => { + println!("Identity verification failed. Exiting..."); + return; + }, + Err(e) => { + eprintln!("An error occurred while trying to read from stderr: {:?}", e); // TODO: add error ID (internal error, restart) + println!("Exiting..."); + return; + }, + }; + + state = IdentifyUserState::SubmitCode; + } + IdentifyUserState::DisplayCodeSecond { self_totp, step } => { + println!("Provide the following code when asked: {}", self_totp); + let seconds_left = get_ms_left_from_now(step as u128) / 1000; + println!("This codes expires in {seconds_left} seconds!"); + let _ = stdout().flush(); + if !matches!(Confirm::new().with_prompt("Continue?").interact(), Ok(true)) { + println!("Identity verification failed. Exiting..."); + return; + } + match Confirm::new() + .with_prompt(format!("Did you confirm that {} correctly verified your code? If you proceed, you won't be able to go back.", other_id.as_deref().unwrap_or_default())) + .interact() { + Ok(true) => {println!( + "{}'s identity has been successfully verified 🎉🎉", + other_id.take().unwrap_or_default() + ); + return;} + Ok(false) => { + println!("Exiting..."); + return; + }, + Err(e) => { + eprintln!("An error occurred while trying to read from stderr: {:?}", e); // TODO: add error ID (internal error, restart) + println!("Exiting..."); + return; + }, + }; + } + } + } + } + + // TODO: this function is somewhat a duplicate of what can be found in the webui, see https://github.com/kanidm/kanidm/blob/003234c2d0a52146683628156e2a106bf61fe9f4/server/web_ui/src/components/totpdisplay.rs#L83 + // * should we move it to a common crate or can we just leave it there? + fn get_ms_left_from_now(step: u128) -> u32 { + #[allow(clippy::expect_used)] + let dur = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("invalid duration from epoch now"); + let ms: u128 = dur.as_millis(); + (step * 1000 - ms % (step * 1000)) as u32 + } +} diff --git a/tools/cli/src/opt/kanidm.rs b/tools/cli/src/opt/kanidm.rs index 6f1aeddd8..00f5f9a66 100644 --- a/tools/cli/src/opt/kanidm.rs +++ b/tools/cli/src/opt/kanidm.rs @@ -602,6 +602,9 @@ pub enum RawOpt { #[derive(Debug, Subcommand)] pub enum SelfOpt { + /// Use the identify user feature + #[clap(name = "identify-user")] + IdentifyUser(CommonOpt), /// Show the current authenticated user's identity Whoami(CommonOpt), }