From 785955b6f049cb538f071446388623f41f8291bb Mon Sep 17 00:00:00 2001 From: Andrea Cimbalo Date: Sun, 21 Aug 2016 23:43:02 +0200 Subject: [PATCH] api works, admin check not implemented yet --- .gitignore | 2 + config.py.sample | 6 +++ lilikusers.py | 122 +++++++++++++++++++++++++++++++++++++++++++++++ server.py | 53 ++++++++++++++++++++ utils.py | 45 +++++++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 .gitignore create mode 100644 config.py.sample create mode 100644 lilikusers.py create mode 100755 server.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df81b2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +config.py diff --git a/config.py.sample b/config.py.sample new file mode 100644 index 0000000..dfc9546 --- /dev/null +++ b/config.py.sample @@ -0,0 +1,6 @@ +DOMAIN = 'dc=lilik', 'dc=it' +PEOPLE = 'o=People' +GROUP = 'o=Group' +#SERVER = '' +#ADMIN_CN = '' +#ADMIN_PASSWORD = '' diff --git a/lilikusers.py b/lilikusers.py new file mode 100644 index 0000000..e347985 --- /dev/null +++ b/lilikusers.py @@ -0,0 +1,122 @@ +#!/bin/usr/env python3 +import ldap3 +import utils +import config + +def connection_decorator(f): + with ldap3.Connection(config.SERVER, auto_bind=True) as conn: + def wrapped_f(*args, **kwargs): + return f(args[0], conn, *args[1:], **kwargs) + return wrapped_f + +def admin_connection_decorator(f): + with ldap3.Connection(config.SERVER, user=config.ADMIN_CN, password=config.ADMIN_PASSWORD, auto_bind=True) as conn: + def wrapped_f(*args, **kwargs): + return f(args[0], conn, *args[1:], **kwargs) + return wrapped_f + +class LILiK_USER(object): + _attributes = {'uid': [], 'cn': ['givenName']} + _flags = {'mail': 'accountActive'} + _hosts = ['ltsp', + 'users'] + _groups = ['admin', + 'wiki', + 'lilik.it', + 'cloud', + 'projects', + 'teambox', + 'im'] + _posix_groups = ['users_sites'] + def __init__(self, entry, posix_groups): + results = {} + for attribute in self._attributes.keys(): + self.__setattr__(attribute, str(entry.__getattr__(attribute))) + services = {} + + for service, flag in self._flags.items(): + services[service] = flag in entry and bool(entry[flag]) + for host in self._hosts: + services[host] = 'host' in entry and host in entry['host'] + for group in self._groups: + services[group] = 'memberOf' in entry and utils.ldap_path("cn=%s"%utils.clean_value(group), config.GROUP, config.DOMAIN) in list(entry['memberOf']) + for posix_group in self._posix_groups: + services[posix_group] = posix_group in posix_groups and str(entry.uid) in list(posix_groups[posix_group]) + self.__setattr__('services', services) + + def to_json(self): + return json.dumps(self, default=lambda o: o.__dict__,) + + def to_dict(self): + return self.__dict__ + + @admin_connection_decorator + def update(self, conn, new_lilik_user): + user_cn = utils.ldap_path('uid=%s'%self.uid, config.PEOPLE, config.DOMAIN) + diff = utils.DictDiffer(new_lilik_user, self.__dict__) + modifiers = {user_cn: {}} + if 'userPassword' in diff.added(): + modifiers[user_cn]['userPassword'] = [(ldap3.MODIFY_REPLACE, [new_lilik_user['userPassword']])] #TODO add hash encryption? + for changed in diff.changed(): + if changed == 'services': + services_diff = utils.DictDiffer(self.__dict__[changed], new_lilik_user[changed]) + for service_changed in services_diff.changed(): + if service_changed in self._flags: + flag = self._flags[service_changed] + modifiers[user_cn][flag] = [(ldap3.MODIFY_REPLACE, [new_lilik_user['services'][service_changed]])] + + elif service_changed in self._hosts: + action = ldap3.MODIFY_ADD if new_lilik_user['services'][service_changed] else ldap3.MODIFY_DELETE + modifiers[user_cn]['host'] = [(action, [service_changed])] + + elif service_changed in self._groups: + group_cn = utils.ldap_path('cn=%s'%service_changed, config.GROUP, config.DOMAIN) + action = ldap3.MODIFY_ADD if new_lilik_user['services'][service_changed] else ldap3.MODIFY_DELETE + modifiers[group_cn] = {'member': [(action, [user_cn])]} + + elif service_changed in self._posix_groups: + group_cn = utils.ldap_path('cn=%s'%service_changed, config.GROUP, config.DOMAIN) + action = ldap3.MODIFY_ADD if new_lilik_user['services'][service_changed] else ldap3.MODIFY_DELETE + modifiers[group_cn] = {'memberUid': [(action, [self.uid])]} + else: + raise Exception('Unknown user attribute') + else: + for alias in self._attributes[changed]: + modifiers[user_cn][alias] = [(ldap3.MODIFY_REPLACE, [new_lilik_user[changed]])] + modifiers[user_cn][changed] = [(ldap3.MODIFY_REPLACE, [new_lilik_user[changed]])] + for entry_cn, modifier in modifiers.items(): + if modifier: + conn.modify(entry_cn, modifier) + if conn.result['result'] != 0: + return False + return True + +class LILiK_LDAP(object): + + def login(self, user_name, password): + bind_dn = utils.ldap_path('uid=%s'%utils.clean_user_name(user_name), config.PEOPLE, config.DOMAIN) + c = ldap3.Connection(config.SERVER, user=bind_dn, password=password) + return c.bind() + + @connection_decorator + def get_users(self, conn): + conn.search(utils.ldap_path(config.PEOPLE, config.DOMAIN), '(objectclass=posixAccount)', attributes=['uid']) + return [str(a.uid) for a in conn.entries] + + @connection_decorator + def get_user(self, conn, user_name): + conn.search(utils.ldap_path(config.PEOPLE, config.DOMAIN), '(&(objectclass=posixAccount)(uid=%s))'%utils.clean_user_name(user_name), attributes=['*', 'memberOf']) + # return clean_user_name(user_name) + if len(conn.entries) == 0: + return None + entry = conn.entries[0] + return LILiK_USER(entry, self.get_posix_groups()) + + @connection_decorator + def get_posix_groups(self, conn): + conn.search(utils.ldap_path(config.GROUP, config.DOMAIN), '(objectclass=posixGroup)', attributes=['*']) + # return clean_user_name(user_name) + results = {} + for group in conn.entries: + results[str(group.cn)] = list(group.memberUid) if 'memberUid' in group else [] + return results diff --git a/server.py b/server.py new file mode 100755 index 0000000..8f61aa8 --- /dev/null +++ b/server.py @@ -0,0 +1,53 @@ +#!/bin/usr/env python3 +import lilikusers +import json +from flask import Flask, jsonify +from flask import request, Response +from functools import wraps + +app = Flask(__name__) + +lilik_ldap = lilikusers.LILiK_LDAP() + +def check_auth(user_name, password): + """This function is called to check if a username / + password combination is valid. + """ + return lilik_ldap.login(user_name, password) + +def authenticate(): + """Sends a 401 response that enables basic auth""" + return Response( + 'Could not verify your access level for that URL.\n' + 'You have to login with proper credentials', 401, + {'WWW-Authenticate': 'Basic realm="Login Required"'}) + +def requires_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() + return f(*args, **kwargs) + return decorated + +@app.route('/api/users', methods=['GET']) +@requires_auth +def get_users(): + ''' return the list of users''' + return jsonify(lilik_ldap.get_users()) + +@app.route('/api/user/', methods=['GET']) +@requires_auth +def get_user(user_name): + ''' return the list of users''' + return jsonify(lilik_ldap.get_user(user_name).to_dict()) + +@app.route('/api/user/', methods=['POST']) +@requires_auth +def post_user(self, user_name): + new_lilik_user = request.get_json() + return lilik_ldap.get_user(user_name).update(new_lilik_user) + +if __name__ == '__main__': + app.run(debug=True) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..1cf15fb --- /dev/null +++ b/utils.py @@ -0,0 +1,45 @@ +def ldap_path(entry, *arg): + if arg: + return "%s,%s"%(ldap_path(entry), ldap_path(arg)) + else: + if type(entry) == tuple: + return ldap_path(*entry) + if entry.find(',') != -1: + print(entry) + raise Exception('Warning: ldap query injection detected') + return entry + +def clean_user_name(user_name): + return clean_value(user_name.rstrip('lilik.it')) + +def clean_value(value): + ''' + TODO: + search_filter: the filter of the search request. It must conform to the LDAP + filter syntax specified in RFC4515. If the search filter contains the + following characters you must use the relevant escape ASCII sequence, as per + RFC4515 (section 3): + ‘*’ -> ‘\\2A’, ‘(‘ -> ‘\\28’, ‘)’ -> ‘\\29’, ‘\’ -> ‘\\5C’, chr(0) -> ‘\\00’. + ''' + return value + +class DictDiffer(object): + """ + Calculate the difference between two dictionaries as: + (1) items added + (2) items removed + (3) keys same in both but changed values + (4) keys same in both and unchanged values + """ + def __init__(self, current_dict, past_dict): + self.current_dict, self.past_dict = current_dict, past_dict + self.set_current, self.set_past = set(current_dict.keys()), set(past_dict.keys()) + self.intersect = self.set_current.intersection(self.set_past) + def added(self): + return self.set_current - self.intersect + def removed(self): + return self.set_past - self.intersect + def changed(self): + return set(o for o in self.intersect if self.past_dict[o] != self.current_dict[o]) + def unchanged(self): + return set(o for o in self.intersect if self.past_dict[o] == self.current_dict[o])