From 35abb8536574a6ba9cbd985fe4382e4a9242f1b3 Mon Sep 17 00:00:00 2001 From: fram3d Date: Mon, 3 Apr 2023 11:16:15 +0200 Subject: [PATCH] init --- README.md | 3 + luser/__init__.py | 5 + luser/models.py | 203 ++++++++++++++++++++++++++++ luser/routes.py | 129 ++++++++++++++++++ luser/templates/changepassword.html | 35 +++++ luser/templates/register.html | 39 ++++++ luser/templates/unregister.html | 33 +++++ run.py | 4 + 8 files changed, 451 insertions(+) create mode 100644 README.md create mode 100644 luser/__init__.py create mode 100644 luser/models.py create mode 100644 luser/routes.py create mode 100644 luser/templates/changepassword.html create mode 100644 luser/templates/register.html create mode 100644 luser/templates/unregister.html create mode 100755 run.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..a48956c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Flask LDAP user management + +Web app for adding,delating and modifying users using LDAP diff --git a/luser/__init__.py b/luser/__init__.py new file mode 100644 index 0000000..76ce4ac --- /dev/null +++ b/luser/__init__.py @@ -0,0 +1,5 @@ +from flask import Flask + +app = Flask(__name__) + +from luser import routes diff --git a/luser/models.py b/luser/models.py new file mode 100644 index 0000000..b6cbb8e --- /dev/null +++ b/luser/models.py @@ -0,0 +1,203 @@ +from ldap3 import Server,Connection,ALL,MODIFY_REPLACE +from datetime import datetime + +class LUSER(): + ''' + Class that represents secure connection to LDAP server + + LDAPhost := string IP or hostname of LDAP server + admin_user := string DN of LDAP admin user + admin_pass := string password of LDAP admin user + base := string base in LDAP system where users are made + basealt := string base in LDAP system where users are made with password hashes generated for openalt + ''' + + def __init__(self, ldap_host, admin_user, admin_pass, base, basealt=''): + self.ldap_host = ldap_host + self.admin_user = admin_user + self.admin_pass = admin_pass + self.base = base + self.basealt = basealt + self.alt = True + ldapserver = Server(ldap_host, use_ssl=True) + self.ldapconnection = Connection(ldapserver, admin_user, admin_pass, auto_bind=True) + + # uid and gid of most recently registered users + self.lastuid = 1337 + self.lastgid = 1337 + + # Set alt boolean to false if basealt not set + if basealt == '': + self.alt = False + + def prepareluser(self): + ''' + Create base on LDAP host + ''' + + # Split base string with commas to find values of organization and dc + baselist = self.base.split(",") + basealtlist = self.basealt.split(",") + + # Find ou in base and set it as organization variable + for i in baselist: + if i.split('=')[0] == 'ou': + organization = i.split('=')[1] + break + + for i in basealtlist: + if i.split('=')[0] == 'ou': + organizationalt = i.split('=')[1] + break + + # Find first dc and set it as dc variable + for i in baselist: + if i.split('=')[0] == 'dc': + dc = i.split('=')[1] + break + + for i in basealtlist: + if i.split('=')[0] == 'dc': + dcalt = i.split('=')[1] + break + + # Find full dc and set it as dcfull variable + for i in baselist: + if i.split('=')[0] == 'dc': + dcfull += ',dc=' + i.split('=')[1] + + for i in basealtlist: + if i.split('=')[0] == 'dc': + dcfullalt += ',dc=' + i.split('=')[1] + + # Remove first column character + dcfull = dcfull[1:] + dcfullalt = dcfull[1:] + + # Create organization on LDAP server and store boolean indicating it's success + + rcode1 = self.ldapconnection.add(f'dc={dcfull}', ['dcObject', 'organization'], {'o' : dc, 'dc' : dc}) + + if self.alt: + rcode2 = self.ldapconnection.add(f'dc={dcfullalt}', ['dcObject', 'organization'], {'o' : dcalt, 'dc' : dcalt}) + else: + rcode2 = True + + # Create organizational units on LDAP server and store boolean indicating it's success + + rcode3 = self.ldapconnection.add(self.base, ['top', 'organizationalUnit'], {'ou' : organization}) + + if self.alt : + rcode4 = self.ldapconnection.add(self.basealt, ['top', 'organizationalUnit'], {'ou' : organizationalt}) + else: + rcode4 = True + + + # Return True only if all return values are true + + return rcode1 and rcode2 and rcode3 and rcode4 + + def lastpwchangenow(self): + ''' + Return time of last password change for the user set to current time + messured in days from UNIX epoch + ''' + + return str((datetime.utcnow() - datetime(1970,1,1)).days) + + def add(self, user, password, althash=""): + ''' + Add a user to base in LDAP with user and pass as credentials + user := string containing username + password := string containing user password + althash := string containing user password/hash for the alternative base + ''' + + # Increase UID and GID counters + self.lastuid += 1 + self.lastgid += 1 + + + # Add user to base + id = f"uid={user}" + + # Object classes of a user entry + objectClass = ['top', 'person', 'organizationalPerson', 'inetOrgPerson', 'posixAccount', 'shadowAccount'] + + # Attributes for a user entry + attributes = {'cn' : user, 'sn' : user, 'givenName' : user, 'uid' : user, 'uidNumber' : self.lastuid, 'gidNumber' : self.lastgid, 'homeDirectory' : f'/home/{user}', 'loginShell' : '/usr/bin/git-shell', 'gecos' : 'SystemUser', 'shadowLastChange' : self.lastpwchangenow(), 'shadowMax' : '45', 'userPassword' : password} + + attributesalt = {'cn' : user, 'sn' : user, 'givenName' : user, 'uid' : user, 'uidNumber' : self.lastuid, 'gidNumber' : self.lastgid, 'homeDirectory' : f'/home/{user}', 'loginShell' : '/usr//bin/git-shell', 'gecos' : 'SystemUser', 'shadowLastChange' : self.lastpwchangenow(), 'shadowMax' : '45', 'userPassword' : althash} + + # Return boolean value of new user entry + rcode1 = self.ldapconnection.add(f'{id},{self.base}', objectClass, attributes) + + # If alternative base is set add the same user to the alternative base as well, if not act as if it was success + if self.alt: + rcode2 = self.ldapconnection.add(f'{id},{self.basealt}', objectClass, attributesalt) + else: + rcode2 = True + + # Return True only if both entries was successful + return rcode1 and rcode2 + + def changepassword(self, user, newpass, althash=''): + ''' + Change password of user to newpass + + user := string containing username + newpass := string containing new password + althash := string containing password/hash for alternative base + ''' + + # This variable holds boolean indicating successful change of user password + chpassbool = self.ldapconnection.modify(f'uid={user},{self.base}', {'userPassword': (MODIFY_REPLACE,[newpass])}) + + # If alternative base is set modify user password/hash with althash, if not, pretend change was successful + if self.alt: + chpassboolalt = self.ldapconnection.modify(f'uid={user},{self.basealt}', {'userPassword': (MODIFY_REPLACE,[althash])}) + else: + chpassboolalt = True + + # This variable holds boolean indicating successful change of shadowLastChange value to current time + chlastchangebool = self.ldapconnection.modify(f'uid={user},{self.base}', {'shadowLastChange' : (MODIFY_REPLACE,[self.lastpwchangenow()])}) + + # If alternative base is set modify user password/hash with althash, if not, pretend change was successful + if self.alt: + chlastchangeboolalt = self.ldapconnection.modify(f'uid={user},{self.base}', {'shadowLastChange' : (MODIFY_REPLACE, [self.lastpwchangenow()])}) + else: + chlastchangeboolalt = True + + # Return True only if changing of both password and time of last password change was successful + return chpassbool and chpassboolalt and chlastchangebool and chlastchangeboolalt + + def delete(self, user): + ''' + Delete user given username of user + + user := string containing username + ''' + + rcode1 = self.ldapconnection.delete(f'uid={user},{self.base}') + + # If alternative base is set delete user from alternative base, if not, pretend deletion was successful + if self.alt: + rcode2 = self.ldapconnection.delete(f'uid={user},{self.basealt}') + else: + rcode2 = True + + # Return True only if deletion from both bases was successful + return rcode1 and rcode2 + + def getpassword(self, user): + ''' + Retrive password of a user + + user := string containing username + ''' + + # Search LDAP entries that have object class inetOrgPerson and uid attribute equal to given user field + self.ldapconnection.search(search_base=self.base,search_filter=f'(&(objectClass=inetOrgPerson)(uid={user}))', attributes=['userPassword']) + + # Return userPassword attribute from the response + return self.ldapconnection.response[0]['attributes']['userPassword'][0].decode('utf-8') diff --git a/luser/routes.py b/luser/routes.py new file mode 100644 index 0000000..23de665 --- /dev/null +++ b/luser/routes.py @@ -0,0 +1,129 @@ +from flask import render_template, request, redirect +from luser import app +from luser.models import LUSER +from passlib.hash import ldap_salted_sha1,sha512_crypt +import subprocess +import random +import base64 +import os + +LDAPHOST = 'ldap.example.org' +LDAPADMINNAME = 'cn=admin,dc=example,dc=org' +LDAPPASS = 'verysecr3t' +USERBASE = 'ou=Users,dc=example,dc=org' +ALTUSERBASE = '' # Optional +CAPTCHA_PATH = '/var/luser/luser/static/account/register/captcha_img/' + +@app.route('/account/changepassword/', methods=['POST', 'GET']) +def changepassword(): + if request.method == 'GET': + return render_template('changepassword.html') + elif request.method == 'POST': + username = request.form['username'] + oldpassword = request.form['oldpassword'] + newpassword = request.form['newpassword'] + + # Check lenght of password + if len(newpassword) < 8: + return 'Error: password is too short' + + # Create a LUSER connection + luser = LUSER(LDAPHOST,LDAPADMINNAME,LDAPPASS,USERBASE,ALTUSERBASE) + + if ldap_salted_sha1.verify(oldpassword, luser.getpassword(username)) == False: + return 'Wrong username/password combination' + + ldaphash = ldap_salted_sha1.hash(newpassword) + althash = sha512_crypt.hash(newpassword) + + # Try to change user password + try: + if luser.changepassword(username, ldaphash, althash) == True: + return 'User password successfuly changed' + else: + return 'User password change failed' + except: + return 'User password change failed, exception raised' + else: + return 'HTTP request method not recogniezed' + +@app.route('/account/unregister/', methods=['POST', 'GET']) +def unregister(): + if request.method == 'GET': + return render_template('unregister.html') + elif request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + + # Create a LUSER connection + luser = LUSER(LDAPHOST,LDAPADMINNAME,LDAPPASS,USERBASE,ALTUSERBASE) + + if ldap_salted_sha1.verify(password, luser.getpassword(username)) == False: + return 'Wrong username/password combination' + + # Try to delete user + try: + if luser.delete(username) == True: + return 'User successfuly unregistered' + else: + return 'User unregistration failed' + except: + return 'User unregistration failed, exception raised' + else: + return 'HTTP request method not recogniezed' + +@app.route('/account/register/', methods=['POST', 'GET']) +def register(): + if request.method == 'GET': + captcha_solution = str(random.randint(0,999999)) + captcha_hash = ldap_salted_sha1.hash(captcha_solution) + captcha_filename = base64.b64encode(captcha_hash.encode('utf-8')).decode('utf-8')[8:-1] + ".png" + captcha_path = CAPTCHA_PATH + captcha_filename + captcha_file = open(captcha_path, 'w') + subprocess.run(["/usr/local/bin/captcha.sh",captcha_solution],stdout=captcha_file) + return render_template('register.html',imgsrc=captcha_filename,captchahash=captcha_hash) + elif request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + captcha_answer = request.form['captchaa'] + captcha_filename = request.form['captchaq'] + captcha_path = CAPTCHA_PATH + captcha_filename + + if captcha_filename[:-4].isalnum() == False and len(captcha_filename) != 47 : + return 'Error: Captcha question is manipulated' + + captcha_valid = True + + if os.path.exists(captcha_path) == False: + captcha_valid = False + else: + os.remove(captcha_path) + + captcha_hash = base64.b64decode(base64.b64encode(b"{SSHA}").decode('utf-8') + captcha_filename[:-4] + "=").decode('utf-8') + + if ldap_salted_sha1.verify(captcha_answer, captcha_hash) == False: + captcha_valid = False + + if captcha_valid == False: + return 'Error: Captcha is wrong!' + + # Check lenght of password + if len(password) < 8: + return 'Error: password is too short' + + # Create a LUSER connection + luser = LUSER(LDAPHOST,LDAPADMINNAME,LDAPPASS,USERBASE,ALTUSERBASE) + # Try to add user + try: + ldaphash = ldap_salted_sha1.hash(password) + althash = sha512_crypt.hash(password) + #smtpctlout=subprocess.run(["smtpctl","encrypt", password],text=True,stdout=subprocess.PIPE) + #smtpdhash=smtpctlout.stdout[:-1] + if luser.add(username,ldaphash,althash): + return 'User successfuly registered' + else: + return 'User registration failed, username probably taken' + except: + return 'User registration failed, exception raised' + else: + return 'HTTP request method not recogniezed' diff --git a/luser/templates/changepassword.html b/luser/templates/changepassword.html new file mode 100644 index 0000000..349c618 --- /dev/null +++ b/luser/templates/changepassword.html @@ -0,0 +1,35 @@ + + + + + + User password change in LDAP system + + + +
+

Change password

+
+ + + + + + +
+ +
+
+ + diff --git a/luser/templates/register.html b/luser/templates/register.html new file mode 100644 index 0000000..f033282 --- /dev/null +++ b/luser/templates/register.html @@ -0,0 +1,39 @@ + + + + + + User registration to LDAP system + + + +
+

Register

+
+ + + + +

password must be at least 8 characters long

+
+ {{ captchahash }} +

+ + + + +

+
+ + diff --git a/luser/templates/unregister.html b/luser/templates/unregister.html new file mode 100644 index 0000000..aeb3182 --- /dev/null +++ b/luser/templates/unregister.html @@ -0,0 +1,33 @@ + + + + + + User unregistration to LDAP system + + + +
+

Unregister

+
+ + + + +
+ +
+
+ + diff --git a/run.py b/run.py new file mode 100755 index 0000000..7b8a9a5 --- /dev/null +++ b/run.py @@ -0,0 +1,4 @@ +from luser import app + +if __name__ == '__main__': + app.run(debug=False)