Skip to content

Commit

Permalink
Adds credentials and custom authentication support (#446)
Browse files Browse the repository at this point in the history
Added credentials and custom authentication support
  • Loading branch information
yuce committed Aug 18, 2021
1 parent 4753b54 commit a6da8ef
Show file tree
Hide file tree
Showing 16 changed files with 453 additions and 20 deletions.
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.

0 comments on commit a6da8ef

Please sign in to comment.