Skip to content

Commit

Permalink
PYTHON-3096 Finish implementation and tests for GSSAPI options (#1985)
Browse files Browse the repository at this point in the history
  • Loading branch information
blink1073 authored Dec 31, 2024
1 parent b3ce932 commit 8d27699
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 19 deletions.
3 changes: 2 additions & 1 deletion .evergreen/scripts/run-enterprise-auth-tests.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/bin/bash
set -eu

# Disable xtrace for security reasons (just in case it was accidentally set).
set +x
# Use the default python to bootstrap secrets.
PYTHON_BINARY="" bash "${DRIVERS_TOOLS}"/.evergreen/auth_aws/setup_secrets.sh drivers/enterprise_auth
bash "${DRIVERS_TOOLS}"/.evergreen/secrets_handling/setup-secrets.sh drivers/enterprise_auth
TEST_ENTERPRISE_AUTH=1 AUTH=auth bash "${PROJECT_DIRECTORY}"/.evergreen/hatch.sh test:test-eg
14 changes: 10 additions & 4 deletions pymongo/asynchronous/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,20 @@ def _auth_key(nonce: str, username: str, password: str) -> str:
return md5hash.hexdigest()


def _canonicalize_hostname(hostname: str) -> str:
def _canonicalize_hostname(hostname: str, option: str | bool) -> str:
"""Canonicalize hostname following MIT-krb5 behavior."""
# https://github.com/krb5/krb5/blob/d406afa363554097ac48646a29249c04f498c88e/src/util/k5test.py#L505-L520
if option in [False, "none"]:
return hostname

af, socktype, proto, canonname, sockaddr = socket.getaddrinfo(
hostname, None, 0, 0, socket.IPPROTO_TCP, socket.AI_CANONNAME
)[0]

# For forward just to resolve the cname as dns.lookup() will not return it.
if option == "forward":
return canonname.lower()

try:
name = socket.getnameinfo(sockaddr, socket.NI_NAMEREQD)
except socket.gaierror:
Expand All @@ -205,9 +212,8 @@ async def _authenticate_gssapi(credentials: MongoCredential, conn: AsyncConnecti
props = credentials.mechanism_properties
# Starting here and continuing through the while loop below - establish
# the security context. See RFC 4752, Section 3.1, first paragraph.
host = conn.address[0]
if props.canonicalize_host_name:
host = _canonicalize_hostname(host)
host = props.service_host or conn.address[0]
host = _canonicalize_hostname(host, props.canonicalize_host_name)
service = props.service_name + "@" + host
if props.service_realm is not None:
service = service + "@" + props.service_realm
Expand Down
14 changes: 10 additions & 4 deletions pymongo/synchronous/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,20 @@ def _auth_key(nonce: str, username: str, password: str) -> str:
return md5hash.hexdigest()


def _canonicalize_hostname(hostname: str) -> str:
def _canonicalize_hostname(hostname: str, option: str | bool) -> str:
"""Canonicalize hostname following MIT-krb5 behavior."""
# https://github.com/krb5/krb5/blob/d406afa363554097ac48646a29249c04f498c88e/src/util/k5test.py#L505-L520
if option in [False, "none"]:
return hostname

af, socktype, proto, canonname, sockaddr = socket.getaddrinfo(
hostname, None, 0, 0, socket.IPPROTO_TCP, socket.AI_CANONNAME
)[0]

# For forward just to resolve the cname as dns.lookup() will not return it.
if option == "forward":
return canonname.lower()

try:
name = socket.getnameinfo(sockaddr, socket.NI_NAMEREQD)
except socket.gaierror:
Expand All @@ -202,9 +209,8 @@ def _authenticate_gssapi(credentials: MongoCredential, conn: Connection) -> None
props = credentials.mechanism_properties
# Starting here and continuing through the while loop below - establish
# the security context. See RFC 4752, Section 3.1, first paragraph.
host = conn.address[0]
if props.canonicalize_host_name:
host = _canonicalize_hostname(host)
host = props.service_host or conn.address[0]
host = _canonicalize_hostname(host, props.canonicalize_host_name)
service = props.service_name + "@" + host
if props.service_realm is not None:
service = service + "@" + props.service_realm
Expand Down
66 changes: 61 additions & 5 deletions test/asynchronous/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import pytest

from pymongo import AsyncMongoClient, monitoring
from pymongo.asynchronous.auth import HAVE_KERBEROS
from pymongo.asynchronous.auth import HAVE_KERBEROS, _canonicalize_hostname
from pymongo.auth_shared import _build_credentials_tuple
from pymongo.errors import OperationFailure
from pymongo.hello import HelloCompat
Expand Down Expand Up @@ -96,10 +96,11 @@ def setUpClass(cls):
cls.service_realm_required = (
GSSAPI_SERVICE_REALM is not None and GSSAPI_SERVICE_REALM not in GSSAPI_PRINCIPAL
)
mech_properties = f"SERVICE_NAME:{GSSAPI_SERVICE_NAME}"
mech_properties += f",CANONICALIZE_HOST_NAME:{GSSAPI_CANONICALIZE}"
mech_properties = dict(
SERVICE_NAME=GSSAPI_SERVICE_NAME, CANONICALIZE_HOST_NAME=GSSAPI_CANONICALIZE
)
if GSSAPI_SERVICE_REALM is not None:
mech_properties += f",SERVICE_REALM:{GSSAPI_SERVICE_REALM}"
mech_properties["SERVICE_REALM"] = GSSAPI_SERVICE_REALM
cls.mech_properties = mech_properties

async def test_credentials_hashing(self):
Expand Down Expand Up @@ -167,7 +168,10 @@ async def test_gssapi_simple(self):
await client[GSSAPI_DB].collection.find_one()

# Log in using URI, with authMechanismProperties.
mech_uri = uri + f"&authMechanismProperties={self.mech_properties}"
mech_properties_str = ""
for key, value in self.mech_properties.items():
mech_properties_str += f"{key}:{value},"
mech_uri = uri + f"&authMechanismProperties={mech_properties_str[:-1]}"
client = self.simple_client(mech_uri)
await client[GSSAPI_DB].collection.find_one()

Expand Down Expand Up @@ -268,6 +272,58 @@ async def test_gssapi_threaded(self):
thread.join()
self.assertTrue(thread.success)

async def test_gssapi_canonicalize_host_name(self):
# Test the low level method.
assert GSSAPI_HOST is not None
result = _canonicalize_hostname(GSSAPI_HOST, "forward")
if "compute-1.amazonaws.com" not in result:
self.assertEqual(result, GSSAPI_HOST)
result = _canonicalize_hostname(GSSAPI_HOST, "forwardAndReverse")
self.assertEqual(result, GSSAPI_HOST)

# Use the equivalent named CANONICALIZE_HOST_NAME.
props = self.mech_properties.copy()
if props["CANONICALIZE_HOST_NAME"] == "true":
props["CANONICALIZE_HOST_NAME"] = "forwardAndReverse"
else:
props["CANONICALIZE_HOST_NAME"] = "none"
client = self.simple_client(
GSSAPI_HOST,
GSSAPI_PORT,
username=GSSAPI_PRINCIPAL,
password=GSSAPI_PASS,
authMechanism="GSSAPI",
authMechanismProperties=props,
)
await client.server_info()

async def test_gssapi_host_name(self):
props = self.mech_properties
props["SERVICE_HOST"] = "example.com"

# Authenticate with authMechanismProperties.
client = self.simple_client(
GSSAPI_HOST,
GSSAPI_PORT,
username=GSSAPI_PRINCIPAL,
password=GSSAPI_PASS,
authMechanism="GSSAPI",
authMechanismProperties=self.mech_properties,
)
with self.assertRaises(OperationFailure):
await client.server_info()

props["SERVICE_HOST"] = GSSAPI_HOST
client = self.simple_client(
GSSAPI_HOST,
GSSAPI_PORT,
username=GSSAPI_PRINCIPAL,
password=GSSAPI_PASS,
authMechanism="GSSAPI",
authMechanismProperties=self.mech_properties,
)
await client.server_info()


class TestSASLPlain(AsyncPyMongoTestCase):
@classmethod
Expand Down
66 changes: 61 additions & 5 deletions test/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from pymongo.hello import HelloCompat
from pymongo.read_preferences import ReadPreference
from pymongo.saslprep import HAVE_STRINGPREP
from pymongo.synchronous.auth import HAVE_KERBEROS
from pymongo.synchronous.auth import HAVE_KERBEROS, _canonicalize_hostname

_IS_SYNC = True

Expand Down Expand Up @@ -96,10 +96,11 @@ def setUpClass(cls):
cls.service_realm_required = (
GSSAPI_SERVICE_REALM is not None and GSSAPI_SERVICE_REALM not in GSSAPI_PRINCIPAL
)
mech_properties = f"SERVICE_NAME:{GSSAPI_SERVICE_NAME}"
mech_properties += f",CANONICALIZE_HOST_NAME:{GSSAPI_CANONICALIZE}"
mech_properties = dict(
SERVICE_NAME=GSSAPI_SERVICE_NAME, CANONICALIZE_HOST_NAME=GSSAPI_CANONICALIZE
)
if GSSAPI_SERVICE_REALM is not None:
mech_properties += f",SERVICE_REALM:{GSSAPI_SERVICE_REALM}"
mech_properties["SERVICE_REALM"] = GSSAPI_SERVICE_REALM
cls.mech_properties = mech_properties

def test_credentials_hashing(self):
Expand Down Expand Up @@ -167,7 +168,10 @@ def test_gssapi_simple(self):
client[GSSAPI_DB].collection.find_one()

# Log in using URI, with authMechanismProperties.
mech_uri = uri + f"&authMechanismProperties={self.mech_properties}"
mech_properties_str = ""
for key, value in self.mech_properties.items():
mech_properties_str += f"{key}:{value},"
mech_uri = uri + f"&authMechanismProperties={mech_properties_str[:-1]}"
client = self.simple_client(mech_uri)
client[GSSAPI_DB].collection.find_one()

Expand Down Expand Up @@ -268,6 +272,58 @@ def test_gssapi_threaded(self):
thread.join()
self.assertTrue(thread.success)

def test_gssapi_canonicalize_host_name(self):
# Test the low level method.
assert GSSAPI_HOST is not None
result = _canonicalize_hostname(GSSAPI_HOST, "forward")
if "compute-1.amazonaws.com" not in result:
self.assertEqual(result, GSSAPI_HOST)
result = _canonicalize_hostname(GSSAPI_HOST, "forwardAndReverse")
self.assertEqual(result, GSSAPI_HOST)

# Use the equivalent named CANONICALIZE_HOST_NAME.
props = self.mech_properties.copy()
if props["CANONICALIZE_HOST_NAME"] == "true":
props["CANONICALIZE_HOST_NAME"] = "forwardAndReverse"
else:
props["CANONICALIZE_HOST_NAME"] = "none"
client = self.simple_client(
GSSAPI_HOST,
GSSAPI_PORT,
username=GSSAPI_PRINCIPAL,
password=GSSAPI_PASS,
authMechanism="GSSAPI",
authMechanismProperties=props,
)
client.server_info()

def test_gssapi_host_name(self):
props = self.mech_properties
props["SERVICE_HOST"] = "example.com"

# Authenticate with authMechanismProperties.
client = self.simple_client(
GSSAPI_HOST,
GSSAPI_PORT,
username=GSSAPI_PRINCIPAL,
password=GSSAPI_PASS,
authMechanism="GSSAPI",
authMechanismProperties=self.mech_properties,
)
with self.assertRaises(OperationFailure):
client.server_info()

props["SERVICE_HOST"] = GSSAPI_HOST
client = self.simple_client(
GSSAPI_HOST,
GSSAPI_PORT,
username=GSSAPI_PRINCIPAL,
password=GSSAPI_PASS,
authMechanism="GSSAPI",
authMechanismProperties=self.mech_properties,
)
client.server_info()


class TestSASLPlain(PyMongoTestCase):
@classmethod
Expand Down

0 comments on commit 8d27699

Please sign in to comment.