Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions docs/securing_client_connection.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ Securing Client Connection

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

TLS/SSL
-------
Expand Down Expand Up @@ -258,3 +259,64 @@ On the client side, you have to provide ``ssl_cafile``, ``ssl_certfile``
and ``ssl_keyfile`` on top of the other TLS/SSL configurations. See the
:ref:`securing_client_connection:tls/ssl for hazelcast python clients`
section for the details of these options.

Username/Password Authentication
================================

You can protect your cluster using a username and password pair.
In order to use it, enable it in member configuration:

.. code:: xml

<security enabled="true">
<member-authentication realm="passwordRealm"/>
<realms>
<realm name="passwordRealm">
<identity>
<username-password username="MY-USERNAME" password="MY-PASSWORD" />
</identity>
</realm>
</realms>
</security>

Then, on the client-side, set ``creds_username`` and ``creds_password`` in the configuration:

.. code:: python

client = hazelcast.HazelcastClient(
creds_username="MY-USERNAME",
creds_password="MY-PASSWORD"
)

Check out the documentation on `Password Credentials
<https://docs.hazelcast.com/imdg/latest/security/security-realms.html#password-credentials>`__
of the Hazelcast Documentation.

Token-Based Authentication
==========================

Python client supports token-based authentication via token providers.
A token provider is a class derived from :class:`hazelcast.security.TokenProvider`.

In order to use token based authentication, first define in the member configuration:

.. code:: xml

<security enabled="true">
<member-authentication realm="tokenRealm"/>
<realms>
<realm name="tokenRealm">
<identity>
<token>MY-SECRET</token>
</identity>
</realm>
</realms>
</security>

Using :class:`hazelcast.security.BasicTokenProvider` you can pass the given token the member:

.. code:: python
token_provider = BasicTokenProvider("MY-SECRET")
client = hazelcast.HazelcastClient(
token_provider=token_provider
)
36 changes: 36 additions & 0 deletions examples/security/token_authentication_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import hazelcast
from hazelcast.security import BasicTokenProvider

# Use the following configuration in the member-side.
#
# <security enabled="true">
# <client-permissions>
# <map-permission name="auth-map" principal="*">
# <actions>
# <action>create</action>
# <action>destroy</action>
# <action>put</action>
# <action>read</action>
# </actions>
# </map-permission>
# </client-permissions>
# <member-authentication realm="tokenRealm"/>
# <realms>
# <realm name="tokenRealm">
# <identity>
# <token>s3crEt</token>
# </identity>
# </realm>
# </realms>
# </security>

# Start a new Hazelcast client with the given token provider.
token_provider = BasicTokenProvider("s3crEt")
client = hazelcast.HazelcastClient(token_provider=token_provider)

hz_map = client.get_map("auth-map").blocking()
hz_map.put("key", "value")

print(hz_map.get("key"))

client.shutdown()
34 changes: 34 additions & 0 deletions examples/security/username_password_authentication_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import hazelcast

# Use the following configuration in the member-side.
#
# <security enabled="true">
# <client-permissions>
# <map-permission name="auth-map" principal="*">
# <actions>
# <action>create</action>
# <action>destroy</action>
# <action>put</action>
# <action>read</action>
# </actions>
# </map-permission>
# </client-permissions>
# <member-authentication realm="passwordRealm"/>
# <realms>
# <realm name="passwordRealm">
# <identity>
# <username-password username="member1" password="s3crEt" />
# </identity>
# </realm>
# </realms>
# </security>

# Start a new Hazelcast client with the given credentials.
client = hazelcast.HazelcastClient(creds_username="member1", creds_password="s3crEt")

hz_map = client.get_map("auth-map").blocking()
hz_map.put("key", "value")

print(hz_map.get("key"))

client.shutdown()
4 changes: 4 additions & 0 deletions hazelcast/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ class SomeClassSerializer(StreamSerializer):
:class:`hazelcast.errors.IndeterminateOperationStateError`. However,
even if the invocation fails, there will not be any rollback on other
successful replicas. By default, set to ``False`` (do not fail).
creds_username (str): Username for credentials authentication (Enterprise feature).
creds_password (str): Password for credentials authentication (Enterprise feature).
token_provider (hazelcast.token_provider.TokenProvider): Token provider for custom authentication (Enterprise feature).
Note that token_provider setting has priority over credentials settings.
"""

_CLIENT_ID = AtomicInteger()
Expand Down
45 changes: 45 additions & 0 deletions hazelcast/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import re
import types

from hazelcast import six
from hazelcast.errors import InvalidConfigurationError
from hazelcast.serialization.api import StreamSerializer, IdentifiedDataSerializable, Portable
from hazelcast.serialization.portable.classdef import ClassDefinition
from hazelcast.security import TokenProvider
from hazelcast.util import (
check_not_none,
number_types,
Expand Down Expand Up @@ -571,6 +573,9 @@ class _Config(object):
"_backup_ack_to_client_enabled",
"_operation_backup_timeout",
"_fail_on_indeterminate_operation_state",
"_creds_username",
"_creds_password",
"_token_provider",
)

def __init__(self):
Expand Down Expand Up @@ -622,6 +627,9 @@ def __init__(self):
self._backup_ack_to_client_enabled = True
self._operation_backup_timeout = _DEFAULT_OPERATION_BACKUP_TIMEOUT
self._fail_on_indeterminate_operation_state = False
self._creds_username = None
self._creds_password = None
self._token_provider = None

@property
def cluster_members(self):
Expand Down Expand Up @@ -1290,6 +1298,43 @@ def fail_on_indeterminate_operation_state(self, value):
else:
raise TypeError("fail_on_indeterminate_operation_state must be a boolean")

@property
def creds_username(self):
# type: (_Config) -> str
return self._creds_username

@creds_username.setter
def creds_username(self, username):
# type: (_Config, str) -> None
if not isinstance(username, six.string_types):
raise TypeError("creds_password must be a string")
self._creds_username = username

@property
def creds_password(self):
# type: (_Config) -> str
return self._creds_password

@creds_password.setter
def creds_password(self, password):
# type: (_Config, str) -> None
if not isinstance(password, six.string_types):
raise TypeError("creds_password must be a string")
self._creds_password = password

@property
def token_provider(self):
# type: (_Config) -> TokenProvider
return self._token_provider

@token_provider.setter
def token_provider(self, token_provider):
# type: (_Config, TokenProvider) -> None
token_fun = getattr(token_provider, "token", None)
if token_fun is None or not isinstance(token_fun, types.MethodType):
raise TypeError("token_provider must be an object with a token method")
self._token_provider = token_provider

@classmethod
def from_dict(cls, d):
config = cls()
Expand Down
46 changes: 29 additions & 17 deletions hazelcast/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
InboundMessage,
ClientMessageBuilder,
)
from hazelcast.protocol.codec import client_authentication_codec, client_ping_codec
from hazelcast.protocol.codec import (
client_authentication_codec,
client_authentication_custom_codec,
client_ping_codec,
)
from hazelcast.util import AtomicInteger, calculate_version, UNKNOWN_VERSION

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -486,18 +490,29 @@ def _authenticate(self, connection):
client = self._client
cluster_name = self._config.cluster_name
client_name = client.name
request = client_authentication_codec.encode_request(
cluster_name,
None,
None,
self.client_uuid,
CLIENT_TYPE,
SERIALIZATION_VERSION,
__version__,
client_name,
self._labels,
)

if self._config.token_provider:
request = client_authentication_custom_codec.encode_request(
cluster_name,
self._config.token_provider.token(),
self.client_uuid,
CLIENT_TYPE,
SERIALIZATION_VERSION,
__version__,
client_name,
self._labels,
)
else:
request = client_authentication_codec.encode_request(
cluster_name,
self._config.creds_username,
self._config.creds_password,
self.client_uuid,
CLIENT_TYPE,
SERIALIZATION_VERSION,
__version__,
client_name,
self._labels,
)
invocation = Invocation(
request, connection=connection, urgent=True, response_handler=lambda m: m
)
Expand All @@ -516,10 +531,7 @@ def _on_auth(self, response, connection):
return self._handle_successful_auth(response, connection)

if status == _AuthenticationStatus.CREDENTIALS_FAILED:
err = AuthenticationError(
"Authentication failed. The configured cluster name on "
"the client does not match the one configured in the cluster."
)
err = AuthenticationError("Authentication failed. Check cluster name and credentials.")
elif status == _AuthenticationStatus.NOT_ALLOWED_IN_CLUSTER:
err = ClientNotAllowedInClusterError("Client is not allowed in the cluster")
elif status == _AuthenticationStatus.SERIALIZATION_VERSION_MISMATCH:
Expand Down
50 changes: 50 additions & 0 deletions hazelcast/protocol/codec/client_authentication_custom_codec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from hazelcast.serialization.bits import *
from hazelcast.protocol.builtin import FixSizedTypesCodec
from hazelcast.protocol.client_message import OutboundMessage, REQUEST_HEADER_SIZE, create_initial_buffer, RESPONSE_HEADER_SIZE
from hazelcast.protocol.builtin import StringCodec
from hazelcast.protocol.builtin import ByteArrayCodec
from hazelcast.protocol.builtin import ListMultiFrameCodec
from hazelcast.protocol.codec.custom.address_codec import AddressCodec
from hazelcast.protocol.builtin import CodecUtil

# hex: 0x000200
_REQUEST_MESSAGE_TYPE = 512
# hex: 0x000201
_RESPONSE_MESSAGE_TYPE = 513

_REQUEST_UUID_OFFSET = REQUEST_HEADER_SIZE
_REQUEST_SERIALIZATION_VERSION_OFFSET = _REQUEST_UUID_OFFSET + UUID_SIZE_IN_BYTES
_REQUEST_INITIAL_FRAME_SIZE = _REQUEST_SERIALIZATION_VERSION_OFFSET + BYTE_SIZE_IN_BYTES
_RESPONSE_STATUS_OFFSET = RESPONSE_HEADER_SIZE
_RESPONSE_MEMBER_UUID_OFFSET = _RESPONSE_STATUS_OFFSET + BYTE_SIZE_IN_BYTES
_RESPONSE_SERIALIZATION_VERSION_OFFSET = _RESPONSE_MEMBER_UUID_OFFSET + UUID_SIZE_IN_BYTES
_RESPONSE_PARTITION_COUNT_OFFSET = _RESPONSE_SERIALIZATION_VERSION_OFFSET + BYTE_SIZE_IN_BYTES
_RESPONSE_CLUSTER_ID_OFFSET = _RESPONSE_PARTITION_COUNT_OFFSET + INT_SIZE_IN_BYTES
_RESPONSE_FAILOVER_SUPPORTED_OFFSET = _RESPONSE_CLUSTER_ID_OFFSET + UUID_SIZE_IN_BYTES


def encode_request(cluster_name, credentials, uuid, client_type, serialization_version, client_hazelcast_version, client_name, labels):
buf = create_initial_buffer(_REQUEST_INITIAL_FRAME_SIZE, _REQUEST_MESSAGE_TYPE)
FixSizedTypesCodec.encode_uuid(buf, _REQUEST_UUID_OFFSET, uuid)
FixSizedTypesCodec.encode_byte(buf, _REQUEST_SERIALIZATION_VERSION_OFFSET, serialization_version)
StringCodec.encode(buf, cluster_name)
ByteArrayCodec.encode(buf, credentials)
StringCodec.encode(buf, client_type)
StringCodec.encode(buf, client_hazelcast_version)
StringCodec.encode(buf, client_name)
ListMultiFrameCodec.encode(buf, labels, StringCodec.encode, True)
return OutboundMessage(buf, True)


def decode_response(msg):
initial_frame = msg.next_frame()
response = dict()
response["status"] = FixSizedTypesCodec.decode_byte(initial_frame.buf, _RESPONSE_STATUS_OFFSET)
response["member_uuid"] = FixSizedTypesCodec.decode_uuid(initial_frame.buf, _RESPONSE_MEMBER_UUID_OFFSET)
response["serialization_version"] = FixSizedTypesCodec.decode_byte(initial_frame.buf, _RESPONSE_SERIALIZATION_VERSION_OFFSET)
response["partition_count"] = FixSizedTypesCodec.decode_int(initial_frame.buf, _RESPONSE_PARTITION_COUNT_OFFSET)
response["cluster_id"] = FixSizedTypesCodec.decode_uuid(initial_frame.buf, _RESPONSE_CLUSTER_ID_OFFSET)
response["failover_supported"] = FixSizedTypesCodec.decode_boolean(initial_frame.buf, _RESPONSE_FAILOVER_SUPPORTED_OFFSET)
response["address"] = CodecUtil.decode_nullable(msg, AddressCodec.decode)
response["server_hazelcast_version"] = StringCodec.decode(msg)
return response
1 change: 1 addition & 0 deletions hazelcast/security/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .token_provider import BasicTokenProvider, TokenProvider
35 changes: 35 additions & 0 deletions hazelcast/security/token_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from hazelcast.six import string_types


class TokenProvider(object):
"""TokenProvider is a base class for token providers."""

def token(self):
# type: (TokenProvider) -> bytes
"""Returns a token to be used for token-based authentication.

Returns:
bytes: token as a bytes object.
"""
pass


class BasicTokenProvider(TokenProvider):
"""BasicTokenProvider sends the given token to the authentication endpoint."""

def __init__(self, token=""):
if isinstance(token, string_types):
self._token = token.encode("utf-8")
elif isinstance(token, bytes):
self._token = token
else:
raise TypeError("token must be either a str or bytes object")

def token(self):
# type: (BasicTokenProvider) -> bytes
"""Returns a token to be used for token-based authentication.

Returns:
bytes: token as a bytes object.
"""
return self._token
2 changes: 1 addition & 1 deletion requirements-test.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
thrift==0.13.0
nose==1.3.7
coverage==4.5.1
coverage==4.5.4
psutil>=5.8.0
mock==3.0.5
parameterized==0.7.4
Empty file.
Loading