From 6c1bff632662fae9a2a11c357bb72958f714035a Mon Sep 17 00:00:00 2001 From: Manan Tyagi Date: Wed, 27 May 2026 12:58:43 +0530 Subject: [PATCH 1/5] Add MSP command examples for CLI and SDK --- examples/cli_examples/msp/msp_add.py | 54 +++++++++ .../cli_examples/msp/msp_billing_report.py | 46 ++++++++ examples/cli_examples/msp/msp_common.py | 88 ++++++++++++++ examples/cli_examples/msp/msp_convert_node.py | 44 +++++++ examples/cli_examples/msp/msp_copy_role.py | 42 +++++++ examples/cli_examples/msp/msp_down.py | 39 +++++++ examples/cli_examples/msp/msp_info.py | 50 ++++++++ .../cli_examples/msp/msp_legacy_report.py | 47 ++++++++ examples/cli_examples/msp/msp_remove.py | 42 +++++++ examples/cli_examples/msp/msp_update.py | 59 ++++++++++ examples/cli_examples/msp/switch_to_mc.py | 58 ++++++++++ examples/cli_examples/msp/switch_to_msp.py | 54 +++++++++ .../msp/login_to_managed_company.py | 62 ++++++++++ examples/sdk_examples/msp/msp_add.py | 51 ++++++++ .../sdk_examples/msp/msp_billing_report.py | 49 ++++++++ examples/sdk_examples/msp/msp_common.py | 109 ++++++++++++++++++ examples/sdk_examples/msp/msp_convert_node.py | 63 ++++++++++ examples/sdk_examples/msp/msp_copy_role.py | 43 +++++++ examples/sdk_examples/msp/msp_down.py | 35 ++++++ examples/sdk_examples/msp/msp_info.py | 51 ++++++++ .../sdk_examples/msp/msp_legacy_report.py | 49 ++++++++ examples/sdk_examples/msp/msp_remove.py | 36 ++++++ examples/sdk_examples/msp/msp_update.py | 53 +++++++++ 23 files changed, 1224 insertions(+) create mode 100644 examples/cli_examples/msp/msp_add.py create mode 100644 examples/cli_examples/msp/msp_billing_report.py create mode 100644 examples/cli_examples/msp/msp_common.py create mode 100644 examples/cli_examples/msp/msp_convert_node.py create mode 100644 examples/cli_examples/msp/msp_copy_role.py create mode 100644 examples/cli_examples/msp/msp_down.py create mode 100644 examples/cli_examples/msp/msp_info.py create mode 100644 examples/cli_examples/msp/msp_legacy_report.py create mode 100644 examples/cli_examples/msp/msp_remove.py create mode 100644 examples/cli_examples/msp/msp_update.py create mode 100644 examples/cli_examples/msp/switch_to_mc.py create mode 100644 examples/cli_examples/msp/switch_to_msp.py create mode 100644 examples/sdk_examples/msp/login_to_managed_company.py create mode 100644 examples/sdk_examples/msp/msp_add.py create mode 100644 examples/sdk_examples/msp/msp_billing_report.py create mode 100644 examples/sdk_examples/msp/msp_common.py create mode 100644 examples/sdk_examples/msp/msp_convert_node.py create mode 100644 examples/sdk_examples/msp/msp_copy_role.py create mode 100644 examples/sdk_examples/msp/msp_down.py create mode 100644 examples/sdk_examples/msp/msp_info.py create mode 100644 examples/sdk_examples/msp/msp_legacy_report.py create mode 100644 examples/sdk_examples/msp/msp_remove.py create mode 100644 examples/sdk_examples/msp/msp_update.py diff --git a/examples/cli_examples/msp/msp_add.py b/examples/cli_examples/msp/msp_add.py new file mode 100644 index 00000000..d51cd909 --- /dev/null +++ b/examples/cli_examples/msp/msp_add.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """Resolve config.json (cwd) or ~/.keeper/config.json.""" + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + +def login_to_keeper_with_config(filename: str) -> KeeperParams: + """Authenticate and return KeeperParams (enterprise loader available for MSP admin).""" + if not os.path.exists(filename): + raise FileNotFoundError(f'Config file {filename} not found') + with open(filename, 'r') as f: + config_data = json.load(f) + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: + raise RuntimeError('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + + +def add_config_argument(parser: argparse.ArgumentParser) -> None: + default_config_path = get_default_config_path() + parser.add_argument( + '-c', '--config', + default=default_config_path, + help=f'Configuration file (default: {default_config_path})', + ) + + +def run_example( + description: str, + epilog: str, + execute_fn: Callable[[KeeperParams], None], +) -> None: + """Parse --config, login, run execute_fn(context), then clear session.""" + parser = argparse.ArgumentParser( + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=epilog, + ) + add_config_argument(parser) + args = parser.parse_args() + + if not os.path.exists(args.config): + print(f'Config file {args.config} not found') + sys.exit(1) + + context: Optional[KeeperParams] = None + try: + context = login_to_keeper_with_config(args.config) + execute_fn(context) + except Exception as e: + print(f'Error: {e}') + sys.exit(1) + finally: + if context: + context.clear_session() diff --git a/examples/cli_examples/msp/msp_convert_node.py b/examples/cli_examples/msp/msp_convert_node.py new file mode 100644 index 00000000..1d591437 --- /dev/null +++ b/examples/cli_examples/msp/msp_convert_node.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Tuple[Optional[keeper_auth.KeeperAuth], Optional[enterprise_loader.EnterpriseLoader]]: + """Login and return (auth, enterprise_loader). Closes auth on failure paths.""" + login_fn, enable_persistent = load_sdk_login() + auth, _endpoint = login_fn() + if not auth: + return None, None + if not auth.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + auth.close() + return None, None + try: + loader = create_enterprise_loader(auth) + return auth, loader + except Exception: + auth.close() + raise + + +def create_enterprise_loader(auth: keeper_auth.KeeperAuth) -> enterprise_loader.EnterpriseLoader: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = auth.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + return enterprise_loader.EnterpriseLoader(auth, storage) + + +def close_loader_and_auth( + loader: Optional[enterprise_loader.EnterpriseLoader], + auth: Optional[keeper_auth.KeeperAuth], +) -> None: + if loader is not None: + loader.close() + if auth is not None: + auth.close() + + +def print_msp_info_report(report: msp_auth.MspInfoReport) -> None: + if report.message: + print(report.message) + return + headers = list(report.headers) + rows = list(report.rows) + if report.row_numbers: + if not headers or headers[0].lower() != '#': + headers = ['#'] + headers + rows = [tuple([i, *row]) for i, row in enumerate(rows, 1)] + _print_table(headers, tuple(rows)) + + +def print_msp_billing_report(report: msp_auth.MspBillingReport) -> None: + print(report.title) + _print_table(list(report.headers), report.rows) + + +def print_msp_legacy_report(report: msp_auth.MspLegacyReport) -> None: + if report.title: + print(report.title) + _print_table(list(report.headers), report.rows) + + +def _print_table(headers: list, rows: tuple) -> None: + if not headers: + return + widths = [len(str(h)) for h in headers] + str_rows = [] + for row in rows: + cells = [str(c) for c in row] + str_rows.append(cells) + for i, cell in enumerate(cells): + if i < len(widths): + widths[i] = max(widths[i], len(cell)) + fmt = ' '.join(f'{{:{w}}}' for w in widths) + print(fmt.format(*[str(h) for h in headers])) + print(fmt.format(*['-' * w for w in widths])) + for cells in str_rows: + padded = cells + [''] * (len(headers) - len(cells)) + print(fmt.format(*padded[: len(headers)])) diff --git a/examples/sdk_examples/msp/msp_convert_node.py b/examples/sdk_examples/msp/msp_convert_node.py new file mode 100644 index 00000000..496b605e --- /dev/null +++ b/examples/sdk_examples/msp/msp_convert_node.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' int: + enterprise_data = loader.enterprise_data + if node_arg.isdigit(): + node = enterprise_data.nodes.get_entity(int(node_arg)) + if node is None: + raise ValueError(f'Node id {node_arg} not found') + return node.node_id + key = node_arg.lower() + for node in enterprise_data.nodes.get_all_entities(): + if node.name and node.name.lower() == key: + return node.node_id + root = enterprise_data.root_node + if key == 'root': + return root.node_id + raise ValueError(f'Node "{node_arg}" not found') + + +def main(): + auth, loader = login_with_enterprise() + if not auth or not loader: + print('Login failed.') + return + try: + msp_auth.msp_down(loader, reset=False) + node_id = _resolve_node_id(loader, NODE_NAME_OR_ID) + mc_id = msp_auth.msp_convert_node( + loader, + node_id=node_id, + seats=SEATS, + plan=PLAN, + ) + print(f'Converted node {NODE_NAME_OR_ID} to managed company id={mc_id}.') + finally: + close_loader_and_auth(loader, auth) + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/msp/msp_copy_role.py b/examples/sdk_examples/msp/msp_copy_role.py new file mode 100644 index 00000000..413cd9cf --- /dev/null +++ b/examples/sdk_examples/msp/msp_copy_role.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Date: Mon, 1 Jun 2026 11:32:53 +0530 Subject: [PATCH 2/5] removed cli examples --- examples/cli_examples/msp/msp_add.py | 54 ------------ .../cli_examples/msp/msp_billing_report.py | 46 ---------- examples/cli_examples/msp/msp_common.py | 88 ------------------- examples/cli_examples/msp/msp_convert_node.py | 44 ---------- examples/cli_examples/msp/msp_copy_role.py | 42 --------- examples/cli_examples/msp/msp_down.py | 39 -------- examples/cli_examples/msp/msp_info.py | 50 ----------- .../cli_examples/msp/msp_legacy_report.py | 47 ---------- examples/cli_examples/msp/msp_remove.py | 42 --------- examples/cli_examples/msp/msp_update.py | 59 ------------- examples/cli_examples/msp/switch_to_mc.py | 58 ------------ examples/cli_examples/msp/switch_to_msp.py | 54 ------------ 12 files changed, 623 deletions(-) delete mode 100644 examples/cli_examples/msp/msp_add.py delete mode 100644 examples/cli_examples/msp/msp_billing_report.py delete mode 100644 examples/cli_examples/msp/msp_common.py delete mode 100644 examples/cli_examples/msp/msp_convert_node.py delete mode 100644 examples/cli_examples/msp/msp_copy_role.py delete mode 100644 examples/cli_examples/msp/msp_down.py delete mode 100644 examples/cli_examples/msp/msp_info.py delete mode 100644 examples/cli_examples/msp/msp_legacy_report.py delete mode 100644 examples/cli_examples/msp/msp_remove.py delete mode 100644 examples/cli_examples/msp/msp_update.py delete mode 100644 examples/cli_examples/msp/switch_to_mc.py delete mode 100644 examples/cli_examples/msp/switch_to_msp.py diff --git a/examples/cli_examples/msp/msp_add.py b/examples/cli_examples/msp/msp_add.py deleted file mode 100644 index d51cd909..00000000 --- a/examples/cli_examples/msp/msp_add.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' str: - """Resolve config.json (cwd) or ~/.keeper/config.json.""" - file_name = 'config.json' - if os.path.isfile(file_name): - return os.path.abspath(file_name) - keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') - if not os.path.exists(keeper_dir): - os.mkdir(keeper_dir) - return os.path.join(keeper_dir, file_name) - - -def login_to_keeper_with_config(filename: str) -> KeeperParams: - """Authenticate and return KeeperParams (enterprise loader available for MSP admin).""" - if not os.path.exists(filename): - raise FileNotFoundError(f'Config file {filename} not found') - with open(filename, 'r') as f: - config_data = json.load(f) - - keeper_config = KeeperConfig(config_filename=filename, config=config_data) - auth = LoginFlow.login(keeper_config) - if not auth: - raise RuntimeError('Failed to authenticate with Keeper') - - context = KeeperParams(keeper_config=keeper_config) - context.set_auth(auth) - return context - - -def add_config_argument(parser: argparse.ArgumentParser) -> None: - default_config_path = get_default_config_path() - parser.add_argument( - '-c', '--config', - default=default_config_path, - help=f'Configuration file (default: {default_config_path})', - ) - - -def run_example( - description: str, - epilog: str, - execute_fn: Callable[[KeeperParams], None], -) -> None: - """Parse --config, login, run execute_fn(context), then clear session.""" - parser = argparse.ArgumentParser( - description=description, - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=epilog, - ) - add_config_argument(parser) - args = parser.parse_args() - - if not os.path.exists(args.config): - print(f'Config file {args.config} not found') - sys.exit(1) - - context: Optional[KeeperParams] = None - try: - context = login_to_keeper_with_config(args.config) - execute_fn(context) - except Exception as e: - print(f'Error: {e}') - sys.exit(1) - finally: - if context: - context.clear_session() diff --git a/examples/cli_examples/msp/msp_convert_node.py b/examples/cli_examples/msp/msp_convert_node.py deleted file mode 100644 index 1d591437..00000000 --- a/examples/cli_examples/msp/msp_convert_node.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' Date: Mon, 1 Jun 2026 18:08:54 +0530 Subject: [PATCH 3/5] corrected interactive session in ksm examples --- .../secrets_manager/create_app.py | 32 +++----- .../sdk_examples/secrets_manager/get_app.py | 78 +++++++++---------- 2 files changed, 49 insertions(+), 61 deletions(-) diff --git a/examples/sdk_examples/secrets_manager/create_app.py b/examples/sdk_examples/secrets_manager/create_app.py index a1cf25dc..05482a21 100644 --- a/examples/sdk_examples/secrets_manager/create_app.py +++ b/examples/sdk_examples/secrets_manager/create_app.py @@ -505,27 +505,19 @@ def create_secrets_manager_application(keeper_auth_context: keeper_auth.KeeperAu vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) try: - app_name = input('Enter name for new Secrets Manager application: ').strip() - - if not app_name: - print("Application name cannot be empty") - else: - force_add = input('Allow duplicate names? (y/n): ').strip().lower() == 'y' - - print(f"\nCreating Secrets Manager application: {app_name}") - - app_uid = ksm_management.create_secrets_manager_app(vault, app_name, force_add=force_add) - - print(f"\n✓ Secrets Manager application created successfully!") - print(f"Application Name: {app_name}") - print(f"Application UID: {app_uid}") - print("\nNext steps:") - print(" 1. Share records or folders with this application") - print(" 2. Generate client devices for access") - print(" 3. Use the application in your integrations") + app_name = "" + force_add = False + print(f"\nCreating Secrets Manager application: {app_name}") + app_uid = ksm_management.create_secrets_manager_app(vault, app_name, force_add=force_add) - vault.sync_down() - + print(f"\n✓ Secrets Manager application created successfully!") + print(f"Application Name: {app_name}") + print(f"Application UID: {app_uid}") + print("\nNext steps:") + print(" 1. Share records or folders with this application") + print(" 2. Generate client devices for access") + print(" 3. Use the application in your integrations") + except ValueError as e: print(f"Error: {e}") except Exception as e: diff --git a/examples/sdk_examples/secrets_manager/get_app.py b/examples/sdk_examples/secrets_manager/get_app.py index e75d99ed..5b77c7f1 100644 --- a/examples/sdk_examples/secrets_manager/get_app.py +++ b/examples/sdk_examples/secrets_manager/get_app.py @@ -506,53 +506,49 @@ def get_secrets_manager_application(keeper_auth_context: keeper_auth.KeeperAuth) vault.sync_down() try: - app_search = input('Enter application name or UID: ').strip() - - if not app_search: - print("Application identifier cannot be empty") - else: - app = ksm_management.get_secrets_manager_app(vault, app_search) + app_uid_or_name = "" + app = ksm_management.get_secrets_manager_app(vault, app_uid_or_name) - print(f"\nSecrets Manager Application Details") - print("=" * 100) - print(f"App Name: {app.name}") - print(f"App UID: {app.uid}") - print(f"Records Shared: {app.records}") - print(f"Folders Shared: {app.folders}") - print(f"Client Devices: {app.count}") + print(f"\nSecrets Manager Application Details") + print("=" * 100) + print(f"App Name: {app.name}") + print(f"App UID: {app.uid}") + print(f"Records Shared: {app.records}") + print(f"Folders Shared: {app.folders}") + print(f"Client Devices: {app.count}") + + if app.client_devices: + print(f"\nClient Devices ({len(app.client_devices)}):") + print("-" * 100) + print(f"{'Name':<25} {'Short ID':<15} {'Created':<20} {'Last Access':<20} {'IP Address':<20}") + print("-" * 100) - if app.client_devices: - print(f"\nClient Devices ({len(app.client_devices)}):") - print("-" * 100) - print(f"{'Name':<25} {'Short ID':<15} {'Created':<20} {'Last Access':<20} {'IP Address':<20}") - print("-" * 100) + for client in app.client_devices: + name = client.name[:24] if client.name else 'N/A' + short_id = client.short_id[:14] if client.short_id else 'N/A' + created = client.created_on.strftime('%Y-%m-%d %H:%M') if client.created_on else 'N/A' + last_access = client.last_access.strftime('%Y-%m-%d %H:%M') if client.last_access else 'Never' + ip_address = client.ip_address[:19] if client.ip_address else 'N/A' - for client in app.client_devices: - name = client.name[:24] if client.name else 'N/A' - short_id = client.short_id[:14] if client.short_id else 'N/A' - created = client.created_on.strftime('%Y-%m-%d %H:%M') if client.created_on else 'N/A' - last_access = client.last_access.strftime('%Y-%m-%d %H:%M') if client.last_access else 'Never' - ip_address = client.ip_address[:19] if client.ip_address else 'N/A' - - print(f"{name:<25} {short_id:<15} {created:<20} {last_access:<20} {ip_address:<20}") + print(f"{name:<25} {short_id:<15} {created:<20} {last_access:<20} {ip_address:<20}") + + if app.shared_secrets: + print(f"\nShared Secrets ({len(app.shared_secrets)}):") + print("-" * 100) + print(f"{'Type':<15} {'Name':<45} {'UID':<40}") + print("-" * 100) - if app.shared_secrets: - print(f"\nShared Secrets ({len(app.shared_secrets)}):") - print("-" * 100) - print(f"{'Type':<15} {'Name':<45} {'UID':<40}") - print("-" * 100) - - for secret in app.shared_secrets[:20]: - secret_type = secret.type[:14] if secret.type else 'N/A' - secret_name = secret.name[:44] if secret.name else 'N/A' - secret_uid = secret.uid[:39] if secret.uid else 'N/A' - - print(f"{secret_type:<15} {secret_name:<45} {secret_uid:<40}") + for secret in app.shared_secrets[:20]: + secret_type = secret.type[:14] if secret.type else 'N/A' + secret_name = secret.name[:44] if secret.name else 'N/A' + secret_uid = secret.uid[:39] if secret.uid else 'N/A' - if len(app.shared_secrets) > 20: - print(f" ... and {len(app.shared_secrets) - 20} more") + print(f"{secret_type:<15} {secret_name:<45} {secret_uid:<40}") - print("=" * 100) + if len(app.shared_secrets) > 20: + print(f" ... and {len(app.shared_secrets) - 20} more") + + print("=" * 100) except ValueError as e: print(f"Error: {e}") From 45ae20b696568ef5e7165bb6deff922cbc1597a1 Mon Sep 17 00:00:00 2001 From: Manan Tyagi Date: Mon, 1 Jun 2026 18:12:15 +0530 Subject: [PATCH 4/5] added self contained login flow and placeholders input value --- .../msp/login_to_managed_company.py | 540 +++++++++++++++- examples/sdk_examples/msp/msp_add.py | 550 ++++++++++++++++- .../sdk_examples/msp/msp_billing_report.py | 572 ++++++++++++++++- examples/sdk_examples/msp/msp_common.py | 109 ---- examples/sdk_examples/msp/msp_convert_node.py | 544 +++++++++++++++- examples/sdk_examples/msp/msp_copy_role.py | 535 +++++++++++++++- examples/sdk_examples/msp/msp_down.py | 531 +++++++++++++++- examples/sdk_examples/msp/msp_info.py | 584 +++++++++++++++++- .../sdk_examples/msp/msp_legacy_report.py | 573 ++++++++++++++++- examples/sdk_examples/msp/msp_remove.py | 531 +++++++++++++++- examples/sdk_examples/msp/msp_update.py | 564 +++++++++++++++-- 11 files changed, 5237 insertions(+), 396 deletions(-) delete mode 100644 examples/sdk_examples/msp/msp_common.py diff --git a/examples/sdk_examples/msp/login_to_managed_company.py b/examples/sdk_examples/msp/login_to_managed_company.py index e475815c..8345d065 100644 --- a/examples/sdk_examples/msp/login_to_managed_company.py +++ b/examples/sdk_examples/msp/login_to_managed_company.py @@ -1,46 +1,533 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() -MANAGED_COMPANY_ID = 0 + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Login to a managed company and switch back to MSP using msp_auth.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return - if not MANAGED_COMPANY_ID: - print('Set MANAGED_COMPANY_ID (from msp_info.py) before running.') - close_loader_and_auth(loader, auth) + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + managed_company_id = 0 + + if not managed_company_id: + print('Set managed_company_id (from msp_info.py) before running.') + loader.close() + keeper_auth_context.close() return mc_auth = None mc_loader = None try: msp_auth.msp_down(loader, reset=False) - print(f'Logging into managed company {MANAGED_COMPANY_ID}...') - mc_auth, mc_tree_key = msp_auth.login_to_managed_company(loader, MANAGED_COMPANY_ID) + print(f'Logging into managed company {managed_company_id}...') + mc_auth, mc_tree_key = msp_auth.login_to_managed_company(loader, managed_company_id) conn = sqlite3.Connection('file::memory:', uri=True) mc_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage( - lambda: conn, MANAGED_COMPANY_ID + lambda: conn, managed_company_id ) mc_loader = enterprise_loader.EnterpriseLoader(mc_auth, mc_storage, tree_key=mc_tree_key) mc_loader.load() @@ -55,7 +542,8 @@ def main(): mc_loader.close() if mc_auth is not None: mc_auth.close() - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_add.py b/examples/sdk_examples/msp/msp_add.py index 5b5dac7c..d7228851 100644 --- a/examples/sdk_examples/msp/msp_add.py +++ b/examples/sdk_examples/msp/msp_add.py @@ -1,50 +1,538 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Register a new managed company using msp_auth.msp_add_managed_company.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + mc_name = '' + plan = 'business' + seats = 10 + file_plan = None + addons = None + try: msp_auth.msp_down(loader, reset=False) root_node_id = loader.enterprise_data.root_node.node_id mc_id = msp_auth.msp_add_managed_company( loader, - enterprise_name=MC_NAME, - plan=PLAN, + enterprise_name=mc_name, + plan=plan, node_id=root_node_id, - seats=SEATS, - file_plan=FILE_PLAN, - addons=ADDONS, + seats=seats, + file_plan=file_plan, + addons=addons, ) - print(f'Created managed company "{MC_NAME}" (enterprise id={mc_id}).') + print(f'Created managed company "{mc_name}" (enterprise id={mc_id}).') finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_billing_report.py b/examples/sdk_examples/msp/msp_billing_report.py index 9691da4a..3ac4711c 100644 --- a/examples/sdk_examples/msp/msp_billing_report.py +++ b/examples/sdk_examples/msp/msp_billing_report.py @@ -1,48 +1,556 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) -MONTH: Optional[str] = None -SHOW_DATE = False -SHOW_COMPANY = False + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def _print_table(headers, rows): + if not headers: + return + widths = [len(str(h)) for h in headers] + str_rows = [] + for row in rows: + cells = [str(c) for c in row] + str_rows.append(cells) + for i, cell in enumerate(cells): + if i < len(widths): + widths[i] = max(widths[i], len(cell)) + fmt = ' '.join(f'{{:{w}}}' for w in widths) + print(fmt.format(*[str(h) for h in headers])) + print(fmt.format(*['-' * w for w in widths])) + for cells in str_rows: + padded = cells + [''] * (len(headers) - len(cells)) + print(fmt.format(*padded[: len(headers)])) + + +def print_msp_billing_report(report): + print(report.title) + _print_table(list(report.headers), report.rows) def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """MSP billing report using msp_auth.msp_billing_report.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + month = None + show_date = False + show_company = False + try: msp_auth.msp_down(loader, reset=False) report = msp_auth.msp_billing_report( loader, - month=MONTH, - show_date=SHOW_DATE, - show_company=SHOW_COMPANY, + month=month, + show_date=show_date, + show_company=show_company, ) print_msp_billing_report(report) finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_common.py b/examples/sdk_examples/msp/msp_common.py deleted file mode 100644 index e3961f36..00000000 --- a/examples/sdk_examples/msp/msp_common.py +++ /dev/null @@ -1,109 +0,0 @@ -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' Tuple[Optional[keeper_auth.KeeperAuth], Optional[enterprise_loader.EnterpriseLoader]]: - """Login and return (auth, enterprise_loader). Closes auth on failure paths.""" - login_fn, enable_persistent = load_sdk_login() - auth, _endpoint = login_fn() - if not auth: - return None, None - if not auth.auth_context.is_enterprise_admin: - print('ERROR: MSP examples require an enterprise administrator account.') - auth.close() - return None, None - try: - loader = create_enterprise_loader(auth) - return auth, loader - except Exception: - auth.close() - raise - - -def create_enterprise_loader(auth: keeper_auth.KeeperAuth) -> enterprise_loader.EnterpriseLoader: - conn = sqlite3.Connection('file::memory:', uri=True) - enterprise_id = auth.auth_context.enterprise_id or 0 - storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - return enterprise_loader.EnterpriseLoader(auth, storage) - - -def close_loader_and_auth( - loader: Optional[enterprise_loader.EnterpriseLoader], - auth: Optional[keeper_auth.KeeperAuth], -) -> None: - if loader is not None: - loader.close() - if auth is not None: - auth.close() - - -def print_msp_info_report(report: msp_auth.MspInfoReport) -> None: - if report.message: - print(report.message) - return - headers = list(report.headers) - rows = list(report.rows) - if report.row_numbers: - if not headers or headers[0].lower() != '#': - headers = ['#'] + headers - rows = [tuple([i, *row]) for i, row in enumerate(rows, 1)] - _print_table(headers, tuple(rows)) - - -def print_msp_billing_report(report: msp_auth.MspBillingReport) -> None: - print(report.title) - _print_table(list(report.headers), report.rows) - - -def print_msp_legacy_report(report: msp_auth.MspLegacyReport) -> None: - if report.title: - print(report.title) - _print_table(list(report.headers), report.rows) - - -def _print_table(headers: list, rows: tuple) -> None: - if not headers: - return - widths = [len(str(h)) for h in headers] - str_rows = [] - for row in rows: - cells = [str(c) for c in row] - str_rows.append(cells) - for i, cell in enumerate(cells): - if i < len(widths): - widths[i] = max(widths[i], len(cell)) - fmt = ' '.join(f'{{:{w}}}' for w in widths) - print(fmt.format(*[str(h) for h in headers])) - print(fmt.format(*['-' * w for w in widths])) - for cells in str_rows: - padded = cells + [''] * (len(headers) - len(cells)) - print(fmt.format(*padded[: len(headers)])) diff --git a/examples/sdk_examples/msp/msp_convert_node.py b/examples/sdk_examples/msp/msp_convert_node.py index 496b605e..ed349229 100644 --- a/examples/sdk_examples/msp/msp_convert_node.py +++ b/examples/sdk_examples/msp/msp_convert_node.py @@ -1,29 +1,501 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' int: +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def _resolve_node_id(loader, node_arg): enterprise_data = loader.enterprise_data if node_arg.isdigit(): node = enterprise_data.nodes.get_entity(int(node_arg)) @@ -41,22 +513,38 @@ def _resolve_node_id(loader, node_arg: str) -> int: def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Convert an enterprise subtree to a managed company using msp_auth.msp_convert_node.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + node_name_or_id = 'root' + seats = None + plan = None + try: msp_auth.msp_down(loader, reset=False) - node_id = _resolve_node_id(loader, NODE_NAME_OR_ID) + node_id = _resolve_node_id(loader, node_name_or_id) mc_id = msp_auth.msp_convert_node( loader, node_id=node_id, - seats=SEATS, - plan=PLAN, + seats=seats, + plan=plan, ) - print(f'Converted node {NODE_NAME_OR_ID} to managed company id={mc_id}.') + print(f'Converted node {node_name_or_id} to managed company id={mc_id}.') finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_copy_role.py b/examples/sdk_examples/msp/msp_copy_role.py index 413cd9cf..31e8ca85 100644 --- a/examples/sdk_examples/msp/msp_copy_role.py +++ b/examples/sdk_examples/msp/msp_copy_role.py @@ -1,42 +1,529 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Copy MSP roles to managed companies using msp_auth.msp_copy_role.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + roles = [''] + managed_companies = [''] + try: msp_auth.msp_down(loader, reset=False) synced = msp_auth.msp_copy_role( loader, - roles=ROLES, - managed_companies=MANAGED_COMPANIES, + roles=roles, + managed_companies=managed_companies, ) print(f'Roles synced to managed company id(s): {sorted(synced)}') finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_down.py b/examples/sdk_examples/msp/msp_down.py index 0e6033d5..4a6fde5b 100644 --- a/examples/sdk_examples/msp/msp_down.py +++ b/examples/sdk_examples/msp/msp_down.py @@ -1,34 +1,523 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Refresh MSP enterprise data using msp_auth.msp_down.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + reset = False + try: - touched = msp_auth.msp_down(loader, reset=RESET) + touched = msp_auth.msp_down(loader, reset=reset) print(f'MSP data synced ({len(touched)} entity type(s) updated).') finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_info.py b/examples/sdk_examples/msp/msp_info.py index 955445dd..2a73cc03 100644 --- a/examples/sdk_examples/msp/msp_info.py +++ b/examples/sdk_examples/msp/msp_info.py @@ -1,50 +1,566 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) -MANAGED_COMPANY: Optional[str] = None -SHOW_PRICING = False -SHOW_RESTRICTION = False -VERBOSE = False + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def _print_table(headers, rows): + if not headers: + return + widths = [len(str(h)) for h in headers] + str_rows = [] + for row in rows: + cells = [str(c) for c in row] + str_rows.append(cells) + for i, cell in enumerate(cells): + if i < len(widths): + widths[i] = max(widths[i], len(cell)) + fmt = ' '.join(f'{{:{w}}}' for w in widths) + print(fmt.format(*[str(h) for h in headers])) + print(fmt.format(*['-' * w for w in widths])) + for cells in str_rows: + padded = cells + [''] * (len(headers) - len(cells)) + print(fmt.format(*padded[: len(headers)])) + + +def print_msp_info_report(report): + if report.message: + print(report.message) + return + headers = list(report.headers) + rows = list(report.rows) + if report.row_numbers: + if not headers or headers[0].lower() != '#': + headers = ['#'] + headers + rows = [tuple([i, *row]) for i, row in enumerate(rows, 1)] + _print_table(headers, tuple(rows)) def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """List managed companies using keepersdk.enterprise.msp_auth.msp_info.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + managed_company = None + show_pricing = False + show_restriction = False + verbose = False + try: msp_auth.msp_down(loader, reset=False) report = msp_auth.msp_info( loader, - restriction=SHOW_RESTRICTION, - pricing=SHOW_PRICING, - managed_company=MANAGED_COMPANY, - verbose=VERBOSE, + restriction=show_restriction, + pricing=show_pricing, + managed_company=managed_company, + verbose=verbose, ) print_msp_info_report(report) finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_legacy_report.py b/examples/sdk_examples/msp/msp_legacy_report.py index fc4384a9..1542dcca 100644 --- a/examples/sdk_examples/msp/msp_legacy_report.py +++ b/examples/sdk_examples/msp/msp_legacy_report.py @@ -1,48 +1,557 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) -RANGE_NAME = 'last_30_days' -FROM_DATE: Optional[str] = None -TO_DATE: Optional[str] = None + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def _print_table(headers, rows): + if not headers: + return + widths = [len(str(h)) for h in headers] + str_rows = [] + for row in rows: + cells = [str(c) for c in row] + str_rows.append(cells) + for i, cell in enumerate(cells): + if i < len(widths): + widths[i] = max(widths[i], len(cell)) + fmt = ' '.join(f'{{:{w}}}' for w in widths) + print(fmt.format(*[str(h) for h in headers])) + print(fmt.format(*['-' * w for w in widths])) + for cells in str_rows: + padded = cells + [''] * (len(headers) - len(cells)) + print(fmt.format(*padded[: len(headers)])) + + +def print_msp_legacy_report(report): + if report.title: + print(report.title) + _print_table(list(report.headers), report.rows) def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Legacy MSP billing report using msp_auth.msp_legacy_report.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + range_name = 'last_30_days' + from_date = None + to_date = None + try: msp_auth.msp_down(loader, reset=False) report = msp_auth.msp_legacy_report( loader, - range_name=RANGE_NAME, - from_date=FROM_DATE, - to_date=TO_DATE, + range_name=range_name, + from_date=from_date, + to_date=to_date, ) print_msp_legacy_report(report) finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_remove.py b/examples/sdk_examples/msp/msp_remove.py index 3eaf06f4..e48c30a8 100644 --- a/examples/sdk_examples/msp/msp_remove.py +++ b/examples/sdk_examples/msp/msp_remove.py @@ -1,35 +1,524 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Remove a managed company using msp_auth.msp_remove_managed_company.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + managed_company = '' + try: msp_auth.msp_down(loader, reset=False) - eid = msp_auth.msp_remove_managed_company(loader, managed_company=MANAGED_COMPANY) + eid = msp_auth.msp_remove_managed_company(loader, managed_company=managed_company) print(f'Removed managed company id={eid}.') finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_update.py b/examples/sdk_examples/msp/msp_update.py index 902f1415..f1c96080 100644 --- a/examples/sdk_examples/msp/msp_update.py +++ b/examples/sdk_examples/msp/msp_update.py @@ -1,52 +1,540 @@ -#!/usr/bin/env python3 -# _ __ -# | |/ /___ ___ _ __ ___ _ _ ® -# | ' None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint def main(): - auth, loader = login_with_enterprise() - if not auth or not loader: - print('Login failed.') + """Update a managed company using msp_auth.msp_update_managed_company.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: MSP examples require an enterprise administrator account.') + keeper_auth_context.close() return + + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + # Fill in your values here. + managed_company = '' + new_name = None + plan = None + seats = None + file_plan = None + add_addons = None + remove_addons = None + try: msp_auth.msp_down(loader, reset=False) eid = msp_auth.msp_update_managed_company( loader, - managed_company=MANAGED_COMPANY, - new_name=NEW_NAME, - plan=PLAN, - seats=SEATS, - file_plan=FILE_PLAN, - add_addons=ADD_ADDONS, - remove_addons=REMOVE_ADDONS, + managed_company=managed_company, + new_name=new_name, + plan=plan, + seats=seats, + file_plan=file_plan, + add_addons=add_addons, + remove_addons=remove_addons, ) print(f'Updated managed company id={eid}.') finally: - close_loader_and_auth(loader, auth) + loader.close() + keeper_auth_context.close() if __name__ == '__main__': From fd5efcf6ca70239028d337744eabb01d75e1d54b Mon Sep 17 00:00:00 2001 From: Manan Tyagi Date: Mon, 1 Jun 2026 18:41:03 +0530 Subject: [PATCH 5/5] added helper functions --- .../msp/login_to_managed_company.py | 49 ++++++++++----- examples/sdk_examples/msp/msp_add.py | 62 ++++++++++++++----- .../sdk_examples/msp/msp_billing_report.py | 47 ++++++++++---- examples/sdk_examples/msp/msp_convert_node.py | 56 ++++++++++++----- examples/sdk_examples/msp/msp_copy_role.py | 50 ++++++++++----- examples/sdk_examples/msp/msp_down.py | 40 ++++++++---- examples/sdk_examples/msp/msp_info.py | 58 ++++++++++++----- .../sdk_examples/msp/msp_legacy_report.py | 47 ++++++++++---- examples/sdk_examples/msp/msp_remove.py | 42 +++++++++---- examples/sdk_examples/msp/msp_update.py | 60 ++++++++++++++---- 10 files changed, 368 insertions(+), 143 deletions(-) diff --git a/examples/sdk_examples/msp/login_to_managed_company.py b/examples/sdk_examples/msp/login_to_managed_company.py index 8345d065..426847a8 100644 --- a/examples/sdk_examples/msp/login_to_managed_company.py +++ b/examples/sdk_examples/msp/login_to_managed_company.py @@ -494,30 +494,25 @@ def login(): keeper_endpoint = flow.endpoint if keeper_auth_context else None return keeper_auth_context, keeper_endpoint -def main(): - """Login to a managed company and switch back to MSP using msp_auth.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) - # Fill in your values here. - managed_company_id = 0 - if not managed_company_id: - print('Set managed_company_id (from msp_info.py) before running.') +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: loader.close() - keeper_auth_context.close() - return + keeper_auth_context.close() +def login_to_managed_company_example(loader, managed_company_id: int): + """Login to a managed company and switch back to MSP context.""" mc_auth = None mc_loader = None try: @@ -542,8 +537,30 @@ def main(): mc_loader.close() if mc_auth is not None: mc_auth.close() - loader.close() - keeper_auth_context.close() + + +def main(): + """Main function to orchestrate login and managed-company context switch.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here. + managed_company_id = 0 + + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + + if not managed_company_id: + print('Set managed_company_id (from msp_info.py) before running.') + close_msp_session(loader, keeper_auth_context) + return + + try: + login_to_managed_company_example(loader, managed_company_id) + finally: + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_add.py b/examples/sdk_examples/msp/msp_add.py index d7228851..7cdc334d 100644 --- a/examples/sdk_examples/msp/msp_add.py +++ b/examples/sdk_examples/msp/msp_add.py @@ -495,20 +495,51 @@ def login(): return keeper_auth_context, keeper_endpoint -def main(): - """Register a new managed company using msp_auth.msp_add_managed_company.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def add_msp_managed_company( + loader, + mc_name: str, + plan: str, + seats=None, + file_plan=None, + addons=None, +): + """Register a new managed company using msp_auth.msp_add_managed_company.""" + msp_auth.msp_down(loader, reset=False) + root_node_id = loader.enterprise_data.root_node.node_id + mc_id = msp_auth.msp_add_managed_company( + loader, + enterprise_name=mc_name, + plan=plan, + node_id=root_node_id, + seats=seats, + file_plan=file_plan, + addons=addons, + ) + print(f'Created managed company "{mc_name}" (enterprise id={mc_id}).') + + +def main(): + """Main function to orchestrate login and add an MSP managed company.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. mc_name = '' @@ -517,22 +548,21 @@ def main(): file_plan = None addons = None + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - root_node_id = loader.enterprise_data.root_node.node_id - mc_id = msp_auth.msp_add_managed_company( + add_msp_managed_company( loader, - enterprise_name=mc_name, + mc_name=mc_name, plan=plan, - node_id=root_node_id, seats=seats, file_plan=file_plan, addons=addons, ) - print(f'Created managed company "{mc_name}" (enterprise id={mc_id}).') finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_billing_report.py b/examples/sdk_examples/msp/msp_billing_report.py index 3ac4711c..54939e83 100644 --- a/examples/sdk_examples/msp/msp_billing_report.py +++ b/examples/sdk_examples/msp/msp_billing_report.py @@ -519,38 +519,59 @@ def print_msp_billing_report(report): _print_table(list(report.headers), report.rows) -def main(): - """MSP billing report using msp_auth.msp_billing_report.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def run_msp_billing_report(loader, month=None, show_date=False, show_company=False): + """MSP billing report using msp_auth.msp_billing_report.""" + msp_auth.msp_down(loader, reset=False) + report = msp_auth.msp_billing_report( + loader, + month=month, + show_date=show_date, + show_company=show_company, + ) + print_msp_billing_report(report) + + +def main(): + """Main function to orchestrate login and run MSP billing report.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. month = None show_date = False show_company = False + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - report = msp_auth.msp_billing_report( + run_msp_billing_report( loader, month=month, show_date=show_date, show_company=show_company, ) - print_msp_billing_report(report) finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_convert_node.py b/examples/sdk_examples/msp/msp_convert_node.py index ed349229..c321103f 100644 --- a/examples/sdk_examples/msp/msp_convert_node.py +++ b/examples/sdk_examples/msp/msp_convert_node.py @@ -512,39 +512,65 @@ def _resolve_node_id(loader, node_arg): raise ValueError(f'Node "{node_arg}" not found') -def main(): - """Convert an enterprise subtree to a managed company using msp_auth.msp_convert_node.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def convert_enterprise_node_to_managed_company( + loader, + node_name_or_id: str, + seats=None, + plan=None, +): + """Convert an enterprise subtree to a managed company using msp_auth.msp_convert_node.""" + msp_auth.msp_down(loader, reset=False) + node_id = _resolve_node_id(loader, node_name_or_id) + mc_id = msp_auth.msp_convert_node( + loader, + node_id=node_id, + seats=seats, + plan=plan, + ) + print(f'Converted node {node_name_or_id} to managed company id={mc_id}.') + + +def main(): + """Main function to orchestrate login and convert a node to a managed company.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. node_name_or_id = 'root' seats = None plan = None + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - node_id = _resolve_node_id(loader, node_name_or_id) - mc_id = msp_auth.msp_convert_node( + convert_enterprise_node_to_managed_company( loader, - node_id=node_id, + node_name_or_id=node_name_or_id, seats=seats, plan=plan, ) - print(f'Converted node {node_name_or_id} to managed company id={mc_id}.') finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_copy_role.py b/examples/sdk_examples/msp/msp_copy_role.py index 31e8ca85..11729d66 100644 --- a/examples/sdk_examples/msp/msp_copy_role.py +++ b/examples/sdk_examples/msp/msp_copy_role.py @@ -494,36 +494,52 @@ def login(): keeper_endpoint = flow.endpoint if keeper_auth_context else None return keeper_auth_context, keeper_endpoint -def main(): - """Copy MSP roles to managed companies using msp_auth.msp_copy_role.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def copy_msp_roles_to_managed_companies(loader, roles, managed_companies): + """Copy MSP roles to managed companies using msp_auth.msp_copy_role.""" + msp_auth.msp_down(loader, reset=False) + synced = msp_auth.msp_copy_role( + loader, + roles=roles, + managed_companies=managed_companies, + ) + print(f'Roles synced to managed company id(s): {sorted(synced)}') + + +def main(): + """Main function to orchestrate login and copy MSP roles.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. roles = [''] managed_companies = [''] + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - synced = msp_auth.msp_copy_role( - loader, - roles=roles, - managed_companies=managed_companies, - ) - print(f'Roles synced to managed company id(s): {sorted(synced)}') + copy_msp_roles_to_managed_companies(loader, roles, managed_companies) finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_down.py b/examples/sdk_examples/msp/msp_down.py index 4a6fde5b..321496a2 100644 --- a/examples/sdk_examples/msp/msp_down.py +++ b/examples/sdk_examples/msp/msp_down.py @@ -494,30 +494,46 @@ def login(): keeper_endpoint = flow.endpoint if keeper_auth_context else None return keeper_auth_context, keeper_endpoint -def main(): - """Refresh MSP enterprise data using msp_auth.msp_down.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def sync_msp_enterprise_data(loader, reset: bool = False): + """Refresh MSP enterprise data using msp_auth.msp_down.""" + touched = msp_auth.msp_down(loader, reset=reset) + print(f'MSP data synced ({len(touched)} entity type(s) updated).') + + +def main(): + """Main function to orchestrate login and sync MSP enterprise data.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. reset = False + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - touched = msp_auth.msp_down(loader, reset=reset) - print(f'MSP data synced ({len(touched)} entity type(s) updated).') + sync_msp_enterprise_data(loader, reset=reset) finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_info.py b/examples/sdk_examples/msp/msp_info.py index 2a73cc03..c3948082 100644 --- a/examples/sdk_examples/msp/msp_info.py +++ b/examples/sdk_examples/msp/msp_info.py @@ -527,20 +527,47 @@ def print_msp_info_report(report): _print_table(headers, tuple(rows)) -def main(): - """List managed companies using keepersdk.enterprise.msp_auth.msp_info.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def list_msp_managed_companies( + loader, + managed_company=None, + show_pricing=False, + show_restriction=False, + verbose=False, +): + """List managed companies using msp_auth.msp_info.""" + msp_auth.msp_down(loader, reset=False) + report = msp_auth.msp_info( + loader, + restriction=show_restriction, + pricing=show_pricing, + managed_company=managed_company, + verbose=verbose, + ) + print_msp_info_report(report) + + +def main(): + """Main function to orchestrate login and list MSP managed companies.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. managed_company = None @@ -548,19 +575,20 @@ def main(): show_restriction = False verbose = False + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - report = msp_auth.msp_info( + list_msp_managed_companies( loader, - restriction=show_restriction, - pricing=show_pricing, managed_company=managed_company, + show_pricing=show_pricing, + show_restriction=show_restriction, verbose=verbose, ) - print_msp_info_report(report) finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_legacy_report.py b/examples/sdk_examples/msp/msp_legacy_report.py index 1542dcca..94332abd 100644 --- a/examples/sdk_examples/msp/msp_legacy_report.py +++ b/examples/sdk_examples/msp/msp_legacy_report.py @@ -520,38 +520,59 @@ def print_msp_legacy_report(report): _print_table(list(report.headers), report.rows) -def main(): - """Legacy MSP billing report using msp_auth.msp_legacy_report.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def run_msp_legacy_report(loader, range_name='last_30_days', from_date=None, to_date=None): + """Legacy MSP billing report using msp_auth.msp_legacy_report.""" + msp_auth.msp_down(loader, reset=False) + report = msp_auth.msp_legacy_report( + loader, + range_name=range_name, + from_date=from_date, + to_date=to_date, + ) + print_msp_legacy_report(report) + + +def main(): + """Main function to orchestrate login and run MSP legacy billing report.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. range_name = 'last_30_days' from_date = None to_date = None + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - report = msp_auth.msp_legacy_report( + run_msp_legacy_report( loader, range_name=range_name, from_date=from_date, to_date=to_date, ) - print_msp_legacy_report(report) finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_remove.py b/examples/sdk_examples/msp/msp_remove.py index e48c30a8..da3624cd 100644 --- a/examples/sdk_examples/msp/msp_remove.py +++ b/examples/sdk_examples/msp/msp_remove.py @@ -494,31 +494,47 @@ def login(): keeper_endpoint = flow.endpoint if keeper_auth_context else None return keeper_auth_context, keeper_endpoint -def main(): - """Remove a managed company using msp_auth.msp_remove_managed_company.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def remove_msp_managed_company(loader, managed_company: str): + """Remove a managed company using msp_auth.msp_remove_managed_company.""" + msp_auth.msp_down(loader, reset=False) + eid = msp_auth.msp_remove_managed_company(loader, managed_company=managed_company) + print(f'Removed managed company id={eid}.') + + +def main(): + """Main function to orchestrate login and remove an MSP managed company.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. managed_company = '' + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - eid = msp_auth.msp_remove_managed_company(loader, managed_company=managed_company) - print(f'Removed managed company id={eid}.') + remove_msp_managed_company(loader, managed_company) finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__': diff --git a/examples/sdk_examples/msp/msp_update.py b/examples/sdk_examples/msp/msp_update.py index f1c96080..38571582 100644 --- a/examples/sdk_examples/msp/msp_update.py +++ b/examples/sdk_examples/msp/msp_update.py @@ -495,20 +495,53 @@ def login(): return keeper_auth_context, keeper_endpoint -def main(): - """Update a managed company using msp_auth.msp_update_managed_company.""" - keeper_auth_context, _ = login() - if not keeper_auth_context: - return +def open_msp_enterprise_loader(keeper_auth_context: keeper_auth.KeeperAuth): + """Open in-memory enterprise storage for MSP operations (enterprise admin required).""" if not keeper_auth_context.auth_context.is_enterprise_admin: print('ERROR: MSP examples require an enterprise administrator account.') keeper_auth_context.close() - return - + return None conn = sqlite3.Connection('file::memory:', uri=True) enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) - loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + +def close_msp_session(loader, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + if loader is not None: + loader.close() + keeper_auth_context.close() + +def update_msp_managed_company( + loader, + managed_company: str, + new_name=None, + plan=None, + seats=None, + file_plan=None, + add_addons=None, + remove_addons=None, +): + """Update a managed company using msp_auth.msp_update_managed_company.""" + msp_auth.msp_down(loader, reset=False) + eid = msp_auth.msp_update_managed_company( + loader, + managed_company=managed_company, + new_name=new_name, + plan=plan, + seats=seats, + file_plan=file_plan, + add_addons=add_addons, + remove_addons=remove_addons, + ) + print(f'Updated managed company id={eid}.') + + +def main(): + """Main function to orchestrate login and update an MSP managed company.""" + keeper_auth_context, _ = login() + if not keeper_auth_context: + return # Fill in your values here. managed_company = '' @@ -519,9 +552,12 @@ def main(): add_addons = None remove_addons = None + loader = open_msp_enterprise_loader(keeper_auth_context) + if not loader: + return + try: - msp_auth.msp_down(loader, reset=False) - eid = msp_auth.msp_update_managed_company( + update_msp_managed_company( loader, managed_company=managed_company, new_name=new_name, @@ -531,10 +567,8 @@ def main(): add_addons=add_addons, remove_addons=remove_addons, ) - print(f'Updated managed company id={eid}.') finally: - loader.close() - keeper_auth_context.close() + close_msp_session(loader, keeper_auth_context) if __name__ == '__main__':