Skip to content

api: support authentication methods #273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 26, 2022
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support [crud module](https://github.com/tarantool/crud) native API (#205).
- Support `ssl_password` and `ssl_password_file` options
to decrypt private SSL key file (#224).
- Support specifying authentication method with `auth_type`
and Tarantool EE `pap-sha256` authentication method (#269).

### Changed

Expand Down
58 changes: 54 additions & 4 deletions tarantool/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@
IPROTO_FEATURE_TRANSACTIONS,
IPROTO_FEATURE_ERROR_EXTENSION,
IPROTO_FEATURE_WATCHERS,
IPROTO_AUTH_TYPE,
IPROTO_CHUNK,
AUTH_TYPE_CHAP_SHA1,
AUTH_TYPE_PAP_SHA256,
AUTH_TYPES,
)
from tarantool.error import (
Error,
Expand Down Expand Up @@ -574,7 +578,8 @@ def __init__(self, host, port,
ssl_password=DEFAULT_SSL_PASSWORD,
ssl_password_file=DEFAULT_SSL_PASSWORD_FILE,
packer_factory=default_packer_factory,
unpacker_factory=default_unpacker_factory):
unpacker_factory=default_unpacker_factory,
auth_type=None):
"""
:param host: Server hostname or IP address. Use ``None`` for
Unix sockets.
Expand Down Expand Up @@ -723,6 +728,14 @@ def __init__(self, host, port,
callable[[:obj:`~tarantool.Connection`], :obj:`~msgpack.Unpacker`],
optional

:param auth_type: Authentication method: ``"chap-sha1"`` (supported in
Tarantool CE and EE) or ``"pap-sha256"`` (supported in Tarantool EE,
``"ssl"`` :paramref:`~tarantool.Connection.transport` must be used).
If `None`, use authentication method provided by server in IPROTO_ID
exchange. If server does not provide an authentication method, use
``"chap-sha1"``.
:type auth_type: :obj:`None` or :obj:`str`, optional

:raise: :exc:`~tarantool.error.ConfigurationError`,
:meth:`~tarantool.Connection.connect` exceptions

Expand Down Expand Up @@ -778,6 +791,8 @@ def __init__(self, host, port,
}
self._packer_factory_impl = packer_factory
self._unpacker_factory_impl = unpacker_factory
self._client_auth_type = auth_type
self._server_auth_type = None

if connect_now:
self.connect()
Expand Down Expand Up @@ -985,6 +1000,7 @@ def handshake(self):
if greeting.protocol != "Binary":
raise NetworkError("Unsupported protocol: " + greeting.protocol)
self.version_id = greeting.version_id
self._check_features()
self.uuid = greeting.uuid
self._salt = greeting.salt
if self.user:
Expand All @@ -1008,7 +1024,6 @@ def connect(self):
self.wrap_socket_ssl()
self.handshake()
self.load_schema()
self._check_features()
except SslError as e:
raise e
except Exception as e:
Expand Down Expand Up @@ -1390,13 +1405,44 @@ def authenticate(self, user, password):
if not self._socket:
return self._opt_reconnect()

request = RequestAuthenticate(self, self._salt, self.user,
self.password)
request = RequestAuthenticate(self,
salt=self._salt,
user=self.user,
password=self.password,
auth_type=self._get_auth_type())
auth_response = self._send_request_wo_reconnect(request)
if auth_response.return_code == 0:
self.flush_schema()
return auth_response

def _get_auth_type(self):
"""
Get authentication method based on client and server settings.

:rtype: :obj:`str`

:raise: :exc:`~tarantool.error.DatabaseError`

:meta private:
"""

if self._client_auth_type is None:
if self._server_auth_type is None:
auth_type = AUTH_TYPE_CHAP_SHA1
else:
if self._server_auth_type not in AUTH_TYPES:
raise ConfigurationError(f'Unknown server authentication type {self._server_auth_type}')
auth_type = self._server_auth_type
else:
if self._client_auth_type not in AUTH_TYPES:
raise ConfigurationError(f'Unknown client authentication type {self._client_auth_type}')
auth_type = self._client_auth_type

if auth_type == AUTH_TYPE_PAP_SHA256 and self.transport != SSL_TRANSPORT:
raise ConfigurationError('Use PAP-SHA256 only with ssl transport')

return auth_type

def _join_v16(self, server_uuid):
"""
Execute a JOIN request for Tarantool 1.6 and older.
Expand Down Expand Up @@ -2037,11 +2083,13 @@ def _check_features(self):
response = self._send_request(request)
server_protocol_version = response.protocol_version
server_features = response.features
server_auth_type = response.auth_type
except DatabaseError as exc:
ER_UNKNOWN_REQUEST_TYPE = 48
if exc.code == ER_UNKNOWN_REQUEST_TYPE:
server_protocol_version = None
server_features = []
server_auth_type = None
else:
raise exc

Expand All @@ -2054,6 +2102,8 @@ def _check_features(self):
for val in features_list:
self._features[val] = True

self._server_auth_type = server_auth_type

def _packer_factory(self):
return self._packer_factory_impl(self)

Expand Down
4 changes: 3 additions & 1 deletion tarantool/connection_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ def __init__(self,
"ssl_ciphers": "str" # optional
"ssl_password": "str", # optional
"ssl_password_file": "str" # optional
"auth_type": "str" # optional
}

Refer to corresponding :class:`~tarantool.Connection`
Expand Down Expand Up @@ -498,7 +499,8 @@ def __init__(self,
ssl_ca_file=addr['ssl_ca_file'],
ssl_ciphers=addr['ssl_ciphers'],
ssl_password=addr['ssl_password'],
ssl_password_file=addr['ssl_password_file'])
ssl_password_file=addr['ssl_password_file'],
auth_type=addr['auth_type'])
)

if connect_now:
Expand Down
8 changes: 8 additions & 0 deletions tarantool/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
#
IPROTO_VERSION = 0x54
IPROTO_FEATURES = 0x55
IPROTO_AUTH_TYPE = 0x5b
IPROTO_CHUNK = 0x80

IPROTO_GREETING_SIZE = 128
Expand Down Expand Up @@ -134,3 +135,10 @@
CONNECTOR_IPROTO_VERSION = 3
# List of connector-supported features
CONNECTOR_FEATURES = [IPROTO_FEATURE_ERROR_EXTENSION,]

# Authenticate with CHAP-SHA1 (Tarantool CE and EE)
AUTH_TYPE_CHAP_SHA1 = "chap-sha1"
# Authenticate with PAP-SHA256 (Tarantool EE)
AUTH_TYPE_PAP_SHA256 = "pap-sha256"
# List of supported auth types.
AUTH_TYPES = [AUTH_TYPE_CHAP_SHA1, AUTH_TYPE_PAP_SHA256]
17 changes: 14 additions & 3 deletions tarantool/mesh_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
'ssl_ciphers': DEFAULT_SSL_CIPHERS,
'ssl_password': DEFAULT_SSL_PASSWORD,
'ssl_password_file': DEFAULT_SSL_PASSWORD_FILE,
'auth_type': None,
}


Expand Down Expand Up @@ -194,6 +195,7 @@ def update_connection(conn, address):
conn.ssl_ciphers = address['ssl_ciphers']
conn.ssl_password = address['ssl_password']
conn.ssl_password_file = address['ssl_password_file']
conn.auth_type = address['auth_type']


class RoundRobinStrategy(object):
Expand Down Expand Up @@ -277,6 +279,7 @@ def __init__(self, host=None, port=None,
ssl_ciphers=DEFAULT_SSL_CIPHERS,
ssl_password=DEFAULT_SSL_PASSWORD,
ssl_password_file=DEFAULT_SSL_PASSWORD_FILE,
auth_type=None,
addrs=None,
strategy_class=RoundRobinStrategy,
cluster_discovery_function=None,
Expand Down Expand Up @@ -357,6 +360,11 @@ def __init__(self, host=None, port=None,
Value would be used to add one more server in
:paramref:`~tarantool.MeshConnection.params.addrs` list.

:param auth_type: Refer to
:paramref:`~tarantool.Connection.params.auth_type`.
Value would be used to add one more server in
:paramref:`~tarantool.MeshConnection.params.addrs` list.

:param addrs: Cluster servers addresses list. Refer to
:paramref:`~tarantool.ConnectionPool.params.addrs`.

Expand Down Expand Up @@ -437,7 +445,8 @@ def __init__(self, host=None, port=None,
'ssl_ca_file': ssl_ca_file,
'ssl_ciphers': ssl_ciphers,
'ssl_password': ssl_password,
'ssl_password_file': ssl_password_file})
'ssl_password_file': ssl_password_file,
'auth_type': auth_type})

# Verify that at least one address is provided.
if not addrs:
Expand Down Expand Up @@ -479,7 +488,8 @@ def __init__(self, host=None, port=None,
ssl_ca_file=addr['ssl_ca_file'],
ssl_ciphers=addr['ssl_ciphers'],
ssl_password=addr['ssl_password'],
ssl_password_file=addr['ssl_password_file'])
ssl_password_file=addr['ssl_password_file'],
auth_type=addr['auth_type'])

def connect(self):
"""
Expand Down Expand Up @@ -588,7 +598,8 @@ def _opt_refresh_instances(self):
'ssl_ca_file': self.ssl_ca_file,
'ssl_ciphers': self.ssl_ciphers,
'ssl_password': self.ssl_password,
'ssl_password_file': self.ssl_password_file}
'ssl_password_file': self.ssl_password_file,
'auth_type': self._client_auth_type}
if current_addr not in self.strategy.addrs:
self.close()
addr = self.strategy.getnext()
Expand Down
56 changes: 38 additions & 18 deletions tarantool/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
REQUEST_TYPE_JOIN,
REQUEST_TYPE_SUBSCRIBE,
REQUEST_TYPE_ID,
AUTH_TYPE_CHAP_SHA1,
AUTH_TYPE_PAP_SHA256,
)
from tarantool.response import (
Response,
Expand Down Expand Up @@ -223,6 +225,26 @@ def __init__(self, conn, space_no, values):

self._body = request_body

def sha1(values):
"""
Compute hash.

:param values: Values to hash.
:type values: :obj:`tuple`

:rtype: :obj:`str`

:meta private:
"""

sha = hashlib.sha1()
for i in values:
if i is not None:
if isinstance(i, bytes):
sha.update(i)
else:
sha.update(i.encode())
return sha.digest()

class RequestAuthenticate(Request):
"""
Expand All @@ -231,7 +253,7 @@ class RequestAuthenticate(Request):

request_type = REQUEST_TYPE_AUTHENTICATE

def __init__(self, conn, salt, user, password):
def __init__(self, conn, salt, user, password, auth_type=AUTH_TYPE_CHAP_SHA1):
"""
:param conn: Request sender.
:type conn: :class:`~tarantool.Connection`
Expand All @@ -246,27 +268,25 @@ def __init__(self, conn, salt, user, password):
:param password: User password for authentication on the
Tarantool server.
:type password: :obj:`str`

:param auth_type: Refer to :paramref:`~tarantool.Connection.auth_type`.
:type auth_type: :obj:`str`, optional
"""

super(RequestAuthenticate, self).__init__(conn)

def sha1(values):
sha = hashlib.sha1()
for i in values:
if i is not None:
if isinstance(i, bytes):
sha.update(i)
else:
sha.update(i.encode())
return sha.digest()

hash1 = sha1((password,))
hash2 = sha1((hash1,))
scramble = sha1((salt, hash2))
scramble = strxor(hash1, scramble)
request_body = self._dumps({IPROTO_USER_NAME: user,
IPROTO_TUPLE: ("chap-sha1", scramble)})
self._body = request_body
if auth_type == AUTH_TYPE_CHAP_SHA1:
hash1 = sha1((password,))
hash2 = sha1((hash1,))
scramble = sha1((salt, hash2))
scramble = strxor(hash1, scramble)
elif auth_type == AUTH_TYPE_PAP_SHA256:
scramble = password
else:
raise ValueError(f'Unexpected auth_type {auth_type}')

self._body = self._dumps({IPROTO_USER_NAME: user,
IPROTO_TUPLE: (auth_type, scramble)})

def header(self, length):
"""
Expand Down
12 changes: 12 additions & 0 deletions tarantool/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
IPROTO_SQL_INFO_AUTOINCREMENT_IDS,
IPROTO_VERSION,
IPROTO_FEATURES,
IPROTO_AUTH_TYPE,
)
from tarantool.types import decode_box_error
from tarantool.error import (
Expand Down Expand Up @@ -386,3 +387,14 @@ def features(self):
return []
return self._body.get(IPROTO_FEATURES)

@property
def auth_type(self):
"""
Server expected authentication method.

:rtype: :obj:`str` or :obj:`None`
"""

if self._return_code != 0:
return None
return self._body.get(IPROTO_AUTH_TYPE)
2 changes: 2 additions & 0 deletions test/suites/box.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ local os = require('os')

local admin_listen = os.getenv("ADMIN")
local primary_listen = os.getenv("LISTEN")
local auth_type = os.getenv("AUTH_TYPE")

require('console').listen(admin_listen)
box.cfg{
listen = primary_listen,
memtx_memory = 0.1 * 1024^3, -- 0.1 GiB
pid_file = "box.pid",
auth_type = (auth_type:len() > 0) and auth_type or nil,
}
15 changes: 15 additions & 0 deletions test/suites/lib/skip.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,18 @@ def skip_or_run_ssl_password_test_call(self):

return skip_or_run_test_tarantool_call(self, '2.11.0',
'does not support SSL passwords')

def skip_or_run_auth_type_test_call(self):
"""Function to skip or run tests related to configuring
authentication method.

Tarantool supports auth_type only in current master since
commit 2574ff1a (after 2.11.0-entrypoint).
See https://github.com/tarantool/tarantool/issues/7988
https://github.com/tarantool/tarantool/issues/7989
https://github.com/tarantool/tarantool-ee/issues/295
https://github.com/tarantool/tarantool-ee/issues/322
"""

return skip_or_run_test_tarantool_call(self, '2.11.0',
'does not support auth type')
Loading