339 lines
14 KiB
Python
Executable file
339 lines
14 KiB
Python
Executable file
#!/usr/bin/python3
|
|
import os
|
|
import sys
|
|
import socketserver
|
|
import logging
|
|
import socket
|
|
import re
|
|
import signal
|
|
|
|
import click
|
|
import requests
|
|
from cachecontrol import CacheControl
|
|
from cachecontrol.heuristics import ExpiresAfter
|
|
|
|
import ldapserver
|
|
from ldapserver.exceptions import LDAPInvalidCredentials, LDAPInsufficientAccessRights, LDAPUnwillingToPerform
|
|
from ldapserver.schema import RFC2307BIS_SCHEMA, RFC2798_SCHEMA
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CUSTOM_SCHEMA = (RFC2307BIS_SCHEMA|RFC2798_SCHEMA).extend(attribute_type_definitions=[
|
|
# pylint: disable=line-too-long
|
|
"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 USAGE dSAOperation )"
|
|
])
|
|
|
|
class UffdAPI:
|
|
def __init__(self, baseurl, client_id, client_secret, cache_ttl=60):
|
|
self.baseurl = baseurl
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self.session = requests.Session()
|
|
self.session.auth = (client_id, client_secret)
|
|
if cache_ttl:
|
|
self.session = CacheControl(self.session, heuristic=ExpiresAfter(seconds=cache_ttl))
|
|
|
|
def get(self, endpoint, **kwargs):
|
|
resp = self.session.get(self.baseurl + endpoint, params=kwargs)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def post(self, endpoint, **kwargs):
|
|
resp = self.session.post(self.baseurl + endpoint, data=kwargs)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
# pylint: disable=invalid-name,redefined-builtin
|
|
def get_users(self, id=None, loginname=None, group=None):
|
|
return self.get('/api/v1/getusers', id=id, loginname=loginname, group=group)
|
|
|
|
def get_groups(self, id=None, name=None, member=None):
|
|
return self.get('/api/v1/getgroups', id=id, name=name, member=member)
|
|
|
|
def check_password(self, loginname, password):
|
|
return self.post('/api/v1/checkpassword', loginname=loginname, password=password)
|
|
|
|
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 ldapserver.rfc4518_stringprep.prepare(loginname, ldapserver.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 UffdLDAPRequestHandler(ldapserver.LDAPRequestHandler):
|
|
subschema = ldapserver.SubschemaSubentry(CUSTOM_SCHEMA, 'cn=Subschema')
|
|
|
|
# Overwritten before use
|
|
api = None
|
|
dn_base = None
|
|
bind_password = None # if None anonymous reads are allowed
|
|
group_filter_regex = None
|
|
|
|
def do_bind_simple_authenticated(self, dn, password):
|
|
dn = self.subschema.DN.from_str(dn)
|
|
if dn == self.subschema.DN('cn=service,ou=system') + self.dn_base and password == self.bind_password:
|
|
return True
|
|
if not dn.is_direct_child_of(self.subschema.DN('ou=users') + self.dn_base) or len(dn[0]) != 1 or dn[0][0].attribute != 'uid':
|
|
raise LDAPInvalidCredentials()
|
|
try:
|
|
if self.api.check_password(loginname=dn[0][0].value, password=password):
|
|
return True
|
|
except requests.exceptions.HTTPError as exc:
|
|
if exc.response.status_code == 403: # We don't have "checkpassword" scope
|
|
raise LDAPInsufficientAccessRights() from exc
|
|
if exc.response.status_code == 429: # Ratelimited
|
|
raise LDAPUnwillingToPerform('Too Many Requests') from exc
|
|
raise exc
|
|
raise LDAPInvalidCredentials()
|
|
|
|
supports_sasl_plain = True
|
|
|
|
def do_bind_sasl_plain(self, identity, password, authzid=None):
|
|
if authzid is not None and identity != authzid:
|
|
raise LDAPInvalidCredentials()
|
|
try:
|
|
if self.api.check_password(loginname=identity, password=password):
|
|
return True
|
|
except requests.exceptions.HTTPError as exc:
|
|
if exc.response.status_code == 403: # We don't have "checkpassword" scope
|
|
raise LDAPInsufficientAccessRights() from exc
|
|
if exc.response.status_code == 429: # Ratelimited
|
|
raise LDAPUnwillingToPerform('Too Many Requests') from exc
|
|
raise exc
|
|
raise LDAPInvalidCredentials()
|
|
|
|
def do_search(self, baseobj, scope, filterobj):
|
|
yield from super().do_search(baseobj, scope, filterobj)
|
|
if self.bind_object or self.bind_password is None:
|
|
yield from self.do_search_static()
|
|
yield from self.do_search_users(baseobj, scope, filterobj)
|
|
yield from self.do_search_groups(baseobj, scope, filterobj)
|
|
|
|
def do_search_static(self):
|
|
base_attrs = {
|
|
'objectClass': ['top', 'dcObject', 'organization'],
|
|
'structuralObjectClass': ['organization'],
|
|
}
|
|
for rdnassertion in self.dn_base[0]: # pylint: disable=unsubscriptable-object
|
|
base_attrs[rdnassertion.attribute] = [rdnassertion.value]
|
|
yield self.subschema.ObjectEntry(self.dn_base, **base_attrs)
|
|
yield self.subschema.ObjectEntry(self.subschema.DN('ou=users') + self.dn_base,
|
|
ou=['users'],
|
|
objectClass=['top', 'organizationalUnit'],
|
|
structuralObjectClass=['organizationalUnit'],
|
|
)
|
|
yield self.subschema.ObjectEntry(self.subschema.DN('ou=groups') + self.dn_base,
|
|
ou=['groups'],
|
|
objectClass=['top', 'organizationalUnit'],
|
|
structuralObjectClass=['organizationalUnit'],
|
|
)
|
|
yield self.subschema.ObjectEntry(self.subschema.DN('ou=system') + self.dn_base,
|
|
ou=['system'],
|
|
objectClass=['top', 'organizationalUnit'],
|
|
structuralObjectClass=['organizationalUnit'],
|
|
)
|
|
yield self.subschema.ObjectEntry(self.subschema.DN('cn=service,ou=system') + self.dn_base,
|
|
cn=['service'],
|
|
objectClass=['top', 'organizationalRole', 'simpleSecurityObject'],
|
|
structuralObjectClass=['organizationalRole'],
|
|
)
|
|
|
|
def do_search_users(self, baseobj, scope, filterobj):
|
|
template = self.subschema.EntryTemplate(self.subschema.DN(self.dn_base, ou='users'), 'uid',
|
|
structuralObjectClass=['inetorgperson'],
|
|
objectClass=['top', 'inetorgperson', 'organizationalperson', 'person', 'posixaccount'],
|
|
cn=ldapserver.WILDCARD,
|
|
displayname=ldapserver.WILDCARD,
|
|
givenname=ldapserver.WILDCARD,
|
|
homeDirectory=ldapserver.WILDCARD,
|
|
mail=ldapserver.WILDCARD,
|
|
sn=[' '],
|
|
uid=ldapserver.WILDCARD,
|
|
uidNumber=ldapserver.WILDCARD,
|
|
memberOf=ldapserver.WILDCARD,
|
|
)
|
|
if not template.match_search(baseobj, scope, filterobj):
|
|
return
|
|
constraints = template.extract_search_constraints(baseobj, scope, filterobj)
|
|
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(self.subschema.DN(self.dn_base, ou='groups')) and value.object_attribute == 'cn':
|
|
request_params = {'group': normalize_group_name(value.object_value)}
|
|
break
|
|
if 'group' in request_params and not self.group_filter_regex.match(request_params['group']):
|
|
return
|
|
for user in self.api.get_users(**request_params):
|
|
yield template.create_entry(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=[self.subschema.DN(self.subschema.DN(self.dn_base, ou='groups'), cn=group)
|
|
for group in user['groups']
|
|
if self.group_filter_regex.match(group)],
|
|
)
|
|
|
|
def do_search_groups(self, baseobj, scope, filterobj):
|
|
template = self.subschema.EntryTemplate(self.subschema.DN(self.dn_base, ou='groups'), 'cn',
|
|
structuralObjectClass=['groupOfUniqueNames'],
|
|
objectClass=['top', 'groupOfUniqueNames', 'posixGroup'],
|
|
cn=ldapserver.WILDCARD,
|
|
description=[' '],
|
|
gidNumber=ldapserver.WILDCARD,
|
|
uniqueMember=ldapserver.WILDCARD,
|
|
)
|
|
if not template.match_search(baseobj, scope, filterobj):
|
|
return
|
|
constraints = template.extract_search_constraints(baseobj, scope, filterobj)
|
|
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(self.subschema.DN(self.dn_base, ou='users')) and value.object_attribute == 'uid':
|
|
request_params = {'member': normalize_user_loginname(value.object_value)}
|
|
break
|
|
if 'name' in request_params and not self.group_filter_regex.match(request_params['name']):
|
|
return
|
|
for group in self.api.get_groups(**request_params):
|
|
if not self.group_filter_regex.match(group['name']):
|
|
continue
|
|
yield template.create_entry(group['name'],
|
|
cn=[group['name']],
|
|
gidNumber=[group['id']],
|
|
uniqueMember=[self.subschema.DN(self.subschema.DN(self.dn_base, ou='users'), uid=user) for user in group['members']],
|
|
)
|
|
|
|
def make_requesthandler(api, dn_base, bind_password=None, group_filter_regex=None):
|
|
class RequestHandler(UffdLDAPRequestHandler):
|
|
pass
|
|
dn_base = RequestHandler.subschema.DN.from_str(dn_base)
|
|
RequestHandler.api = api
|
|
RequestHandler.dn_base = dn_base
|
|
RequestHandler.bind_password = bind_password.encode() if bind_password else None
|
|
RequestHandler.group_filter_regex = re.compile(group_filter_regex) if group_filter_regex else re.compile('')
|
|
return RequestHandler
|
|
|
|
class FilenoUnixStreamServer(socketserver.UnixStreamServer):
|
|
def __init__(self, fd, RequestHandlerClass, bind_and_activate=True):
|
|
self.server_fd = fd
|
|
super().__init__(None, RequestHandlerClass, bind_and_activate=bind_and_activate)
|
|
|
|
def server_bind(self):
|
|
self.socket.close() # UnixStreamServer.__init__ creates an unbound socket
|
|
self.socket = socket.fromfd(self.server_fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.server_address = self.socket.getsockname()
|
|
|
|
class ThreadingFilenoUnixStreamServer(socketserver.ThreadingMixIn, FilenoUnixStreamServer):
|
|
pass
|
|
|
|
def cleanup_unix_socket(path):
|
|
if not os.path.exists(path):
|
|
return
|
|
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
conn.connect(path)
|
|
except ConnectionRefusedError:
|
|
os.remove(path)
|
|
conn.close()
|
|
|
|
def parse_network_address(addr):
|
|
port = '389'
|
|
if addr.startswith('['):
|
|
addr, remainder = addr[1:].split(']', 1)
|
|
if remainder.startswith(':'):
|
|
port = remainder[1:]
|
|
elif ':' in addr:
|
|
addr, port = addr.split(':')
|
|
return addr, port
|
|
|
|
class StdoutFilter(logging.Filter):
|
|
def filter(self, record):
|
|
return record.levelno <= logging.INFO
|
|
|
|
def sigterm_handler(signum, frame):
|
|
logger.info("Received SIGTERM, shutting down gracefully ...")
|
|
sys.exit(0)
|
|
|
|
# pylint: disable=line-too-long
|
|
@click.command(help='LDAP proxy for integrating LDAP service with uffd SSO. Supports user and group searches and as well as binds with user passwords.')
|
|
@click.option('--socket-address', help='Host and port "ip:port" to listen on')
|
|
@click.option('--socket-path', type=click.Path(), help='Path for UNIX domain socket')
|
|
@click.option('--socket-fd', type=int, help='Use fd number as server socket (alternative to --socket-path)')
|
|
@click.option('--api-url', required=True, help='Uffd base URL without API prefix or trailing slash (e.g. https://example.com)')
|
|
@click.option('--api-user', required=True, help='API user/client id')
|
|
@click.option('--api-secret', required=True, help='API secret, do not set this on the command-line, use environment variable SERVER_API_SECRET instead')
|
|
@click.option('--cache-ttl', default=60, help='Time-to-live for API response caching in seconds')
|
|
@click.option('--base-dn', required=True, help='Base DN for user, group and system objects. E.g. "dc=example,dc=com"')
|
|
@click.option('--bind-password', help='Authentication password for the service connection to LDAP. Bind DN is always "cn=service,ou=system,BASEDN". If set, anonymous access is disabled.')
|
|
@click.option('--group-filter-regex', help='Python regular expression that group names must match for the group to be visible to LDAP clients')
|
|
def main(socket_address, socket_path, socket_fd, api_url, api_user, api_secret, cache_ttl, base_dn, bind_password, group_filter_regex):
|
|
# Register signal handler for proper SIGTERM handling
|
|
signal.signal(signal.SIGTERM, sigterm_handler)
|
|
|
|
# pylint: disable=too-many-locals
|
|
if (socket_address is not None) \
|
|
+ (socket_path is not None) \
|
|
+ (socket_fd is not None) != 1:
|
|
raise click.ClickException('Either --socket-address, --socket-path or --socket-fd must be specified')
|
|
|
|
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
stdout_handler.setLevel(logging.INFO)
|
|
stdout_handler.addFilter(StdoutFilter())
|
|
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
stderr_handler.setLevel(logging.WARNING)
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(logging.INFO)
|
|
root_logger.addHandler(stdout_handler)
|
|
root_logger.addHandler(stderr_handler)
|
|
|
|
api = UffdAPI(api_url, api_user, api_secret, cache_ttl)
|
|
RequestHandler = make_requesthandler(api, base_dn, bind_password, group_filter_regex)
|
|
if socket_address is not None:
|
|
host, port = parse_network_address(socket_address)
|
|
server = socketserver.ThreadingTCPServer((host, int(port)), RequestHandler)
|
|
elif socket_path is not None:
|
|
cleanup_unix_socket(socket_path)
|
|
server = socketserver.ThreadingUnixStreamServer(socket_path, RequestHandler)
|
|
else:
|
|
server = ThreadingFilenoUnixStreamServer(socket_fd, RequestHandler)
|
|
server.serve_forever()
|
|
|
|
if __name__ == '__main__':
|
|
# Pylint does not seem to understand click's decorators
|
|
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
|
main(auto_envvar_prefix='SERVER')
|