-
Notifications
You must be signed in to change notification settings - Fork 384
refactor(azure-iot-device): Auth Revisions #555
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from .signing_mechanism import SymmetricKeySigningMechanism | ||
|
||
# NOTE: Please import the connection_string and sastoken modules directly | ||
# rather than through the package interface, as the modules contain many | ||
# related items for their respective domains, which we do not wish to expose | ||
# at length here. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
# ------------------------------------------------------------------------- | ||
# Copyright (c) Microsoft Corporation. All rights reserved. | ||
# Licensed under the MIT License. See License.txt in the project root for | ||
# license information. | ||
# -------------------------------------------------------------------------- | ||
"""This module contains tools for working with Shared Access Signature (SAS) Tokens""" | ||
|
||
import base64 | ||
import hmac | ||
import hashlib | ||
import time | ||
import six.moves.urllib as urllib | ||
from azure.iot.device.common.chainable_exception import ChainableException | ||
|
||
|
||
class SasTokenError(ChainableException): | ||
"""Error in SasToken""" | ||
|
||
pass | ||
|
||
|
||
class SasToken(object): | ||
"""Shared Access Signature Token used to authenticate a request | ||
|
||
Data Attributes: | ||
expiry_time (int): Time that token will expire (in UTC, since epoch) | ||
ttl (int): Time to live for the token, in seconds | ||
""" | ||
|
||
_auth_rule_token_format = ( | ||
"SharedAccessSignature sr={resource}&sig={signature}&se={expiry}&skn={keyname}" | ||
) | ||
_simple_token_format = "SharedAccessSignature sr={resource}&sig={signature}&se={expiry}" | ||
|
||
def __init__(self, uri, signing_mechanism, key_name=None, ttl=3600): | ||
""" | ||
:param str uri: URI of the resouce to be accessed | ||
:param signing_mechanism: The signing mechanism to use in the SasToken | ||
:type signing_mechanism: Child classes of :class:`azure.iot.common.SigningMechanism` | ||
:param str key_name: Symmetric Key Name (optional) | ||
:param int ttl: Time to live for the token, in seconds (default 3600) | ||
|
||
:raises: SasTokenError if an error occurs building a SasToken | ||
""" | ||
self._uri = uri | ||
self._signing_mechanism = signing_mechanism | ||
self._key_name = key_name | ||
self._expiry_time = None # This will be overwritten by the .refresh() call below | ||
self._token = None # This will be overwritten by the .refresh() call below | ||
|
||
self.ttl = ttl | ||
self.refresh() | ||
|
||
def __str__(self): | ||
return self._token | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mental exercise :#2 (written after the one in pipeline_stages_base.py). postulate: The notion that a SasToken has a value and that we need to periodically "refresh" that value is not quite right. It's better to think about the SasToken object as a "password generator" object. When a consumer of this class needs to talk to a network service, he can use this object to generate a password that gives him access for some period of time. When that password is set to expire, the consumer is responsible for generating a new one. This is how the code is written and I like it. Saying why this matters: It doesn't really matter that much, but, there an odd ownership relationship with the root owning the SasToken, object and 2 different pipelines controlling it. Both MQTT and HTTP can refresh the token. If we think of the SasToken object as having a value, having someone else change the value feels wrong. But if we think of each pipeline as owning it's own password that it gets from the generator object, it just feels better. #Pending |
||
|
||
def refresh(self): | ||
""" | ||
Refresh the SasToken lifespan, giving it a new expiry time, and generating a new token. | ||
""" | ||
self._expiry_time = int(time.time() + self.ttl) | ||
self._token = self._build_token() | ||
|
||
def _build_token(self): | ||
"""Buid SasToken representation | ||
|
||
:returns: String representation of the token | ||
""" | ||
url_encoded_uri = urllib.parse.quote(self._uri, safe="") | ||
message = url_encoded_uri + "\n" + str(self.expiry_time) | ||
try: | ||
signature = self._signing_mechanism.sign(message) | ||
except Exception as e: | ||
# Because of variant signing mechanisms, we don't know what error might be raised. | ||
# So we catch all of them. | ||
raise SasTokenError("Unable to build SasToken from given values", e) | ||
url_encoded_signature = urllib.parse.quote(signature, safe="") | ||
if self._key_name: | ||
token = self._auth_rule_token_format.format( | ||
resource=url_encoded_uri, | ||
signature=url_encoded_signature, | ||
expiry=str(self.expiry_time), | ||
keyname=self._key_name, | ||
) | ||
else: | ||
token = self._simple_token_format.format( | ||
resource=url_encoded_uri, | ||
signature=url_encoded_signature, | ||
expiry=str(self.expiry_time), | ||
) | ||
return token | ||
|
||
@property | ||
def expiry_time(self): | ||
"""Expiry Time is READ ONLY""" | ||
return self._expiry_time |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
# ------------------------------------------------------------------------- | ||
# Copyright (c) Microsoft Corporation. All rights reserved. | ||
# Licensed under the MIT License. See License.txt in the project root for | ||
# license information. | ||
# -------------------------------------------------------------------------- | ||
"""This module defines an abstract SigningMechanism, as well as common child implementations of it | ||
""" | ||
|
||
import six | ||
import abc | ||
import hmac | ||
import hashlib | ||
import base64 | ||
from six.moves import urllib | ||
|
||
|
||
@six.add_metaclass(abc.ABCMeta) | ||
class SigningMechanism(object): | ||
@abc.abstractmethod | ||
def sign(self, data_str): | ||
pass | ||
|
||
|
||
class SymmetricKeySigningMechanism(SigningMechanism): | ||
def __init__(self, key): | ||
""" | ||
A mechanism that signs data using a symmetric key | ||
|
||
:param key: Symmetric Key (base64 encoded) | ||
:type key: str or bytes | ||
""" | ||
# Convert key to bytes | ||
try: | ||
key = key.encode("utf-8") | ||
except AttributeError: | ||
# If byte string, no need to encode | ||
pass | ||
|
||
# Derives the signing key | ||
# CT-TODO: is "signing key" the right term? | ||
try: | ||
self._signing_key = base64.b64decode(key) | ||
except (base64.binascii.Error, TypeError): | ||
# NOTE: TypeError can only be raised in Python 2.7 | ||
raise ValueError("Invalid Symmetric Key") | ||
|
||
def sign(self, data_str): | ||
""" | ||
Sign a data string with symmetric key and the HMAC-SHA256 algorithm. | ||
|
||
:param data_str: Data string to be signed | ||
:type data_str: str or bytes | ||
|
||
:returns: The signed data | ||
:rtype: str | ||
""" | ||
# Convert data_str to bytes | ||
try: | ||
data_str = data_str.encode("utf-8") | ||
except AttributeError: | ||
# If byte string, no need to encode | ||
pass | ||
|
||
# Derive signature via HMAC-SHA256 algorithm | ||
try: | ||
hmac_digest = hmac.HMAC( | ||
key=self._signing_key, msg=data_str, digestmod=hashlib.sha256 | ||
).digest() | ||
signed_data = base64.b64encode(hmac_digest) | ||
except (TypeError): | ||
raise ValueError("Unable to sign string using the provided symmetric key") | ||
# Convert from bytes to string | ||
return signed_data.decode("utf-8") |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,9 +19,28 @@ class BasePipelineConfig(object): | |
config files. | ||
""" | ||
|
||
def __init__(self, websockets=False, cipher="", proxy_options=None): | ||
def __init__( | ||
self, | ||
hostname, | ||
gateway_hostname=None, | ||
sastoken=None, | ||
x509=None, | ||
server_verification_cert=None, | ||
websockets=False, | ||
cipher="", | ||
proxy_options=None, | ||
): | ||
"""Initializer for BasePipelineConfig | ||
|
||
:param str hostname: The hostname being connected to | ||
:param str gateway_hostname: The gateway hostname optionally being used | ||
:param sastoken: SasToken to be used for authentication. Mutually exclusive with x509. | ||
:type sastoken: :class:`azure.iot.device.common.auth.SasToken` | ||
:param x509: X509 to be used for authentication. Mutually exclusive with sastoken. | ||
:type x509: :class:`azure.iot.device.models.X509` | ||
:param str server_verification_cert: The trusted certificate chain. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
this has nothing to do with you PR...but i just wanted to mention should we update the doc string to say that it is the content of the cert chain...i have faced ppl with some confusion #Pending There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At some point we'll change the functionality to take both the contents or filepath In reply to: 426949443 [](ancestors = 426949443) |
||
Necessary when using connecting to an endpoint which has a non-standard root of trust, | ||
such as a protocol gateway. | ||
:param bool websockets: Enabling/disabling websockets in MQTT. This feature is relevant | ||
if a firewall blocks port 8883 from use. | ||
:param cipher: Optional cipher suite(s) for TLS/SSL, as a string in | ||
|
@@ -30,6 +49,16 @@ def __init__(self, websockets=False, cipher="", proxy_options=None): | |
:param proxy_options: Details of proxy configuration | ||
:type proxy_options: :class:`azure.iot.device.common.models.ProxyOptions` | ||
""" | ||
# Network | ||
self.hostname = hostname | ||
self.gateway_hostname = gateway_hostname | ||
|
||
# Auth | ||
self.sastoken = sastoken | ||
self.x509 = x509 | ||
if (not sastoken and not x509) or (sastoken and x509): | ||
raise ValueError("One of either 'sastoken' or 'x509' must be provided") | ||
self.server_verification_cert = server_verification_cert | ||
self.websockets = websockets | ||
self.cipher = self._sanitize_cipher(cipher) | ||
self.proxy_options = proxy_options | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not
keyname_token_format
? #ByDesignThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because apparently for some reason the official name of of the
skn
field is not...shared key name
as you might expect, and is actually.... for some reason...auth rule
.I don't get it either.
In reply to: 426946266 [](ancestors = 426946266)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering if this was the name in the azure portal at one point. Right now, the key names (iothubowner, etc) show up in the portal under "Shared Access Policy" as "Access policy name". We had this same thing happen with one of the DPS names -- I think ProvisioningHostName shows up in the portal with one name and in the SDK as another.
In reply to: 427500301 [](ancestors = 427500301,426946266)