From 8cd904dcdcfee163a1f60540a8995920d3fb05a5 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 26 Jan 2019 15:19:58 +0100 Subject: [PATCH] Add ldap user create support --- dal/dal/env.sample | 1 + dal/dal/views.py | 174 +++++++++++++--------- nameko-func.py | 350 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 461 insertions(+), 64 deletions(-) create mode 100644 nameko-func.py diff --git a/dal/dal/env.sample b/dal/dal/env.sample index 83a33cb..0e81220 100644 --- a/dal/dal/env.sample +++ b/dal/dal/env.sample @@ -6,3 +6,4 @@ LDAPSEARCHUSERPASSWORD="password here" # Space separated list of search bases for users LDAPSEARCH="ou=users,dc=ungleich,dc=ch ou=customers,dc=ungleich,dc=ch" +LDAPCREATE="ou=customers,dc=ungleich,dc=ch" diff --git a/dal/dal/views.py b/dal/dal/views.py index 0ece0a1..fa2c684 100644 --- a/dal/dal/views.py +++ b/dal/dal/views.py @@ -17,52 +17,91 @@ from datetime import datetime from random import choice, randint import string -from configparser import ConfigParser -config = ConfigParser() -config.read('userservice.conf') +from django.conf import settings +from django_auth_ldap.backend import LDAPBackend +from ldap3 import Server, ServerPool, Connection, ObjectDef, AttrDef, Reader, Writer + + -# Check to see if the username is already taken -# Helper function, not to be set up as a view -# Check the LDAP if the user exists def check_user_exists(username): - with get_pool().next() as rpc: - return rpc.userlookup.lookup(username) + user = LDAPBackend().populate_user(username) -# To trick the tokengenerator to work with us, because we don't really -# have the expected user Class since we are reading the user from a form -# We store the tokens and don't have to use the check function, -# some one time data works fine. - -class LastLogin(): - - def replace(self, microsecond=0, tzinfo=None): - return randint(1,100000) - -class PseudoUser(): - # easiest way to handle the check for lastlogin - last_login = LastLogin() - # random alphanumeric strings for primary key and password, just used for token generation - pk = ''.join(choice(string.ascii_letters + string.digits) for _ in range(20)) - password = ''.join(choice(string.ascii_letters + string.digits) for _ in range(30)) + if not user == None: + return True + else: + return False -# The index page -# If there's a session open, it will give the user the options he/she/it can do, if not, -# it will show a landing page explaining what this is and prompt them to login +class LDAP(object): + def __init__(self): + self.server = settings.AUTH_LDAP_SERVER_URI + self.user = settings.AUTH_LDAP_BIND_DN + self.password = settings.AUTH_LDAP_BIND_PASSWORD + + # FIXME: take from settings + self.search = os.environ['LDAPSEARCH'] + + # FIXME: hard coded + self.dn = "uid={{}},{}".format(os.environ['LDAPCREATE']) + + def create_user(self, user, password, firstname, lastname, email): + conn = Connection(self.server, + self.user, + self.password) + + if not conn.bind(): + raise Exception("Could not connect to LDAPserver {}".format(self.server)) + + # set objectClasses for the new user + obj_new_user = ObjectDef(['inetOrgPerson', 'posixAccount', 'ldapPublicKey'], conn) + + w = Writer(conn, obj_new_user) + dn = self.dn.format(user) + w.new(dn) + + # Filling in some of the data + # required attributes are sn, cn, homeDirectory, uid (already handled by dn), uidNumber, gidNumber + + w[0].givenName = firstname + w[0].sn = lastname + w[0].cn = firstname + " " + lastname + w[0].mail = email + w[0].userPassword = password + w[0].homeDirectory = "/home/{}".format(user) + + # Set uidNumber as last used uidNumber+1 + w[0].uidNumber = self.get_new_uid_number(conn) + # gidNumber for users created by userservice, nice and clear + w[0].gidNumber = 10004 + + if not w.commit(): + conn.unbind() + raise Exception("Could not write new user {} to LDAP DB, commit error".format(user)) + + conn.unbind() + + # Function to get the next uid number. Not elegant, but LAM does it too and didn't really find anything + # nicer. The sorted() seems to be quite efficient, so it shouldn't take too long even on larger arrays + def get_new_uid_number(self, conn): + newuid = 0 + uidlist = [] + + for search in self.search.split(): + conn.search(search, '(&(objectClass=posixAccount)(uidNumber=*))', attributes = [ 'uidNumber' ]) + for c in conn.response: + uidlist.append(c['attributes']['uidNumber']) + + # New uid is highest old uidnumber plus one + newuid = (sorted(uidlist)[len(uidlist)-1] + 1) + return newuid class Index(View): - - # Basic binary choice, if it is an authenticated user, go straight to the options page, - # if not, then show the landing page def get(self, request): if request.user.is_authenticated: return render(request, 'useroptions.html', { 'user': request.user } ) return render(request, 'landing.html') - # Basically does the same as the GET request, just with trying to login the user beforehand - # Shows an errorpage if authentication fails, since just looping to the landing page - # would be frustrating def post(self, request): username = request.POST.get('username') password = request.POST.get('password') @@ -73,12 +112,7 @@ class Index(View): return render(request, 'useroptions.html', { 'user': user } ) return render(request, 'loginfailed.html') - -# Registering a user - class Register(View): - - # Someone wants to register, throw up the page for that def get(self, request): return render(request, 'registeruser.html') @@ -91,23 +125,17 @@ class Register(View): username = request.POST.get('username') if username == "" or not username: return render(request, 'error.html', { 'urlname': urlname, 'service': service, 'error': 'Please supply a username.' } ) - # Check to see if username is already taken - # isalnum() may be a bit harsh, but is the most logical choice to make sure it's a username we - # can use - if not username.isalnum(): - return render(request, 'error.html', { 'urlname': urlname, 'service': service, 'error': 'Username has to be alphanumeric.' } ) - elif check_user_exists(username): + + if check_user_exists(username): return render(request, 'error.html', { 'urlname': urlname, 'service': service, 'error': 'User already exists.' } ) password1 = request.POST.get('password1') password2 = request.POST.get('password2') + # check if the supplied passwords match if password1 != password2: - return render(request, 'error.html', { 'urlname': urlname, 'service': service, - 'error': 'Your passwords did not match. Please supply the same password twice.' } ) - # check for at least a bit of length on the password - if len(password1) < 8: - return render(request, 'error.html', { 'urlname': urlname, 'service': service, - 'error': 'Your password is too short, please use a longer one. At least 8 characters.' } ) + return render(request, 'error.html', { 'urlname': urlname, + 'service': service, + 'error': 'Your passwords did not match. Please supply the same password twice.' } ) email = request.POST.get('email') # Is the emailaddress valid? @@ -120,23 +148,23 @@ class Register(View): lastname = request.POST.get('lastname') if firstname == "" or not firstname or lastname == "" or not lastname: return render(request, 'error.html', { 'urlname': urlname, 'service': service, 'error': 'Please enter your firstname and lastname.' } ) - # throw it to nameko to create the user - with get_pool().next() as rpc: - # so nothing strange happens if there are escapable chars - pwd = r'%s' % password1 - result = rpc.createuser.create_user(username, pwd, firstname, lastname, email) - if result == True: - return render(request, 'usercreated.html', { 'user': username } ) - else: - return render(request, 'error.html', { 'urlname': urlname, 'service': service, 'error': result } ) + # so nothing strange happens if there are escapable chars + pwd = r'%s' % password1 + l = LDAP() + + try: + l.create_user(username, pwd, firstname, lastname, email) + except Exception as e: + return render(request, 'error.html', { 'urlname': urlname, + 'service': service, + 'error': e } ) + + return render(request, 'usercreated.html', { 'user': username } ) -# Change user data for logged in users class ChangeData(View): - - # provide the form for the change request def get(self, request): urlname = 'change_data' @@ -395,7 +423,6 @@ class ChangePassword(View): # Deletes an account class DeleteAccount(View): - # Show the basic form for deleting an account def get(self, request): return render(request, 'deleteaccount.html') @@ -431,7 +458,26 @@ class DeleteAccount(View): # Log out the session class LogOut(View): - def get(self, request): logout(request) return HttpResponse("You have been logged out.", status=200) + + + +# TO be clarified + +# To trick the tokengenerator to work with us, because we don't really +# have the expected user Class since we are reading the user from a form +# We store the tokens and don't have to use the check function, +# some one time data works fine. + +class LastLogin(): + def replace(self, microsecond=0, tzinfo=None): + return randint(1,100000) + +class PseudoUser(): + # easiest way to handle the check for lastlogin + last_login = LastLogin() + # random alphanumeric strings for primary key and password, just used for token generation + pk = ''.join(choice(string.ascii_letters + string.digits) for _ in range(20)) + password = ''.join(choice(string.ascii_letters + string.digits) for _ in range(30)) diff --git a/nameko-func.py b/nameko-func.py new file mode 100644 index 0000000..d43a4bc --- /dev/null +++ b/nameko-func.py @@ -0,0 +1,350 @@ +from nameko.events import EventDispatcher, event_handler +from nameko.rpc import rpc +from configparser import ConfigParser +from ldap3 import Server, ServerPool, Connection, ObjectDef, AttrDef, Reader, Writer +from datetime import datetime + +# For testing +from random import randint + +# Read the config in the nameko.conf +config = ConfigParser() +config.read('nameko.conf') + +# Sanity check for config +try: + mult_server = int(config['LDAP']['SERVERMULTIPLE']) +# SERVERMULTIPLE is set to something not a number +except: + exit("[LDAP] SERVERMULTIPLE has to be an integer >= 1") +# less than one server is not a sensible option +if mult_server < 1: + exit("[LDAP] SERVERMULTIPLE has to be an integer >= 1") + + +# Function to setup the server or serverpool +def ldapservers(): + # Just one server, no need for a pool + if mult_server == 1: + ldapserver = Server(config['LDAP']['LDAPSERVER1'], use_ssl=True) + return ldapserver + # Multiple servers, set up a pool + else: + ldapserver = ServerPool(None) + for x in range(1, (mult_server+1)): + ins = Server(config['LDAP']['LDAPSERVER' + str(x)], use_ssl=True) + ldapserver.add(ins) + return ldapserver + + +# Since there's no reason why someone in ou=users shouldn't use the service, +# here's the helper function to check whether an uid is in ou=customers or +# ou=users +# returns the full dn +def user_or_customer(uid): + server = ldapservers() + conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP','LDAPMANAGERPASSWORD', raw=True)) + conn.bind() + search_customers = conn.search('ou=customers,dc=ungleich,dc=ch', '(%s)' % uid) + if search_customers: + conn.unbind() + return '%s,ou=customers,dc=ungleich,dc=ch' % uid + search_users = conn.search('ou=users,dc=ungleich,dc=ch', '(%s)' % uid) + if search_users: + conn.unbind() + return '%s,ou=users,dc=ungleich,dc=ch' % uid + conn.unbind() + return False + +# Get the objectclasses +def objclasses(rdn, uid, connection): + # search for objectClasses + connection.search(rdn, '(%s)' % uid, attributes=['objectClass']) + objclass = [] + # get the relevant data + tmp = connection.entries[0]['objectClass'] + # This one sets up the array + for y in tmp: + objclass.append(y) + # return the array containing the objectClasses, like ['inetOrgPerson', 'posixAccount', 'ldapPublicKey'] + return objclass + +# checks if a user already exists in the LDAP +class UserLookUp(object): + name = "userlookup" + dispatch = EventDispatcher() + + @rpc + def lookup(self, user): + # Setup the search parameter and connect to LDAP + LDAP_UID = 'uid=%s' % user + server = ldapservers() + conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP','LDAPMANAGERPASSWORD', raw=True)) + conn.bind() + # Strange result. It keeps complaining LDAP_UID not set if I try to directly + # substitute x and y to the if + # Searches for user in ou=customers and ou=users + x = conn.search('ou=customers,dc=ungleich,dc=ch', '(%s)' % LDAP_UID) + y = conn.search('ou=users,dc=ungleich,dc=ch', '(%s)' % LDAP_UID) + if x or y: + # return conn.entries[0] for first search result since we can assume uid is unique + self.dispatch('ldap', '%s [Info: UserLookUp] Searched for %s and found it\n' % (datetime.now(), LDAP_UID) ) + conn.unbind() + # return True since the user is already in LDAP + return True + # User not in LDAP, so just close it down, write the log and return False + else: + conn.unbind() + self.dispatch('ldap', '%s [Info: UserLookUp] Searched for %s and not found it.\n' % (datetime.now(), LDAP_UID) ) + return False + + +# Create a user in the LDAP. Assumes the checks are already in place for existing users +class CreateUser(object): + name = "createuser" + dispatch = EventDispatcher() + + @rpc + def create_user(self, user, password, firstname, lastname, email): + # Creates a user with some basic data + server = ldapservers() + conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP','LDAPMANAGERPASSWORD', raw=True)) + if not conn.bind(): + self.dispatch('ldap', '%s [Error CreateUser] Could not connect to LDAPserver\n' % datetime.now() ) + return "Could not connect to LDAP Server." + + # set objectClasses for the new user + obj_new_user = ObjectDef(['inetOrgPerson', 'posixAccount', 'ldapPublicKey'], conn) + w = Writer(conn, obj_new_user) + dn = 'uid=%s,ou=users,dc=ungleich,dc=ch' % user + w.new(dn) + # Filling in some of the data + # required attributes are sn, cn, homeDirectory, uid (already handled by dn), uidNumber, gidNumber + w[0].givenName = firstname + w[0].sn = lastname + w[0].cn = firstname + " " + lastname + w[0].mail = email + w[0].userPassword = password + w[0].homeDirectory = '/home/%s' % user + # Set uidNumber as last used uidNumber+1 + w[0].uidNumber = self.get_new_uid_number(conn) + # gidNumber for users created by userservice, nice and clear + w[0].gidNumber = 10004 + if not w.commit(): + conn.unbind() + self.dispatch('ldap', '%s [Error CreateUser] Could not write new user %s to LDAP DB\n' % (datetime.now(), dn) ) + return "Couldn't write data to the LDAP Server." + conn.unbind() + self.dispatch('ldap', '%s [Info CreateUser] %s created.\n' % (datetime.now(), dn) ) + return True + + # Function to get the next uid number. Not elegant, but LAM does it too and didn't really find anything + # nicer. The sorted() seems to be quite efficient, so it shouldn't take too long even on larger arrays + def get_new_uid_number(self, conn): + conn.search('dc=ungleich,dc=ch', '(&(objectClass=posixAccount)(uidNumber=*))', attributes = [ 'uidNumber' ]) + newuid = 0 + uidlist = [] + for c in conn.response: + uidlist.append(c['attributes']['uidNumber']) + # New uid is highest old uidnumber plus one + newuid = (sorted(uidlist)[len(uidlist)-1] + 1) + return newuid + + + +# Returns some basic data from an user +class GetUserData(object): + name = "getuserdata" + dispatch = EventDispatcher() + + @rpc + def get_data(self, user): + # Setup the search parameter and connect to LDAP + LDAP_UID = 'uid=%s' % user + server = ldapservers() + conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True)) + conn.bind() + if not conn.bound: + self.dispatch('ldap', '%s [Error GetUserData] Could not connect to LDAP server.\n' % datetime.now() ) + return ("error", "Could not connect to LDAP server.", "", "") + rdn = user_or_customer(LDAP_UID) + if rdn == False: + conn.unbind() + self.dispatch('ldap', '%s [Info GetUserData] Could not find user %s\n' % (datetime.now(), LDAP_UID) ) + return ("error", "Could not find the user.", "", "") + # Workaround because not all users have the same objectClasses + objclass = objclasses(rdn, LDAP_UID, conn) + obj = ObjectDef(objclass, conn) + # The Reader gets the data for the user + r = Reader(conn, obj, rdn) + r.search() + # Since the DN is basically confirmed by user_or_customer() it shouldn't throw an exception, but better check + try: + x = r[0].sn + except: + conn.unbind() + self.dispatch('ldap', '%s [Error GetUserData] Could not open Reader for %s\n' % (datetime.now(), rdn) ) + return ("error", "Could not read data for user.", "", "") + # Putting the results into strings and then clean it up a bit if some attribute is not set in LDAP + (firstname, lastname, email) = (str(r[0].givenName), str(r[0].sn), str(r[0].mail)) + if firstname == '[]': + firstname = 'No firstname given' + if lastname == '[]': + lastname = 'No lastname given' + if email == '[]': + email = 'No email given' + conn.unbind() + self.dispatch('ldap', '%s [Info GetUserData] Got data for %s Firstname: %s Lastname: %s Email: %s\n' % (datetime.now(), rdn, firstname, lastname, email) ) + return ("OK", firstname, lastname, email) + + + +# change some (firstname, lastname, email) data for the user +class ChangeUserData(object): + name = "changeuserdata" + dispatch = EventDispatcher() + + @rpc + def change_data(self, user, firstname, lastname, email): + LDAP_UID = 'uid=%s' % user + server = ldapservers() + # Establish connection with a user who can change the data + conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True)) + if not conn.bind(): + self.dispatch('ldap', '%s [Error ChangeUserData] Could not connect to LDAP server.\n' % datetime.now() ) + return "Could not connect to LDAP server." + # get the DN of the user + rdn = user_or_customer(LDAP_UID) + if rdn == False: + conn.unbind() + self.dispatch('ldap', '%s [Info ChangeUserData] User with %s not found.\n' % (datetime.now(), LDAP_UID) ) + return "Could not find user." + # Fix because not every user has the same objectClasses + objclass = objclasses(rdn, LDAP_UID, conn) + # Set up a reader for the user + obj = ObjectDef(objclass, conn) + r = Reader(conn, obj, rdn) + r.search() + # Again, user_or_customer() should prevent it from throwing an exception because it's a confirmed user + try: + x = r[0].sn + except: + conn.unbind() + self.dispatch('ldap', '%s [Error ChangeUserData] Could not open Reader for %s\n' % (datetime.now(), rdn) ) + return "Could not open the data of user." + # Opens a Writer instance prefilled with the old data + # We could check if something has changed, but since the form takes the old data as standard values, let's + # just update the relevant attributes + w = Writer.from_cursor(r) + w[0].sn = lastname + w[0].cn = firstname + " " + lastname + w[0].givenName = firstname + w[0].mail = email + # check if the data is written + if not w.commit(): + conn.unbind() + self.dispatch('ldap', '%s [Error ChangeUserData] Could not write changes for %s\n' % (datetime.now(), rdn) ) + return "Could not write changes for user." + conn.unbind() + self.dispatch('ldap', '%s [Info ChangeUserData] Changed data for %s Firstname: %s Lastname: %s Email: %s\n' % (datetime.now(), rdn, firstname, lastname, email) ) + return True + + +# change the password for the user +class ChangePassword(object): + name = "changepassword" + dispatch = EventDispatcher() + + @rpc + def change_password(self, user, newpassword): + LDAP_UID = 'uid=%s' % user + server = ldapservers() + conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True)) + if not conn.bind(): + self.dispatch('ldap', '%s [Error ChangePassword] Could not connect to LDAP server.\n' % datetime.now() ) + return "Could not connect to LDAP server." + # check if uid=user is in either ou=customers or ou=users + rdn = user_or_customer(LDAP_UID) + if rdn == False: + conn.unbind() + self.dispatch('ldap', '%s [Error ChangePassword] Could not find user %s\n' % (datetime.now(), LDAP_UID) ) + return "Could not find the user." + # Plus not everyone has the same objectClasses, so workaround + objclass = objclasses(rdn, LDAP_UID, conn) + obj = ObjectDef(objclass, conn) + # Set up a Reader for the DN + r = Reader(conn, obj, rdn) + r.search() + # Shouldn't throw an exception, since the user is confirmed to be there + try: + x = r[0].sn + except: + conn.unbind() + self.dispatch('ldap', '%s [Error ChangePassword] Could not open Reader for %s\n' % (datetime.now(), rdn) ) + return "Could not open the data for the user." + # Set up the writer and overwrite the attribute with the new password + w = Writer.from_cursor(r) + w[0].userPassword = newpassword + # Check to see if the change has gone through + if not w.commit(): + conn.unbind() + self.dispatch('ldap', '%s [Error ChangePassword] Could not write data for %s\n' % (datetime.now(), rdn) ) + return "Could not write data for the user." + conn.unbind() + self.dispatch('ldap', '%s [Info ChangePassword] Password changed for %s\n' % (datetime.now(), rdn) ) + return True + + +# Deletes a user from LDAP +class DeleteUser(object): + name = "deleteuser" + dispatch = EventDispatcher() + + @rpc + def delete_user(self, user): + LDAP_UID = 'uid=%s' % user + server = ldapservers() + conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True)) + conn.bind() + if not conn.bound: + self.dispatch('ldap', '%s [Error DeleteUser] Could not connect to LDAP server.\n' % datetime.now() ) + return "Could not connect to LDAP server." + # again, check whether the uid= is in ou=users or ou=customers + dn = user_or_customer(LDAP_UID) + if dn == False: + conn.unbind() + self.dispatch('ldap', '%s [Error DeleteUser] Could not find the user %s\n' % (datetime.now(), LDAP_UID) ) + return "Could not find the user." + # Check if the delete was successfull + deleted = conn.delete(dn) + if not deleted: + conn.unbind() + self.dispatch('ldap', '%s [Error DeleteUser] Could not delete %s\n' % (datetime.now(), dn) ) + return "Could not delete the user." + conn.unbind() + self.dispatch('ldap', '%s [Info DeleteUser] Deleted %s\n' % (datetime.now(), dn) ) + return True + + +# the class to log all the dispatches +# for now everything gets logged into the same logfile, but +# I don't forsee that much traffic plus with timestamps and the class name +# in the log should be readable +class Log(object): + name = "log" + ldaplog = config['System']['LOGDIR'] + '/ldap.log' + + + # Gets all the dispatches with 'ldap' and writes them into the ldap.log + @event_handler('userlookup', 'ldap') + @event_handler('createuser', 'ldap') + @event_handler('getuserdata', 'ldap') + @event_handler('changeuserdata', 'ldap') + @event_handler('passwordresetrequest', 'ldap') + @event_handler('changepassword', 'ldap') + @event_handler('deleteuser', 'ldap') + def event_handler_ldap(self, payload): + f = open(self.ldaplog, mode='a', encoding='utf-8') + f.write(payload) + f.close +