diff --git a/requirements.txt b/requirements.txt index b636ab0156383ac8a5b81f7180d803d0e6b9849e..13331b700bbaed402feb962a0253b10ea531e11b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ --extra-index-url https://git.cccv.de/api/v4/projects/220/packages/pypi/simple -ldapserver==0.0.1.dev3 +ldapserver==0.0.1.dev4 +requests==2.* CacheControl diff --git a/server.py b/server.py index 502d168260e8be9352f9953d8134a52a721ac017..06681dde85f3499df8bf97a2cbc1386d46ea3b28 100644 --- a/server.py +++ b/server.py @@ -5,13 +5,22 @@ import requests from cachecontrol import CacheControl from cachecontrol.heuristics import ExpiresAfter -from ldapserver import SimpleLDAPRequestHandler +import ldapserver +from ldapserver import LDAPRequestHandler, rfc4518_stringprep from ldapserver.dn import DN -from ldapserver.ldap import FilterEqual, FilterAnd -from ldapserver.directory import BaseDirectory, SimpleFilterMixin, StaticDirectory, eval_ldap_filter -from ldapserver.util import encode_attribute, CaseInsensitiveDict from ldapserver.exceptions import LDAPInvalidCredentials -from ldapserver.schema import RFC2307BIS_SUBSCHEMA +from ldapserver.schema import RFC2307BIS_SUBSCHEMA, RFC2798_SUBSCHEMA, WILDCARD_VALUE + +memberOf = ldapserver.schema.AttributeType( + '1.2.840.113556.1.2.102', + name='memberOf', + desc='Group that the entry belongs to', + equality=ldapserver.schema.rfc4517.matching_rules.distinguishedNameMatch, + syntax=ldapserver.schema.rfc4517.syntaxes.DN(), + usage=ldapserver.schema.AttributeTypeUsage.dSAOperation +) + +CUSTOM_SUBSCHEMA = RFC2307BIS_SUBSCHEMA.extend(RFC2798_SUBSCHEMA, attribute_types=[memberOf]) class UffdAPI: def __init__(self, baseurl, key, cache_ttl=60): @@ -30,211 +39,50 @@ class UffdAPI: assert(resp.ok) return resp.json() -class UserDirectory(SimpleFilterMixin, BaseDirectory): - def __init__(self, api, dn_base): - self.api = api - self.rdn_attr = 'uid' - self.dn_base = DN('ou=users') + DN(dn_base) - self.group_dn_base = DN('ou=groups') + DN(dn_base) - self.structuralobjectclass = b'inetorgperson' - self.objectclasses = [b'top', b'inetorgperson', b'organizationalperson', b'person', b'posixaccount'] - self.attributes = ['structuralobjectclass', 'objectclass', 'cn', 'displayname', 'givenname', 'homedirectory', 'mail', 'sn', 'uid', 'uidnumber', 'memberof'] - - def generate_result(self, user): - attributes = CaseInsensitiveDict( - structuralObjectClass=[self.structuralobjectclass], - objectClass=self.objectclasses, - cn=[encode_attribute(user['displayname'])], - displayname=[encode_attribute(user['displayname'])], - givenname=[encode_attribute(user['displayname'])], - homeDirectory=[encode_attribute('/home/'+user['loginname'])], - mail=[encode_attribute(user['email'])], - sn=[encode_attribute(' ')], - uid=[encode_attribute(user['loginname'])], - uidNumber=[encode_attribute(user['id'])], - memberOf=[encode_attribute(DN(cn=group) + self.group_dn_base) for group in user['groups']], - ) - dn = str(DN(uid=user['loginname']) + self.dn_base) - return dn, attributes - - def get_best_api_param(self, expr): - if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uid': - return 'loginname', expr.value - if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uidnumber': - return 'id', expr.value - if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'mail': - return 'email', expr.value - if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'memberof': - group_dn = DN.from_str(expr.value.decode()) - if group_dn.is_direct_child_of(self.group_dn_base) and len(group_dn[0]) == 1 and group_dn[0][0].attribute == 'cn': - return 'group', group_dn[0][0].value - if isinstance(expr, FilterAnd): - params = dict([self.get_best_api_param(subexpr) for subexpr in expr.filters]) - for key in ['loginname', 'id', 'email', 'group']: - if key in params: - return key, params[key] - return None, None - - def search_fetch(self, expr): - if expr is False: - return - kwargs = {} - key, value = self.get_best_api_param(expr) - if key is not None: - kwargs[key] = value - for user in self.api.get('getusers', **kwargs): - dn, obj = self.generate_result(user) - if eval_ldap_filter(obj, expr): - yield dn, obj - - def filter_equal(self, attribute, value): - if attribute == 'memberof': - value = str(DN.from_str(value.decode())).encode() - return super().filter_equal(attribute, value) - - def filter_present(self, attribute): - if attribute not in self.attributes: - return False - return super().filter_present(attribute) - -class GroupDirectory(SimpleFilterMixin, BaseDirectory): - def __init__(self, api, dn_base): - self.api = api - self.rdn_attr = 'cn' - self.dn_base = DN('ou=groups') + DN(dn_base) - self.user_dn_base = DN('ou=users') + DN(dn_base) - self.structuralobjectclass = b'groupOfUniqueNames' - self.objectclasses = [b'top', b'groupOfUniqueNames', b'posixGroup'] - self.attributes = ['structuralobjectclass', 'objectclass', 'cn', 'description', 'gidnumber', 'uniquemember'] - - def generate_result(self, group): - attributes = CaseInsensitiveDict( - structuralObjectClass=[self.structuralobjectclass], - objectClass=self.objectclasses, - cn=[encode_attribute(group['name'])], - description=[encode_attribute(' ')], - gidNumber=[encode_attribute(group['id'])], - uniqueMember=[encode_attribute(DN(uid=user) + self.user_dn_base) for user in group['members']], - ) - dn = str(DN(cn=group['name']) + self.dn_base) - return dn, attributes - - def get_best_api_param(self, expr): - if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'cn': - return 'name', expr.value - elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'gidnumber': - return 'id', expr.value - elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uniquemember': - user_dn = DN.from_str(expr.value.decode()) - if user_dn.is_direct_child_of(self.user_dn_base) and len(user_dn[0]) == 1 and user_dn[0][0].attribute == 'uid': - return 'member', user_dn[0][0].value - if isinstance(expr, FilterAnd): - params = dict([self.get_best_api_param(subexpr) for subexpr in expr.filters]) - for key in ['name', 'id', 'member']: - if key in params: - return key, params[key] - return None, None - - def search_fetch(self, expr): - if expr is False: - return - kwargs = {} - key, value = self.get_best_api_param(expr) - if key is not None: - kwargs[key] = value - for group in self.api.get('getgroups', **kwargs): - dn, obj = self.generate_result(group) - if eval_ldap_filter(obj, expr): - yield dn, obj - - def filter_equal(self, attribute, value): - if attribute == 'uniquemember': - value = str(DN.from_str(value.decode())).encode() - return super().filter_equal(attribute, value) - - def filter_present(self, attribute): - if attribute not in self.attributes: - return False - return super().filter_present(attribute) - -class MailDirectory(SimpleFilterMixin, BaseDirectory): - def __init__(self, api, dn_base): - self.api = api - self.rdn_attr = 'uid' - self.dn_base = DN('ou=postfix') + DN(dn_base) - self.structuralobjectclass = b'postfixVirtual' - self.objectclasses = [b'top', b'postfixVirtual'] - self.attributes = ['structuralobjectclass', 'objectclass', 'uid', 'mailacceptinggeneralid', 'maildrop'] - - def generate_result(self, mail): - attributes = CaseInsensitiveDict( - structuralObjectClass=[self.structuralobjectclass], - objectClass=self.objectclasses, - uid=[encode_attribute(mail['name'])], - mailacceptinggeneralid=[encode_attribute(address) for address in mail['receive_addresses']], - maildrop=[encode_attribute(address) for address in mail['destination_addresses']], - ) - dn = str(DN(uid=mail['name']) + self.dn_base) - return dn, attributes - - def get_best_api_param(self, expr): - if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uid': - return 'name', expr.value - elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'mailacceptinggeneralid': - return 'receive_address', expr.value - elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'maildrop': - return 'destination_address', expr.value - if isinstance(expr, FilterAnd): - params = dict([self.get_best_api_param(subexpr) for subexpr in expr.filters]) - for key in ['name', 'receive_address', 'destination_address']: - if key in params: - return key, params[key] - return None, None - - def search_fetch(self, expr): - if expr is False: - return - kwargs = {} - key, value = self.get_best_api_param(expr) - if key is not None: - kwargs[key] = value - for mail in self.api.get('getmails', **kwargs): - dn, obj = self.generate_result(mail) - if eval_ldap_filter(obj, expr): - yield dn, obj - - def filter_present(self, attribute): - if attribute not in self.attributes: - return False - return super().filter_present(attribute) - -class RequestHandler(SimpleLDAPRequestHandler): - subschema = RFC2307BIS_SUBSCHEMA +def normalize_user_loginname(loginname): + # The equality matching rule for uid is caseIgnoreMatch. It prepares + # attribute and assertion value according to LDAP stringprep with + # case-folding. + # + # Uffd restricts loginnames to lower-case ASCII letters, digits, + # underscores and dashes. None of these characters are changed or + # rejetced by stringprep with case-folding. The effect stringprep has + # on loginnames is that it adds a leading and a final SPACE character. + # + # The assertion value (the argument to this function) could however contain + # characters that are mapped to SPACE or nothing for example. So we apply + # stringprep to the assertion value and then strip the added leading and + # final SPACE characters. Stringprep case-folds the input string to + # lower-case. + # + # The resulting string can be compared to an actual loginname with simple + # byte-for-byte or codepoint-for-codepoint comparison with or without + # case-folding. It matches if and only if the loginname matches the input + # value according to caseIgnoreMatch. + try: + return rfc4518_stringprep.prepare(loginname, rfc4518_stringprep.MatchingType.CASE_IGNORE_STRING).strip(' ') + except ValueError: # Input value contains prohibited characters + return None + +def normalize_group_name(name): + # Currently uffd has no restrictions for group names, but it is planned + # to add restrictions similar to loginname restrictions. + # See https://git.cccv.de/uffd/uffd/-/issues/127 + return normalize_user_loginname(name) + +class RequestHandler(LDAPRequestHandler): + subschema = CUSTOM_SUBSCHEMA # Overwritten before use api = None dn_base = None - static_directory = None - user_directory = None - group_directory = None - mail_directory = None bind_dn = None bind_password = None def setup(self): super().setup() - def handle(self): - print('CONNECT') - super().handle() - print('DISCONNECT') - - def handle_message(self, shallowmsg): - print('MSG', shallowmsg.data) - return super().handle_message(shallowmsg) - def do_bind_simple_authenticated(self, dn, password): - print('BIND plain', dn) dn = DN.from_str(dn) if dn == self.bind_dn and password == self.bind_password: return True @@ -247,7 +95,6 @@ class RequestHandler(SimpleLDAPRequestHandler): supports_sasl_plain = True def do_bind_sasl_plain(self, identity, password, authzid=None): - print('BIND sasl', identity, authzid) if authzid is not None and identity != authzid: raise LDAPInvalidCredentials() user = self.api.post('checkpassword', loginname=identity, password=password) @@ -256,56 +103,114 @@ class RequestHandler(SimpleLDAPRequestHandler): return user def do_search(self, baseobj, scope, filter): - print('SEARCH %s "%s" %s'%(scope.name, baseobj, filter.get_filter_string())) yield from super().do_search(baseobj, scope, filter) if self.bind_object: - yield from self.static_directory.search(baseobj, scope, filter) - yield from self.user_directory.search(baseobj, scope, filter) - yield from self.group_directory.search(baseobj, scope, filter) - if self.mail_directory is not None: - yield from self.mail_directory.search(baseobj, scope, filter) + yield from self.do_search_static() + yield from self.do_search_users(baseobj, scope, filter) + yield from self.do_search_groups(baseobj, scope, filter) + + def do_search_static(self): + base_attrs = { + 'objectClass': ['top', 'dcObject', 'organization'], + 'structuralObjectClass': ['organization'], + } + for rdnassertion in self.dn_base[0]: + base_attrs[rdnassertion.attribute] = [rdnassertion.value] + yield self.subschema.Object(self.dn_base, **base_attrs) + yield self.subschema.Object(DN('ou=users') + self.dn_base, + ou=['users'], + objectClass=['top', 'organizationalUnit'], + structuralObjectClass=['organizationalUnit'], + ) + yield self.subschema.Object(DN('ou=groups') + self.dn_base, + ou=['groups'], + objectClass=['top', 'organizationalUnit'], + structuralObjectClass=['organizationalUnit'], + ) + yield self.subschema.Object(DN('ou=system') + self.dn_base, + ou=['system'], + objectClass=['top', 'organizationalUnit'], + structuralObjectClass=['organizationalUnit'], + ) + yield self.subschema.Object(DN('cn=service,ou=system') + self.dn_base, + cn=['service'], + objectClass=['top', 'organizationalRole', 'simpleSecurityObject'], + structuralObjectClass=['organizationalRole'], + ) + + def do_search_users(self, baseobj, scope, filter): + template = self.subschema.ObjectTemplate(DN(self.dn_base, ou='users'), 'uid', + structuralObjectClass=['inetorgperson'], + objectClass=['top', 'inetorgperson', 'organizationalperson', 'person', 'posixaccount'], + cn=[WILDCARD_VALUE], + displayname=[WILDCARD_VALUE], + givenname=[WILDCARD_VALUE], + homeDirectory=[WILDCARD_VALUE], + mail=[WILDCARD_VALUE], + sn=[' '], + uid=[WILDCARD_VALUE], + uidNumber=[WILDCARD_VALUE], + memberOf=[WILDCARD_VALUE], + ) + if not template.match_search(baseobj, scope, filter): + return + constraints = template.extract_search_constraints(baseobj, scope, filter) + request_params = {} + if 'uid' in constraints: + request_params = {'loginname': normalize_user_loginname(constraints['uid'][0])} + elif 'uidnumber' in constraints: + request_params = {'id': constraints['uidnumber'][0]} + elif 'memberof' in constraints: + for value in constraints['memberof']: + if value.is_direct_child_of(DN(self.dn_base, ou='groups')) and value.object_attribute == 'cn': + request_params = {'group': normalize_group_name(value.object_value)} + break + for user in self.api.get('getusers', **request_params): + yield template.create_object(user['loginname'], + cn=[user['displayname']], + displayname=[user['displayname']], + givenname=[user['displayname']], + homeDirectory=['/home/'+user['loginname']], + mail=[user['email']], + uid=[user['loginname']], + uidNumber=[user['id']], + memberOf=[DN(DN(self.dn_base, ou='groups'), cn=group) for group in user['groups']], + ) + + def do_search_groups(self, baseobj, scope, filter): + template = self.subschema.ObjectTemplate(DN(self.dn_base, ou='groups'), 'cn', + structuralObjectClass=['groupOfUniqueNames'], + objectClass=['top', 'groupOfUniqueNames', 'posixGroup'], + cn=[WILDCARD_VALUE], + description=[' '], + gidNumber=[WILDCARD_VALUE], + uniqueMember=[WILDCARD_VALUE], + ) + if not template.match_search(baseobj, scope, filter): + return + constraints = template.extract_search_constraints(baseobj, scope, filter) + request_params = {} + if 'cn' in constraints: + request_params = {'name': normalize_group_name(constraints['cn'][0])} + elif 'gidnumber' in constraints: + request_params = {'id': constraints['gidnumber'][0]} + elif 'uniquemember' in constraints: + for value in constraints['uniquemember']: + if value.is_direct_child_of(DN(self.dn_base, ou='users')) and value.object_attribute == 'uid': + request_params = {'member': normalize_user_loginname(value.object_value)} + break + for group in self.api.get('getgroups', **request_params): + yield template.create_object(group['name'], + cn=[group['name']], + gidNumber=[group['id']], + uniqueMember=[DN(DN(self.dn_base, ou='users'), uid=user) for user in group['members']], + ) def main(config): dn_base = DN.from_str(config['dn_base']) api = UffdAPI(config['api_baseurl'], config['api_key'], config.get('cache_ttl', 60)) - user_directory = UserDirectory(api, dn_base) - group_directory = GroupDirectory(api, dn_base) - mail_directory = MailDirectory(api, dn_base) - static_directory = StaticDirectory() - base_attrs = { - 'objectClass': ['top', 'dcObject', 'organization'], - 'structuralObjectClass': ['organization'], - } - for rdnassertion in dn_base[0]: - base_attrs[rdnassertion.attribute] = [rdnassertion.value] - static_directory.add(dn_base, base_attrs) - static_directory.add(DN('ou=users') + dn_base, { - 'ou': ['users'], - 'objectClass': ['top', 'organizationalUnit'], - 'structuralObjectClass': ['organizationalUnit'], - }) - static_directory.add(DN('ou=groups') + dn_base, { - 'ou': ['groups'], - 'objectClass': ['top', 'organizationalUnit'], - 'structuralObjectClass': ['organizationalUnit'], - }) - if config.get('enable_mail'): - static_directory.add(DN('ou=postfix') + dn_base, { - 'ou': ['postfix'], - 'objectClass': ['top', 'organizationalUnit'], - 'structuralObjectClass': ['organizationalUnit'], - }) - static_directory.add(DN('ou=system') + dn_base, { - 'ou': ['system'], - 'objectClass': ['top', 'organizationalUnit'], - 'structuralObjectClass': ['organizationalUnit'], - }) - static_directory.add(DN('cn=service,ou=system') + dn_base, { - 'cn': ['service'], - 'objectClass': ['top', 'organizationalRole', 'simpleSecurityObject'], - 'structuralObjectClass': ['organizationalRole'], - }) + subschema = RFC2307BIS_SUBSCHEMA class CustomRequestHandler(RequestHandler): pass @@ -314,11 +219,6 @@ def main(config): CustomRequestHandler.dn_base = dn_base CustomRequestHandler.bind_dn = DN('cn=service,ou=system') + dn_base CustomRequestHandler.bind_password = config['bind_password'].encode() - CustomRequestHandler.static_directory = static_directory - CustomRequestHandler.user_directory = user_directory - CustomRequestHandler.group_directory = group_directory - if config.get('enable_mail'): - CustomRequestHandler.mail_directory = mail_directory if config['listen_addr'].startswith('unix:'): socketserver.ThreadingUnixStreamServer(config['listen_addr'][5:], CustomRequestHandler).serve_forever()