diff --git a/mwdblib/api/api.py b/mwdblib/api/api.py index d7ba606..6400257 100644 --- a/mwdblib/api/api.py +++ b/mwdblib/api/api.py @@ -77,9 +77,21 @@ class APIClient: mwdb.api.delete(f'object/{sha256}') """ - def __init__(self, _auth_token: Optional[str] = None, **api_options: Any) -> None: + def __init__( + self, + _auth_token: Optional[str] = None, + autologin: bool = True, + **api_options: Any, + ) -> None: self.options: APIClientOptions = APIClientOptions(**api_options) self.auth_token: Optional[JWTAuthToken] = None + + # These state variables will be filled after + # successful authentication + self.username: Optional[str] = None + self.password: Optional[str] = None + self.api_key: Optional[str] = None + self._server_metadata: Optional[dict] = None self.session: requests.Session = requests.Session() @@ -92,10 +104,12 @@ def __init__(self, _auth_token: Optional[str] = None, **api_options: Any) -> Non if _auth_token: self.set_auth_token(_auth_token) - if self.options.api_key: - self.set_api_key(self.options.api_key) - elif self.options.username and self.options.password: - self.login(self.options.username, self.options.password) + + if autologin: + if self.options.api_key: + self.set_api_key(self.options.api_key) + elif self.options.username and self.options.password: + self.login(self.options.username, self.options.password) @property def server_metadata(self) -> dict: @@ -150,9 +164,9 @@ def login(self, username: str, password: str) -> None: "auth/login", json={"login": username, "password": password}, noauth=True )["token"] self.set_auth_token(token) - # Store credentials in API options - self.options.username = username - self.options.password = password + # Store credentials in API state + self.username = username + self.password = password def set_api_key(self, api_key: str) -> None: """ @@ -165,10 +179,10 @@ def set_api_key(self, api_key: str) -> None: :param api_key: API key to set """ self.set_auth_token(api_key) - # Store credentials in API options - self.options.api_key = api_key + # Store credentials in API state + self.api_key = api_key if self.auth_token is not None: - self.options.username = self.auth_token.username + self.username = self.auth_token.username def logout(self) -> None: """ @@ -253,10 +267,10 @@ def request( # Forget current auth_key self.logout() # If no password set: re-raise - if self.options.password is None: + if self.username is None or self.password is None: raise # Try to log in - self.login(self.options.username, self.options.password) + self.login(self.username, self.password) # Retry failed request... except LimitExceededError as e: if not self.options.obey_ratelimiter: diff --git a/mwdblib/api/options.py b/mwdblib/api/options.py index b26cbc0..2b2837e 100644 --- a/mwdblib/api/options.py +++ b/mwdblib/api/options.py @@ -158,14 +158,14 @@ def __init__( f"mwdb:{self.api_url}", self.username ) - def clear_stored_credentials(self, config_writeback: bool = True) -> None: + def clear_stored_credentials(self, config_writeback: bool = True) -> bool: """ Clears stored credentials in configuration for current user. Used by ``mwdb logout`` CLI command. """ if not self.username: - return + return False # Remove credentials from keyring if self.use_keyring: try: @@ -188,15 +188,21 @@ def clear_stored_credentials(self, config_writeback: bool = True) -> None: if config_writeback and self.config_path: with self.config_path.open("w") as f: self.config_parser.write(f) + return True - def store_credentials(self) -> None: + def store_credentials( + self, username: Optional[str], password: Optional[str], api_key: Optional[str] + ) -> bool: """ Stores current credentials in configuration for current user. Used by ``mwdb login`` CLI command. """ - if not self.username or (not self.api_key and not self.password): - return + if not username or (not api_key and not password): + return False + self.username = username + self.password = password + self.api_key = api_key # Clear currently stored credentials self.clear_stored_credentials(config_writeback=False) # Ensure that 'mwdb' section exists in configuration @@ -215,16 +221,23 @@ def store_credentials(self) -> None: keyring.set_password( f"mwdb-apikey:{self.api_url}", self.username, self.api_key ) - else: + elif self.password: keyring.set_password( f"mwdb:{self.api_url}", self.username, self.password ) + else: + raise RuntimeError("Implementation error: no api_key nor password") + self.config_parser.set(instance_section, "use_keyring", "1") else: if self.api_key: self.config_parser.set(instance_section, "api_key", self.api_key) - else: + elif self.password: self.config_parser.set(instance_section, "password", self.password) + else: + raise RuntimeError("Implementation error: no api_key nor password") + self.config_parser.set(instance_section, "use_keyring", "0") # Perform configuration writeback if self.config_path: with self.config_path.open("w") as f: self.config_parser.write(f) + return True diff --git a/mwdblib/cli/login.py b/mwdblib/cli/login.py index eefacf2..b518001 100644 --- a/mwdblib/cli/login.py +++ b/mwdblib/cli/login.py @@ -11,6 +11,11 @@ @click.option( "--password", "-p", type=str, default=None, help="MWDB password (default: ask)" ) +@click.option( + "--use-keyring/--no-keyring", + default=None, + help="Don't use keyring, store credentials in plaintext", +) @click.option("--via-api-key", "-A", is_flag=True, help="Use API key provided by stdin") @click.option( "--api-key", @@ -19,18 +24,22 @@ default=None, help="API key token (default: password-based authentication)", ) -@pass_mwdb +@pass_mwdb(autologin=False) @click.pass_context -def login_command(ctx, mwdb, username, password, via_api_key, api_key): +def login_command(ctx, mwdb, username, password, use_keyring, via_api_key, api_key): """Store credentials for MWDB authentication""" if via_api_key: api_key = click.prompt("Provide your API key token", hide_input=True) + if api_key is None: if username is None: username = click.prompt("Username") if password is None: password = click.prompt("Password", hide_input=True) + if use_keyring is not None: + mwdb.api.options.use_keyring = use_keyring + try: # Try to use credentials if api_key is None: @@ -42,11 +51,28 @@ def login_command(ctx, mwdb, username, password, via_api_key, api_key): except (InvalidCredentialsError, NotAuthenticatedError) as e: click.echo("Error: Login failed - {}".format(str(e)), err=True) ctx.abort() - mwdb.api.options.store_credentials() + mwdb.api.options.store_credentials(username, password, api_key) + if not mwdb.api.options.use_keyring: + click.echo( + f"Warning! Your password is stored in plaintext in " + f"{mwdb.api.options.config_path}. Use --use-keyring to store " + f"credentials in keyring (if available on your system).", + err=True, + ) + click.echo( + f"Logged in successfully to {mwdb.api.options.api_url} " + f"as {mwdb.api.logged_user}", + err=True, + ) @main.command("logout") -@pass_mwdb +@pass_mwdb(autologin=False) def logout_command(mwdb): """Reset stored credentials""" - mwdb.api.options.clear_stored_credentials() + if mwdb.api.options.clear_stored_credentials(): + click.echo(f"Logged out successfully from {mwdb.api.options.api_url}", err=True) + else: + click.echo( + f"Error: user already logged out from {mwdb.api.options.api_url}!", err=True + ) diff --git a/mwdblib/cli/main.py b/mwdblib/cli/main.py index fa57925..fd886ad 100644 --- a/mwdblib/cli/main.py +++ b/mwdblib/cli/main.py @@ -8,37 +8,49 @@ from ..exc import MWDBError, NotAuthenticatedError -def pass_mwdb(fn): - @click.option("--api-url", type=str, default=None, help="URL to MWDB instance API") - @click.option( - "--config-path", type=str, default=None, help="Alternative configuration path" - ) - @functools.wraps(fn) - def wrapper(*args, **kwargs): - ctx = get_current_context() - mwdb_options = {} - api_url = kwargs.pop("api_url") - if api_url: - mwdb_options["api_url"] = api_url - config_path = kwargs.pop("config_path") - if config_path: - mwdb_options["config_path"] = config_path - mwdb = MWDB(**mwdb_options) - try: - return fn(mwdb=mwdb, *args, **kwargs) - except NotAuthenticatedError: - click.echo( - "Error: Not authenticated. Use `mwdb login` first to set credentials.", - err=True, - ) - ctx.abort() - except MWDBError as error: - click.echo( - "{}: {}".format(error.__class__.__name__, error.args[0]), err=True - ) - ctx.abort() - - return wrapper +def pass_mwdb(*fn, autologin=True): + def uses_mwdb(fn): + @click.option( + "--api-url", type=str, default=None, help="URL to MWDB instance API" + ) + @click.option( + "--config-path", + type=str, + default=None, + help="Alternative configuration path", + ) + @functools.wraps(fn) + def wrapper(*args, **kwargs): + ctx = get_current_context() + mwdb_options = {} + api_url = kwargs.pop("api_url") + if api_url: + mwdb_options["api_url"] = api_url + config_path = kwargs.pop("config_path") + if config_path: + mwdb_options["config_path"] = config_path + mwdb = MWDB(autologin=autologin, **mwdb_options) + try: + return fn(mwdb=mwdb, *args, **kwargs) + except NotAuthenticatedError: + click.echo( + "Error: Not authenticated. Use `mwdb login` first " + "to set credentials.", + err=True, + ) + ctx.abort() + except MWDBError as error: + click.echo( + "{}: {}".format(error.__class__.__name__, error.args[0]), err=True + ) + ctx.abort() + + return wrapper + + if fn: + return uses_mwdb(fn[0]) + else: + return uses_mwdb @click.group() diff --git a/mwdblib/core.py b/mwdblib/core.py index f4e141b..82a9246 100644 --- a/mwdblib/core.py +++ b/mwdblib/core.py @@ -36,6 +36,8 @@ class MWDB: :param api_key: MWDB API key :param username: MWDB account username :param password: MWDB account password + :param autologin: Login automatically using credentials stored in configuration + or provided in arguments (default: True) :param verify_ssl: Verify SSL certificate correctness (default: True) :param obey_ratelimiter: If ``False``, HTTP 429 errors will cause an exception like all other error codes. @@ -78,6 +80,9 @@ class MWDB: Added ``use_keyring``, ``emit_warnings`` and ``config_path`` options. ``username`` and ``password`` can be passed directly to the constructor. + .. versionadded:: 4.4.0 + Added ``autologin`` option. + Usage example: .. code-block:: python