Skip to content

Commit aa2664b

Browse files
StephenSorriauxjeffwidman
authored andcommitted
feat(core): add SASL DIGEST-MD5 support
This adds the possibility to connect to Zookeeper using DIGEST-MD5 SASL. It uses the pure-sasl library to connect using SASL. In case the library is missing, connection to Zookeeper will be done without any authentification and a warning message will be displayed. Tests have been added for this feature. Documentation also has been updated.
1 parent 287749b commit aa2664b

7 files changed

Lines changed: 154 additions & 9 deletions

File tree

kazoo/client.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,15 @@ def _retry(*args, **kwargs):
303303
self.Semaphore = partial(Semaphore, self)
304304
self.ShallowParty = partial(ShallowParty, self)
305305

306+
# Managing SASL client
307+
self.use_sasl = False
308+
for scheme, auth in self.auth_data:
309+
if scheme == "sasl":
310+
self.use_sasl = True
311+
# Could be used later for GSSAPI implementation
312+
self.sasl_server_principal = "zk-sasl-md5"
313+
break
314+
306315
# If we got any unhandled keywords, complain like Python would
307316
if kwargs:
308317
raise TypeError('__init__() got unexpected keyword arguments: %s'
@@ -728,8 +737,12 @@ def add_auth(self, scheme, credential):
728737
"""Send credentials to server.
729738
730739
:param scheme: authentication scheme (default supported:
731-
"digest").
740+
"digest", "sasl"). Note that "sasl" scheme is
741+
requiring "pure-sasl" library to be
742+
installed.
732743
:param credential: the credential -- value depends on scheme.
744+
"digest": user:password
745+
"sasl": user:password
733746
734747
:returns: True if it was successful.
735748
:rtype: bool

kazoo/protocol/connection.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
Ping,
2626
PingInstance,
2727
ReplyHeader,
28+
SASL,
2829
Transaction,
2930
Watch,
3031
int_struct
@@ -39,6 +40,11 @@
3940
ForceRetryError,
4041
RetryFailedError
4142
)
43+
try:
44+
from puresasl.client import SASLClient
45+
PURESASL_AVAILABLE = True
46+
except ImportError:
47+
PURESASL_AVAILABLE = False
4248

4349

4450
log = logging.getLogger(__name__)
@@ -154,6 +160,8 @@ def __init__(self, client, retry_sleeper, logger=None):
154160

155161
self._connection_routine = None
156162

163+
self.sasl_cli = None
164+
157165
# This is instance specific to avoid odd thread bug issues in Python
158166
# during shutdown global cleanup
159167
@contextmanager
@@ -416,6 +424,24 @@ def _read_socket(self, read_timeout):
416424
async_object.set(True)
417425
elif header.xid == WATCH_XID:
418426
self._read_watch_event(buffer, offset)
427+
elif self.sasl_cli and not self.sasl_cli.complete:
428+
# SASL authentication is not yet finished, this can only
429+
# be a SASL packet
430+
self.logger.log(BLATHER, 'Received SASL')
431+
try:
432+
challenge, _ = SASL.deserialize(buffer, offset)
433+
except Exception:
434+
raise ConnectionDropped('error while SASL authentication.')
435+
response = self.sasl_cli.process(challenge)
436+
if response:
437+
# authentication not yet finished, answering the challenge
438+
self._send_sasl_request(challenge=response,
439+
timeout=client._session_timeout)
440+
else:
441+
# authentication is ok, state is CONNECTED
442+
# remove sensible information from the object
443+
client._session_callback(KeeperState.CONNECTED)
444+
self.sasl_cli.dispose()
419445
else:
420446
self.logger.log(BLATHER, 'Reading for header %r', header)
421447

@@ -544,11 +570,11 @@ def _connect_attempt(self, host, port, retry):
544570
client._session_callback(KeeperState.CONNECTING)
545571

546572
try:
573+
self._xid = 0
547574
read_timeout, connect_timeout = self._connect(host, port)
548575
read_timeout = read_timeout / 1000.0
549576
connect_timeout = connect_timeout / 1000.0
550577
retry.reset()
551-
self._xid = 0
552578
self.ping_outstanding.clear()
553579
with self._socket_error_handling():
554580
while not close_connection:
@@ -660,13 +686,53 @@ def _connect(self, host, port):
660686
client._session_callback(KeeperState.CONNECTED_RO)
661687
self._ro_mode = iter(self._server_pinger())
662688
else:
663-
client._session_callback(KeeperState.CONNECTED)
664689
self._ro_mode = None
665-
666-
for scheme, auth in client.auth_data:
667-
ap = Auth(0, scheme, auth)
668-
zxid = self._invoke(connect_timeout / 1000.0, ap, xid=AUTH_XID)
669-
if zxid:
670-
client.last_zxid = zxid
690+
if client.use_sasl and self.sasl_cli is None:
691+
if PURESASL_AVAILABLE:
692+
for scheme, auth in client.auth_data:
693+
if scheme == 'sasl':
694+
username, password = auth.split(":")
695+
self.sasl_cli = SASLClient(
696+
host=client.sasl_server_principal,
697+
service='zookeeper',
698+
mechanism='DIGEST-MD5',
699+
username=username,
700+
password=password
701+
)
702+
break
703+
704+
# As described in rfc
705+
# https://tools.ietf.org/html/rfc2831#section-2.1
706+
# sending empty challenge
707+
self._send_sasl_request(challenge=b'',
708+
timeout=connect_timeout)
709+
else:
710+
self.logger.warn('Pure-sasl library is missing while sasl'
711+
' authentification is configured. Please'
712+
' install pure-sasl library to connect '
713+
'using sasl. Now falling back '
714+
'connecting WITHOUT any '
715+
'authentification.')
716+
client.use_sasl = False
717+
client._session_callback(KeeperState.CONNECTED)
718+
else:
719+
client._session_callback(KeeperState.CONNECTED)
720+
for scheme, auth in client.auth_data:
721+
if scheme == "digest":
722+
ap = Auth(0, scheme, auth)
723+
zxid = self._invoke(
724+
connect_timeout / 1000.0,
725+
ap,
726+
xid=AUTH_XID
727+
)
728+
if zxid:
729+
client.last_zxid = zxid
671730

672731
return read_timeout, connect_timeout
732+
733+
def _send_sasl_request(self, challenge, timeout):
734+
""" Called when sending a SASL request, xid needs be to incremented """
735+
sasl_request = SASL(challenge)
736+
self._xid = (self._xid % 2147483647) + 1
737+
xid = self._xid
738+
self._submit(sasl_request, timeout / 1000.0, xid)

kazoo/protocol/serialization.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,19 @@ def serialize(self):
378378
write_string(self.auth))
379379

380380

381+
class SASL(namedtuple('SASL', 'challenge')):
382+
type = 102
383+
384+
def serialize(self):
385+
b = bytearray()
386+
b.extend(write_buffer(self.challenge))
387+
return b
388+
389+
@classmethod
390+
def deserialize(cls, bytes, offset):
391+
challenge, offset = read_buffer(bytes, offset)
392+
return challenge, offset
393+
381394
class Watch(namedtuple('Watch', 'type state path')):
382395
@classmethod
383396
def deserialize(cls, bytes, offset):

kazoo/testing/common.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def run(self):
9696
if self.running:
9797
return
9898
config_path = os.path.join(self.working_path, "zoo.cfg")
99+
jass_config_path = os.path.join(self.working_path, "jaas.conf")
99100
log_path = os.path.join(self.working_path, "log")
100101
log4j_path = os.path.join(self.working_path, "log4j.properties")
101102
data_path = os.path.join(self.working_path, "data")
@@ -115,6 +116,7 @@ def run(self):
115116
clientPort=%s
116117
maxClientCnxns=0
117118
admin.serverPort=%s
119+
authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider
118120
""" % (to_java_compatible_path(data_path),
119121
self.server_info.client_port,
120122
self.server_info.admin_port)) # NOQA
@@ -137,6 +139,14 @@ def run(self):
137139
# Write server ids into datadir
138140
with open(os.path.join(data_path, "myid"), "w") as myid_file:
139141
myid_file.write(str(self.server_info.server_id))
142+
# Write JAAS configuration
143+
with open(jass_config_path, "w") as jaas_file:
144+
jaas_file.write("""
145+
Server {
146+
org.apache.zookeeper.server.auth.DigestLoginModule required
147+
user_super="super_secret"
148+
user_jaasuser="jaas_password";
149+
};""")
140150

141151
with open(log4j_path, "w") as log4j:
142152
log4j.write("""
@@ -163,6 +173,9 @@ def run(self):
163173
# and from activation of the main workspace on run.
164174
"-Djava.awt.headless=true",
165175

176+
# JAAS configuration for SASL authentication
177+
"-Djava.security.auth.login.config=%s" % jass_config_path,
178+
166179
"org.apache.zookeeper.server.quorum.QuorumPeerMain",
167180
config_path,
168181
]

kazoo/tests/test_client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,34 @@ def test_connect_auth(self):
179179
client.stop()
180180
client.close()
181181

182+
def test_connect_sasl_auth(self):
183+
from kazoo.security import make_acl
184+
185+
if TRAVIS_ZK_VERSION:
186+
version = TRAVIS_ZK_VERSION
187+
else:
188+
version = self.client.server_version()
189+
if not version or version < (3, 4):
190+
raise SkipTest("Must use Zookeeper 3.4 or above")
191+
192+
username = "jaasuser"
193+
password = "jaas_password"
194+
sasl_auth = "%s:%s" % (username, password)
195+
196+
acl = make_acl('sasl', credential=username, all=True)
197+
198+
client = self._get_client(auth_data=[('sasl', sasl_auth)])
199+
client.start()
200+
try:
201+
client.create('/1', acl=(acl,))
202+
# give ZK a chance to copy data to other node
203+
time.sleep(0.1)
204+
self.assertRaises(NoAuthError, self.client.get, "/1")
205+
finally:
206+
client.delete('/1')
207+
client.stop()
208+
client.close()
209+
182210
def test_unicode_auth(self):
183211
username = u("xe4/\hm")
184212
password = u("/\xe4hm")
@@ -217,6 +245,16 @@ def test_invalid_auth(self):
217245
self.assertRaises(TypeError, client.add_auth,
218246
None, ('user', 'pass'))
219247

248+
def test_invalid_sasl_auth(self):
249+
if TRAVIS_ZK_VERSION:
250+
version = TRAVIS_ZK_VERSION
251+
else:
252+
version = self.client.server_version()
253+
if not version or version < (3, 4):
254+
raise SkipTest("Must use Zookeeper 3.4 or above")
255+
client = self._get_client(auth_data=[('sasl', 'baduser:badpassword')])
256+
self.assertRaises(ConnectionLoss, client.start)
257+
220258
def test_async_auth(self):
221259
client = self._get_client()
222260
client.start()

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
coverage==3.7.1
22
mock==1.0.1
33
nose==1.3.3
4+
pure-sasl==0.5.1
45
flake8==2.3.0

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
'mock',
2525
'nose',
2626
'flake8',
27+
'pure-sasl',
2728
]
2829

2930
if not (PYTHON3 or PYPY):

0 commit comments

Comments
 (0)