diff --git a/changelog.md b/changelog.md index d982c741..427dedcc 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ Features * "Eager" completions for the `source` command, limited to `*.sql` files. * Suggest column names from all tables in the current database after SELECT (#212) * Put fuzzy completions more often to the bottom of the suggestion list. +* Store and retrieve passwords using the system keyring. Bug Fixes diff --git a/mycli/main.py b/mycli/main.py index 98ced43f..5cc6a5f2 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -28,6 +28,7 @@ from cli_helpers.utils import strip_ansi import click from configobj import ConfigObj +import keyring from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import Completion, DynamicCompleter from prompt_toolkit.document import Document @@ -480,6 +481,8 @@ def connect( ssh_key_filename: str | None = "", init_command: str | None = "", unbuffered: bool | None = None, + use_keyring: bool | None = None, + reset_keyring: bool | None = None, ) -> None: cnf = { "database": None, @@ -537,11 +540,27 @@ def connect( # 3. envvar (MYSQL_PWD) # 4. DSN (mysql://user:password) # 5. cnf (.my.cnf / etc) + # 6. keyring + + keychain_user = f'{user}@{host}' + keychain_domain = 'mycli.net' + keychain_retrieved = False + + if passwd is None and use_keyring and not reset_keyring: + passwd = keyring.get_password(keychain_domain, keychain_user) + keychain_retrieved = True # if no password was found from all of the above sources, ask for a password if passwd is None: passwd = click.prompt("Enter password", hide_input=True, show_default=False, default='', type=str, err=True) + if reset_keyring or (use_keyring and not keychain_retrieved): + try: + keyring.set_password(keychain_domain, keychain_user, passwd) + click.secho('Password saved to the system keychain', err=True) + except Exception as e: + click.secho(f'Password not saved to the system keychain: {e}', err=True, fg='red') + # Connect to the database. def _connect() -> None: try: @@ -1538,6 +1557,13 @@ def get_last_query(self) -> str | None: '--format', 'batch_format', type=click.Choice(['default', 'csv', 'tsv', 'table']), help='Format for batch or --execute output.' ) @click.option('--throttle', type=float, default=0.0, help='Pause in seconds between queries in batch mode.') +@click.option( + '--use-keyring', + 'use_keyring_cli_opt', + type=click.Choice(['true', 'false', 'reset']), + default=None, + help='Store and retrieve passwords from the system keyring: true/false/reset.', +) @click.pass_context def cli( ctx: click.Context, @@ -1590,6 +1616,7 @@ def cli( noninteractive: bool, batch_format: str | None, throttle: float, + use_keyring_cli_opt: str | None, ) -> None: """A MySQL terminal client with auto-completion and syntax highlighting. @@ -1863,6 +1890,16 @@ def get_password_from_file(password_file: str | None) -> str | None: if show_warnings: mycli.show_warnings = show_warnings + if use_keyring_cli_opt is not None and use_keyring_cli_opt.lower() == 'reset': + use_keyring = True + reset_keyring = True + elif use_keyring_cli_opt is None: + use_keyring = str_to_bool(mycli.config['main'].get('use_keyring', 'False')) + reset_keyring = False + else: + use_keyring = str_to_bool(use_keyring_cli_opt) + reset_keyring = False + mycli.connect( database=database, user=user, @@ -1880,6 +1917,8 @@ def get_password_from_file(password_file: str | None) -> str | None: init_command=combined_init_cmd, unbuffered=unbuffered, charset=charset, + use_keyring=use_keyring, + reset_keyring=reset_keyring, ) if combined_init_cmd: diff --git a/mycli/myclirc b/mycli/myclirc index 91d92294..b10a07e6 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -137,6 +137,13 @@ pager = 'less' # character set for connections without --charset being set at the CLI default_character_set = utf8mb4 +# Whether to store and retrieve passwords from the system keyring. +# See the documentation for https://pypi.org/project/keyring/ for your OS. +# Note that the hostname is considered to be different if short or qualified. +# This can be overridden with --use-keyring= at the CLI. +# A password can be reset with --use-keyring=reset at the CLI. +use_keyring = False + [keys] # possible values: auto, fzf, reverse_isearch control_r = auto diff --git a/pyproject.toml b/pyproject.toml index 8bbe011c..fca04495 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "pycryptodomex", "pyfzf >= 0.3.1", "rapidfuzz ~= 3.14.3", + "keyring ~= 25.7.0", ] [build-system] diff --git a/test/myclirc b/test/myclirc index 870ef552..0cfa1362 100644 --- a/test/myclirc +++ b/test/myclirc @@ -135,6 +135,13 @@ pager = less # character set for connections without --charset being set at the CLI default_character_set = utf8mb4 +# Whether to store and retrieve passwords from the system keyring. +# See the documentation for https://pypi.org/project/keyring/ for your OS. +# Note that the hostname is considered to be different if short or qualified. +# This can be overridden with --use-keyring= at the CLI. +# A password can be reset with --use-keyring=reset at the CLI. +use_keyring = False + [keys] # possible values: auto, fzf, reverse_isearch control_r = auto diff --git a/test/test_main.py b/test/test_main.py index 451277a4..58dcf77a 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -656,7 +656,10 @@ def warning(self, *args, **args_dict): pass class MockMyCli: - config = {"alias_dsn": {}} + config = { + "main": {}, + "alias_dsn": {}, + } def __init__(self, **args): self.logger = Logger() @@ -718,7 +721,10 @@ def run_query(self, query, new_line=True): and MockMyCli.connect_args["database"] == "arg_database" ) - MockMyCli.config = {"alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}} + MockMyCli.config = { + "main": {}, + "alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}, + } MockMyCli.connect_args = None # When a user uses a DSN from the configuration file (alias_dsn), @@ -733,7 +739,10 @@ def run_query(self, query, new_line=True): and MockMyCli.connect_args["database"] == "alias_dsn_database" ) - MockMyCli.config = {"alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}} + MockMyCli.config = { + "main": {}, + "alias_dsn": {"test": "mysql://alias_dsn_user:alias_dsn_passwd@alias_dsn_host:4/alias_dsn_database"}, + } MockMyCli.connect_args = None # When a user uses a DSN from the configuration file (alias_dsn) @@ -821,7 +830,10 @@ def warning(self, *args, **args_dict): pass class MockMyCli: - config = {"alias_dsn": {}} + config = { + "main": {}, + "alias_dsn": {}, + } def __init__(self, **args): self.logger = Logger()