This commit is contained in:
34
venv/lib/python3.12/site-packages/kafka/sasl/__init__.py
Normal file
34
venv/lib/python3.12/site-packages/kafka/sasl/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import platform
|
||||
|
||||
from kafka.sasl.gssapi import SaslMechanismGSSAPI
|
||||
from kafka.sasl.msk import SaslMechanismAwsMskIam
|
||||
from kafka.sasl.oauth import SaslMechanismOAuth
|
||||
from kafka.sasl.plain import SaslMechanismPlain
|
||||
from kafka.sasl.scram import SaslMechanismScram
|
||||
from kafka.sasl.sspi import SaslMechanismSSPI
|
||||
|
||||
|
||||
SASL_MECHANISMS = {}
|
||||
|
||||
|
||||
def register_sasl_mechanism(name, klass, overwrite=False):
|
||||
if not overwrite and name in SASL_MECHANISMS:
|
||||
raise ValueError('Sasl mechanism %s already defined!' % name)
|
||||
SASL_MECHANISMS[name] = klass
|
||||
|
||||
|
||||
def get_sasl_mechanism(name):
|
||||
return SASL_MECHANISMS[name]
|
||||
|
||||
|
||||
register_sasl_mechanism('AWS_MSK_IAM', SaslMechanismAwsMskIam)
|
||||
if platform.system() == 'Windows':
|
||||
register_sasl_mechanism('GSSAPI', SaslMechanismSSPI)
|
||||
else:
|
||||
register_sasl_mechanism('GSSAPI', SaslMechanismGSSAPI)
|
||||
register_sasl_mechanism('OAUTHBEARER', SaslMechanismOAuth)
|
||||
register_sasl_mechanism('PLAIN', SaslMechanismPlain)
|
||||
register_sasl_mechanism('SCRAM-SHA-256', SaslMechanismScram)
|
||||
register_sasl_mechanism('SCRAM-SHA-512', SaslMechanismScram)
|
||||
33
venv/lib/python3.12/site-packages/kafka/sasl/abc.py
Normal file
33
venv/lib/python3.12/site-packages/kafka/sasl/abc.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
|
||||
from kafka.vendor.six import add_metaclass
|
||||
|
||||
|
||||
@add_metaclass(abc.ABCMeta)
|
||||
class SaslMechanism(object):
|
||||
@abc.abstractmethod
|
||||
def __init__(self, **config):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def auth_bytes(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def receive(self, auth_bytes):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_done(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_authenticated(self):
|
||||
pass
|
||||
|
||||
def auth_details(self):
|
||||
if not self.is_authenticated:
|
||||
raise RuntimeError('Not authenticated yet!')
|
||||
return 'Authenticated via SASL'
|
||||
96
venv/lib/python3.12/site-packages/kafka/sasl/gssapi.py
Normal file
96
venv/lib/python3.12/site-packages/kafka/sasl/gssapi.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import struct
|
||||
|
||||
# needed for SASL_GSSAPI authentication:
|
||||
try:
|
||||
import gssapi
|
||||
from gssapi.raw.misc import GSSError
|
||||
except (ImportError, OSError):
|
||||
#no gssapi available, will disable gssapi mechanism
|
||||
gssapi = None
|
||||
GSSError = None
|
||||
|
||||
from kafka.sasl.abc import SaslMechanism
|
||||
|
||||
|
||||
class SaslMechanismGSSAPI(SaslMechanism):
|
||||
# Establish security context and negotiate protection level
|
||||
# For reference RFC 2222, section 7.2.1
|
||||
|
||||
SASL_QOP_AUTH = 1
|
||||
SASL_QOP_AUTH_INT = 2
|
||||
SASL_QOP_AUTH_CONF = 4
|
||||
|
||||
def __init__(self, **config):
|
||||
assert gssapi is not None, 'GSSAPI lib not available'
|
||||
if 'sasl_kerberos_name' not in config and 'sasl_kerberos_service_name' not in config:
|
||||
raise ValueError('sasl_kerberos_service_name or sasl_kerberos_name required for GSSAPI sasl configuration')
|
||||
self._is_done = False
|
||||
self._is_authenticated = False
|
||||
self.gssapi_name = None
|
||||
if config.get('sasl_kerberos_name', None) is not None:
|
||||
self.auth_id = str(config['sasl_kerberos_name'])
|
||||
if isinstance(config['sasl_kerberos_name'], gssapi.Name):
|
||||
self.gssapi_name = config['sasl_kerberos_name']
|
||||
else:
|
||||
kerberos_domain_name = config.get('sasl_kerberos_domain_name', '') or config.get('host', '')
|
||||
self.auth_id = config['sasl_kerberos_service_name'] + '@' + kerberos_domain_name
|
||||
if self.gssapi_name is None:
|
||||
self.gssapi_name = gssapi.Name(self.auth_id, name_type=gssapi.NameType.hostbased_service).canonicalize(gssapi.MechType.kerberos)
|
||||
self._client_ctx = gssapi.SecurityContext(name=self.gssapi_name, usage='initiate')
|
||||
self._next_token = self._client_ctx.step(None)
|
||||
|
||||
def auth_bytes(self):
|
||||
# GSSAPI Auth does not have a final broker->client message
|
||||
# so mark is_done after the final auth_bytes are provided
|
||||
# in practice we'll still receive a response when using SaslAuthenticate
|
||||
# but not when using the prior unframed approach.
|
||||
if self._is_authenticated:
|
||||
self._is_done = True
|
||||
return self._next_token or b''
|
||||
|
||||
def receive(self, auth_bytes):
|
||||
if not self._client_ctx.complete:
|
||||
# The server will send a token back. Processing of this token either
|
||||
# establishes a security context, or it needs further token exchange.
|
||||
# The gssapi will be able to identify the needed next step.
|
||||
self._next_token = self._client_ctx.step(auth_bytes)
|
||||
elif self._is_done:
|
||||
# The final step of gssapi is send, so we do not expect any additional bytes
|
||||
# however, allow an empty message to support SaslAuthenticate response
|
||||
if auth_bytes != b'':
|
||||
raise ValueError("Unexpected receive auth_bytes after sasl/gssapi completion")
|
||||
else:
|
||||
# unwraps message containing supported protection levels and msg size
|
||||
msg = self._client_ctx.unwrap(auth_bytes).message
|
||||
# Kafka currently doesn't support integrity or confidentiality security layers, so we
|
||||
# simply set QoP to 'auth' only (first octet). We reuse the max message size proposed
|
||||
# by the server
|
||||
client_flags = self.SASL_QOP_AUTH
|
||||
server_flags = struct.Struct('>b').unpack(msg[0:1])[0]
|
||||
message_parts = [
|
||||
struct.Struct('>b').pack(client_flags & server_flags),
|
||||
msg[1:], # always agree to max message size from server
|
||||
self.auth_id.encode('utf-8'),
|
||||
]
|
||||
# add authorization identity to the response, and GSS-wrap
|
||||
self._next_token = self._client_ctx.wrap(b''.join(message_parts), False).message
|
||||
# We need to identify the last token in auth_bytes();
|
||||
# we can't rely on client_ctx.complete because it becomes True after generating
|
||||
# the second-to-last token (after calling .step(auth_bytes) for the final time)
|
||||
# We could introduce an additional state variable (i.e., self._final_token),
|
||||
# but instead we just set _is_authenticated. Since the plugin interface does
|
||||
# not read is_authenticated() until after is_done() is True, this should be fine.
|
||||
self._is_authenticated = True
|
||||
|
||||
def is_done(self):
|
||||
return self._is_done
|
||||
|
||||
def is_authenticated(self):
|
||||
return self._is_authenticated
|
||||
|
||||
def auth_details(self):
|
||||
if not self.is_authenticated:
|
||||
raise RuntimeError('Not authenticated yet!')
|
||||
return 'Authenticated as %s to %s via SASL / GSSAPI' % (self._client_ctx.initiator_name, self._client_ctx.target_name)
|
||||
244
venv/lib/python3.12/site-packages/kafka/sasl/msk.py
Normal file
244
venv/lib/python3.12/site-packages/kafka/sasl/msk.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import string
|
||||
|
||||
# needed for AWS_MSK_IAM authentication:
|
||||
try:
|
||||
from botocore.session import Session as BotoSession
|
||||
except ImportError:
|
||||
# no botocore available, will disable AWS_MSK_IAM mechanism
|
||||
BotoSession = None
|
||||
|
||||
from kafka.errors import KafkaConfigurationError
|
||||
from kafka.sasl.abc import SaslMechanism
|
||||
from kafka.vendor.six.moves import urllib
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaslMechanismAwsMskIam(SaslMechanism):
|
||||
def __init__(self, **config):
|
||||
assert BotoSession is not None, 'AWS_MSK_IAM requires the "botocore" package'
|
||||
assert config.get('security_protocol', '') == 'SASL_SSL', 'AWS_MSK_IAM requires SASL_SSL'
|
||||
assert 'host' in config, 'AWS_MSK_IAM requires host configuration'
|
||||
self.host = config['host']
|
||||
self._auth = None
|
||||
self._is_done = False
|
||||
self._is_authenticated = False
|
||||
|
||||
def _build_client(self):
|
||||
session = BotoSession()
|
||||
credentials = session.get_credentials().get_frozen_credentials()
|
||||
if not session.get_config_variable('region'):
|
||||
raise KafkaConfigurationError('Unable to determine region for AWS MSK cluster. Is AWS_DEFAULT_REGION set?')
|
||||
return AwsMskIamClient(
|
||||
host=self.host,
|
||||
access_key=credentials.access_key,
|
||||
secret_key=credentials.secret_key,
|
||||
region=session.get_config_variable('region'),
|
||||
token=credentials.token,
|
||||
)
|
||||
|
||||
def auth_bytes(self):
|
||||
client = self._build_client()
|
||||
log.debug("Generating auth token for MSK scope: %s", client._scope)
|
||||
return client.first_message()
|
||||
|
||||
def receive(self, auth_bytes):
|
||||
self._is_done = True
|
||||
self._is_authenticated = auth_bytes != b''
|
||||
self._auth = auth_bytes.decode('utf-8')
|
||||
|
||||
def is_done(self):
|
||||
return self._is_done
|
||||
|
||||
def is_authenticated(self):
|
||||
return self._is_authenticated
|
||||
|
||||
def auth_details(self):
|
||||
if not self.is_authenticated:
|
||||
raise RuntimeError('Not authenticated yet!')
|
||||
return 'Authenticated via SASL / AWS_MSK_IAM %s' % (self._auth,)
|
||||
|
||||
|
||||
class AwsMskIamClient:
|
||||
UNRESERVED_CHARS = string.ascii_letters + string.digits + '-._~'
|
||||
|
||||
def __init__(self, host, access_key, secret_key, region, token=None):
|
||||
"""
|
||||
Arguments:
|
||||
host (str): The hostname of the broker.
|
||||
access_key (str): An AWS_ACCESS_KEY_ID.
|
||||
secret_key (str): An AWS_SECRET_ACCESS_KEY.
|
||||
region (str): An AWS_REGION.
|
||||
token (Optional[str]): An AWS_SESSION_TOKEN if using temporary
|
||||
credentials.
|
||||
"""
|
||||
self.algorithm = 'AWS4-HMAC-SHA256'
|
||||
self.expires = '900'
|
||||
self.hashfunc = hashlib.sha256
|
||||
self.headers = [
|
||||
('host', host)
|
||||
]
|
||||
self.version = '2020_10_22'
|
||||
|
||||
self.service = 'kafka-cluster'
|
||||
self.action = '{}:Connect'.format(self.service)
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
self.datestamp = now.strftime('%Y%m%d')
|
||||
self.timestamp = now.strftime('%Y%m%dT%H%M%SZ')
|
||||
|
||||
self.host = host
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
self.region = region
|
||||
self.token = token
|
||||
|
||||
@property
|
||||
def _credential(self):
|
||||
return '{0.access_key}/{0._scope}'.format(self)
|
||||
|
||||
@property
|
||||
def _scope(self):
|
||||
return '{0.datestamp}/{0.region}/{0.service}/aws4_request'.format(self)
|
||||
|
||||
@property
|
||||
def _signed_headers(self):
|
||||
"""
|
||||
Returns (str):
|
||||
An alphabetically sorted, semicolon-delimited list of lowercase
|
||||
request header names.
|
||||
"""
|
||||
return ';'.join(sorted(k.lower() for k, _ in self.headers))
|
||||
|
||||
@property
|
||||
def _canonical_headers(self):
|
||||
"""
|
||||
Returns (str):
|
||||
A newline-delited list of header names and values.
|
||||
Header names are lowercased.
|
||||
"""
|
||||
return '\n'.join(map(':'.join, self.headers)) + '\n'
|
||||
|
||||
@property
|
||||
def _canonical_request(self):
|
||||
"""
|
||||
Returns (str):
|
||||
An AWS Signature Version 4 canonical request in the format:
|
||||
<Method>\n
|
||||
<Path>\n
|
||||
<CanonicalQueryString>\n
|
||||
<CanonicalHeaders>\n
|
||||
<SignedHeaders>\n
|
||||
<HashedPayload>
|
||||
"""
|
||||
# The hashed_payload is always an empty string for MSK.
|
||||
hashed_payload = self.hashfunc(b'').hexdigest()
|
||||
return '\n'.join((
|
||||
'GET',
|
||||
'/',
|
||||
self._canonical_querystring,
|
||||
self._canonical_headers,
|
||||
self._signed_headers,
|
||||
hashed_payload,
|
||||
))
|
||||
|
||||
@property
|
||||
def _canonical_querystring(self):
|
||||
"""
|
||||
Returns (str):
|
||||
A '&'-separated list of URI-encoded key/value pairs.
|
||||
"""
|
||||
params = []
|
||||
params.append(('Action', self.action))
|
||||
params.append(('X-Amz-Algorithm', self.algorithm))
|
||||
params.append(('X-Amz-Credential', self._credential))
|
||||
params.append(('X-Amz-Date', self.timestamp))
|
||||
params.append(('X-Amz-Expires', self.expires))
|
||||
if self.token:
|
||||
params.append(('X-Amz-Security-Token', self.token))
|
||||
params.append(('X-Amz-SignedHeaders', self._signed_headers))
|
||||
|
||||
return '&'.join(self._uriencode(k) + '=' + self._uriencode(v) for k, v in params)
|
||||
|
||||
@property
|
||||
def _signing_key(self):
|
||||
"""
|
||||
Returns (bytes):
|
||||
An AWS Signature V4 signing key generated from the secret_key, date,
|
||||
region, service, and request type.
|
||||
"""
|
||||
key = self._hmac(('AWS4' + self.secret_key).encode('utf-8'), self.datestamp)
|
||||
key = self._hmac(key, self.region)
|
||||
key = self._hmac(key, self.service)
|
||||
key = self._hmac(key, 'aws4_request')
|
||||
return key
|
||||
|
||||
@property
|
||||
def _signing_str(self):
|
||||
"""
|
||||
Returns (str):
|
||||
A string used to sign the AWS Signature V4 payload in the format:
|
||||
<Algorithm>\n
|
||||
<Timestamp>\n
|
||||
<Scope>\n
|
||||
<CanonicalRequestHash>
|
||||
"""
|
||||
canonical_request_hash = self.hashfunc(self._canonical_request.encode('utf-8')).hexdigest()
|
||||
return '\n'.join((self.algorithm, self.timestamp, self._scope, canonical_request_hash))
|
||||
|
||||
def _uriencode(self, msg):
|
||||
"""
|
||||
Arguments:
|
||||
msg (str): A string to URI-encode.
|
||||
|
||||
Returns (str):
|
||||
The URI-encoded version of the provided msg, following the encoding
|
||||
rules specified: https://github.com/aws/aws-msk-iam-auth#uriencode
|
||||
"""
|
||||
return urllib.parse.quote(msg, safe=self.UNRESERVED_CHARS)
|
||||
|
||||
def _hmac(self, key, msg):
|
||||
"""
|
||||
Arguments:
|
||||
key (bytes): A key to use for the HMAC digest.
|
||||
msg (str): A value to include in the HMAC digest.
|
||||
Returns (bytes):
|
||||
An HMAC digest of the given key and msg.
|
||||
"""
|
||||
return hmac.new(key, msg.encode('utf-8'), digestmod=self.hashfunc).digest()
|
||||
|
||||
def first_message(self):
|
||||
"""
|
||||
Returns (bytes):
|
||||
An encoded JSON authentication payload that can be sent to the
|
||||
broker.
|
||||
"""
|
||||
signature = hmac.new(
|
||||
self._signing_key,
|
||||
self._signing_str.encode('utf-8'),
|
||||
digestmod=self.hashfunc,
|
||||
).hexdigest()
|
||||
msg = {
|
||||
'version': self.version,
|
||||
'host': self.host,
|
||||
'user-agent': 'kafka-python',
|
||||
'action': self.action,
|
||||
'x-amz-algorithm': self.algorithm,
|
||||
'x-amz-credential': self._credential,
|
||||
'x-amz-date': self.timestamp,
|
||||
'x-amz-signedheaders': self._signed_headers,
|
||||
'x-amz-expires': self.expires,
|
||||
'x-amz-signature': signature,
|
||||
}
|
||||
if self.token:
|
||||
msg['x-amz-security-token'] = self.token
|
||||
|
||||
return json.dumps(msg, separators=(',', ':')).encode('utf-8')
|
||||
100
venv/lib/python3.12/site-packages/kafka/sasl/oauth.py
Normal file
100
venv/lib/python3.12/site-packages/kafka/sasl/oauth.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import abc
|
||||
import logging
|
||||
|
||||
from kafka.sasl.abc import SaslMechanism
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaslMechanismOAuth(SaslMechanism):
|
||||
|
||||
def __init__(self, **config):
|
||||
assert 'sasl_oauth_token_provider' in config, 'sasl_oauth_token_provider required for OAUTHBEARER sasl'
|
||||
assert isinstance(config['sasl_oauth_token_provider'], AbstractTokenProvider), \
|
||||
'sasl_oauth_token_provider must implement kafka.sasl.oauth.AbstractTokenProvider'
|
||||
self.token_provider = config['sasl_oauth_token_provider']
|
||||
self._error = None
|
||||
self._is_done = False
|
||||
self._is_authenticated = False
|
||||
|
||||
def auth_bytes(self):
|
||||
if self._error:
|
||||
# Server should respond to this with SaslAuthenticate failure, which ends the auth process
|
||||
return self._error
|
||||
token = self.token_provider.token()
|
||||
extensions = self._token_extensions()
|
||||
return "n,,\x01auth=Bearer {}{}\x01\x01".format(token, extensions).encode('utf-8')
|
||||
|
||||
def receive(self, auth_bytes):
|
||||
if auth_bytes != b'':
|
||||
error = auth_bytes.decode('utf-8')
|
||||
log.debug("Sending x01 response to server after receiving SASL OAuth error: %s", error)
|
||||
self._error = b'\x01'
|
||||
else:
|
||||
self._is_done = True
|
||||
self._is_authenticated = True
|
||||
|
||||
def is_done(self):
|
||||
return self._is_done
|
||||
|
||||
def is_authenticated(self):
|
||||
return self._is_authenticated
|
||||
|
||||
def _token_extensions(self):
|
||||
"""
|
||||
Return a string representation of the OPTIONAL key-value pairs that can be sent with an OAUTHBEARER
|
||||
initial request.
|
||||
"""
|
||||
# Builds up a string separated by \x01 via a dict of key value pairs
|
||||
extensions = self.token_provider.extensions()
|
||||
msg = '\x01'.join(['{}={}'.format(k, v) for k, v in extensions.items()])
|
||||
return '\x01' + msg if msg else ''
|
||||
|
||||
def auth_details(self):
|
||||
if not self.is_authenticated:
|
||||
raise RuntimeError('Not authenticated yet!')
|
||||
return 'Authenticated via SASL / OAuth'
|
||||
|
||||
# This statement is compatible with both Python 2.7 & 3+
|
||||
ABC = abc.ABCMeta('ABC', (object,), {'__slots__': ()})
|
||||
|
||||
class AbstractTokenProvider(ABC):
|
||||
"""
|
||||
A Token Provider must be used for the SASL OAuthBearer protocol.
|
||||
|
||||
The implementation should ensure token reuse so that multiple
|
||||
calls at connect time do not create multiple tokens. The implementation
|
||||
should also periodically refresh the token in order to guarantee
|
||||
that each call returns an unexpired token. A timeout error should
|
||||
be returned after a short period of inactivity so that the
|
||||
broker can log debugging info and retry.
|
||||
|
||||
Token Providers MUST implement the token() method
|
||||
"""
|
||||
|
||||
def __init__(self, **config):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def token(self):
|
||||
"""
|
||||
Returns a (str) ID/Access Token to be sent to the Kafka
|
||||
client.
|
||||
"""
|
||||
pass
|
||||
|
||||
def extensions(self):
|
||||
"""
|
||||
This is an OPTIONAL method that may be implemented.
|
||||
|
||||
Returns a map of key-value pairs that can
|
||||
be sent with the SASL/OAUTHBEARER initial client request. If
|
||||
not implemented, the values are ignored. This feature is only available
|
||||
in Kafka >= 2.1.0.
|
||||
|
||||
All returned keys and values should be type str
|
||||
"""
|
||||
return {}
|
||||
41
venv/lib/python3.12/site-packages/kafka/sasl/plain.py
Normal file
41
venv/lib/python3.12/site-packages/kafka/sasl/plain.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
|
||||
from kafka.sasl.abc import SaslMechanism
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaslMechanismPlain(SaslMechanism):
|
||||
|
||||
def __init__(self, **config):
|
||||
if config.get('security_protocol', '') == 'SASL_PLAINTEXT':
|
||||
log.warning('Sending username and password in the clear')
|
||||
assert 'sasl_plain_username' in config, 'sasl_plain_username required for PLAIN sasl'
|
||||
assert 'sasl_plain_password' in config, 'sasl_plain_password required for PLAIN sasl'
|
||||
|
||||
self.username = config['sasl_plain_username']
|
||||
self.password = config['sasl_plain_password']
|
||||
self._is_done = False
|
||||
self._is_authenticated = False
|
||||
|
||||
def auth_bytes(self):
|
||||
# Send PLAIN credentials per RFC-4616
|
||||
return bytes('\0'.join([self.username, self.username, self.password]).encode('utf-8'))
|
||||
|
||||
def receive(self, auth_bytes):
|
||||
self._is_done = True
|
||||
self._is_authenticated = auth_bytes == b''
|
||||
|
||||
def is_done(self):
|
||||
return self._is_done
|
||||
|
||||
def is_authenticated(self):
|
||||
return self._is_authenticated
|
||||
|
||||
def auth_details(self):
|
||||
if not self.is_authenticated:
|
||||
raise RuntimeError('Not authenticated yet!')
|
||||
return 'Authenticated as %s via SASL / Plain' % self.username
|
||||
133
venv/lib/python3.12/site-packages/kafka/sasl/scram.py
Normal file
133
venv/lib/python3.12/site-packages/kafka/sasl/scram.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
|
||||
from kafka.sasl.abc import SaslMechanism
|
||||
from kafka.vendor import six
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if six.PY2:
|
||||
def xor_bytes(left, right):
|
||||
return bytearray(ord(lb) ^ ord(rb) for lb, rb in zip(left, right))
|
||||
else:
|
||||
def xor_bytes(left, right):
|
||||
return bytes(lb ^ rb for lb, rb in zip(left, right))
|
||||
|
||||
|
||||
class SaslMechanismScram(SaslMechanism):
|
||||
def __init__(self, **config):
|
||||
assert 'sasl_plain_username' in config, 'sasl_plain_username required for SCRAM sasl'
|
||||
assert 'sasl_plain_password' in config, 'sasl_plain_password required for SCRAM sasl'
|
||||
assert config.get('sasl_mechanism', '') in ScramClient.MECHANISMS, 'Unrecognized SCRAM mechanism'
|
||||
if config.get('security_protocol', '') == 'SASL_PLAINTEXT':
|
||||
log.warning('Exchanging credentials in the clear during Sasl Authentication')
|
||||
|
||||
self.username = config['sasl_plain_username']
|
||||
self.mechanism = config['sasl_mechanism']
|
||||
self._scram_client = ScramClient(
|
||||
config['sasl_plain_username'],
|
||||
config['sasl_plain_password'],
|
||||
config['sasl_mechanism']
|
||||
)
|
||||
self._state = 0
|
||||
|
||||
def auth_bytes(self):
|
||||
if self._state == 0:
|
||||
return self._scram_client.first_message()
|
||||
elif self._state == 1:
|
||||
return self._scram_client.final_message()
|
||||
else:
|
||||
raise ValueError('No auth_bytes for state: %s' % self._state)
|
||||
|
||||
def receive(self, auth_bytes):
|
||||
if self._state == 0:
|
||||
self._scram_client.process_server_first_message(auth_bytes)
|
||||
elif self._state == 1:
|
||||
self._scram_client.process_server_final_message(auth_bytes)
|
||||
else:
|
||||
raise ValueError('Cannot receive bytes in state: %s' % self._state)
|
||||
self._state += 1
|
||||
return self.is_done()
|
||||
|
||||
def is_done(self):
|
||||
return self._state == 2
|
||||
|
||||
def is_authenticated(self):
|
||||
# receive raises if authentication fails...?
|
||||
return self._state == 2
|
||||
|
||||
def auth_details(self):
|
||||
if not self.is_authenticated:
|
||||
raise RuntimeError('Not authenticated yet!')
|
||||
return 'Authenticated as %s via SASL / %s' % (self.username, self.mechanism)
|
||||
|
||||
|
||||
class ScramClient:
|
||||
MECHANISMS = {
|
||||
'SCRAM-SHA-256': hashlib.sha256,
|
||||
'SCRAM-SHA-512': hashlib.sha512
|
||||
}
|
||||
|
||||
def __init__(self, user, password, mechanism):
|
||||
self.nonce = str(uuid.uuid4()).replace('-', '').encode('utf-8')
|
||||
self.auth_message = b''
|
||||
self.salted_password = None
|
||||
self.user = user.encode('utf-8')
|
||||
self.password = password.encode('utf-8')
|
||||
self.hashfunc = self.MECHANISMS[mechanism]
|
||||
self.hashname = ''.join(mechanism.lower().split('-')[1:3])
|
||||
self.stored_key = None
|
||||
self.client_key = None
|
||||
self.client_signature = None
|
||||
self.client_proof = None
|
||||
self.server_key = None
|
||||
self.server_signature = None
|
||||
|
||||
def first_message(self):
|
||||
client_first_bare = b'n=' + self.user + b',r=' + self.nonce
|
||||
self.auth_message += client_first_bare
|
||||
return b'n,,' + client_first_bare
|
||||
|
||||
def process_server_first_message(self, server_first_message):
|
||||
self.auth_message += b',' + server_first_message
|
||||
params = dict(pair.split('=', 1) for pair in server_first_message.decode('utf-8').split(','))
|
||||
server_nonce = params['r'].encode('utf-8')
|
||||
if not server_nonce.startswith(self.nonce):
|
||||
raise ValueError("Server nonce, did not start with client nonce!")
|
||||
self.nonce = server_nonce
|
||||
self.auth_message += b',c=biws,r=' + self.nonce
|
||||
|
||||
salt = base64.b64decode(params['s'].encode('utf-8'))
|
||||
iterations = int(params['i'])
|
||||
self.create_salted_password(salt, iterations)
|
||||
|
||||
self.client_key = self.hmac(self.salted_password, b'Client Key')
|
||||
self.stored_key = self.hashfunc(self.client_key).digest()
|
||||
self.client_signature = self.hmac(self.stored_key, self.auth_message)
|
||||
self.client_proof = xor_bytes(self.client_key, self.client_signature)
|
||||
self.server_key = self.hmac(self.salted_password, b'Server Key')
|
||||
self.server_signature = self.hmac(self.server_key, self.auth_message)
|
||||
|
||||
def hmac(self, key, msg):
|
||||
return hmac.new(key, msg, digestmod=self.hashfunc).digest()
|
||||
|
||||
def create_salted_password(self, salt, iterations):
|
||||
self.salted_password = hashlib.pbkdf2_hmac(
|
||||
self.hashname, self.password, salt, iterations
|
||||
)
|
||||
|
||||
def final_message(self):
|
||||
return b'c=biws,r=' + self.nonce + b',p=' + base64.b64encode(self.client_proof)
|
||||
|
||||
def process_server_final_message(self, server_final_message):
|
||||
params = dict(pair.split('=', 1) for pair in server_final_message.decode('utf-8').split(','))
|
||||
if self.server_signature != base64.b64decode(params['v'].encode('utf-8')):
|
||||
raise ValueError("Server sent wrong signature!")
|
||||
111
venv/lib/python3.12/site-packages/kafka/sasl/sspi.py
Normal file
111
venv/lib/python3.12/site-packages/kafka/sasl/sspi.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
|
||||
# Windows-only
|
||||
try:
|
||||
import sspi
|
||||
import pywintypes
|
||||
import sspicon
|
||||
import win32security
|
||||
except ImportError:
|
||||
sspi = None
|
||||
|
||||
from kafka.sasl.abc import SaslMechanism
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaslMechanismSSPI(SaslMechanism):
|
||||
# Establish security context and negotiate protection level
|
||||
# For reference see RFC 4752, section 3
|
||||
|
||||
SASL_QOP_AUTH = 1
|
||||
SASL_QOP_AUTH_INT = 2
|
||||
SASL_QOP_AUTH_CONF = 4
|
||||
|
||||
def __init__(self, **config):
|
||||
assert sspi is not None, 'No GSSAPI lib available (gssapi or sspi)'
|
||||
if 'sasl_kerberos_name' not in config and 'sasl_kerberos_service_name' not in config:
|
||||
raise ValueError('sasl_kerberos_service_name or sasl_kerberos_name required for GSSAPI sasl configuration')
|
||||
self._is_done = False
|
||||
self._is_authenticated = False
|
||||
if config.get('sasl_kerberos_name', None) is not None:
|
||||
self.auth_id = str(config['sasl_kerberos_name'])
|
||||
else:
|
||||
kerberos_domain_name = config.get('sasl_kerberos_domain_name', '') or config.get('host', '')
|
||||
self.auth_id = config['sasl_kerberos_service_name'] + '/' + kerberos_domain_name
|
||||
scheme = "Kerberos" # Do not try with Negotiate for SASL authentication. Tokens are different.
|
||||
# https://docs.microsoft.com/en-us/windows/win32/secauthn/context-requirements
|
||||
flags = (
|
||||
sspicon.ISC_REQ_MUTUAL_AUTH | # mutual authentication
|
||||
sspicon.ISC_REQ_INTEGRITY | # check for integrity
|
||||
sspicon.ISC_REQ_SEQUENCE_DETECT | # enable out-of-order messages
|
||||
sspicon.ISC_REQ_CONFIDENTIALITY # request confidentiality
|
||||
)
|
||||
self._client_ctx = sspi.ClientAuth(scheme, targetspn=self.auth_id, scflags=flags)
|
||||
self._next_token = self._client_ctx.step(None)
|
||||
|
||||
def auth_bytes(self):
|
||||
# GSSAPI Auth does not have a final broker->client message
|
||||
# so mark is_done after the final auth_bytes are provided
|
||||
# in practice we'll still receive a response when using SaslAuthenticate
|
||||
# but not when using the prior unframed approach.
|
||||
if self._client_ctx.authenticated:
|
||||
self._is_done = True
|
||||
self._is_authenticated = True
|
||||
return self._next_token or b''
|
||||
|
||||
def receive(self, auth_bytes):
|
||||
log.debug("Received token from server (size %s)", len(auth_bytes))
|
||||
if not self._client_ctx.authenticated:
|
||||
# calculate an output token from kafka token (or None on first iteration)
|
||||
# https://docs.microsoft.com/en-us/windows/win32/api/sspi/nf-sspi-initializesecuritycontexta
|
||||
# https://docs.microsoft.com/en-us/windows/win32/secauthn/initializesecuritycontext--kerberos
|
||||
# authorize method will wrap for us our token in sspi structures
|
||||
error, auth = self._client_ctx.authorize(auth_bytes)
|
||||
if len(auth) > 0 and len(auth[0].Buffer):
|
||||
log.debug("Got token from context")
|
||||
# this buffer must be sent to the server whatever the result is
|
||||
self._next_token = auth[0].Buffer
|
||||
else:
|
||||
log.debug("Got no token, exchange finished")
|
||||
# seems to be the end of the loop
|
||||
self._next_token = b''
|
||||
elif self._is_done:
|
||||
# The final step of gssapi is send, so we do not expect any additional bytes
|
||||
# however, allow an empty message to support SaslAuthenticate response
|
||||
if auth_bytes != b'':
|
||||
raise ValueError("Unexpected receive auth_bytes after sasl/gssapi completion")
|
||||
else:
|
||||
# Process the security layer negotiation token, sent by the server
|
||||
# once the security context is established.
|
||||
|
||||
# The following part is required by SASL, but not by classic Kerberos.
|
||||
# See RFC 4752
|
||||
|
||||
# unwraps message containing supported protection levels and msg size
|
||||
msg, _was_encrypted = self._client_ctx.unwrap(auth_bytes)
|
||||
|
||||
# Kafka currently doesn't support integrity or confidentiality security layers, so we
|
||||
# simply set QoP to 'auth' only (first octet). We reuse the max message size proposed
|
||||
# by the server
|
||||
client_flags = self.SASL_QOP_AUTH
|
||||
server_flags = msg[0]
|
||||
message_parts = [
|
||||
bytes(client_flags & server_flags),
|
||||
msg[:1],
|
||||
self.auth_id.encode('utf-8'),
|
||||
]
|
||||
# add authorization identity to the response, and GSS-wrap
|
||||
self._next_token = self._client_ctx.wrap(b''.join(message_parts), False)
|
||||
|
||||
def is_done(self):
|
||||
return self._is_done
|
||||
|
||||
def is_authenticated(self):
|
||||
return self._is_authenticated
|
||||
|
||||
def auth_details(self):
|
||||
return 'Authenticated as %s to %s via SASL / SSPI/GSSAPI \\o/' % (self._client_ctx.initiator_name, self._client_ctx.service_name)
|
||||
Reference in New Issue
Block a user