Skip to content

Commit 5a9b52c

Browse files
authored
Support for Active Directory multidomain forest. (#93)
In AD forest samAccountName (or uid) may not be unique in the entire forest and userPrincipalName contains "@" symbol disallowed in Matrix User Identifiers. This commit adds mxid generation logic to work around above restrictions. Signed-off-by: Yuri Konotopov <[email protected]>
1 parent 5abd2ac commit 5a9b52c

File tree

5 files changed

+442
-25
lines changed

5 files changed

+442
-25
lines changed

README.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,51 @@ in search mode is provided below:
8080
bind_password: "ch33kym0nk3y"
8181
#filter: "(objectClass=posixAccount)"
8282
83+
Active Directory forest support
84+
-------------------------------
85+
86+
If the ``active_directory`` flag is set to ``true``, an Active Directory forest will be
87+
searched for the login details.
88+
In this mode, the user enters their login details in one of the forms:
89+
90+
- ``<login>/<domain>``
91+
- ``<domain>\<login>``
92+
93+
In either case, this will be mapped to the Matrix UID ``<login>/<domain>`` (The
94+
normal AD domain separators, ``@`` and ``\``, cannot be used in Matrix User Identifiers, so
95+
``/`` is used instead.)
96+
97+
Let's say you have several domains in the ``example.com`` forest:
98+
99+
.. code:: yaml
100+
101+
password_providers:
102+
- module: "ldap_auth_provider.LdapAuthProvider"
103+
config:
104+
enabled: true
105+
mode: "search"
106+
uri: "ldap://main.example.com:389"
107+
base: "dc=example,dc=com"
108+
# Must be true for this feature to work
109+
active_directory: true
110+
# Optional. Users from this domain may log in without specifying the domain part
111+
default_domain: main.example.com
112+
attributes:
113+
uid: "userPrincipalName"
114+
mail: "mail"
115+
name: "givenName"
116+
bind_dn: "cn=hacker,ou=svcaccts,dc=example,dc=com"
117+
bind_password: "ch33kym0nk3y"
118+
119+
With this configuration the user can log in with either ``main.example.com\someuser``,
120+
``someuser/main.example.com`` or ``someuser``.
121+
122+
Users of other domains in the ``example.com`` forest can log in with ``domain\login``
123+
or ``login/domain``.
124+
125+
Please note that ``userPrincipalName`` or a similar-looking LDAP attribute in the format
126+
``login@domain`` must be used when the ``active_directory`` option is enabled.
127+
83128
Troubleshooting and Debugging
84129
-----------------------------
85130

ldap_auth_provider.py

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
logger = logging.getLogger(__name__)
4848

4949

50+
class ActiveDirectoryUPNException(Exception):
51+
"""Raised in case the user's login credentials cannot be mapped to a UPN"""
52+
pass
53+
54+
5055
class LDAPMode(object):
5156
SIMPLE = "simple",
5257
SEARCH = "search",
@@ -76,18 +81,48 @@ def __init__(self, config, account_handler):
7681
self.ldap_bind_password = config.bind_password
7782
self.ldap_filter = config.filter
7883

84+
self.ldap_active_directory = config.active_directory
85+
if self.ldap_active_directory:
86+
self.ldap_default_domain = config.default_domain
87+
88+
def get_supported_login_types(self):
89+
return {'m.login.password': ('password',)}
90+
7991
@defer.inlineCallbacks
80-
def check_password(self, user_id, password):
92+
def check_auth(self, username, login_type, login_dict):
8193
""" Attempt to authenticate a user against an LDAP Server
8294
and register an account if none exists.
8395
8496
Returns:
85-
True if authentication against LDAP was successful
97+
Canonical user ID if authentication against LDAP was successful
8698
"""
99+
password = login_dict['password']
100+
# According to section 5.1.2. of RFC 4513 an attempt to log in with
101+
# non-empty DN and empty password is called Unauthenticated
102+
# Authentication Mechanism of Simple Bind which is used to establish
103+
# an anonymous authorization state and not suitable for user
104+
# authentication.
87105
if not password:
88106
defer.returnValue(False)
89-
# user_id is of the form @foo:bar.com
90-
localpart = user_id.split(":", 1)[0][1:]
107+
108+
if username.startswith("@") and ":" in username:
109+
# username is of the form @foo:bar.com
110+
username = username.split(":", 1)[0][1:]
111+
112+
# Used in LDAP queries as value of ldap_attributes['uid'] attribute.
113+
uid_value = username
114+
# Default display name for the user, if a new account is registered.
115+
default_display_name = username
116+
# Local part of Matrix ID which will be used in registration process
117+
localpart = username
118+
119+
if self.ldap_active_directory:
120+
try:
121+
(login, domain, localpart) = self._map_login_to_upn(username)
122+
uid_value = login + "@" + domain
123+
default_display_name = login
124+
except ActiveDirectoryUPNException:
125+
defer.returnValue(False)
91126

92127
try:
93128
tls = ldap3.Tls(validate=ssl.CERT_REQUIRED)
@@ -102,7 +137,7 @@ def check_password(self, user_id, password):
102137
if self.ldap_mode == LDAPMode.SIMPLE:
103138
bind_dn = "{prop}={value},{base}".format(
104139
prop=self.ldap_attributes['uid'],
105-
value=localpart,
140+
value=uid_value,
106141
base=self.ldap_base
107142
)
108143
result, conn = yield self._ldap_simple_bind(
@@ -117,7 +152,7 @@ def check_password(self, user_id, password):
117152
if not result:
118153
defer.returnValue(False)
119154
elif self.ldap_mode == LDAPMode.SEARCH:
120-
filters = [(self.ldap_attributes["uid"], localpart)]
155+
filters = [(self.ldap_attributes["uid"], uid_value)]
121156
result, conn, _ = yield self._ldap_authenticated_search(
122157
server=server, password=password, filters=filters
123158
)
@@ -148,41 +183,46 @@ def check_password(self, user_id, password):
148183
)
149184
defer.returnValue(False)
150185

186+
# Get full user id from localpart
187+
user_id = self.account_handler.get_qualified_user_id(localpart)
188+
151189
# check if user with user_id exists
152190
if (yield self.account_handler.check_user_exists(user_id)):
153191
# exists, authentication complete
154192
if hasattr(conn, "unbind"):
155193
yield threads.deferToThread(conn.unbind)
156-
defer.returnValue(True)
194+
defer.returnValue(user_id)
157195

158196
else:
159197
# does not exist, register
160198
if self.ldap_mode == LDAPMode.SEARCH:
161199
# search enabled, fetch metadata for account creation from
162200
# existing ldap connection
163-
filters = [(self.ldap_attributes['uid'], localpart)]
201+
filters = [(self.ldap_attributes['uid'], uid_value)]
164202

165203
result, conn, response = yield self._ldap_authenticated_search(
166204
server=server, password=password, filters=filters,
167205
)
168206

169207
# These results will always return an array
170-
givenName = response["attributes"].get(
208+
display_name = response["attributes"].get(
171209
self.ldap_attributes["name"], [localpart]
172210
)
173-
givenName = (
174-
givenName[0] if len(givenName) == 1 else localpart
211+
display_name = (
212+
display_name[0]
213+
if len(display_name) == 1
214+
else default_display_name
175215
)
176216

177217
mail = response["attributes"].get("mail", [None])
178218
mail = mail[0] if len(mail) == 1 else None
179219
else:
180220
# search disabled, register account with basic information
181-
givenName = localpart
221+
display_name = default_display_name
182222
mail = None
183223

184224
# Register the user
185-
user_id = yield self.register_user(localpart, givenName, mail)
225+
user_id = yield self.register_user(localpart, display_name, mail)
186226

187227
defer.returnValue(user_id)
188228

@@ -237,6 +277,10 @@ def check_3pid_auth(self, medium, address, password):
237277
response
238278
)
239279

280+
# Close connection
281+
if hasattr(conn, "unbind"):
282+
yield threads.deferToThread(conn.unbind)
283+
240284
if not result:
241285
defer.returnValue(None)
242286

@@ -245,6 +289,16 @@ def check_3pid_auth(self, medium, address, password):
245289
self.ldap_attributes["uid"], [None]
246290
)
247291
localpart = localpart[0] if len(localpart) == 1 else None
292+
if self.ldap_active_directory and localpart and "@" in localpart:
293+
(login, domain) = localpart.lower().rsplit("@", 1)
294+
localpart = login + "/" + domain
295+
296+
if (
297+
self.ldap_default_domain
298+
and domain.lower() == self.ldap_default_domain.lower()
299+
):
300+
# Users in default AD domain don't have `/domain` suffix
301+
localpart = login
248302

249303
givenName = response["attributes"].get(
250304
self.ldap_attributes["name"], [localpart]
@@ -347,6 +401,10 @@ class _LdapConfig(object):
347401
"mail",
348402
])
349403

404+
ldap_config.active_directory = config.get("active_directory", False)
405+
if ldap_config.active_directory:
406+
ldap_config.default_domain = config.get("default_domain", None)
407+
350408
return ldap_config
351409

352410
@defer.inlineCallbacks
@@ -523,6 +581,45 @@ def _ldap_authenticated_search(self, server, password, filters):
523581
logger.warning("Error during LDAP authentication: %s", e)
524582
raise
525583

584+
def _map_login_to_upn(self, username):
585+
"""Maps user provided login to Active Directory UPN and
586+
local part of Matrix ID.
587+
588+
Args:
589+
username (str): The user's login
590+
591+
Raises:
592+
ActiveDirectoryUPNException: if username can not be
593+
mapped to userPrincipalName
594+
595+
Returns:
596+
Tuple[str, str, str]: a tuple of Active Directory login,
597+
Active Directory domain and local part of Matrix ID.
598+
"""
599+
login = username.lower()
600+
domain = self.ldap_default_domain
601+
localpart = username
602+
603+
if '\\' in username:
604+
(domain, login) = username.lower().rsplit('\\', 1)
605+
elif "/" in username:
606+
(login, domain) = username.lower().rsplit("/", 1)
607+
else:
608+
if not self.ldap_default_domain:
609+
logger.info(
610+
'No LDAP separator "/" was found in uid "%s" '
611+
'and LDAP default domain was not configured.',
612+
username
613+
)
614+
raise ActiveDirectoryUPNException()
615+
616+
if self.ldap_default_domain and domain == self.ldap_default_domain.lower():
617+
localpart = login
618+
else:
619+
localpart = login + "/" + domain
620+
621+
return (login, domain, localpart)
622+
526623

527624
def _require_keys(config, required):
528625
missing = [key for key in required if key not in config]

tests/__init__.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,28 @@
2323
objectClass: dcObject
2424
objectClass: organization
2525
26+
dn: dc=main,dc=example,dc=org
27+
dc: main
28+
objectClass: dcObject
29+
objectClass: organization
30+
31+
dn: dc=subsidiary,dc=example,dc=org
32+
dc: subsidiary
33+
objectClass: dcObject
34+
objectClass: organization
35+
2636
dn: ou=people,dc=example,dc=org
2737
objectClass: organizationalUnit
2838
ou: people
2939
40+
dn: ou=users,dc=main,dc=example,dc=org
41+
objectClass: organizationalUnit
42+
ou: users
43+
44+
dn: ou=users,dc=subsidiary,dc=example,dc=org
45+
objectClass: organizationalUnit
46+
ou: users
47+
3048
dn: cn=bob,ou=people,dc=example,dc=org
3149
cn: bob
3250
objectclass: person
@@ -49,6 +67,42 @@
4967
# password is: eekretsay
5068
userPassword: {SSHA}mtIQXzjeID+j1LdjduYB1kjaHPgup8UnK4ofgw==
5169
70+
dn: cn=mainuser,ou=users,dc=main,dc=example,dc=org
71+
userPrincipalName: [email protected]
72+
cn: mainuser
73+
gn: One Of
74+
75+
objectClass: user
76+
# password is: abracadabra
77+
userPassword: {SSHA}qLzlip9HesTLxT6qpWIawKXeKsy4L2h6
78+
79+
dn: cn=uniqueuser,ou=users,dc=main,dc=example,dc=org
80+
userPrincipalName: [email protected]
81+
cn: uniqueuser
82+
gn: One Of
83+
84+
objectClass: user
85+
# password is: nothing
86+
userPassword: {SSHA}jK5IJ/ozmZnEE5g6UU9WBsBBPe6LKFZz
87+
88+
dn: cn=nonmainuser,ou=users,dc=subsidiary,dc=example,dc=org
89+
userPrincipalName: [email protected]
90+
cn: nonmainuser
91+
gn: Someone Else
92+
93+
objectClass: user
94+
# password is: simsalabim
95+
userPassword: {SSHA}sHNj89kojBZ5DBHWDwwvzqmL0iuXn0mM
96+
97+
dn: cn=mainuser,ou=users,dc=subsidiary,dc=example,dc=org
98+
userPrincipalName: [email protected]
99+
cn: mainuser
100+
gn: One Of
101+
102+
objectClass: user
103+
# password is: changeit
104+
userPassword: {SSHA}AmOdJt9kOXZ2X4L89w00eKaPQN69W6yb
105+
52106
"""
53107

54108

@@ -136,3 +190,10 @@ def create_auth_provider(server, account_handler, config=None):
136190
})
137191

138192
return LdapAuthProvider(config, account_handler=account_handler)
193+
194+
195+
def get_qualified_user_id(username):
196+
if not username.startswith('@'):
197+
return "@%s:test" % username
198+
199+
return username

0 commit comments

Comments
 (0)