diff --git a/.secrets.baseline b/.secrets.baseline index d236a8ed1..ea850e071 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2024-04-18T01:09:09Z", + "generated_at": "2024-04-25T01:18:20Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -554,7 +554,7 @@ "hashed_secret": "a4c805a62a0387010cd172cfed6f6772eb92a5d6", "is_secret": false, "is_verified": false, - "line_number": 76, + "line_number": 81, "type": "Secret Keyword", "verified_result": null } diff --git a/README-internal.md b/README-internal.md new file mode 100644 index 000000000..f519d7023 --- /dev/null +++ b/README-internal.md @@ -0,0 +1,40 @@ +This document is for internal users wanting to use this library to interact with the internal API. It will not work for `api.softlayer.com`. + + +## Certificate Example + +For use with a utility certificate. In your config file (usually `~/.softlayer`), you need to set the following: + +``` +[softlayer] +endpoint_url = https:///v3/internal/rest/ +timeout = 0 +theme = dark +auth_cert = /etc/ssl/certs/my_utility_cert-dev.pem +server_cert = /etc/ssl/certs/allCAbundle.pem +``` + +`auth_cert`: is your utility user certificate +`server_cert`: is the CA certificate bundle to validate the internal API ssl chain. Otherwise you get self-signed ssl errors without this. + + +``` +import SoftLayer +import logging +import click + +@click.command() +def testAuthentication(): + client = SoftLayer.CertificateClient() + result = client.call('SoftLayer_Account', 'getObject', id=12345, mask="mask[id,companyName]") + print(result) + + +if __name__ == "__main__": + logger = logging.getLogger() + logger.addHandler(logging.StreamHandler()) + logger.setLevel(logging.DEBUG) + testAuthentication() +``` + +## Employee Example \ No newline at end of file diff --git a/SoftLayer/API.py b/SoftLayer/API.py index 4d2918611..dc24914fe 100644 --- a/SoftLayer/API.py +++ b/SoftLayer/API.py @@ -7,7 +7,6 @@ """ # pylint: disable=invalid-name import time -import warnings import concurrent.futures as cf import json @@ -28,11 +27,13 @@ __all__ = [ 'create_client_from_env', + 'employee_client', 'Client', 'BaseClient', 'API_PUBLIC_ENDPOINT', 'API_PRIVATE_ENDPOINT', 'IAMClient', + 'CertificateClient' ] VALID_CALL_ARGS = set(( @@ -143,13 +144,86 @@ def create_client_from_env(username=None, return BaseClient(auth=auth, transport=transport, config_file=config_file) -def Client(**kwargs): - """Get a SoftLayer API Client using environmental settings. +def employee_client(username=None, + access_token=None, + endpoint_url=None, + timeout=None, + auth=None, + config_file=None, + proxy=None, + user_agent=None, + transport=None, + verify=True): + """Creates an INTERNAL SoftLayer API client using your environment. + + Settings are loaded via keyword arguments, environemtal variables and config file. - Deprecated in favor of create_client_from_env() + :param username: your user ID + :param access_token: hash from SoftLayer_User_Employee::performExternalAuthentication(username, password, token) + :param password: password to use for employee authentication + :param endpoint_url: the API endpoint base URL you wish to connect to. + Set this to API_PRIVATE_ENDPOINT to connect via SoftLayer's private network. + :param proxy: proxy to be used to make API calls + :param integer timeout: timeout for API requests + :param auth: an object which responds to get_headers() to be inserted into the xml-rpc headers. + Example: `BasicAuthentication` + :param config_file: A path to a configuration file used to load settings + :param user_agent: an optional User Agent to report when making API + calls if you wish to bypass the packages built in User Agent string + :param transport: An object that's callable with this signature: transport(SoftLayer.transports.Request) + :param bool verify: decide to verify the server's SSL/TLS cert. """ - warnings.warn("use SoftLayer.create_client_from_env() instead", - DeprecationWarning) + settings = config.get_client_settings(username=username, + api_key=None, + endpoint_url=endpoint_url, + timeout=timeout, + proxy=proxy, + verify=None, + config_file=config_file) + + url = settings.get('endpoint_url') + verify = settings.get('verify', True) + + if 'internal' not in url: + raise exceptions.SoftLayerError(f"{url} does not look like an Internal Employee url.") + + if transport is None: + if url is not None and '/rest' in url: + # If this looks like a rest endpoint, use the rest transport + transport = transports.RestTransport( + endpoint_url=settings.get('endpoint_url'), + proxy=settings.get('proxy'), + timeout=settings.get('timeout'), + user_agent=user_agent, + verify=verify, + ) + else: + # Default the transport to use XMLRPC + transport = transports.XmlRpcTransport( + endpoint_url=settings.get('endpoint_url'), + proxy=settings.get('proxy'), + timeout=settings.get('timeout'), + user_agent=user_agent, + verify=verify, + ) + + if access_token is None: + access_token = settings.get('access_token') + + user_id = settings.get('userid') + + # Assume access_token is valid for now, user has logged in before at least. + if access_token and user_id: + auth = slauth.EmployeeAuthentication(user_id, access_token) + return EmployeeClient(auth=auth, transport=transport) + else: + # This is for logging in mostly. + LOGGER.info("No access_token or userid found in settings, creating a No Auth client for now.") + return EmployeeClient(auth=None, transport=transport) + + +def Client(**kwargs): + """Get a SoftLayer API Client using environmental settings.""" return create_client_from_env(**kwargs) @@ -157,19 +231,25 @@ class BaseClient(object): """Base SoftLayer API client. :param auth: auth driver that looks like SoftLayer.auth.AuthenticationBase - :param transport: An object that's callable with this signature: - transport(SoftLayer.transports.Request) + :param transport: An object that's callable with this signature: transport(SoftLayer.transports.Request) """ - _prefix = "SoftLayer_" + auth: slauth.AuthenticationBase def __init__(self, auth=None, transport=None, config_file=None): if config_file is None: config_file = CONFIG_FILE - self.auth = auth self.config_file = config_file self.settings = config.get_config(self.config_file) + self.__setAuth(auth) + self.__setTransport(transport) + def __setAuth(self, auth=None): + """Prepares the authentication property""" + self.auth = auth + + def __setTransport(self, transport=None): + """Prepares the transport property""" if transport is None: url = self.settings['softlayer'].get('endpoint_url') if url is not None and '/rest' in url: @@ -194,9 +274,7 @@ def __init__(self, auth=None, transport=None, config_file=None): self.transport = transport - def authenticate_with_password(self, username, password, - security_question_id=None, - security_question_answer=None): + def authenticate_with_password(self, username, password, security_question_id=None, security_question_answer=None): """Performs Username/Password Authentication :param string username: your SoftLayer username @@ -259,8 +337,7 @@ def call(self, service, method, *args, **kwargs): invalid_kwargs = set(kwargs.keys()) - VALID_CALL_ARGS if invalid_kwargs: - raise TypeError( - 'Invalid keyword arguments: %s' % ','.join(invalid_kwargs)) + raise TypeError('Invalid keyword arguments: %s' % ','.join(invalid_kwargs)) prefixes = (self._prefix, 'BluePages_Search', 'IntegratedOfferingTeam_Region') if self._prefix and not service.startswith(prefixes): @@ -286,6 +363,7 @@ def call(self, service, method, *args, **kwargs): request.filter = kwargs.get('filter') request.limit = kwargs.get('limit') request.offset = kwargs.get('offset') + request.url = self.settings['softlayer'].get('endpoint_url') if kwargs.get('verify') is not None: request.verify = kwargs.get('verify') @@ -391,6 +469,31 @@ def __len__(self): return 0 +class CertificateClient(BaseClient): + """Client that works with a X509 Certificate for authentication. + + Will read the certificate file from the config file (~/.softlayer usually). + > auth_cert = /path/to/authentication/cert.pm + > server_cert = /path/to/CAcert.pem + Set auth to a SoftLayer.auth.Authentication class to manually set authentication + """ + + def __init__(self, auth=None, transport=None, config_file=None): + BaseClient.__init__(self, auth, transport, config_file) + self.__setAuth(auth) + + def __setAuth(self, auth=None): + """Prepares the authentication property""" + if auth is None: + auth_cert = self.settings['softlayer'].get('auth_cert') + serv_cert = self.settings['softlayer'].get('server_cert', None) + auth = slauth.X509Authentication(auth_cert, serv_cert) + self.auth = auth + + def __repr__(self): + return "CertificateClient(transport=%r, auth=%r)" % (self.transport, self.auth) + + class IAMClient(BaseClient): """IBM ID Client for using IAM authentication @@ -575,6 +678,94 @@ def __repr__(self): return "IAMClient(transport=%r, auth=%r)" % (self.transport, self.auth) +class EmployeeClient(BaseClient): + """Internal SoftLayer Client + + :param auth: auth driver that looks like SoftLayer.auth.AuthenticationBase + :param transport: An object that's callable with this signature: transport(SoftLayer.transports.Request) + """ + + def __init__(self, auth=None, transport=None, config_file=None, account_id=None): + BaseClient.__init__(self, auth, transport, config_file) + self.account_id = account_id + + def authenticate_with_internal(self, username, password, security_token=None): + """Performs internal authentication + + :param string username: your softlayer username + :param string password: your softlayer password + :param int security_token: your 2FA token, prompt if None + """ + + self.auth = None + if security_token is None: + security_token = input("Enter your 2FA Token now: ") + if len(security_token) != 6: + raise exceptions.SoftLayerAPIError("Invalid security token: {}".format(security_token)) + + auth_result = self.call('SoftLayer_User_Employee', 'performExternalAuthentication', + username, password, security_token) + + self.settings['softlayer']['access_token'] = auth_result['hash'] + self.settings['softlayer']['userid'] = str(auth_result['userId']) + # self.settings['softlayer']['refresh_token'] = tokens['refresh_token'] + + config.write_config(self.settings, self.config_file) + self.auth = slauth.EmployeeAuthentication(auth_result['userId'], auth_result['hash']) + + return auth_result + + def authenticate_with_hash(self, userId, access_token): + """Authenticates to the Internal SL API with an employee userid + token + + :param string userId: Employee UserId + :param string access_token: Employee Hash Token + """ + self.auth = slauth.EmployeeAuthentication(userId, access_token) + + def refresh_token(self, userId, auth_token): + """Refreshes the login token""" + + # Go directly to base client, to avoid infite loop if the token is super expired. + auth_result = BaseClient.call(self, 'SoftLayer_User_Employee', 'refreshEncryptedToken', auth_token, id=userId) + if len(auth_result) > 1: + for returned_data in auth_result: + # Access tokens should be 188 characters, but just incase its longer or something. + if len(returned_data) > 180: + self.settings['softlayer']['access_token'] = returned_data + else: + message = "Excepted 2 properties from refreshEncryptedToken, got {}|".format(auth_result) + raise exceptions.SoftLayerAPIError(message) + + config.write_config(self.settings, self.config_file) + self.auth = slauth.EmployeeAuthentication(userId, auth_result[0]) + return auth_result + + def call(self, service, method, *args, **kwargs): + """Handles refreshing Employee tokens in case of a HTTP 401 error""" + if (service == 'SoftLayer_Account' or service == 'Account') and not kwargs.get('id'): + if not self.account_id: + raise exceptions.SoftLayerError("SoftLayer_Account service requires an ID") + kwargs['id'] = self.account_id + + try: + return BaseClient.call(self, service, method, *args, **kwargs) + except exceptions.SoftLayerAPIError as ex: + if ex.faultCode == "SoftLayer_Exception_EncryptedToken_Expired": + userId = self.settings['softlayer'].get('userid') + access_token = self.settings['softlayer'].get('access_token') + LOGGER.warning("Token has expired, trying to refresh. %s", ex.faultString) + self.refresh_token(userId, access_token) + # Try the Call again this time.... + return BaseClient.call(self, service, method, *args, **kwargs) + + else: + raise ex + + def __repr__(self): + return "EmployeeClient(transport=%r, auth=%r)" % (self.transport, self.auth) + + class Service(object): """A SoftLayer Service. diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py index df979324b..870c47f0f 100644 --- a/SoftLayer/CLI/core.py +++ b/SoftLayer/CLI/core.py @@ -74,34 +74,28 @@ def get_version_message(ctx, param, value): The easiest way to do that is to use: 'slcli setup'""", cls=CommandLoader, context_settings=CONTEXT_SETTINGS) -@click.option('--format', - default=DEFAULT_FORMAT, - show_default=True, +@click.option('--format', default=DEFAULT_FORMAT, show_default=True, help="Output format", type=click.Choice(VALID_FORMATS)) -@click.option('-C', '--config', - required=False, +@click.option('-C', '--config', required=False, show_default=True, default=click.get_app_dir('softlayer', force_posix=True), - show_default=True, help="Config file location", type=click.Path(resolve_path=True)) @click.option('--verbose', '-v', help="Sets the debug noise level, specify multiple times for more verbosity.", type=click.IntRange(0, 3, clamp=True), count=True) -@click.option('--proxy', - required=False, +@click.option('--proxy', required=False, help="HTTPS or HTTP proxy to be use to make API calls") -@click.option('--really / --not-really', '-y', - is_flag=True, - required=False, +@click.option('--really / --not-really', '-y', is_flag=True, required=False, help="Confirm all prompt actions") -@click.option('--demo / --no-demo', - is_flag=True, - required=False, +@click.option('--demo / --no-demo', is_flag=True, required=False, help="Use demo data instead of actually making API calls") @click.option('--version', is_flag=True, expose_value=False, is_eager=True, callback=get_version_message, help="Show version information.", allow_from_autoenv=False,) +@click.option('--account', '-a', help="Account Id, only needed for some API calls.") +@click.option('--internal', '-i', is_flag=True, required=False, + help="Use the Employee Client instead of the Customer Client.") @environment.pass_env def cli(env, format='table', @@ -110,6 +104,8 @@ def cli(env, proxy=None, really=False, demo=False, + account=None, + internal=False, **kwargs): """Main click CLI entry-point.""" @@ -118,7 +114,10 @@ def cli(env, env.config_file = config env.format = format env.set_env_theme(config_file=config) - env.ensure_client(config_file=config, is_demo=demo, proxy=proxy) + if internal: + env.ensure_emp_client(config_file=config, is_demo=demo, proxy=proxy) + else: + env.ensure_client(config_file=config, is_demo=demo, proxy=proxy) env.vars['_start'] = time.time() logger = logging.getLogger() @@ -133,6 +132,7 @@ def cli(env, env.vars['_timings'] = SoftLayer.DebugTransport(env.client.transport) env.vars['verbose'] = verbose env.client.transport = env.vars['_timings'] + env.client.account_id = account @cli.result_callback() diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py index c17ee72a8..e2fde6e30 100644 --- a/SoftLayer/CLI/environment.py +++ b/SoftLayer/CLI/environment.py @@ -183,16 +183,26 @@ def ensure_client(self, config_file=None, is_demo=False, proxy=None): # Environment can be passed in explicitly. This is used for testing if is_demo: - client = SoftLayer.BaseClient( - transport=SoftLayer.FixtureTransport(), - auth=None, - ) + client = SoftLayer.BaseClient(transport=SoftLayer.FixtureTransport(), auth=None) else: # Create SL Client - client = SoftLayer.create_client_from_env( - proxy=proxy, - config_file=config_file, - ) + client = SoftLayer.create_client_from_env(proxy=proxy, config_file=config_file) + self.client = client + + def ensure_emp_client(self, config_file=None, is_demo=False, proxy=None): + """Create a new SLAPI client to the environment. + + This will be a no-op if there is already a client in this environment. + """ + if self.client is not None: + return + + # Environment can be passed in explicitly. This is used for testing + if is_demo: + client = SoftLayer.BaseClient(transport=SoftLayer.FixtureTransport(), auth=None) + else: + # Create SL Client + client = SoftLayer.employee_client(proxy=proxy, config_file=config_file) self.client = client def set_env_theme(self, config_file=None): diff --git a/SoftLayer/CLI/login.py b/SoftLayer/CLI/login.py new file mode 100644 index 000000000..7f3e76e35 --- /dev/null +++ b/SoftLayer/CLI/login.py @@ -0,0 +1,68 @@ +"""Login with your employee username, password, 2fa token""" +# :license: MIT, see LICENSE for more details. +import os + +import click + +from SoftLayer.API import employee_client +from SoftLayer.CLI.command import SLCommand as SLCommand +from SoftLayer.CLI import environment +from SoftLayer import config +from SoftLayer import consts + + +def censor_password(value): + """Replaces a password with *s""" + if value: + value = '*' * len(value) + return value + + +@click.command(cls=SLCommand) +@environment.pass_env +def cli(env): + """Logs you into the internal SoftLayer Network. + + username: Set this in either the softlayer config, or SL_USER ENV variable + password: Set this in SL_PASSWORD env variable. You will be prompted for them otherwise. + """ + config_settings = config.get_config(config_file=env.config_file) + settings = config_settings['softlayer'] + username = settings.get('username') or os.environ.get('SLCLI_USER', None) + password = os.environ.get('SLCLI_PASSWORD', '') + yubi = None + client = employee_client() + + # Might already be logged in, try and refresh token + if settings.get('access_token') and settings.get('userid'): + client.authenticate_with_hash(settings.get('userid'), settings.get('access_token')) + try: + emp_id = settings.get('userid') + client.call('SoftLayer_User_Employee', 'getObject', id=emp_id, mask="mask[id,username]") + client.refresh_token(emp_id, settings.get('access_token')) + client.call('SoftLayer_User_Employee', 'refreshEncryptedToken', settings.get('access_token'), id=emp_id) + + config_settings['softlayer'] = settings + config.write_config(config_settings, env.config_file) + return + # pylint: disable=broad-exception-caught + except Exception as ex: + print("Error with Hash Authentication, try with password: {}".format(ex)) + + url = settings.get('endpoint_url') or consts.API_EMPLOYEE_ENDPOINT + click.echo("URL: {}".format(url)) + if username is None: + username = input("Username: ") + click.echo("Username: {}".format(username)) + if not password: + password = env.getpass("Password: ") + click.echo("Password: {}".format(censor_password(password))) + yubi = input("Yubi: ") + try: + result = client.authenticate_with_internal(username, password, str(yubi)) + print(result) + # pylint: disable=broad-exception-caught + except Exception as e: + click.echo("EXCEPTION: {}".format(e)) + + print("OK") diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py index f6ba2a8cb..705b2cac6 100644 --- a/SoftLayer/CLI/routes.py +++ b/SoftLayer/CLI/routes.py @@ -8,6 +8,7 @@ ALL_ROUTES = [ ('shell', 'SoftLayer.shell.core:cli'), + ('emplogin', 'SoftLayer.CLI.login:cli'), ('call-api', 'SoftLayer.CLI.call_api:cli'), diff --git a/SoftLayer/auth.py b/SoftLayer/auth.py index 1c16ef51b..8698249ae 100644 --- a/SoftLayer/auth.py +++ b/SoftLayer/auth.py @@ -12,6 +12,8 @@ 'TokenAuthentication', 'BasicHTTPAuthentication', 'AuthenticationBase', + 'X509Authentication', + 'EmployeeAuthentication' ] @@ -137,3 +139,50 @@ def get_request(self, request): def __repr__(self): return f"BearerAuthentication(username={self.username}, token={self.api_key})" + + +class X509Authentication(AuthenticationBase): + """X509Authentication authentication class. + + :param certificate str: Path to a users SSL certificate for authentication + :param CA Cert str: Path to the Servers signed certificate. + """ + + def __init__(self, cert, ca_cert): + self.cert = cert + self.ca_cert = ca_cert + + def get_request(self, request): + """Sets token-based auth headers.""" + request.cert = self.cert + request.verify = self.ca_cert + return request + + def __repr__(self): + return f"X509Authentication(cert={self.cert}, ca_cert={self.ca_cert})" + + +class EmployeeAuthentication(AuthenticationBase): + """Token-based authentication class. + + :param username str: a user's username + :param user_hash str: a user's Authentication hash + """ + def __init__(self, user_id, user_hash): + self.user_id = user_id + self.hash = user_hash + + def get_request(self, request): + """Sets token-based auth headers.""" + if 'xml' in request.url: + request.headers['employeesession'] = { + 'userId': self.user_id, + 'authToken': self.hash, + } + else: + request.transport_user = self.user_id + request.transport_password = self.hash + return request + + def __repr__(self): + return "EmployeeAuthentication(userId=%r,hash=%s)" % (self.user_id, self.hash) diff --git a/SoftLayer/config.py b/SoftLayer/config.py index 5ae8c7131..c32c0b5e1 100644 --- a/SoftLayer/config.py +++ b/SoftLayer/config.py @@ -59,6 +59,9 @@ def get_client_settings_config_file(**kwargs): # pylint: disable=inconsistent-r 'endpoint_url': '', 'timeout': '0', 'proxy': '', + 'userid': '', + 'access_token': '', + 'verify': True }) config.read(config_files) @@ -69,6 +72,9 @@ def get_client_settings_config_file(**kwargs): # pylint: disable=inconsistent-r 'proxy': config.get('softlayer', 'proxy'), 'username': config.get('softlayer', 'username'), 'api_key': config.get('softlayer', 'api_key'), + 'userid': config.get('softlayer', 'userid'), + 'access_token': config.get('softlayer', 'access_token'), + 'verify': config.get('softlayer', 'verify') } @@ -109,6 +115,8 @@ def get_config(config_file=None): config['softlayer']['endpoint_url'] = '' config['softlayer']['api_key'] = '' config['softlayer']['timeout'] = '0' + config['softlayer']['userid'] = '' + config['softlayer']['access_tokne'] = '' return config diff --git a/SoftLayer/fixtures/xmlrpc/Employee_getObject.xml b/SoftLayer/fixtures/xmlrpc/Employee_getObject.xml new file mode 100644 index 000000000..57ec3f140 --- /dev/null +++ b/SoftLayer/fixtures/xmlrpc/Employee_getObject.xml @@ -0,0 +1,21 @@ + + + + + + + id + + 5555 + + + + username + + testUser + + + + + + \ No newline at end of file diff --git a/SoftLayer/fixtures/xmlrpc/expiredToken.xml b/SoftLayer/fixtures/xmlrpc/expiredToken.xml new file mode 100644 index 000000000..43237bb3e --- /dev/null +++ b/SoftLayer/fixtures/xmlrpc/expiredToken.xml @@ -0,0 +1,21 @@ + + + + + + + faultCode + + SoftLayer_Exception_EncryptedToken_Expired + + + + faultString + + The token has expired. + + + + + + \ No newline at end of file diff --git a/SoftLayer/fixtures/xmlrpc/invalidLogin.xml b/SoftLayer/fixtures/xmlrpc/invalidLogin.xml new file mode 100644 index 000000000..1c993d2b5 --- /dev/null +++ b/SoftLayer/fixtures/xmlrpc/invalidLogin.xml @@ -0,0 +1,21 @@ + + + + + + + faultCode + + SoftLayer_Exception_Public + + + + faultString + + Invalid username/password + + + + + + \ No newline at end of file diff --git a/SoftLayer/fixtures/xmlrpc/refreshFailure.xml b/SoftLayer/fixtures/xmlrpc/refreshFailure.xml new file mode 100644 index 000000000..e69de29bb diff --git a/SoftLayer/fixtures/xmlrpc/refreshSuccess.xml b/SoftLayer/fixtures/xmlrpc/refreshSuccess.xml new file mode 100644 index 000000000..0b8003b30 --- /dev/null +++ b/SoftLayer/fixtures/xmlrpc/refreshSuccess.xml @@ -0,0 +1,17 @@ + + + + + + + + REFRESHEDTOKENaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + + 300 + + + + + + \ No newline at end of file diff --git a/SoftLayer/fixtures/xmlrpc/successLogin.xml b/SoftLayer/fixtures/xmlrpc/successLogin.xml new file mode 100644 index 000000000..880d9497e --- /dev/null +++ b/SoftLayer/fixtures/xmlrpc/successLogin.xml @@ -0,0 +1,21 @@ + + + + + + + hash + + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + + + userId + + 1234 + + + + + + diff --git a/SoftLayer/transports/rest.py b/SoftLayer/transports/rest.py index 7f8d2554f..30ce11bad 100644 --- a/SoftLayer/transports/rest.py +++ b/SoftLayer/transports/rest.py @@ -108,7 +108,6 @@ def __call__(self, request): request.url = '%s.%s' % ('/'.join(url_parts), 'json') # Prefer the request setting, if it's not None - if request.verify is None: request.verify = self.verify diff --git a/SoftLayer/transports/transport.py b/SoftLayer/transports/transport.py index 616e20cf8..0454632ba 100644 --- a/SoftLayer/transports/transport.py +++ b/SoftLayer/transports/transport.py @@ -56,10 +56,12 @@ def __init__(self): #: Transport headers. self.transport_headers = {} - #: Boolean specifying if the server certificate should be verified. + #: False -> Don't verify the SSL certificate + #: True -> Verify the SSL certificate + #: Path String -> Verify the SSL certificate with the .pem file at path self.verify = None - #: Client certificate file path. + #: Client certificate file path. (Used by X509Authentication) self.cert = None #: InitParameter/identifier of an object. @@ -99,9 +101,12 @@ def __repr__(self): """Prints out what this call is all about""" pretty_mask = utils.clean_string(self.mask) pretty_filter = self.filter - param_string = "id={id}, mask='{mask}', filter='{filter}', args={args}, limit={limit}, offset={offset}".format( - id=self.identifier, mask=pretty_mask, filter=pretty_filter, - args=self.args, limit=self.limit, offset=self.offset) + clean_args = self.args + # Passwords can show up here, so censor them before logging. + if self.method in ["performExternalAuthentication", "refreshEncryptedToken", "getPortalLoginToken"]: + clean_args = "*************" + param_string = (f"id={self.identifier}, mask='{pretty_mask}', filter='{pretty_filter}', args={clean_args}, " + f"limit={self.limit}, offset={self.offset}") return "{service}::{method}({params})".format( service=self.service, method=self.method, params=param_string) diff --git a/SoftLayer/transports/xmlrpc.py b/SoftLayer/transports/xmlrpc.py index 99fbe1ddc..57ba4e9f6 100644 --- a/SoftLayer/transports/xmlrpc.py +++ b/SoftLayer/transports/xmlrpc.py @@ -84,8 +84,7 @@ def __call__(self, request): encoding="iso-8859-1") # Prefer the request setting, if it's not None - verify = request.verify - if verify is None: + if request.verify is None: request.verify = self.verify try: diff --git a/docs/cli/commands.rst b/docs/cli/commands.rst index d99126b00..9efe5803e 100644 --- a/docs/cli/commands.rst +++ b/docs/cli/commands.rst @@ -45,3 +45,13 @@ Can be called with an un-authenticated API call. .. click:: SoftLayer.CLI.search:cli :prog: search :show-nested: + + +Employee Login +============== + +Allows employees to use their login information to make API calls. + +.. click:: SoftLayer.CLI.login:cli + :prog: emplogin + :show-nested: diff --git a/setup.py b/setup.py index 28462d005..48f643d5d 100644 --- a/setup.py +++ b/setup.py @@ -24,11 +24,10 @@ packages=find_packages(exclude=['tests']), license='MIT', zip_safe=False, - url='http://github.com/softlayer/softlayer-python', + url='https://github.com/SoftLayer/softlayer-python', entry_points={ 'console_scripts': [ 'slcli = SoftLayer.CLI.core:main', - 'sl = SoftLayer.CLI.deprecated:main', ], }, python_requires='>=3.7', @@ -41,7 +40,7 @@ 'urllib3 >= 1.24', 'rich == 13.7.1' ], - keywords=['softlayer', 'cloud', 'slcli'], + keywords=['softlayer', 'cloud', 'slcli', 'ibmcloud'], classifiers=[ 'Environment :: Console', 'Environment :: Web Environment', @@ -50,7 +49,6 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Topic :: Software Development :: Libraries :: Python Modules', - 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', diff --git a/tests/api_tests.py b/tests/api_tests.py index ea4726a6e..438d77020 100644 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -4,10 +4,15 @@ :license: MIT, see LICENSE for more details. """ +import io +import os +import requests from unittest import mock as mock import SoftLayer import SoftLayer.API +from SoftLayer import auth as slauth +from SoftLayer import exceptions from SoftLayer import testing from SoftLayer import transports @@ -310,3 +315,94 @@ def test_authenticate_with_password(self, _call): self.assertIsNotNone(self.client.auth) self.assertEqual(self.client.auth.user_id, 12345) self.assertEqual(self.client.auth.auth_token, 'TOKEN') + + +class EmployeeClientTests(testing.TestCase): + + @staticmethod + def setup_response(filename, status_code=200, total_items=1): + basepath = os.path.dirname(__file__) + body = b'' + with open(f"{basepath}/../SoftLayer/fixtures/xmlrpc/{filename}.xml", 'rb') as fixture: + body = fixture.read() + response = requests.Response() + list_body = body + response.raw = io.BytesIO(list_body) + response.headers['SoftLayer-Total-Items'] = total_items + response.status_code = status_code + return response + + def set_up(self): + self.client = SoftLayer.API.EmployeeClient(config_file='./tests/testconfig') + + @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request') + def test_auth_with_pass_failure(self, api_response): + api_response.return_value = self.setup_response('invalidLogin') + exception = self.assertRaises( + exceptions.SoftLayerAPIError, + self.client.authenticate_with_password, 'testUser', 'testPassword', '123456') + self.assertEqual(exception.faultCode, "SoftLayer_Exception_Public") + + @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request') + def test_auth_with_pass_success(self, api_response): + api_response.return_value = self.setup_response('successLogin') + result = self.client.authenticate_with_internal('testUser', 'testPassword', '123456') + print(result) + self.assertEqual(result['userId'], 1234) + self.assertEqual(self.client.settings['softlayer']['userid'], '1234') + self.assertIn('x'*200, self.client.settings['softlayer']['access_token']) + + def test_auth_with_hash(self): + self.client.auth = None + self.client.authenticate_with_hash(5555, 'abcdefg') + self.assertEqual(self.client.auth.user_id, 5555) + self.assertEqual(self.client.auth.hash, 'abcdefg') + + @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request') + def test_refresh_token(self, api_response): + api_response.return_value = self.setup_response('refreshSuccess') + self.client.refresh_token(9999, 'qweasdzxcqweasdzxcqweasdzxc') + self.assertEqual(self.client.auth.user_id, 9999) + self.assertIn('REFRESHEDTOKENaaaa', self.client.auth.hash) + + @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request') + def test_expired_token_is_refreshed(self, api_response): + api_response.side_effect = [ + self.setup_response('expiredToken'), + self.setup_response('refreshSuccess'), + self.setup_response('Employee_getObject') + ] + self.client.auth = slauth.EmployeeAuthentication(5555, 'aabbccee') + self.client.settings['softlayer']['userid'] = '5555' + result = self.client.call('SoftLayer_User_Employee', 'getObject', id=5555) + self.assertIn('REFRESHEDTOKENaaaa', self.client.auth.hash) + self.assertEqual('testUser', result['username']) + + @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request') + def test_expired_token_is_really_expired(self, api_response): + api_response.side_effect = [ + self.setup_response('expiredToken'), + self.setup_response('expiredToken') + ] + self.client.auth = slauth.EmployeeAuthentication(5555, 'aabbccee') + self.client.settings['softlayer']['userid'] = '5555' + exception = self.assertRaises( + exceptions.SoftLayerAPIError, + self.client.call, 'SoftLayer_User_Employee', 'getObject', id=5555) + self.assertEqual(exception.faultCode, "SoftLayer_Exception_EncryptedToken_Expired") + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_account_check(self, _call): + self.client.transport = self.mocks + exception = self.assertRaises( + exceptions.SoftLayerError, + self.client.call, "SoftLayer_Account", "getObject") + self.assertEqual(str(exception), "SoftLayer_Account service requires an ID") + self.client.account_id = 1234 + self.client.call("SoftLayer_Account", "getObject") + self.client.call("SoftLayer_Account", "getObject1", id=9999) + + _call.assert_has_calls([ + mock.call(self.client, 'SoftLayer_Account', 'getObject', id=1234), + mock.call(self.client, 'SoftLayer_Account', 'getObject1', id=9999), + ]) diff --git a/tests/auth_tests.py b/tests/auth_tests.py index 6bac999f9..cd8ffaa65 100644 --- a/tests/auth_tests.py +++ b/tests/auth_tests.py @@ -83,3 +83,22 @@ def test_repr(self): s = repr(self.auth) self.assertIn('BasicHTTPAuthentication', s) self.assertIn('USERNAME', s) + + +class TestX509AUthentication(testing.TestCase): + def set_up(self): + self.auth = auth.X509Authentication('authcert.pm', 'servercert.pm') + + def test_attribs(self): + self.assertEqual(self.auth.cert, 'authcert.pm') + self.assertEqual(self.auth.ca_cert, 'servercert.pm') + + def test_get_request(self): + req = transports.Request() + authed_req = self.auth.get_request(req) + self.assertEqual(authed_req.cert, 'authcert.pm') + self.assertEqual(authed_req.verify, 'servercert.pm') + + def test_repr(self): + s = repr(self.auth) + self.assertEqual(s, "X509Authentication(cert=authcert.pm, ca_cert=servercert.pm)")