Skip to content

Commit

Permalink
Updates and tests to support mode 2 login. (#23)
Browse files Browse the repository at this point in the history
* Updates and tests to support mode 2 login.

* Mode 2 login currently focuses on IoT Hub
* Added login mechanism and validators
* Fixed SAS URI double encode and device SAS issues.
* Tweaks and user agent addition.
  • Loading branch information
digimaun authored Apr 13, 2018
1 parent d7457eb commit bf8ba8a
Show file tree
Hide file tree
Showing 21 changed files with 635 additions and 263 deletions.
5 changes: 2 additions & 3 deletions azext_iot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@

from azure.cli.core import AzCommandsLoader
from azure.cli.core.commands import CliCommandType
from azext_iot._factory import iot_hub_service_factory, iot_service_provisioning_factory
from azext_iot._factory import iot_service_provisioning_factory
from azext_iot._constants import VERSION
import azext_iot._help # pylint: disable=unused-import


iothub_ops = CliCommandType(
operations_tmpl='azext_iot.operations.hub#{}',
client_factory=iot_hub_service_factory
operations_tmpl='azext_iot.operations.hub#{}'
)

iotdps_ops = CliCommandType(
Expand Down
19 changes: 18 additions & 1 deletion azext_iot/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,24 @@

helps['iot hub generate-sas-token'] = """
type: command
short-summary: Generate a SAS token for a target hub or device.
short-summary: Generate a SAS token for a target IoT Hub or device.
long-summary: For device SAS tokens, the policy parameter is used to
access the the device registry only. Therefore the policy should have
read access to the registry. For IoT Hub tokens the policy is part of the SAS.
examples:
- name: Generate an IoT Hub SAS token using the iothubowner policy and primary key.
text: >
az iot hub generate-sas-token -n [IoTHub Name]
- name: Generate an IoT Hub SAS token using the registryRead policy and secondary key.
text: >
az iot hub generate-sas-token -n [IoTHub Name] --policy registryRead --key-type secondary
- name: Generate a device SAS token using the iothubowner policy to access the [IoTHub Name] device registry.
text: >
az iot hub generate-sas-token -d [Device ID] -n [IoTHub Name]
- name: Generate a device SAS token using an IoT Hub connection string (with registry access)
text: >
az iot hub generate-sas-token -d [Device ID]
--login 'HostName=myhub.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=12345'
"""

helps['iot hub invoke-module-method'] = """
Expand Down
10 changes: 9 additions & 1 deletion azext_iot/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
AttestationType,
ProtocolType
)
from azext_iot._validators import mode2_iot_login_handler

hub_name_type = CLIArgumentType(
completer=get_resource_name_completion_list('Microsoft.Devices/IotHubs'),
Expand All @@ -42,14 +43,19 @@ def load_arguments(self, _):
Load CLI Args for Knack parser
"""
with self.argument_context('iot') as context:
context.argument('login', options_list=['--login', '-l'],
validator=mode2_iot_login_handler,
help='This command supports an entity connection string with rights to perform action. '
'Use to avoid session login via "az login". '
'If both an entity connection string and name are provided the connection string takes priority.')
context.argument('resource_group_name', arg_type=resource_group_name_type)
context.argument('hub_name', options_list=['--hub-name', '-n'], arg_type=hub_name_type)
context.argument('device_id', options_list=['--device-id', '-d'], help='Target Device.')
context.argument('module_id', options_list=['--module-id', '-m'], help='Target Module.')
context.argument('key_type', options_list=['--key-type', '-kt'],
arg_type=get_enum_type(KeyType),
help='Shared access policy key type for auth.')
context.argument('policy_name', options_list=['--policy-name', '-po'],
context.argument('policy_name', options_list=['--policy-name', '-pn'],
help='Shared access policy to use for auth.')
context.argument('duration', options_list=['--duration', '-du'],
help='Valid token duration in seconds.')
Expand All @@ -70,6 +76,8 @@ def load_arguments(self, _):
with self.argument_context('iot hub') as context:
context.argument('target_json', options_list=['--json', '-j'],
help='Json to replace existing twin with. Provide file path or raw json.')
context.argument('policy_name', options_list=['--policy-name', '-pn'],
help='Shared access policy with operation permissions for target IoT Hub entity.')

with self.argument_context('iot hub monitor-events') as context:
context.argument('timeout', options_list=['--timeout', '-to'], type=int,
Expand Down
27 changes: 27 additions & 0 deletions azext_iot/_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# coding=utf-8
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from knack.util import CLIError
from azext_iot.assets.user_messages import ERROR_NO_HUB_OR_LOGIN_ON_INPUT


def mode2_iot_login_handler(cmd, namespace):
if cmd.name.startswith('iot'):
args = vars(namespace)
arg_keys = args.keys()
if 'login' in arg_keys:
login_value = args.get('login')
iot_cmd_type = None
entity_value = None
if 'hub_name' in arg_keys:
iot_cmd_type = 'IoT Hub'
entity_value = args.get('hub_name')
elif 'dps_name' in arg_keys:
iot_cmd_type = 'DPS'
entity_value = args.get('dps_name')

if not any([login_value, entity_value]):
raise CLIError(ERROR_NO_HUB_OR_LOGIN_ON_INPUT(iot_cmd_type))
4 changes: 3 additions & 1 deletion azext_iot/assets/user_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

ERROR_NO_HUB_OR_LOGIN_ON_INPUT = 'Either an IoT Hub name or connection string via --login (prioritized) should be specified...'

def ERROR_NO_HUB_OR_LOGIN_ON_INPUT(entity_type='IoT Hub'):
return 'Please provide an {0} entity name or {0} connection string via --login...'.format(entity_type)
4 changes: 2 additions & 2 deletions azext_iot/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def load_command_table(self, _):
Load CLI commands
"""
with self.command_group('iot hub', command_type=iothub_ops) as cmd_group:
cmd_group.command('query', 'iot_query', client_factory=None)
cmd_group.command('query', 'iot_query')
cmd_group.command('invoke-device-method', 'iot_device_method')
cmd_group.command('invoke-module-method', 'iot_device_module_method')
cmd_group.command('generate-sas-token', 'iot_get_sas_token')
Expand Down Expand Up @@ -70,7 +70,7 @@ def load_command_table(self, _):

with self.command_group('iot device', command_type=iothub_ops) as cmd_group:
cmd_group.command('send-d2c-message', 'iot_device_send_message')
cmd_group.command('simulate', 'iot_simulate_device', client_factory=None)
cmd_group.command('simulate', 'iot_simulate_device')
cmd_group.command('upload-file', 'iot_device_upload_file')

with self.command_group('iot device c2d-message', command_type=iothub_ops) as cmd_group:
Expand Down
17 changes: 13 additions & 4 deletions azext_iot/common/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,23 @@
from azext_iot._factory import iot_hub_service_factory


def _parse_connection_string(cs, validate=None):
def _parse_connection_string(cs, validate=None, cstring_type='entity'):
decomposed = validate_key_value_pairs(cs)
if validate:
for k in validate:
if not decomposed.get(k):
raise ValueError('connection string has missing property: {}'.format(k))
raise ValueError('{} connection string has missing property: {}'.format(cstring_type, k))
return decomposed


def parse_iot_hub_connection_string(cs):
validate = ['HostName', 'SharedAccessKeyName', 'SharedAccessKey']
return _parse_connection_string(cs, validate)
return _parse_connection_string(cs, validate, 'IoT Hub')


def parse_iot_device_connection_string(cs):
validate = ['HostName', 'DeviceId', 'SharedAccessKey']
return _parse_connection_string(cs, validate, 'Device')


CONN_STR_TEMPLATE = 'HostName={};SharedAccessKeyName={};SharedAccessKey={}'
Expand Down Expand Up @@ -60,7 +65,11 @@ def get_iot_hub_connection_string(
policy = None

if login:
decomposed = parse_iot_hub_connection_string(login)
try:
decomposed = parse_iot_hub_connection_string(login)
except ValueError as e:
raise CLIError(e)

result = {}
result['cs'] = login
result['policy'] = decomposed['SharedAccessKeyName']
Expand Down
21 changes: 11 additions & 10 deletions azext_iot/common/sas_token_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
from hmac import HMAC
from time import time
try:
from urllib import (urlencode, quote)
from urllib import (urlencode, quote_plus)
except ImportError:
from urllib.parse import (urlencode, quote) # pylint: disable=import-error
from urllib.parse import (urlencode, quote_plus) # pylint: disable=import-error
from msrest.authentication import Authentication


Expand All @@ -32,8 +32,7 @@ class SasTokenAuthentication(Authentication):
be seconds since the epoch, in UTC. Default is an hour later from now.
"""
def __init__(self, uri, shared_access_policy_name, shared_access_key, expiry=None):

self.uri = quote(uri.lower(), safe='').lower()
self.uri = uri
self.policy = shared_access_policy_name
self.key = shared_access_key
if expiry is None:
Expand All @@ -59,16 +58,18 @@ def generate_sas_token(self):
Returns:
result (str): SAS token as string literal.
"""
encoded_uri = quote(self.uri, safe='').lower()
encoded_uri = quote_plus(self.uri)
ttl = int(self.expiry)
sign_key = '%s\n%d' % (encoded_uri, ttl)
signature = b64encode(HMAC(b64decode(self.key), sign_key.encode('utf-8'), sha256).digest())

result = 'SharedAccessSignature ' + urlencode({
result = {
'sr': self.uri,
'sig': signature,
'se': str(ttl),
'skn': self.policy
})
'se': str(ttl)
}

if self.policy:
result['skn'] = self.policy

return result
return 'SharedAccessSignature ' + urlencode(result)
4 changes: 3 additions & 1 deletion azext_iot/custom_sdk/custom_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from msrestazure import AzureConfiguration
from . import models
from .version import VERSION
from azext_iot._constants import VERSION as extver


# pylint: disable=too-few-public-methods
Expand All @@ -43,7 +44,8 @@ def __init__(

super(CustomAPIConfiguration, self).__init__(base_url)

self.add_user_agent('iotextension/{}'.format(VERSION))
self.add_user_agent('customclient/{}'.format(VERSION))
self.add_user_agent('MicrosoftAzure/IoTPlatformCliExtension/{}'.format(extver))

self.credentials = credentials

Expand Down
9 changes: 3 additions & 6 deletions azext_iot/device_msg_sdk/iot_hub_device_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .version import VERSION
from .operations.iot_hub_devices_operations import IotHubDevicesOperations
from . import models
from azext_iot._constants import VERSION as extver


class IotHubDeviceClientConfiguration(AzureConfiguration):
Expand Down Expand Up @@ -49,21 +50,17 @@ def __init__(

if credentials is None:
raise ValueError("Parameter 'credentials' must not be None.")
if subscription_id is None:
raise ValueError("Parameter 'subscription_id' must not be None.")
if not isinstance(subscription_id, str):
raise TypeError("Parameter 'subscription_id' must be str.")
if api_version is not None and not isinstance(api_version, str):
raise TypeError("Optional parameter 'api_version' must be str.")
if accept_language is not None and not isinstance(accept_language, str):
raise TypeError("Optional parameter 'accept_language' must be str.")
if not base_url:
base_url = 'https://example.azure-devices.net'
base_url = 'https://<fully-qualified IoT hub domain name>'

super(IotHubDeviceClientConfiguration, self).__init__(base_url, filepath)

self.add_user_agent('iothubdeviceclient/{}'.format(VERSION))
self.add_user_agent('Azure-SDK-For-Python')
self.add_user_agent('MicrosoftAzure/IoTPlatformCliExtension/{}'.format(extver))

self.credentials = credentials
self.subscription_id = subscription_id
Expand Down
3 changes: 2 additions & 1 deletion azext_iot/device_query_sdk/device_identities_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .version import VERSION
from .operations.device_api_operations import DeviceApiOperations
from . import models
from azext_iot._constants import VERSION as extver


class DeviceIdentitiesAPIConfiguration(AzureConfiguration):
Expand All @@ -35,7 +36,7 @@ def __init__(
super(DeviceIdentitiesAPIConfiguration, self).__init__(base_url)

self.add_user_agent('deviceidentitiesapi/{}'.format(VERSION))
self.add_user_agent('Azure-SDK-For-Python')
self.add_user_agent('MicrosoftAzure/IoTPlatformCliExtension/{}'.format(extver))

self.credentials = credentials

Expand Down
3 changes: 2 additions & 1 deletion azext_iot/device_twin_sdk/device_twin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from msrestazure.azure_exceptions import CloudError
from . import models
from .version import VERSION
from azext_iot._constants import VERSION as extver


class DeviceTwinAPIConfiguration(AzureConfiguration):
Expand All @@ -37,7 +38,7 @@ def __init__(
super(DeviceTwinAPIConfiguration, self).__init__(base_url)

self.add_user_agent('devicetwinapi/{}'.format(VERSION))
self.add_user_agent('Azure-SDK-For-Python')
self.add_user_agent('MicrosoftAzure/IoTPlatformCliExtension/{}'.format(extver))

self.credentials = credentials

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .operations.device_enrollment_group_operations import DeviceEnrollmentGroupOperations
from .operations.registration_status_operations import RegistrationStatusOperations
from . import models
from azext_iot._constants import VERSION as extver


class DeviceProvisioningServiceServiceRuntimeClientConfiguration(AzureConfiguration):
Expand All @@ -32,12 +33,12 @@ def __init__(
if credentials is None:
raise ValueError("Parameter 'credentials' must not be None.")
if not base_url:
base_url = 'https://contoso.azure-devices-provisioning.net'
base_url = 'https://<fully-qualified IoT hub domain name>'

super(DeviceProvisioningServiceServiceRuntimeClientConfiguration, self).__init__(base_url)

self.add_user_agent('deviceprovisioningserviceserviceruntimeclient/{}'.format(VERSION))
self.add_user_agent('Azure-SDK-For-Python')
self.add_user_agent('MicrosoftAzure/IoTPlatformCliExtension/{}'.format(extver))

self.credentials = credentials

Expand Down
3 changes: 2 additions & 1 deletion azext_iot/modules_sdk/iot_hub_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .operations.iot_dps_admin_operations import IotDpsAdminOperations
from .operations.device_twin_api_operations import DeviceTwinApiOperations
from . import models
from azext_iot._constants import VERSION as extver


class IotHubClientConfiguration(AzureConfiguration):
Expand Down Expand Up @@ -45,7 +46,7 @@ def __init__(
super(IotHubClientConfiguration, self).__init__(base_url)

self.add_user_agent('iothubclient/{}'.format(VERSION))
self.add_user_agent('Azure-SDK-For-Python')
self.add_user_agent('MicrosoftAzure/IoTPlatformCliExtension/{}'.format(extver))

self.credentials = credentials
self.subscription_id = subscription_id
Expand Down
Loading

0 comments on commit bf8ba8a

Please sign in to comment.