From 29306d98dd6ce316830d5bb0e03586c802b2ab6b Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Fri, 3 Sep 2021 14:30:32 +0200 Subject: [PATCH 1/4] Add bearer auth token for SSO --- neo4j/__init__.py | 2 ++ neo4j/api.py | 58 ++++++++++++++++++++++++++------- neo4j/exceptions.py | 12 ++++++- testkitbackend/requests.py | 21 +++++++++--- testkitbackend/test_config.json | 9 +++++ tests/unit/test_security.py | 27 +++++++++++++-- 6 files changed, 110 insertions(+), 19 deletions(-) diff --git a/neo4j/__init__.py b/neo4j/__init__.py index 9aa75c0a3..950bf6fa8 100644 --- a/neo4j/__init__.py +++ b/neo4j/__init__.py @@ -29,6 +29,7 @@ "AuthToken", "basic_auth", "kerberos_auth", + "bearer_auth", "custom_auth", "Bookmark", "ServerInfo", @@ -69,6 +70,7 @@ AuthToken, basic_auth, kerberos_auth, + bearer_auth, custom_auth, Bookmark, ServerInfo, diff --git a/neo4j/api.py b/neo4j/api.py index 8ed2ce265..3782b4873 100644 --- a/neo4j/api.py +++ b/neo4j/api.py @@ -66,22 +66,32 @@ class Auth: :param scheme: specifies the type of authentication, examples: "basic", "kerberos" :type scheme: str :param principal: specifies who is being authenticated - :type principal: str + :type principal: str or None :param credentials: authenticates the principal - :type credentials: str + :type credentials: str or None :param realm: specifies the authentication provider - :type realm: str + :type realm: str or None :param parameters: extra key word parameters passed along to the authentication provider - :type parameters: str + :type parameters: Dict[str, Any] """ - #: By default we should not send any realm - realm = None - + # TODO in 5.0: change signature to + # def __init__(self, scheme, principal=None, credentials=None, + # ticket=None, realm=None, **parameters): def __init__(self, scheme, principal, credentials, realm=None, **parameters): self.scheme = scheme - self.principal = principal - self.credentials = credentials + # Neo4j servers pre 4.4 require the principal field to always be + # present. Therefore, we transmit it even if it's an empty sting. + if principal is not None: + self.principal = principal + if credentials: + self.credentials = credentials + # TODO in 5.0: add ticket + # :param ticket: alternative to authenticate the principal (depends on + # scheme) + # :type ticket: str or None + # if ticket is not None: + # self.ticket = ticket if realm: self.realm = realm if parameters: @@ -108,7 +118,7 @@ def basic_auth(user, password, realm=None): def kerberos_auth(base64_encoded_ticket): - """ Generate a kerberos auth token with the base64 encoded ticket + """ Generate a kerberos auth token with the base64 encoded ticket. This will set the scheme to "kerberos" for the auth token. @@ -117,7 +127,24 @@ def kerberos_auth(base64_encoded_ticket): :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` """ - return Auth("kerberos", "", base64_encoded_ticket) + token = Auth("kerberos", "", None) + # token field is not supported by any other auth scheme. So we inject it. + token.ticket = base64_encoded_ticket + return token + + +def bearer_auth(base64_encoded_token): + """ Generate an auth token for Single-Sign-On providers. + + This will set the scheme to "bearer" for the auth token. + + :param base64_encoded_token: a base64 encoded authentication token generated + by a Single-Sign-On provider. + + :return: auth token for use with :meth:`GraphDatabase.driver` + :rtype: :class:`neo4j.Auth` + """ + return Auth("bearer", None, base64_encoded_token) def custom_auth(principal, credentials, realm, scheme, **parameters): @@ -134,6 +161,15 @@ def custom_auth(principal, credentials, realm, scheme, **parameters): """ return Auth(scheme, principal, credentials, realm, **parameters) +# TODO in 5.0: alter custom_auth to +# def custom_auth(principal, credentials, ticket, realm, scheme, **parameters): +# """... +# :param ticket: alternative to authenticate the principal (depends on +# scheme) +# ...""" +# return Auth(scheme, principal=principal, credentials=credentials, +# ticket=ticket, realm=realm, **parameter + class Bookmark: """A Bookmark object contains an immutable list of bookmark string values. diff --git a/neo4j/exceptions.py b/neo4j/exceptions.py index d8541a31a..efd3174bd 100644 --- a/neo4j/exceptions.py +++ b/neo4j/exceptions.py @@ -30,6 +30,7 @@ + CypherTypeError + ConstraintError + AuthError + + TokenExpired + Forbidden + DatabaseError + TransientError @@ -199,6 +200,12 @@ class AuthError(ClientError): """ +class TokenExpired(AuthError): + """ Raised when the authentication token has expired. + + A new driver instance with a fresh authentication token needs to be created. + """ + client_errors = { # ConstraintError @@ -228,8 +235,11 @@ class AuthError(ClientError): "Neo.ClientError.Security.AuthorizationFailed": AuthError, "Neo.ClientError.Security.Unauthorized": AuthError, + # TokenExpired + "Neo.ClientError.Security.TokenExpired": TokenExpired, + # NotALeader - "Neo.ClientError.Cluster.NotALeader": NotALeader + "Neo.ClientError.Cluster.NotALeader": NotALeader, } transient_errors = { diff --git a/testkitbackend/requests.py b/testkitbackend/requests.py index 53f595f98..619853ee9 100644 --- a/testkitbackend/requests.py +++ b/testkitbackend/requests.py @@ -54,10 +54,23 @@ def NewDriver(backend, data): data["authorizationToken"].mark_item_as_read_if_equals( "name", "AuthorizationToken" ) - auth = neo4j.Auth( - auth_token["scheme"], auth_token["principal"], - auth_token["credentials"], realm=auth_token["realm"]) - auth_token.mark_item_as_read_if_equals("ticket", "") + scheme = auth_token["scheme"] + if scheme == "basic": + auth = neo4j.basic_auth( + auth_token["principal"], auth_token["credentials"], + realm=auth_token.get("realm", None) + ) + elif scheme == "kerberos": + auth = neo4j.kerberos_auth(auth_token["ticket"]) + elif scheme == "bearer": + auth = neo4j.bearer_auth(auth_token["credentials"]) + else: + auth = neo4j.custom_auth( + auth_token["principal"], auth_token["credentials"], + auth_token["realm"], auth_token["scheme"], + **auth_token.get("parameters", {}) + ) + auth_token.mark_item_as_read("parameters", recursive=True) resolver = None if data["resolverRegistered"] or data["domainNameResolverRegistered"]: resolver = resolution_func(backend, data["resolverRegistered"], diff --git a/testkitbackend/test_config.json b/testkitbackend/test_config.json index 8e4450e8b..2c75df11a 100644 --- a/testkitbackend/test_config.json +++ b/testkitbackend/test_config.json @@ -26,12 +26,21 @@ "Flaky: test requires the driver to contact servers in a specific order", "stub.authorization.test_authorization.TestAuthorizationV4x1.test_should_retry_on_auth_expired_on_begin_using_tx_function": "Flaky: test requires the driver to contact servers in a specific order", + "stub.authorization.test_authorization.TestAuthorizationV4x3.test_should_fail_on_token_expired_on_begin_using_tx_function": + "Flaky: test requires the driver to contact servers in a specific order", + "stub.authorization.test_authorization.TestAuthorizationV3.test_should_fail_on_token_expired_on_begin_using_tx_function": + "Flaky: test requires the driver to contact servers in a specific order", + "stub.authorization.test_authorization.TestAuthorizationV4x1.test_should_fail_on_token_expired_on_begin_using_tx_function": + "Flaky: test requires the driver to contact servers in a specific order", "stub.session_run_parameters.test_session_run_parameters.TestSessionRunParameters.test_empty_query": "Driver rejects empty queries before sending it to the server", "tls.tlsversions.TestTlsVersions.test_1_1": "TLSv1.1 and below are disabled in the driver" }, "features": { + "Feature:Auth:Bearer": true, + "Feature:Auth:Custom": true, + "Feature:Auth:Kerberos": true, "AuthorizationExpiredTreatment": true, "Optimization:ImplicitDefaultArguments": true, "Optimization:MinimalResets": true, diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index ae009cf45..c56a1cc20 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -22,6 +22,7 @@ from neo4j.api import ( kerberos_auth, basic_auth, + bearer_auth, custom_auth, ) @@ -32,8 +33,19 @@ def test_should_generate_kerberos_auth_token_correctly(): auth = kerberos_auth("I am a base64 service ticket") assert auth.scheme == "kerberos" assert auth.principal == "" - assert auth.credentials == "I am a base64 service ticket" - assert not auth.realm + assert auth.ticket == "I am a base64 service ticket" + assert not hasattr(auth, "credentials") + assert not hasattr(auth, "realm") + assert not hasattr(auth, "parameters") + + +def test_should_generate_bearer_auth_token_correctly(): + auth = bearer_auth("I am a base64 SSO ticket") + assert auth.scheme == "bearer" + assert auth.credentials == "I am a base64 SSO ticket" + assert not hasattr(auth, "principal") + assert not hasattr(auth, "ticket") + assert not hasattr(auth, "realm") assert not hasattr(auth, "parameters") @@ -42,7 +54,7 @@ def test_should_generate_basic_auth_without_realm_correctly(): assert auth.scheme == "basic" assert auth.principal == "molly" assert auth.credentials == "meoooow" - assert not auth.realm + assert not hasattr(auth, "realm") assert not hasattr(auth, "parameters") @@ -55,6 +67,15 @@ def test_should_generate_base_auth_with_realm_correctly(): assert not hasattr(auth, "parameters") +def test_should_generate_base_auth_with_keyword_realm_correctly(): + auth = basic_auth("molly", "meoooow", realm="cat_cafe") + assert auth.scheme == "basic" + assert auth.principal == "molly" + assert auth.credentials == "meoooow" + assert auth.realm == "cat_cafe" + assert not hasattr(auth, "parameters") + + def test_should_generate_custom_auth_correctly(): auth = custom_auth("molly", "meoooow", "cat_cafe", "cat", age="1", color="white") assert auth.scheme == "cat" From 76e9e7bf0ce34637745c5fe008f133a09b48b190 Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Tue, 28 Sep 2021 13:44:48 +0200 Subject: [PATCH 2/4] Use `credentials` for kerberos auth --- neo4j/api.py | 47 ++++++++++++++++--------------------- testkitbackend/requests.py | 2 +- tests/unit/test_security.py | 6 ++--- 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/neo4j/api.py b/neo4j/api.py index 3782b4873..23925b5d7 100644 --- a/neo4j/api.py +++ b/neo4j/api.py @@ -63,7 +63,8 @@ class Auth: """ Container for auth details. - :param scheme: specifies the type of authentication, examples: "basic", "kerberos" + :param scheme: specifies the type of authentication, examples: "basic", + "kerberos" :type scheme: str :param principal: specifies who is being authenticated :type principal: str or None @@ -71,13 +72,11 @@ class Auth: :type credentials: str or None :param realm: specifies the authentication provider :type realm: str or None - :param parameters: extra key word parameters passed along to the authentication provider + :param parameters: extra key word parameters passed along to the + authentication provider :type parameters: Dict[str, Any] """ - # TODO in 5.0: change signature to - # def __init__(self, scheme, principal=None, credentials=None, - # ticket=None, realm=None, **parameters): def __init__(self, scheme, principal, credentials, realm=None, **parameters): self.scheme = scheme # Neo4j servers pre 4.4 require the principal field to always be @@ -86,12 +85,6 @@ def __init__(self, scheme, principal, credentials, realm=None, **parameters): self.principal = principal if credentials: self.credentials = credentials - # TODO in 5.0: add ticket - # :param ticket: alternative to authenticate the principal (depends on - # scheme) - # :type ticket: str or None - # if ticket is not None: - # self.ticket = ticket if realm: self.realm = realm if parameters: @@ -107,9 +100,12 @@ def basic_auth(user, password, realm=None): This will set the scheme to "basic" for the auth token. - :param user: user name, this will set the principal + :param user: user name, this will set the + :type user: stro :param password: current password, this will set the credentials + :type password: stro :param realm: specifies the authentication provider + :type realm: str or None :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` @@ -122,15 +118,14 @@ def kerberos_auth(base64_encoded_ticket): This will set the scheme to "kerberos" for the auth token. - :param base64_encoded_ticket: a base64 encoded service ticket, this will set the credentials + :param base64_encoded_ticket: a base64 encoded service ticket, this will set + the credentials + :type base64_encoded_ticket: str :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` """ - token = Auth("kerberos", "", None) - # token field is not supported by any other auth scheme. So we inject it. - token.ticket = base64_encoded_ticket - return token + return Auth("kerberos", "", base64_encoded_ticket) def bearer_auth(base64_encoded_token): @@ -140,6 +135,7 @@ def bearer_auth(base64_encoded_token): :param base64_encoded_token: a base64 encoded authentication token generated by a Single-Sign-On provider. + :type base64_encoded_token: str :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` @@ -151,25 +147,22 @@ def custom_auth(principal, credentials, realm, scheme, **parameters): """ Generate a custom auth token. :param principal: specifies who is being authenticated + :type principal: str or None :param credentials: authenticates the principal + :type credentials: str or None :param realm: specifies the authentication provider + :type realm: str or None :param scheme: specifies the type of authentication - :param parameters: extra key word parameters passed along to the authentication provider + :type scheme: str or None + :param parameters: extra key word parameters passed along to the + authentication provider + :type parameters: Dict[str, Any] :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` """ return Auth(scheme, principal, credentials, realm, **parameters) -# TODO in 5.0: alter custom_auth to -# def custom_auth(principal, credentials, ticket, realm, scheme, **parameters): -# """... -# :param ticket: alternative to authenticate the principal (depends on -# scheme) -# ...""" -# return Auth(scheme, principal=principal, credentials=credentials, -# ticket=ticket, realm=realm, **parameter - class Bookmark: """A Bookmark object contains an immutable list of bookmark string values. diff --git a/testkitbackend/requests.py b/testkitbackend/requests.py index 619853ee9..6b667642f 100644 --- a/testkitbackend/requests.py +++ b/testkitbackend/requests.py @@ -61,7 +61,7 @@ def NewDriver(backend, data): realm=auth_token.get("realm", None) ) elif scheme == "kerberos": - auth = neo4j.kerberos_auth(auth_token["ticket"]) + auth = neo4j.kerberos_auth(auth_token["credentials"]) elif scheme == "bearer": auth = neo4j.bearer_auth(auth_token["credentials"]) else: diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index c56a1cc20..3ac760fcd 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -33,9 +33,9 @@ def test_should_generate_kerberos_auth_token_correctly(): auth = kerberos_auth("I am a base64 service ticket") assert auth.scheme == "kerberos" assert auth.principal == "" - assert auth.ticket == "I am a base64 service ticket" - assert not hasattr(auth, "credentials") - assert not hasattr(auth, "realm") + assert auth.credentials == "I am a base64 service ticket" + assert not hasattr(auth, "ticket") + assert not hasattr(auth, "realm") assert not hasattr(auth, "parameters") From fd3e1e158da4a133f7e4fe4821fb42f408ae8f0d Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Tue, 28 Sep 2021 13:46:36 +0200 Subject: [PATCH 3/4] Add docs for bearer_auth helper --- docs/source/api.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 2e934a2b5..8cace8da5 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -116,7 +116,7 @@ Example: import neo4j - auth = neo4j.Auth(scheme="basic", principal="neo4j", credentials="password") + auth = neo4j.Auth("basic", "neo4j", "password") Auth Token Helper Functions @@ -128,6 +128,8 @@ Alternatively, one of the auth token helper functions can be used. .. autofunction:: neo4j.kerberos_auth +.. autofunction:: neo4j.bearer_auth + .. autofunction:: neo4j.custom_auth From d0987246fd65af29df112d4e8fd41d83dce7b77d Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Fri, 1 Oct 2021 14:37:49 +0200 Subject: [PATCH 4/4] Fix typos --- neo4j/api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/neo4j/api.py b/neo4j/api.py index 23925b5d7..1275c2a33 100644 --- a/neo4j/api.py +++ b/neo4j/api.py @@ -61,7 +61,7 @@ # TODO: This class is not tested class Auth: - """ Container for auth details. + """Container for auth details. :param scheme: specifies the type of authentication, examples: "basic", "kerberos" @@ -96,14 +96,14 @@ def __init__(self, scheme, principal, credentials, realm=None, **parameters): def basic_auth(user, password, realm=None): - """ Generate a basic auth token for a given user and password. + """Generate a basic auth token for a given user and password. This will set the scheme to "basic" for the auth token. :param user: user name, this will set the - :type user: stro + :type user: str :param password: current password, this will set the credentials - :type password: stro + :type password: str :param realm: specifies the authentication provider :type realm: str or None @@ -114,7 +114,7 @@ def basic_auth(user, password, realm=None): def kerberos_auth(base64_encoded_ticket): - """ Generate a kerberos auth token with the base64 encoded ticket. + """Generate a kerberos auth token with the base64 encoded ticket. This will set the scheme to "kerberos" for the auth token. @@ -129,7 +129,7 @@ def kerberos_auth(base64_encoded_ticket): def bearer_auth(base64_encoded_token): - """ Generate an auth token for Single-Sign-On providers. + """Generate an auth token for Single-Sign-On providers. This will set the scheme to "bearer" for the auth token. @@ -144,7 +144,7 @@ def bearer_auth(base64_encoded_token): def custom_auth(principal, credentials, realm, scheme, **parameters): - """ Generate a custom auth token. + """Generate a custom auth token. :param principal: specifies who is being authenticated :type principal: str or None