diff --git a/src/memberdb/account.py b/src/memberdb/account.py index a2fa0ee957b5aff4e5982ca8ffe3247ee5770416..5f911c0f1cd3747cdce27e480f708ca32356af96 100644 --- a/src/memberdb/account.py +++ b/src/memberdb/account.py @@ -9,27 +9,26 @@ from formtools.wizard.views import SessionWizardView from .models import Member from .forms import MyModelForm, MyForm from .views import MyUpdateView, MyWizardView -from memberdb.account_backend import validate_username +from memberdb.account_backend import validate_username, create_ad_user class AccountForm(MyModelForm): - # form fields username = forms.SlugField( validators=[validate_username], max_length=19, ) password = forms.CharField( - min_length=10, - max_length=127, - widget=forms.PasswordInput, + min_length=10, + max_length=127, + widget=forms.PasswordInput, strip=False, help_text="Password must be between 10 and 127 characters long" - ) + ) confirm_password = forms.CharField( - min_length=10, - max_length=127, + min_length=10, + max_length=127, widget=forms.PasswordInput, strip=False, ) @@ -48,11 +47,11 @@ class AccountForm(MyModelForm): def save(self): return - + class EmailForm(MyModelForm): forward = forms.BooleanField(required=False) email_address = forms.EmailField( - label='Forwarding address (optional)', + label='Forwarding address (optional)', required=False, help_text="Your club email will be forwarded to this address. Leave blank if email forwarding is not required" ) @@ -60,7 +59,7 @@ class EmailForm(MyModelForm): class Meta: model = Member fields = ['forward', 'email_address'] - + def clean(self): if self['forward'].value() == True: try: @@ -74,16 +73,16 @@ class EmailForm(MyModelForm): class DispenseForm(MyForm): pin = forms.CharField( - min_length=0, - max_length=4, - widget=forms.PasswordInput, + min_length=0, + max_length=4, + widget=forms.PasswordInput, strip=False, required=False, - help_text="PIN must be 4 digits long") + help_text="PIN must be 4 digits long") confirm_pin = forms.CharField( - min_length=0, - max_length=4, + min_length=0, + max_length=4, widget=forms.PasswordInput, required=False, strip=False, @@ -99,7 +98,7 @@ class DispenseForm(MyForm): except: pass super().clean(); - + class AccountView(MyWizardView): form_list = [AccountForm,EmailForm,DispenseForm] @@ -110,7 +109,7 @@ class AccountView(MyWizardView): return self.object def get_context_data(self, **kwargs): - m = self.object + m = self.object context = super().get_context_data(**kwargs) context.update(self.admin.admin_site.each_context(self.request)) context.update({ @@ -121,6 +120,12 @@ class AccountView(MyWizardView): def done(self, form_list, form_dict, **kwargs): + + # create the user and save their username if successfull + if create_ad_user(self.get_cleaned_data_for_step('0'), self.object): + form_list[0].save() + + messages.success(self.request, 'Your membership renewal has been submitted.') return HttpResponseRedirect(reverse("admin:memberdb_membership_changelist")) @@ -130,8 +135,8 @@ class AccountView(MyWizardView): def accountProgressView(request, member): return - - + + def accountFinalView(): - return render(request, 'accountfinal.html', context) + return render(request, 'accountfinal.html', context) diff --git a/src/memberdb/account_backend.py b/src/memberdb/account_backend.py index 3f795ed6c41d2a3a82bb054c021b326adf6637dd..cf3162ada40cefea82bcdcfb0006fdf577c5a5eb 100644 --- a/src/memberdb/account_backend.py +++ b/src/memberdb/account_backend.py @@ -5,9 +5,12 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils.translation import gettext_lazy as _ -import ldap import re import socket +from ldap3 import Server, Connection, MODIFY_REPLACE,MODIFY_ADD +from ldap3.core.results import RESULT_SUCCESS +from ldap3.core.exceptions import * + import subprocess from subprocess import CalledProcessError, TimeoutExpired @@ -18,69 +21,94 @@ from squarepay import dispense log = logging.getLogger('ldap') -# load config +# load config ldap_uri = getattr(settings, 'AUTH_LDAP_SERVER_URI') -ldap_search_dn = getattr(settings, 'LDAP_USER_SEARCH_DN') +ldap_user_dn = getattr(settings, 'LDAP_USER_SEARCH_DN') +ldap_base_dn = getattr(settings, 'LDAP_BASE_DN') ldap_bind_dn = getattr(settings, 'LDAP_BIND_DN') ldap_bind_secret = getattr(settings, 'LDAP_BIND_SECRET') -ldap_opts = getattr(settings, 'AUTH_LDAP_GLOBAL_OPTIONS') +maxuid_dn = "CN=uccdomayne,CN=ypservers,CN=ypServ30,CN=RpcServices,CN=System,"+ldap_base_dn #initalise ldap instace -_ldap_inst = ldap.initialize(ldap_uri) -for option,value in ldap_opts.items(): - _ldap_inst.set_option(option,value) - -_ldap_inst.set_option(ldap.OPT_X_TLS_NEWCTX, 0) +_ldap_inst = Connection( + Server(ldap_uri), + client_strategy='SYNC', + user=ldap_bind_dn, + password=ldap_bind_secret, + raise_exceptions=True, + ) +# get the ldap instance and bind if required def get_ldap_instance(): - try: - _ldap_inst.bind(ldap_bind_dn, ldap_bind_secret) - except ldap.INVALID_CREDENTIALS: - log.error("LDAP: Invalid bind credentials") - except ldap.SERVER_DOWN: - log.error("LDAP: Cannot Contact LDAP server") - + if not _ldap_inst.bound: + try: + _ldap_inst.bind() + except LDAPInvalidCredentialsResult: + log.error("LDAP: Invalid bind credentials") + raise return _ldap_inst -def get_user_attrs(username, attrs): - # TODO verify bind +def get_ldap_attrs(dn, filter, limit, attrs): ld = get_ldap_instance() - filter = "cn=" + username - result = ld.search_s(ldap_search_dn, ldap.SCOPE_SUBTREE, filter, attrs) - if len(result) > 1: - # multiple accounts matched, this is a problem - return ldap.NO_UNIQUE_ENTRY - if len(result) == 0: - return ldap.NO_SUCH_OBJECT - return result[0]; + ld.search(dn, filter, size_limit=limit, attributes=attrs) + result = ld.result + # fetch matched objects on success + if (result['result'] == RESULT_SUCCESS): + entries = ld.entries + else: + # otherwise raise an exception + raise LDAPOperationResult( + result=result['result'], + description=result['description'], + dn=result['dn'], + message=result['message'], + response_type=result['type']) + + if len(entries) == 0: + raise LDAPNoSuchObjectResult() + + return entries; + +def get_user_attrs(username, attrs): + # find the user + filter = "(cn=" + username + ')' + + result = get_ldap_attrs(ldap_user_dn, filter, 1, attrs) + + return result[0]; + +def get_maxuid(): + ld = get_ldap_instance() + filter = "(cn=*)" + attrs = ['msSFU30MaxUidNumber'] + result = get_ldap_attrs(maxuid_dn, filter, 1, attrs) + return result[0] def get_account_lock_status(username): ld = get_ldap_instance() try: result = get_user_attrs(username, ['userAccountControl']) - finally: - ld.unbind() + # user does not exist + except LDAPNoSuchObjectResult: + return None + # return UAC flag 0x002 ('ACCOUNT_DISABLE') return bool(result[1]['userAccountControl'] & 0x002) def validate_username(value : str): # note: slug validator ensures that username only contains [a-z0-9_-] # usernames can't begin with a numeric if not value[0].isalpha(): - raise ValidationError( - _('Username must begin with a letter') - ) + raise ValidationError(_('Username must begin with a letter')) # ensure username is lowercase - elif not value.islower(): - raise ValidationError( - _('Username cannot contain uppercase characters') - ) + if not value.islower(): + raise ValidationError(_('Username cannot contain uppercase characters')) # check if the user exists, this test should catch *most* cases - if subprocess.call(["id", value]) == 0: + if subprocess.call(["id", value], stderr=subprocess.DEVNULL) == 0: raise ValidationError(_('Username already taken (passwd)')) # usernames cannot conflict with hostnames @@ -90,20 +118,14 @@ def validate_username(value : str): _('Username already taken (CNAME)') ) except socket.gaierror: - pass - + pass # lookup user in ldap, required because not all users are mapped to *nix users try: - if get_user_attrs(value, ['cn']) != ldap.NO_SUCH_OBJECT: - raise ValidationError( - _('Username already taken (AD)') - ) - except ldap.LDAPError: - log.error("Network error, cannot verify username") - raise ldap.OPERATIONS_ERROR - - - + get_user_attrs(value, None) + except LDAPNoSuchObjectResult: + pass + else: + raise ValidationError(_('Username already taken (AD)')) # locks the specified User Account by performing the following actions: @@ -117,19 +139,20 @@ def lock_account(username): try: # fetch current uac result = get_user_attrs(username, ['userAccountControl']) - - dn = result[0] - uac = result[1]['userAccountControl'] | 0x002 # set ACCOUNTDISABLE - actions = [ - (ldap.MOD_REPLACE, "userAccountControl", uac), - (ldap.MOD_REPLACE, "userShell", "/etc/locked" + str(today.year)) - ] + + dn = result.entry_dn + uac = result['userAccountControl'] | 0x002 # set ACCOUNTDISABLE + actions = { + "userAccountControl": [(MODIFY_REPLACE,[uac])], + "userShell": [(MODIFY_REPLACE,["/etc/locked"+str(today.year)])] + } # write record ld.modify(dn, actions) - + except LDAPOperationResult: + raise finally: ld.unbind() - + reason = "account locked by uccportal on %s" % str(today) dispense.set_dispense_flag(username, 'disabled', reason) @@ -140,31 +163,108 @@ def unlock_account(username): try: # fetch current uac result = get_user_attrs(username, ['userAccountControl']) - + dn = result[0] uac = result[1]['userAccountControl'] & ~0x002 # clear ACCOUNTDISABLE - actions = [ - (ldap.MOD_REPLACE, "userAccountControl",uac), - (ldap.MOD_REPLACE, "userShell", "/bin/zsh") - ] + actions = { + "userAccountControl": [(MODIFY_REPLACE,[uac])], + "userShell": [(MODIFY_REPLACE,["/bin/zsh"])] + } # write record ld.modify(dn, actions) - + except LDAPOperationResult: + raise finally: ld.unbind() - reason = "account unlocked by uccportal on %s" % str(today) dispense.set_dispense_flag(username, '!disabled', reason) # Account creation steps: -# -def create_account(member, passwd): - username = "changeme"; - log.info("I: creating new account for %s (%s %s)") +# +def create_ad_user(form_data, member): + log.info("I: creating new account for %s (%s)") + + # store user details + # TODO add overides + username=form_data['username'] + displayName = member.first_name + ' ' + member.last_name + dn = 'CN=' + username +','+ ldap_user_dn + + # enclose password in quotes and convert to utf16 as required: + # https://msdn.microsoft.com/en-us/library/cc223248.aspx + quotedpass = '"'+ form_data['password']+'"' + utf16pass = quotedpass.encode('utf-16-le') + + # generate uid + try: + result = get_maxuid() + except: + log.error("LDAP: cannot find base uid") + return False + + maxuid = int(result.msSFU30MaxUidNumber.value) + + # gets all uids >= maxuid + # this is done so that we don't encounter the 1000 item limit to ad queries + entries = get_ldap_attrs(ldap_user_dn,"(uidNumber>=%s)" % maxuid, 100, ['uidNumber']) + + # generate a new uid + uids = [] + for user in entries: + uids.append(int(user.uidNumber.value)) + + uids.sort() + # use max uid if it is free + if uids[0] != maxuid: + newuid = str(maxuid) + else: + prev = uids[0] + for uid in uids: + if uid - prev > 1: + newuid = uid + 1 + break; + prev = uid + #increment uid + newuid = str(prev + 1) + + # sanity check: make sure the uid is free + if subprocess.call(["id", newuid], stderr=subprocess.DEVNULL) == 0: + log.error("LDAP: uid already taken") + return False + + # create the new user struct + objclass = ['top','posixAccount','person','organizationalPerson','user'] + attrs = { + 'cn' : username, + 'sAMAccountName' : username, + 'givenName' : member.first_name, + 'sn': member.last_name, + 'displayName': displayName, + 'userAccountControl' : '512', + 'unixHomeDirectory' : "/home/ucc/" + username, + 'loginShell' : '/bin/zsh', + 'gidNumber' : '20021', + 'uidNumber' : newuid, + 'gecos' : displayName, + 'mail' : username + '@ucc.gu.uwa.edu.au', + 'unicodePwd': utf16pass + } + + # commit the new user to AD + ld = get_ldap_instance() + result = ld.add(dn, objclass, attrs) + if not result: + log.error("LDAP: user add failed") + return False + + # set maxuid + result = ld.modify(maxuid_dn, {'msSFU30MaxUidNumber': [(MODIFY_REPLACE, newuid)]}) + if not result: + log.warning("LDAP: user created but msSFU30MaxUidNumber not update") + ld.unbind(); + return True; - - return None; def create_homes(member): return def set_email_forwarding(member, addr):