Skip to content

Commit a6da8ef

Browse files
authored
Adds credentials and custom authentication support (#446)
Added credentials and custom authentication support
1 parent 4753b54 commit a6da8ef

16 files changed

+453
-20
lines changed

docs/securing_client_connection.rst

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ Securing Client Connection
33

44
This chapter describes the security features of Hazelcast Python client.
55
These include using TLS/SSL for connections between members and between
6-
clients and members, and mutual authentication. These security features
7-
require **Hazelcast IMDG Enterprise** edition.
6+
clients and members, mutual authentication, username/password authentication
7+
and token authentication. These security features require
8+
**Hazelcast IMDG Enterprise** edition.
89

910
TLS/SSL
1011
-------
@@ -258,3 +259,64 @@ On the client side, you have to provide ``ssl_cafile``, ``ssl_certfile``
258259
and ``ssl_keyfile`` on top of the other TLS/SSL configurations. See the
259260
:ref:`securing_client_connection:tls/ssl for hazelcast python clients`
260261
section for the details of these options.
262+
263+
Username/Password Authentication
264+
================================
265+
266+
You can protect your cluster using a username and password pair.
267+
In order to use it, enable it in member configuration:
268+
269+
.. code:: xml
270+
271+
<security enabled="true">
272+
<member-authentication realm="passwordRealm"/>
273+
<realms>
274+
<realm name="passwordRealm">
275+
<identity>
276+
<username-password username="MY-USERNAME" password="MY-PASSWORD" />
277+
</identity>
278+
</realm>
279+
</realms>
280+
</security>
281+
282+
Then, on the client-side, set ``creds_username`` and ``creds_password`` in the configuration:
283+
284+
.. code:: python
285+
286+
client = hazelcast.HazelcastClient(
287+
creds_username="MY-USERNAME",
288+
creds_password="MY-PASSWORD"
289+
)
290+
291+
Check out the documentation on `Password Credentials
292+
<https://docs.hazelcast.com/imdg/latest/security/security-realms.html#password-credentials>`__
293+
of the Hazelcast Documentation.
294+
295+
Token-Based Authentication
296+
==========================
297+
298+
Python client supports token-based authentication via token providers.
299+
A token provider is a class derived from :class:`hazelcast.security.TokenProvider`.
300+
301+
In order to use token based authentication, first define in the member configuration:
302+
303+
.. code:: xml
304+
305+
<security enabled="true">
306+
<member-authentication realm="tokenRealm"/>
307+
<realms>
308+
<realm name="tokenRealm">
309+
<identity>
310+
<token>MY-SECRET</token>
311+
</identity>
312+
</realm>
313+
</realms>
314+
</security>
315+
316+
Using :class:`hazelcast.security.BasicTokenProvider` you can pass the given token the member:
317+
318+
.. code:: python
319+
token_provider = BasicTokenProvider("MY-SECRET")
320+
client = hazelcast.HazelcastClient(
321+
token_provider=token_provider
322+
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import hazelcast
2+
from hazelcast.security import BasicTokenProvider
3+
4+
# Use the following configuration in the member-side.
5+
#
6+
# <security enabled="true">
7+
# <client-permissions>
8+
# <map-permission name="auth-map" principal="*">
9+
# <actions>
10+
# <action>create</action>
11+
# <action>destroy</action>
12+
# <action>put</action>
13+
# <action>read</action>
14+
# </actions>
15+
# </map-permission>
16+
# </client-permissions>
17+
# <member-authentication realm="tokenRealm"/>
18+
# <realms>
19+
# <realm name="tokenRealm">
20+
# <identity>
21+
# <token>s3crEt</token>
22+
# </identity>
23+
# </realm>
24+
# </realms>
25+
# </security>
26+
27+
# Start a new Hazelcast client with the given token provider.
28+
token_provider = BasicTokenProvider("s3crEt")
29+
client = hazelcast.HazelcastClient(token_provider=token_provider)
30+
31+
hz_map = client.get_map("auth-map").blocking()
32+
hz_map.put("key", "value")
33+
34+
print(hz_map.get("key"))
35+
36+
client.shutdown()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import hazelcast
2+
3+
# Use the following configuration in the member-side.
4+
#
5+
# <security enabled="true">
6+
# <client-permissions>
7+
# <map-permission name="auth-map" principal="*">
8+
# <actions>
9+
# <action>create</action>
10+
# <action>destroy</action>
11+
# <action>put</action>
12+
# <action>read</action>
13+
# </actions>
14+
# </map-permission>
15+
# </client-permissions>
16+
# <member-authentication realm="passwordRealm"/>
17+
# <realms>
18+
# <realm name="passwordRealm">
19+
# <identity>
20+
# <username-password username="member1" password="s3crEt" />
21+
# </identity>
22+
# </realm>
23+
# </realms>
24+
# </security>
25+
26+
# Start a new Hazelcast client with the given credentials.
27+
client = hazelcast.HazelcastClient(creds_username="member1", creds_password="s3crEt")
28+
29+
hz_map = client.get_map("auth-map").blocking()
30+
hz_map.put("key", "value")
31+
32+
print(hz_map.get("key"))
33+
34+
client.shutdown()

hazelcast/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,10 @@ class SomeClassSerializer(StreamSerializer):
327327
:class:`hazelcast.errors.IndeterminateOperationStateError`. However,
328328
even if the invocation fails, there will not be any rollback on other
329329
successful replicas. By default, set to ``False`` (do not fail).
330+
creds_username (str): Username for credentials authentication (Enterprise feature).
331+
creds_password (str): Password for credentials authentication (Enterprise feature).
332+
token_provider (hazelcast.token_provider.TokenProvider): Token provider for custom authentication (Enterprise feature).
333+
Note that token_provider setting has priority over credentials settings.
330334
"""
331335

332336
_CLIENT_ID = AtomicInteger()

hazelcast/config.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import re
2+
import types
23

34
from hazelcast import six
45
from hazelcast.errors import InvalidConfigurationError
56
from hazelcast.serialization.api import StreamSerializer, IdentifiedDataSerializable, Portable
67
from hazelcast.serialization.portable.classdef import ClassDefinition
8+
from hazelcast.security import TokenProvider
79
from hazelcast.util import (
810
check_not_none,
911
number_types,
@@ -571,6 +573,9 @@ class _Config(object):
571573
"_backup_ack_to_client_enabled",
572574
"_operation_backup_timeout",
573575
"_fail_on_indeterminate_operation_state",
576+
"_creds_username",
577+
"_creds_password",
578+
"_token_provider",
574579
)
575580

576581
def __init__(self):
@@ -622,6 +627,9 @@ def __init__(self):
622627
self._backup_ack_to_client_enabled = True
623628
self._operation_backup_timeout = _DEFAULT_OPERATION_BACKUP_TIMEOUT
624629
self._fail_on_indeterminate_operation_state = False
630+
self._creds_username = None
631+
self._creds_password = None
632+
self._token_provider = None
625633

626634
@property
627635
def cluster_members(self):
@@ -1290,6 +1298,43 @@ def fail_on_indeterminate_operation_state(self, value):
12901298
else:
12911299
raise TypeError("fail_on_indeterminate_operation_state must be a boolean")
12921300

1301+
@property
1302+
def creds_username(self):
1303+
# type: (_Config) -> str
1304+
return self._creds_username
1305+
1306+
@creds_username.setter
1307+
def creds_username(self, username):
1308+
# type: (_Config, str) -> None
1309+
if not isinstance(username, six.string_types):
1310+
raise TypeError("creds_password must be a string")
1311+
self._creds_username = username
1312+
1313+
@property
1314+
def creds_password(self):
1315+
# type: (_Config) -> str
1316+
return self._creds_password
1317+
1318+
@creds_password.setter
1319+
def creds_password(self, password):
1320+
# type: (_Config, str) -> None
1321+
if not isinstance(password, six.string_types):
1322+
raise TypeError("creds_password must be a string")
1323+
self._creds_password = password
1324+
1325+
@property
1326+
def token_provider(self):
1327+
# type: (_Config) -> TokenProvider
1328+
return self._token_provider
1329+
1330+
@token_provider.setter
1331+
def token_provider(self, token_provider):
1332+
# type: (_Config, TokenProvider) -> None
1333+
token_fun = getattr(token_provider, "token", None)
1334+
if token_fun is None or not isinstance(token_fun, types.MethodType):
1335+
raise TypeError("token_provider must be an object with a token method")
1336+
self._token_provider = token_provider
1337+
12931338
@classmethod
12941339
def from_dict(cls, d):
12951340
config = cls()

hazelcast/connection.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
InboundMessage,
2828
ClientMessageBuilder,
2929
)
30-
from hazelcast.protocol.codec import client_authentication_codec, client_ping_codec
30+
from hazelcast.protocol.codec import (
31+
client_authentication_codec,
32+
client_authentication_custom_codec,
33+
client_ping_codec,
34+
)
3135
from hazelcast.util import AtomicInteger, calculate_version, UNKNOWN_VERSION
3236

3337
_logger = logging.getLogger(__name__)
@@ -486,18 +490,29 @@ def _authenticate(self, connection):
486490
client = self._client
487491
cluster_name = self._config.cluster_name
488492
client_name = client.name
489-
request = client_authentication_codec.encode_request(
490-
cluster_name,
491-
None,
492-
None,
493-
self.client_uuid,
494-
CLIENT_TYPE,
495-
SERIALIZATION_VERSION,
496-
__version__,
497-
client_name,
498-
self._labels,
499-
)
500-
493+
if self._config.token_provider:
494+
request = client_authentication_custom_codec.encode_request(
495+
cluster_name,
496+
self._config.token_provider.token(),
497+
self.client_uuid,
498+
CLIENT_TYPE,
499+
SERIALIZATION_VERSION,
500+
__version__,
501+
client_name,
502+
self._labels,
503+
)
504+
else:
505+
request = client_authentication_codec.encode_request(
506+
cluster_name,
507+
self._config.creds_username,
508+
self._config.creds_password,
509+
self.client_uuid,
510+
CLIENT_TYPE,
511+
SERIALIZATION_VERSION,
512+
__version__,
513+
client_name,
514+
self._labels,
515+
)
501516
invocation = Invocation(
502517
request, connection=connection, urgent=True, response_handler=lambda m: m
503518
)
@@ -516,10 +531,7 @@ def _on_auth(self, response, connection):
516531
return self._handle_successful_auth(response, connection)
517532

518533
if status == _AuthenticationStatus.CREDENTIALS_FAILED:
519-
err = AuthenticationError(
520-
"Authentication failed. The configured cluster name on "
521-
"the client does not match the one configured in the cluster."
522-
)
534+
err = AuthenticationError("Authentication failed. Check cluster name and credentials.")
523535
elif status == _AuthenticationStatus.NOT_ALLOWED_IN_CLUSTER:
524536
err = ClientNotAllowedInClusterError("Client is not allowed in the cluster")
525537
elif status == _AuthenticationStatus.SERIALIZATION_VERSION_MISMATCH:
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from hazelcast.serialization.bits import *
2+
from hazelcast.protocol.builtin import FixSizedTypesCodec
3+
from hazelcast.protocol.client_message import OutboundMessage, REQUEST_HEADER_SIZE, create_initial_buffer, RESPONSE_HEADER_SIZE
4+
from hazelcast.protocol.builtin import StringCodec
5+
from hazelcast.protocol.builtin import ByteArrayCodec
6+
from hazelcast.protocol.builtin import ListMultiFrameCodec
7+
from hazelcast.protocol.codec.custom.address_codec import AddressCodec
8+
from hazelcast.protocol.builtin import CodecUtil
9+
10+
# hex: 0x000200
11+
_REQUEST_MESSAGE_TYPE = 512
12+
# hex: 0x000201
13+
_RESPONSE_MESSAGE_TYPE = 513
14+
15+
_REQUEST_UUID_OFFSET = REQUEST_HEADER_SIZE
16+
_REQUEST_SERIALIZATION_VERSION_OFFSET = _REQUEST_UUID_OFFSET + UUID_SIZE_IN_BYTES
17+
_REQUEST_INITIAL_FRAME_SIZE = _REQUEST_SERIALIZATION_VERSION_OFFSET + BYTE_SIZE_IN_BYTES
18+
_RESPONSE_STATUS_OFFSET = RESPONSE_HEADER_SIZE
19+
_RESPONSE_MEMBER_UUID_OFFSET = _RESPONSE_STATUS_OFFSET + BYTE_SIZE_IN_BYTES
20+
_RESPONSE_SERIALIZATION_VERSION_OFFSET = _RESPONSE_MEMBER_UUID_OFFSET + UUID_SIZE_IN_BYTES
21+
_RESPONSE_PARTITION_COUNT_OFFSET = _RESPONSE_SERIALIZATION_VERSION_OFFSET + BYTE_SIZE_IN_BYTES
22+
_RESPONSE_CLUSTER_ID_OFFSET = _RESPONSE_PARTITION_COUNT_OFFSET + INT_SIZE_IN_BYTES
23+
_RESPONSE_FAILOVER_SUPPORTED_OFFSET = _RESPONSE_CLUSTER_ID_OFFSET + UUID_SIZE_IN_BYTES
24+
25+
26+
def encode_request(cluster_name, credentials, uuid, client_type, serialization_version, client_hazelcast_version, client_name, labels):
27+
buf = create_initial_buffer(_REQUEST_INITIAL_FRAME_SIZE, _REQUEST_MESSAGE_TYPE)
28+
FixSizedTypesCodec.encode_uuid(buf, _REQUEST_UUID_OFFSET, uuid)
29+
FixSizedTypesCodec.encode_byte(buf, _REQUEST_SERIALIZATION_VERSION_OFFSET, serialization_version)
30+
StringCodec.encode(buf, cluster_name)
31+
ByteArrayCodec.encode(buf, credentials)
32+
StringCodec.encode(buf, client_type)
33+
StringCodec.encode(buf, client_hazelcast_version)
34+
StringCodec.encode(buf, client_name)
35+
ListMultiFrameCodec.encode(buf, labels, StringCodec.encode, True)
36+
return OutboundMessage(buf, True)
37+
38+
39+
def decode_response(msg):
40+
initial_frame = msg.next_frame()
41+
response = dict()
42+
response["status"] = FixSizedTypesCodec.decode_byte(initial_frame.buf, _RESPONSE_STATUS_OFFSET)
43+
response["member_uuid"] = FixSizedTypesCodec.decode_uuid(initial_frame.buf, _RESPONSE_MEMBER_UUID_OFFSET)
44+
response["serialization_version"] = FixSizedTypesCodec.decode_byte(initial_frame.buf, _RESPONSE_SERIALIZATION_VERSION_OFFSET)
45+
response["partition_count"] = FixSizedTypesCodec.decode_int(initial_frame.buf, _RESPONSE_PARTITION_COUNT_OFFSET)
46+
response["cluster_id"] = FixSizedTypesCodec.decode_uuid(initial_frame.buf, _RESPONSE_CLUSTER_ID_OFFSET)
47+
response["failover_supported"] = FixSizedTypesCodec.decode_boolean(initial_frame.buf, _RESPONSE_FAILOVER_SUPPORTED_OFFSET)
48+
response["address"] = CodecUtil.decode_nullable(msg, AddressCodec.decode)
49+
response["server_hazelcast_version"] = StringCodec.decode(msg)
50+
return response

hazelcast/security/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .token_provider import BasicTokenProvider, TokenProvider
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from hazelcast.six import string_types
2+
3+
4+
class TokenProvider(object):
5+
"""TokenProvider is a base class for token providers."""
6+
7+
def token(self):
8+
# type: (TokenProvider) -> bytes
9+
"""Returns a token to be used for token-based authentication.
10+
11+
Returns:
12+
bytes: token as a bytes object.
13+
"""
14+
pass
15+
16+
17+
class BasicTokenProvider(TokenProvider):
18+
"""BasicTokenProvider sends the given token to the authentication endpoint."""
19+
20+
def __init__(self, token=""):
21+
if isinstance(token, string_types):
22+
self._token = token.encode("utf-8")
23+
elif isinstance(token, bytes):
24+
self._token = token
25+
else:
26+
raise TypeError("token must be either a str or bytes object")
27+
28+
def token(self):
29+
# type: (BasicTokenProvider) -> bytes
30+
"""Returns a token to be used for token-based authentication.
31+
32+
Returns:
33+
bytes: token as a bytes object.
34+
"""
35+
return self._token

requirements-test.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
thrift==0.13.0
22
nose==1.3.7
3-
coverage==4.5.1
3+
coverage==4.5.4
44
psutil>=5.8.0
55
mock==3.0.5
66
parameterized==0.7.4

0 commit comments

Comments
 (0)