Skip to content

Commit 866d916

Browse files
authored
Merge pull request #683 from MultiDirectoryLab/add_spnego_support
Add SPNEGO support 664
2 parents 0ab9d62 + b7f89da commit 866d916

7 files changed

Lines changed: 179 additions & 3 deletions

File tree

app/ldap_protocol/ldap_requests/bind.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
get_bad_response,
2626
sasl_mechanism_map,
2727
)
28+
from ldap_protocol.ldap_requests.bind_methods.sasl_spnego import (
29+
SaslSPNEGOAuthentication,
30+
)
2831
from ldap_protocol.ldap_responses import BaseResponse, BindResponse
2932
from ldap_protocol.multifactor import MultifactorAPI
3033
from ldap_protocol.objects import ProtocolRequests
@@ -235,7 +238,14 @@ async def handle(
235238
await ctx.ldap_session.set_user(user)
236239
await set_user_logon_attrs(user, ctx.session, ctx.settings.TIMEZONE)
237240

238-
yield BindResponse(result_code=LDAPCodes.SUCCESS)
241+
server_sasl_creds = None
242+
if isinstance(self.authentication_choice, SaslSPNEGOAuthentication):
243+
server_sasl_creds = self.authentication_choice.server_sasl_creds
244+
245+
yield BindResponse(
246+
result_code=LDAPCodes.SUCCESS,
247+
server_sasl_creds=server_sasl_creds,
248+
)
239249

240250

241251
class UnbindRequest(BaseRequest):

app/ldap_protocol/ldap_requests/bind_methods/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
)
1414
from .sasl_gssapi import GSSAPISL, GSSAPIAuthStatus, SaslGSSAPIAuthentication
1515
from .sasl_plain import SaslPLAINAuthentication
16+
from .sasl_spnego import SaslSPNEGOAuthentication
1617
from .simple import SimpleAuthentication
1718

1819
sasl_mechanism: list[type[SaslAuthentication]] = [
1920
SaslPLAINAuthentication,
2021
SaslGSSAPIAuthentication,
22+
SaslSPNEGOAuthentication,
2123
]
2224

2325
sasl_mechanism_map: dict[SASLMethod, type[SaslAuthentication]] = {
@@ -31,6 +33,7 @@
3133
"SASLMethod",
3234
"SaslAuthentication",
3335
"SaslGSSAPIAuthentication",
36+
"SaslSPNEGOAuthentication",
3437
"SaslPLAINAuthentication",
3538
"SimpleAuthentication",
3639
"GSSAPIAuthStatus",

app/ldap_protocol/ldap_requests/bind_methods/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class SASLMethod(StrEnum):
2323
PLAIN = "PLAIN"
2424
EXTERNAL = "EXTERNAL"
2525
GSSAPI = "GSSAPI"
26+
GSS_SPNEGO = "GSS-SPNEGO"
2627
CRAM_MD5 = "CRAM-MD5"
2728
DIGEST_MD5 = "DIGEST-MD5"
2829
SCRAM_SHA_1 = "SCRAM-SHA-1"

app/ldap_protocol/ldap_requests/bind_methods/sasl_gssapi.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ async def _init_security_context(
127127
name=server_name,
128128
usage="accept",
129129
store={"keytab": settings.KRB5_LDAP_KEYTAB},
130-
mechs=[gssapi.MechType.kerberos],
131130
)
132131

133132
self._ldap_session.gssapi_security_context = gssapi.SecurityContext(
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Sasl SPNEGO auth method.
2+
3+
Copyright (c) 2024 MultiFactor
4+
License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE
5+
"""
6+
7+
from typing import ClassVar
8+
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
11+
from config import Settings
12+
from ldap_protocol.dialogue import LDAPSession
13+
from ldap_protocol.ldap_codes import LDAPCodes
14+
from ldap_protocol.ldap_responses import BindResponse
15+
16+
from .base import LDAPBindErrors, SASLMethod, get_bad_response
17+
from .sasl_gssapi import GSSAPISL, GSSAPIAuthStatus, SaslGSSAPIAuthentication
18+
19+
20+
class SaslSPNEGOAuthentication(SaslGSSAPIAuthentication):
21+
"""Sasl SPNEGO auth method.
22+
23+
Implements SPNEGO (RFC 4178) as a negotiation wrapper around GSS-API.
24+
In practice the negotiated mechanism is Kerberos.
25+
26+
Flow:
27+
28+
1. Negotiation & Context Initialization:
29+
- The server acquires acceptor credentials from keytab using the
30+
ldap/{REALM} service principal.
31+
- Creates a GSS-API acceptor security context with the SPNEGO
32+
mechanism (which in turn negotiates Kerberos).
33+
- Stores the context in the LDAP session.
34+
35+
2. Intermediate Request:
36+
- The client and server may exchange several SPNEGO tokens
37+
(NegTokenResp with responseToken/mechListMIC) until the wrapped
38+
GSS (Kerberos) context becomes established.
39+
- When `server_ctx.complete` becomes true, the initiator principal
40+
is available via `ctx.initiator_name` and server sends NegTokenResp
41+
with success.
42+
43+
"""
44+
45+
mechanism: ClassVar[SASLMethod] = SASLMethod.GSS_SPNEGO
46+
47+
async def step(
48+
self,
49+
session: AsyncSession,
50+
ldap_session: LDAPSession,
51+
settings: Settings,
52+
) -> BindResponse | None:
53+
"""SPNEGO step.
54+
55+
:param AsyncSession session: db session
56+
:param LDAPSession ldap_session: ldap session
57+
:param Settings settings: settings
58+
"""
59+
self._ldap_session = ldap_session
60+
61+
if not self._ldap_session.gssapi_security_context:
62+
await self._init_security_context(session, settings)
63+
64+
server_ctx = self._ldap_session.gssapi_security_context
65+
if server_ctx is None or self.ticket == b"":
66+
return get_bad_response(LDAPBindErrors.LOGON_FAILURE)
67+
68+
status = self._handle_ticket(server_ctx)
69+
70+
if not server_ctx.complete:
71+
return BindResponse(
72+
result_code=LDAPCodes.SASL_BIND_IN_PROGRESS,
73+
server_sasl_creds=self.server_sasl_creds,
74+
)
75+
76+
if status == GSSAPIAuthStatus.SEND_TO_CLIENT:
77+
self._ldap_session.gssapi_authenticated = True
78+
self._ldap_session.gssapi_security_layer = GSSAPISL.CONFIDENTIALITY
79+
return None
80+
81+
return get_bad_response(LDAPBindErrors.LOGON_FAILURE)

app/ldap_protocol/ldap_requests/search.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,12 @@ async def get_root_dse(
217217
data["currentTime"].append(get_generalized_now(settings.TIMEZONE))
218218
data["subschemaSubentry"].append(schema)
219219
data["schemaNamingContext"].append(schema)
220-
data["supportedSASLMechanisms"] = ["ANONYMOUS", "PLAIN", "GSSAPI"]
220+
data["supportedSASLMechanisms"] = [
221+
"ANONYMOUS",
222+
"PLAIN",
223+
"GSSAPI",
224+
"GSS-SPNEGO",
225+
]
221226
data["highestCommittedUSN"].append("126991")
222227
data["supportedExtension"] = [
223228
"1.3.6.1.4.1.4203.1.11.3", # whoami

tests/test_ldap/test_bind.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
SimpleAuthentication,
2424
UnbindRequest,
2525
)
26+
from ldap_protocol.ldap_requests.bind_methods.sasl_spnego import (
27+
SaslSPNEGOAuthentication,
28+
)
2629
from ldap_protocol.ldap_requests.contexts import (
2730
LDAPBindRequestContext,
2831
LDAPUnbindRequestContext,
@@ -199,6 +202,80 @@ async def mock_init_security_context(
199202
)
200203

201204

205+
@pytest.mark.asyncio
206+
@pytest.mark.usefixtures("session")
207+
@pytest.mark.usefixtures("setup_session")
208+
async def test_spnego_bind_ok(
209+
creds: TestCreds,
210+
container: AsyncContainer,
211+
) -> None:
212+
"""Test spnego bind ok."""
213+
mock_security_context = Mock(spec=gssapi.SecurityContext)
214+
mock_security_context.step.return_value = b"server_ticket"
215+
mock_security_context.complete = False
216+
mock_security_context.initiator_name = f"{creds.un}@domain"
217+
218+
async def mock_init_security_context(
219+
session: AsyncSession, # noqa: ARG001
220+
settings: Settings, # noqa: ARG001
221+
) -> None:
222+
auth_choice._ldap_session.gssapi_security_context = (
223+
mock_security_context
224+
)
225+
226+
auth_choice = SaslSPNEGOAuthentication(ticket=b"client_ticket")
227+
auth_choice._init_security_context = mock_init_security_context # type: ignore
228+
229+
first_bind = BindRequest(
230+
version=0,
231+
name=creds.un,
232+
AuthenticationChoice=auth_choice,
233+
)
234+
235+
second_bind = MutePolicyBindRequest(
236+
version=0,
237+
name=creds.un,
238+
AuthenticationChoice=SaslSPNEGOAuthentication(ticket=b"client_ticket"),
239+
)
240+
241+
async with container(scope=Scope.REQUEST) as container:
242+
kwargs = await resolve_deps(first_bind.handle, container)
243+
result = await anext(first_bind.handle(**kwargs))
244+
assert result == BindResponse(
245+
result_code=LDAPCodes.SASL_BIND_IN_PROGRESS,
246+
serverSaslCreds=b"server_ticket",
247+
)
248+
249+
mock_security_context.complete = True
250+
251+
kwargs = await resolve_deps(second_bind.handle, container)
252+
result = await anext(second_bind.handle(**kwargs))
253+
assert result == BindResponse(
254+
result_code=LDAPCodes.SUCCESS,
255+
serverSaslCreds=b"server_ticket",
256+
)
257+
258+
259+
@pytest.mark.asyncio
260+
@pytest.mark.usefixtures("session")
261+
@pytest.mark.usefixtures("setup_session")
262+
async def test_spnego_bind_missing_credentials(
263+
creds: TestCreds,
264+
container: AsyncContainer,
265+
) -> None:
266+
"""Test spnego bind with missing credentials."""
267+
bind = BindRequest(
268+
version=0,
269+
name=creds.un,
270+
AuthenticationChoice=SaslSPNEGOAuthentication(),
271+
)
272+
273+
async with container(scope=Scope.REQUEST) as container:
274+
kwargs = await resolve_deps(bind.handle, container)
275+
with pytest.raises(gssapi.exceptions.MissingCredentialsError):
276+
await anext(bind.handle(**kwargs))
277+
278+
202279
@pytest.mark.asyncio
203280
@pytest.mark.usefixtures("session")
204281
async def test_bind_invalid_password_or_user(

0 commit comments

Comments
 (0)