├── .gitignore ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── settings.py └── settingslocal.py ├── img ├── django-academia-ou-manager.png ├── preview.png └── search.png ├── ldap_peoples ├── __init__.py ├── admin.py ├── admin_actions.py ├── admin_filters.py ├── admin_utils.py ├── apps.py ├── auth.py ├── form_fields.py ├── forms.py ├── hash_functions.py ├── idem_affiliation_mapping.py ├── ldap_utils.py ├── ldif.py ├── management │ └── commands │ │ ├── __init__.py │ │ └── import_file.py ├── migrations │ └── __init__.py ├── model_fields.py ├── models.py ├── serializers.py ├── settings.py ├── static │ └── js │ │ ├── given_display_name_autofill.js │ │ └── textarea-autosize.js ├── templates │ ├── change_form.html │ ├── change_list.html │ └── filters │ │ └── custom_search.html ├── tests.py ├── tests │ └── create_delete_academia_user.py ├── urls.py ├── version.py ├── views.py └── widgets.py ├── publiccode.yml ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary files 2 | .*.swp 3 | *.pyc 4 | *.pyo 5 | 6 | env/* 7 | 8 | # Build-related files 9 | docs/_build/ 10 | .coverage 11 | .tox 12 | *.egg-info 13 | *.egg 14 | .eggs/ 15 | build/ 16 | dist/ 17 | htmlcov/ 18 | MANIFEST 19 | migrations/* 20 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | --------- 3 | 4 | v0.9.2 5 | ------ 6 | 7 | - fixed get_membership on user add 8 | - Added schac Gender 9 | - no more default entitlements on user creation 10 | - idem affiliation mapping fix 11 | - set_default_schacExpiryDate coherent with pwdChangedTime 12 | - schac personal uniqueID nation lowercased by default 13 | - removed indent in json dumps 14 | - search filter lookup fixed 15 | - advanced admin search filter builder 16 | - admin uid search __exact 17 | - IAP/low as default LoA 18 | 19 | 20 | v0.8.9 21 | ------ 22 | 23 | - fixed titles values as widget 24 | - no affiliation filter (to get deprovisioned guys) 25 | - sambaSID added in model and admin 26 | 27 | 28 | v0.8.8 29 | ------ 30 | 31 | - eduPersonPrincipalName is automatically saved on each update 32 | - eduPersonPrincipalName is now a readonly field in admin 33 | - eduPersonScopedAffiliation is automatically updated followin eduPersonAffiliation 34 | - Added 'EU' and 'INT' in the country codes 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) The django-ldap-academia-ou-manager project 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include README.md 4 | include ldap_peoples/static/js/* 5 | include ldap_peoples/templates/* 6 | include ldap_peoples/templates/filters/* 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django admin LDAP manager for Academia OU 2 | ----------------------------------------- 3 | Django Admin manager for Academia Users, usable with an OpenLDAP Server configured with eduPerson, SCHAC (SCHema for ACademia) and Samba schema. It also needs PPolicy overlay and some other schemas as described as follow. 4 | 5 | References 6 | ---------- 7 | 8 | - [OpenLDAP compatible configuration](https://github.com/peppelinux/ansible-slapd-eduperson2016) 9 | - [eduPerson Schema](https://software.internet2.edu/eduperson/internet2-mace-dir-eduperson-201602.html) 10 | - [SCHAC](https://wiki.refeds.org/display/STAN/SCHAC) 11 | 12 | Requirements 13 | ------------ 14 | 15 | - OpenLDAP 2.4.x 16 | - Python 3.x 17 | - Django 2.x 18 | - django-ldapdb (custom repository) 19 | 20 | 21 | Tested on Debian9 and Debian 10. 22 | 23 | Preview 24 | ------- 25 | 26 | **Note:** Labels and strings can be localized with .po dictionaries (gettext). See [i18n documentation](https://docs.djangoproject.com/en/dev/topics/i18n/translation/) 27 | 28 | ![Alt text](img/search.png) 29 | ![Alt text](img/preview.png) 30 | 31 | LDAP Setup 32 | ----- 33 | For those who need to setup a LDAP server for development or production use: 34 | ```` 35 | pip3 install ansible 36 | git clone https://github.com/peppelinux/ansible-slapd-eduperson2016.git 37 | cd ansible-slapd-eduperson2016 38 | ansible-playbook -i "localhost," -c local playbook.yml 39 | ```` 40 | **Note:** The playbook will backup any existing slapd installations in **backups** folder. 41 | 42 | Setup 43 | ----- 44 | 45 | #### Create an virtual environment and activate it 46 | ```` 47 | pip3 install virtualenv 48 | 49 | export PROJ_NAME=django-ldap-academia-ou-manager 50 | export DEST_DIR=$PROJ_NAME.env 51 | virtualenv -p python3 $DEST_DIR 52 | source $DEST_DIR/bin/activate 53 | pip3 install django 54 | ```` 55 | 56 | #### Install dependencies 57 | ```` 58 | apt install python3-dev python3-pip python3-setuptools 59 | apt install libsasl2-dev python-dev libldap2-dev libssl-dev 60 | pip install git+https://github.com/peppelinux/django-ldapdb.git 61 | pip install git+https://github.com/peppelinux/pySSHA-slapd.git 62 | pip install pycountry 63 | pip install git+https://github.com/silentsokolov/django-admin-rangefilter.git 64 | pip install git+https://github.com/peppelinux/django-ldap-academia-ou-manager.git 65 | ```` 66 | 67 | #### Create a project 68 | ```` 69 | django-admin startproject $PROJ_NAME 70 | cd $PROJ_NAME 71 | ```` 72 | 73 | #### Install the app 74 | **Note:** It uses a django-ldapdb fork to handle readonly (non editable) fields. 75 | 76 | ```` 77 | # pip3 install git+https://github.com/peppelinux/django-ldapdb.git 78 | pip3 install git+https://github.com/peppelinux/django-ldap-academia-ou-manager 79 | ```` 80 | 81 | #### Edit settings.py 82 | Read settings.py and settingslocal.py in the example folder. 83 | 84 | In settings.py do the following: 85 | 86 | - Add *ldap_peoples* in INSTALLED_APPS; 87 | - Add *rangefilter* in INSTALLED_APPS; 88 | - import default ldap_peoples settings as follows; 89 | - import default app url as follows; 90 | 91 | #### import default ldap_peoples settings 92 | ```` 93 | # settings.py 94 | if 'ldap_peoples' in INSTALLED_APPS: 95 | from ldap_peoples.settings import * 96 | ```` 97 | #### import default app url 98 | ```` 99 | # urls.py 100 | if 'ldap_peoples' in settings.INSTALLED_APPS: 101 | import ldap_peoples.urls 102 | urlpatterns += path('', include(ldap_peoples.urls, namespace='ldap_peoples')), 103 | ```` 104 | 105 | Using the Object Relation Mapper 106 | -------------------------------- 107 | One of the advantage of using the ORM is the possibility to make these kind of queries 108 | to a LDAP database. 109 | 110 | #### User update attributes 111 | ```` 112 | from ldap_peoples.models import LdapAcademiaUser 113 | lu = LdapAcademiaUser.objects.get(uid='mario') 114 | 115 | # as multivalue 116 | lu.eduPersonAffiliation.append('alumn') 117 | lu.save() 118 | 119 | lu.set_password('secr3tP4ss20rd') 120 | 121 | # search into multivalue field 122 | other_lus = LdapAcademiaUser.objects.filter(mail_contains='unical') 123 | 124 | ```` 125 | 126 | #### User creation example 127 | ```` 128 | # user creation 129 | import datetime 130 | 131 | d = {'cn': 'pedppe', 132 | 'displayName': 'peppde Rossi', 133 | 'eduPersonAffiliation': ['faculty', 'member'], 134 | 'eduPersonEntitlement': ['urn:mace:terena.org:tcs:escience-user', 135 | 'urn:mace:terena.org:tcs:personal-user'], 136 | 'eduPersonOrcid': '', 137 | 'eduPersonPrincipalName': 'grodsfssi@unical', 138 | 'eduPersonScopedAffiliation': ['member@testunical.it', 'staff@testunical.it'], 139 | 'givenName': 'peppe', 140 | 'mail': ['peppe44.grossi@testunical.it', 'pgros44si@edu.testunical.it'], 141 | 'sambaNTPassword': 'a2137530237ad733fdc26d5d7157d43f', 142 | 'schacHomeOrganization': 'testunical.it', 143 | 'schacHomeOrganizationType': ['educationInstitution', 'university'], 144 | 'schacPersonalUniqueID': ['urn:schac:personalUniqueID:IT:CF:CODICEFISCALEpe3245ppe'], 145 | 'schacPlaceOfBirth': '', 146 | 'sn': 'grossi', 147 | 'telephoneNumber': [], 148 | 'uid': 'perrrppe', 149 | 'userPassword': '{SHA512}oMKZtxqeWdXrsHkX5wYBo1cKoQPpmnu2WljngOyQd7GQLR3tsxsUV77aWV/k1x13m2ypytR2JmzAdZDjHYSyBg=='} 150 | 151 | u = LdapAcademiaUser.objects.create(**d) 152 | u.delete() 153 | ```` 154 | 155 | #### Unit test 156 | ```` 157 | ./manage.py test ldap_peoples.tests.LdapAcademiaUserTestCase 158 | ```` 159 | 160 | TODO 161 | ---- 162 | - form .clean methods could be cleaned with a better OOP refactor on FormFields and Widgets; 163 | 164 | 165 | **Django-ldapdb related** 166 | - We use custom django-ldapdb fork because readonly fields like createTimestamps and other are fautly on save in the official django-ldapdb repo. [See related PR](https://github.com/django-ldapdb/django-ldapdb/pull/185); 167 | - ListFields doesn't handle properly **verbose_name**. It depends on the form class, we use our fork for elude this; 168 | - Aggregate lookup for evaluating min max on records, this come from django-ldapdb; 169 | -------------------------------------------------------------------------------- /examples/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_idm project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | from . settingslocal import * 15 | 16 | #APP_NAME=settingslocal.APP_NAME 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | #SECRET_KEY = settingslocal.SECRET_KEY 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | #DEBUG = settingslocal.DEBUG 28 | 29 | #ALLOWED_HOSTS = settingslocal.ALLOWED_HOSTS 30 | 31 | # Application definition 32 | INSTALLED_APPS = [ 33 | 'django.contrib.admin', 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.messages', 38 | 'django.contrib.staticfiles', 39 | 40 | 'django_admin_multiple_choice_list_filter', 41 | 42 | 'rangefilter', 43 | 'ldapdb', 44 | 'ldap_peoples', 45 | ] 46 | 47 | if 'ldap_peoples' in INSTALLED_APPS: 48 | from ldap_peoples.settings import * 49 | # otherwise overload whatever needed... 50 | # import ldap_peoples.settings as ldap_peoples_settings 51 | # LDAP_DATETIME_FORMAT = ldap_peoples_settings.LDAP_DATETIME_FORMAT 52 | # LDAP_DATETIME_MILLISECONDS_FORMAT = ldap_peoples_settings.LDAP_DATETIME_MILLISECONDS_FORMAT 53 | # PPOLICY_PERMANENT_LOCKED_TIME = ldap_peoples_settings.PPOLICY_PERMANENT_LOCKED_TIME 54 | # PPOLICY_PASSWD_MAX_LEN= ldap_peoples_settings.PPOLICY_PASSWD_MAX_LEN 55 | # PPOLICY_PASSWD_MIN_LEN= ldap_peoples_settings.PPOLICY_PASSWD_MIN_LEN 56 | 57 | # PASSWD_FIELDS_MAP = ldap_peoples_settings.PASSWD_FIELDS_MAP 58 | # SECRET_PASSWD_TYPE = ldap_peoples_settings.SECRET_PASSWD_TYPE 59 | # DISABLED_SECRET_TYPES = ldap_peoples_settings.DISABLED_SECRET_TYPES 60 | # DEFAULT_SECRET_TYPE = ldap_peoples_settings.DEFAULT_SECRET_TYPE 61 | # SECRET_FIELD_VALIDATORS = ldap_peoples_settings.SECRET_FIELD_VALIDATORS 62 | 63 | MIDDLEWARE = [ 64 | 'django.middleware.security.SecurityMiddleware', 65 | 'django.contrib.sessions.middleware.SessionMiddleware', 66 | 'django.middleware.common.CommonMiddleware', 67 | 'django.middleware.csrf.CsrfViewMiddleware', 68 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 69 | 'django.contrib.messages.middleware.MessageMiddleware', 70 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 71 | ] 72 | 73 | # Messages were getting stored in CookiesStorage, but for some weird reason the Messages in CookiesStorage were getting expired or deleted for the 2nd request 74 | # MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage' 75 | 76 | # GETTEXT LOCALIZATION 77 | MIDDLEWARE.append('django.middleware.locale.LocaleMiddleware') 78 | LOCALE_PATHS = ( 79 | os.path.join(BASE_DIR, "locale"), 80 | ) 81 | # 82 | 83 | AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend',] 84 | 85 | LOGIN_URL = '/login' 86 | LOGIN_REDIRECT_URL = '/dashboard' 87 | 88 | TEMPLATES = [ 89 | { 90 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 91 | 'DIRS': ['templates'], 92 | 'APP_DIRS': True, 93 | 'OPTIONS': { 94 | 'context_processors': [ 95 | 'django.template.context_processors.debug', 96 | 'django.template.context_processors.request', 97 | 'django.contrib.auth.context_processors.auth', 98 | 'django.contrib.messages.context_processors.messages', 99 | ], 100 | }, 101 | }, 102 | ] 103 | 104 | WSGI_APPLICATION = 'django_idm.wsgi.application' 105 | 106 | AUTH_PASSWORD_VALIDATORS = [ 107 | { 108 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 109 | }, 110 | { 111 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 112 | }, 113 | { 114 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 115 | }, 116 | { 117 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 118 | }, 119 | ] 120 | 121 | 122 | # Internationalization 123 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 124 | 125 | # LANGUAGE_CODE = settingslocal.LANGUAGE_CODE 126 | # TIME_ZONE = settingslocal.TIME_ZONE 127 | USE_I18N = True 128 | USE_L10N = True 129 | USE_TZ = True 130 | 131 | # shacExpiryDate e shacDateOfBirth validation works on these: 132 | DATE_FORMAT = "%d/%m/%Y" 133 | DATETIME_FORMAT = "{} %H:%M:%S".format(DATE_FORMAT) 134 | 135 | DATE_INPUT_FORMATS = [DATE_FORMAT, "%Y-%m-%d"] 136 | DATETIME_INPUT_FORMATS = ["{} %H:%M:%S".format(i) for i in DATE_INPUT_FORMATS] 137 | 138 | # Static files (CSS, JavaScript, Images) 139 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 140 | DATA_DIR = os.path.join(BASE_DIR, "data") 141 | STATIC_URL = '/static/' 142 | STATIC_ROOT = os.path.join(DATA_DIR, 'static') 143 | 144 | MEDIA_ROOT = os.path.join(DATA_DIR, 'media') 145 | MEDIA_URL = '/media/' 146 | -------------------------------------------------------------------------------- /examples/settingslocal.py: -------------------------------------------------------------------------------- 1 | import ldap 2 | import os 3 | 4 | APP_NAME = 'APP NAME LDAP' 5 | HOSTNAME = 'your.hostname.eu' 6 | 7 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | # you can generate a valid one issuing the command: 11 | # manage.py generate_secret_key 12 | SECRET_KEY = '7nv@u)8-s!ro*nji2)#moi25tkx^5$=9w_he2hw20rfya)l!j!' 13 | 14 | # SECURITY WARNING: don't run with debug turned on in production! 15 | DEBUG = True 16 | 17 | ALLOWED_HOSTS = [HOSTNAME, 'localhost'] 18 | 19 | if DEBUG: 20 | SESSION_COOKIE_SECURE = False 21 | CSRF_COOKIE_SECURE = False 22 | else: 23 | SESSION_COOKIE_SECURE = True 24 | CSRF_COOKIE_SECURE = True 25 | 26 | # The maximum number of parameters that may be received via GET or POST before a 27 | # SuspiciousOperation (TooManyFields) is raised. You can set this to None to disable the check. 28 | DATA_UPLOAD_MAX_NUMBER_FIELDS = 100 29 | 30 | # Database 31 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 32 | 33 | LDAP_OU = 'people' 34 | LDAP_BASE_DOMAIN = 'testunical.it' 35 | LDAP_BASEDN = 'dc='+',dc='.join(LDAP_BASE_DOMAIN.split('.')) 36 | LDAP_CACERT = os.path.sep.join((BASE_DIR, 37 | 'certificates', 38 | 'slapd-cacert.pem')) 39 | 40 | # Also interesting is their use as values on ldap.OPT_X_TLS_REQUIRE_CERT (TLS equivalent: TLS_REQCERT) 41 | # demand and hard (default): 42 | # no certificate provided: quits 43 | # bad certificate provided: quits 44 | # try 45 | # no certificate provided: continues 46 | # bad certificate provided: quits 47 | # allow 48 | # no certificate provided: continues 49 | # bad certificate provided: continues 50 | # never 51 | # no certificate is requested 52 | 53 | LDAP_CONNECTION_OPTIONS = { 54 | # ldap.OPT_REFERRALS: 0, 55 | ldap.OPT_PROTOCOL_VERSION: 3, 56 | ldap.OPT_DEBUG_LEVEL: 255, 57 | ldap.OPT_X_TLS_CACERTFILE: LDAP_CACERT, 58 | 59 | # a cached connection to be dropped an 60 | # recreated after it has been idle for the specified time 61 | ldap.OPT_TIMEOUT: 180, 62 | 63 | # used to check whether a socket is alive 64 | ldap.OPT_X_KEEPALIVE_IDLE: 120, 65 | ldap.OPT_X_KEEPALIVE_PROBES: 10, 66 | ldap.OPT_X_KEEPALIVE_INTERVAL: 30, 67 | 68 | ldap.OPT_NETWORK_TIMEOUT: 20, 69 | ldap.OPT_RESTART: True, 70 | 71 | # force /etc/ldap.conf configuration. 72 | ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_DEMAND, 73 | ldap.OPT_X_TLS: ldap.OPT_X_TLS_DEMAND, 74 | ldap.OPT_X_TLS_DEMAND: True, 75 | 76 | # ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER, 77 | # ldap.OPT_X_TLS: ldap.OPT_X_TLS_NEVER, 78 | # ldap.OPT_X_TLS_DEMAND: False, 79 | } 80 | 81 | 82 | DATABASES = { 83 | 'ldap': { 84 | 'ENGINE': 'ldapdb.backends.ldap', 85 | # only in localhost 86 | #'NAME': 'ldapi:///', 87 | 'NAME': 'ldaps://ldap.{}'.format(HOSTNAME), 88 | # 'NAME': 'ldaps://127.0.0.1/', 89 | 'USER': 'cn=admin,{}'.format(LDAP_BASEDN), 90 | 'PASSWORD': 'slapdsecret', 91 | 'PORT': 636, 92 | #'TLS': True, 93 | 'CONNECTION_OPTIONS': LDAP_CONNECTION_OPTIONS 94 | }, 95 | 'default': { 96 | 'ENGINE': 'django.db.backends.mysql', 97 | 'NAME': 'DBNAME', 98 | 'HOST': 'localhost', 99 | 'USER': 'DBUSER', 100 | 'PASSWORD': 'DBPASSWORD', 101 | 'PORT': '' 102 | } 103 | } 104 | 105 | DATABASE_ROUTERS = ['ldapdb.router.Router'] 106 | 107 | LANGUAGE_CODE = 'it-it' 108 | TIME_ZONE = 'Europe/Rome' 109 | 110 | ADMINS = [('Name surname', 'name.surname@{}'.format(HOSTNAME)),] 111 | -------------------------------------------------------------------------------- /img/django-academia-ou-manager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peppelinux/django-ldap-academia-ou-manager/2e546ac59793673516c66f55fb5f9589dc1759fc/img/django-academia-ou-manager.png -------------------------------------------------------------------------------- /img/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peppelinux/django-ldap-academia-ou-manager/2e546ac59793673516c66f55fb5f9589dc1759fc/img/preview.png -------------------------------------------------------------------------------- /img/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peppelinux/django-ldap-academia-ou-manager/2e546ac59793673516c66f55fb5f9589dc1759fc/img/search.png -------------------------------------------------------------------------------- /ldap_peoples/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'ldap_peoples.apps.LdapPeoplesConfig' 2 | -------------------------------------------------------------------------------- /ldap_peoples/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.contrib.admin.models import LogEntry 4 | from django.utils.safestring import mark_safe 5 | from django.utils.translation import gettext as _ 6 | from django.utils.html import mark_safe 7 | 8 | from rangefilter.filter import DateRangeFilter, DateTimeRangeFilter 9 | 10 | from .admin_actions import * 11 | from .admin_filters import TitleListFilter, AffiliationListFilter, GenericSearch 12 | from .admin_utils import (get_values_as_html_ul,) 13 | from .forms import (LdapAcademiaUserAdminForm, 14 | LdapGroupAdminMultiValuedForm) #, FileImportActionForm 15 | from .hash_functions import encode_secret 16 | from .models import * 17 | 18 | 19 | class ReadOnlyAdmin(admin.ModelAdmin): 20 | """ 21 | Disables all editing capabilities 22 | """ 23 | def __init__(self, *args, **kwargs): 24 | super(ReadOnlyAdmin, self).__init__(*args, **kwargs) 25 | self.readonly_fields = [f.name for f in self.model._meta.fields] 26 | 27 | def get_actions(self, request): 28 | actions = super(ReadOnlyAdmin, self).get_actions(request) 29 | if actions.get('delete_selected'): 30 | del actions["delete_selected"] 31 | return actions 32 | 33 | def has_add_permission(self, request): 34 | return False 35 | 36 | def has_delete_permission(self, request, obj=None): 37 | return False 38 | 39 | def save_model(self, request, obj, form, change): # pragma: nocover 40 | pass 41 | 42 | def delete_model(self, request, obj): # pragma: nocover 43 | pass 44 | 45 | def save_related(self, request, form, formsets, change): # pragma: nocover 46 | pass 47 | 48 | def change_view(self, request, object_id, extra_context=None): 49 | extra_context = extra_context or {} 50 | extra_context['show_save_and_continue'] = False 51 | extra_context['show_save'] = False 52 | return super(ReadOnlyAdmin, self).change_view(request, 53 | object_id, 54 | extra_context=extra_context) 55 | 56 | 57 | @admin.register(LogEntry) 58 | class LogEntryAdmin(ReadOnlyAdmin): 59 | list_display = ( 60 | # 'object_id', 61 | 'repr_action', 62 | 'object_repr', 63 | # 'content', 64 | 'change_message', 65 | 'user', 66 | 'action_time') 67 | list_filter = ('action_flag', 68 | ) 69 | search_fields = ('user__username', 70 | 'object_repr',) 71 | readonly_fields = ('repr_action', 72 | ) 73 | 74 | class Media: 75 | js = ('js/textarea-autosize.js',) 76 | 77 | def repr_action(self, obj): 78 | d = { 79 | 1: 'Create', 80 | 2: 'Change', 81 | 3: 'Delete', 82 | } 83 | return d[obj.action_flag] 84 | repr_action.short_description = 'Action' 85 | 86 | 87 | class LdapDbModelAdmin(admin.ModelAdmin): 88 | exclude = ['dn', 'objectClass'] 89 | 90 | class Media: 91 | js = ('js/textarea-autosize.js', 92 | 'js/given_display_name_autofill.js') 93 | 94 | 95 | @admin.register(LdapAcademiaUser) 96 | class LdapAcademiaUserAdmin(LdapDbModelAdmin): 97 | form = LdapAcademiaUserAdminForm 98 | list_display = ('uid', 99 | 'givenName', 100 | 'get_emails_as_ul', 101 | 'get_affiliation_as_ul', 102 | 'get_status', 103 | # 'get_membership_as_ul', 104 | 'createTimestamp', 105 | 'modifyTimestamp') 106 | list_filter = (GenericSearch, 107 | AffiliationListFilter, 108 | TitleListFilter, 109 | # 'pwdChangedTime', 'created', 'modified', 110 | ('createTimestamp', DateRangeFilter), 111 | ('modifyTimestamp', DateTimeRangeFilter), 112 | ('pwdChangedTime', DateTimeRangeFilter), 113 | ('schacExpiryDate', DateTimeRangeFilter), 114 | ) 115 | search_fields = ('uid__exact', 116 | # 'givenName', 117 | # 'sn', 118 | # 'mail', # https://github.com/django-ldapdb/django-ldapdb/issues/104 119 | ) 120 | readonly_fields = ( 121 | 'createTimestamp', 122 | 'modifyTimestamp', 123 | 'distinguished_name', 124 | 'creatorsName', 125 | 'modifiersName', 126 | 'get_membership_as_ul', 127 | 'membership', 128 | #'pwdAccountLockedTime', 129 | 'locked_time', 130 | 'failure_times', 131 | 'pwdChangedTime', 132 | #'pwd_changed' 133 | 'pwdHistory_repr', 134 | 'userPassword', 135 | 'sambaNTPassword', 136 | 'eduPersonPrincipalName', 137 | ) 138 | actions = [send_reset_token_email, 139 | enable_account, 140 | disable_account, 141 | lock_account, 142 | export_as_json, 143 | export_as_ldif] 144 | 145 | # action_form = FileImportActionForm 146 | 147 | # TODO: aggregate lookup for evaluating min max on records 148 | # date_hierarchy = 'created' 149 | 150 | fieldsets = ( 151 | (None, { 'fields' : (('uid', 152 | 'distinguished_name' 153 | ), 154 | ('givenName', 'sn', ), 155 | ('cn', 'displayName',), 156 | ('mail', 'telephoneNumber'), 157 | ('title'), 158 | ), 159 | }), 160 | ('Samba and Azure related', { 161 | 'classes': ('collapse',), 162 | 'fields': ( 163 | ('sambaSID', 'sambaNTPassword'), 164 | ), 165 | } 166 | ), 167 | ('Password', { 168 | 'classes': ('collapse',), 169 | 'fields': ( 170 | # ('password_encoding', 'new_passwd'), 171 | ('userPassword',), 172 | ('new_passwd',), 173 | ), 174 | } 175 | ), 176 | ('Password Policy', { 177 | 'classes': ('collapse',), 178 | 'fields': ( 179 | (## 'pwdAccountLockedTime', 180 | 'locked_time',), 181 | ('failure_times',), 182 | ('pwdChangedTime',), 183 | 'pwdHistory_repr', 184 | ), 185 | } 186 | ), 187 | ('Additional info', { 188 | 'classes': ('collapse',), 189 | 'fields': ( 190 | ('createTimestamp', 'creatorsName',), 191 | ('modifyTimestamp', 'modifiersName',), 192 | ('get_membership_as_ul', 193 | ## 'membership', 194 | ), 195 | ), 196 | } 197 | ), 198 | ('Academia eduPerson', { 199 | ##'classes': ('collapse',), 200 | 'fields': ( 201 | ('eduPersonPrincipalName', 'eduPersonOrcid',), 202 | ('eduPersonAssurance',), 203 | ('eduPersonAffiliation', 204 | 'eduPersonScopedAffiliation',), 205 | 'eduPersonEntitlement', 206 | ), 207 | } 208 | ), 209 | ('Academia Schac)', { 210 | ##'classes': ('collapse',), 211 | 'fields': ( 212 | ('schacPlaceOfBirth', 'schacDateOfBirth', 'schacGender'), 213 | ('schacPersonalUniqueID', 'schacPersonalUniqueCode'), 214 | ('schacExpiryDate'), 215 | ('schacHomeOrganization', 216 | 'schacHomeOrganizationType'), 217 | ), 218 | } 219 | ) 220 | ) 221 | 222 | def pwdHistory_repr(self, obj): 223 | return mark_safe('
'.join(obj.pwdHistory)) 224 | 225 | def get_emails_as_ul(self, obj): 226 | value = get_values_as_html_ul(obj.mail) 227 | return mark_safe(value) 228 | get_emails_as_ul.short_description = 'Email' 229 | 230 | def get_affiliation_as_ul(self, obj): 231 | value = get_values_as_html_ul(obj.eduPersonScopedAffiliation) 232 | return mark_safe(value) 233 | get_affiliation_as_ul.short_description = 'Affiliation' 234 | 235 | def get_membership_as_ul(self, obj): 236 | value = get_values_as_html_ul(obj.membership()) 237 | return mark_safe(value) 238 | get_membership_as_ul.short_description = 'MemberOf' 239 | 240 | def get_status(self, obj): 241 | return obj.is_active() 242 | get_status.boolean = True 243 | get_status.short_description = _('Status') 244 | 245 | def save_model(self, request, obj, form, change): 246 | """ 247 | method that trigger password encoding 248 | """ 249 | obj.set_default_schacHomeOrganization() 250 | obj.set_default_schacHomeOrganizationType() 251 | obj.update_eduPersonScopedAffiliation() 252 | if not form.data.get('eduPersonPrincipalName'): 253 | obj.set_default_eppn() 254 | obj.save() 255 | 256 | if form.data.get('new_passwd'): 257 | passw = form.data.get('new_passwd') 258 | obj.set_password(passw) 259 | 260 | 261 | @admin.register(LdapGroup) 262 | class LdapGroupAdmin(LdapDbModelAdmin): 263 | form = LdapGroupAdminMultiValuedForm 264 | -------------------------------------------------------------------------------- /ldap_peoples/admin_actions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from django.conf import settings 5 | from django.contrib import messages 6 | from django.contrib.admin.models import LogEntry, ADDITION, CHANGE 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.http import HttpResponse 9 | from django.utils import timezone 10 | from django.utils.translation import gettext as _ 11 | from .models import * 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def export_as_json(modeladmin, request, queryset): 18 | response = HttpResponse(content_type="application/force-download") 19 | fname = 'ldapuser_export_{}.json'.format(timezone.localtime().isoformat()) 20 | response['Content-Disposition'] = 'attachment; filename={}'.format(fname) 21 | app_name = queryset.model._meta.app_label 22 | model_name = queryset.model.__name__ 23 | d = {'app': app_name, 'model': model_name, 'entries': []} 24 | for i in queryset: 25 | d['entries'].append(i.serialize()) 26 | response.content = json.dumps(d, indent=2) 27 | return response 28 | export_as_json.short_description = _("Export as JSON") 29 | 30 | 31 | def export_as_ldif(modeladmin, request, queryset): 32 | response = HttpResponse(content_type="application/ldif") 33 | attach_str = 'attachment; filename="ldapuser_export_{}.ldif"' 34 | t = timezone.localtime().isoformat() 35 | if settings.USE_TZ: 36 | response['Content-Disposition'] = attach_str.format(t) 37 | else: 38 | response['Content-Disposition'] = attach_str.format(t) 39 | for i in queryset: 40 | response.content += i.ldif().encode(settings.FILE_CHARSET) 41 | return response 42 | export_as_ldif.short_description = _("Export as LDIF") 43 | 44 | 45 | def send_reset_token_email(modeladmin, request, queryset): 46 | num_sync = 0 47 | for i in queryset: 48 | num_sync += 1 49 | msg = _('{}, email sent').format(i.__str__()) 50 | ch_msg = _('Password reset token sent {}').format(i.__str__()) 51 | messages.add_message(request, messages.INFO, msg) 52 | logger.info(msg) 53 | LogEntry.objects.log_action( 54 | user_id = request.user.pk, 55 | content_type_id = ContentType.objects.get_for_model(i).pk, 56 | object_id = i.pk, 57 | object_repr = i.__str__(), 58 | action_flag = ADDITION, 59 | change_message = ch_msg) 60 | if num_sync: 61 | messages.add_message(request, messages.INFO, _('{} Token sent via E-Mail').format(num_sync)) 62 | send_reset_token_email.short_description = _("Send reset Password Token via E-Mail") 63 | 64 | 65 | def lock_account(modeladmin, request, queryset): 66 | num_sync = 0 67 | for i in queryset: 68 | num_sync += 1 69 | i.lock() 70 | msg = _('{}, disabled').format(i.__str__()) 71 | logger.info(msg) 72 | messages.add_message(request, messages.WARNING, ) 73 | LogEntry.objects.log_action( 74 | user_id = request.user.pk, 75 | content_type_id = ContentType.objects.get_for_model(i).pk, 76 | object_id = i.pk, 77 | object_repr = i.__str__(), 78 | action_flag = CHANGE, 79 | change_message = _('Locked User (pwdAccountLockedTime)') 80 | ) 81 | if num_sync: 82 | messages.add_message(request, messages.INFO, _('{} Accounts disabled').format(num_sync)) 83 | lock_account.short_description = _("Lock Account with pwdAccountLockedTime: {}").format(settings.PPOLICY_PERMANENT_LOCKED_TIME) 84 | 85 | 86 | def disable_account(modeladmin, request, queryset): 87 | num_sync = 0 88 | for i in queryset: 89 | num_sync += 1 90 | i.disable() 91 | msg = _('{}, disabled').format(i.__str__()) 92 | logger.info(msg) 93 | messages.add_message(request, messages.WARNING, msg) 94 | LogEntry.objects.log_action( 95 | user_id = request.user.pk, 96 | content_type_id = ContentType.objects.get_for_model(i).pk, 97 | object_id = i.pk, 98 | object_repr = i.__str__(), 99 | action_flag = CHANGE, 100 | change_message = _('Disabled User (pwdAccountLockedTime)') 101 | ) 102 | if num_sync: 103 | messages.add_message(request, messages.INFO, _('{} Accounts disabled').format(num_sync)) 104 | disable_account.short_description = _("Disable Account (expire password)") 105 | 106 | 107 | 108 | def enable_account(modeladmin, request, queryset): 109 | num_sync = 0 110 | for i in queryset: 111 | num_sync += 1 112 | i.enable() 113 | msg = _('{}, enabled').format(i.__str__()) 114 | logger.info(msg) 115 | messages.add_message(request, messages.INFO, msg) 116 | LogEntry.objects.log_action( 117 | user_id = request.user.pk, 118 | content_type_id = ContentType.objects.get_for_model(i).pk, 119 | object_id = i.pk, 120 | object_repr = i.__str__(), 121 | action_flag = CHANGE, 122 | change_message = _('Enabled User (pwdAccountLockedTime)') 123 | ) 124 | if num_sync: 125 | messages.add_message(request, messages.INFO, _('{} Accounts enabled').format(num_sync)) 126 | enable_account.short_description = _("Enable Account - Clean pwdAccountLockedTime") 127 | -------------------------------------------------------------------------------- /ldap_peoples/admin_filters.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib import messages 3 | from django.conf import settings 4 | from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter 5 | 6 | 7 | class GenericSearch(admin.SimpleListFilter): 8 | title = 'Custom Search' 9 | parameter_name = 'custom_search' 10 | template = 'filters/custom_search.html' 11 | 12 | def lookups(self, request, model_admin): 13 | """ it can be overloaded as follows 14 | return (('uid', 'uid'), 15 | ('mail', 'mail'), 16 | ('sn', 'sn'), 17 | ('schacPersonalUniqueID','schacPersonalUniqueID'), 18 | ('schacPersonalUniqueCode','schacPersonalUniqueCode'), 19 | ) 20 | """ 21 | l = [] 22 | for i in model_admin.model._meta.fields: 23 | l.append((i.name, i.name)) 24 | return l 25 | 26 | def queryset(self, request, queryset): 27 | """?custom_search=filter,mail__exact,peppelinux%40yahoo.it|| 28 | """ 29 | if request.GET.get(self.parameter_name): 30 | post = dict(request.GET)[self.parameter_name][0] 31 | search_list = [] 32 | search_list = post.split('||') 33 | for se in search_list: 34 | sple = se.split(',') 35 | se_dict = {'{}__{}'.format(sple[0], sple[2]): sple[3]} 36 | try: 37 | queryset = getattr(queryset, sple[1])(**se_dict) 38 | except Exception as e: 39 | messages.add_message(request, messages.ERROR, 40 | 'Search filter {} failed: {}'.format(se, e)) 41 | return queryset 42 | 43 | 44 | def choices(self, changelist): 45 | for lookup, title in self.lookup_choices: 46 | yield { 47 | 'selected': self.value() == str(lookup), 48 | 'query_string': changelist.get_query_string({self.parameter_name: lookup}), 49 | 'display': title, 50 | } 51 | 52 | 53 | class TitleListFilter(MultipleChoiceListFilter): 54 | title = 'Title' 55 | parameter_name = 'title' 56 | 57 | def lookups(self, request, model_admin): 58 | return [(k, v) for k,v in sorted(settings.LDAP_PEOPLES_TITLES)] 59 | 60 | def queryset(self, request, queryset): 61 | pk_list = [] 62 | if request.GET.get(self.parameter_name): 63 | for value in request.GET[self.parameter_name].split(','): 64 | kwargs = {self.parameter_name: value} 65 | q = queryset.filter(**kwargs) 66 | for dip in q.values_list('pk'): 67 | pk_list.append(dip[0]) 68 | return queryset.filter(pk__in=pk_list) 69 | 70 | 71 | class AffiliationListFilter(MultipleChoiceListFilter): 72 | title = 'Affiliation' 73 | parameter_name = 'eduPersonAffiliation' 74 | 75 | def lookups(self, request, model_admin): 76 | l = [(k, v) for k,v in sorted(settings.AFFILIATION)] 77 | l.append((None, 'no-affiliation')) 78 | return l 79 | def queryset(self, request, queryset): 80 | pk_list = [] 81 | if request.GET.get(self.parameter_name): 82 | for value in request.GET[self.parameter_name].split(','): 83 | kwargs = {self.parameter_name: value} 84 | q = queryset.filter(**kwargs) 85 | for dip in q.values_list('pk'): 86 | pk_list.append(dip[0]) 87 | return queryset.filter(pk__in=pk_list) 88 | -------------------------------------------------------------------------------- /ldap_peoples/admin_utils.py: -------------------------------------------------------------------------------- 1 | def get_values_as_html_ul(data): 2 | if not data: return '' 3 | value = '' 9 | return value 10 | -------------------------------------------------------------------------------- /ldap_peoples/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LdapPeoplesConfig(AppConfig): 5 | name = 'ldap_peoples' 6 | verbose_name = "LDAP people accounts" 7 | -------------------------------------------------------------------------------- /ldap_peoples/auth.py: -------------------------------------------------------------------------------- 1 | import ldap 2 | from ldap3.utils import conv 3 | import logging 4 | 5 | 6 | from django.conf import settings 7 | # from django.contrib.auth.models import User 8 | from django.contrib.auth import get_user_model 9 | from django.contrib.auth.backends import ModelBackend 10 | from django.contrib.sessions.models import Session 11 | # from django.contrib.auth.decorators import user_passes_test 12 | from django.db import connections 13 | from django.utils import timezone 14 | 15 | from . models import LdapAcademiaUser 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class LdapAcademiaAuthBackend(ModelBackend): 22 | """ 23 | This class logout a user if another session of that user 24 | will be created 25 | 26 | in settings.py 27 | AUTHENTICATION_BACKENDS = [ 'django.contrib.auth.backends.ModelBackend', 28 | 'ldap_peoples.auth.LdapAcademiaAuthBackend' 29 | ] 30 | """ 31 | def authenticate(self, request, username=None, password=None): 32 | ldap_conn = connections['ldap'] 33 | user = None 34 | username = conv.escape_filter_chars(username, encoding=None) 35 | lu = LdapAcademiaUser.objects.filter(uid=username).first() 36 | if not lu: 37 | return None 38 | 39 | # check if username exists and if it is active 40 | try: 41 | ldap_conn.connect() 42 | ldap_conn.connection.bind_s(lu.distinguished_name(), 43 | password) 44 | ldap_conn.connection.unbind_s() 45 | except Exception as e: 46 | logger.error(e) 47 | return None 48 | 49 | # if account beign unlocked this will be always false 50 | if not lu.is_active(): 51 | return None 52 | 53 | try: 54 | # user = get_user_model().objects.get(username=scoped_username) 55 | user = get_user_model().objects.get(username=lu.uid) 56 | # update attrs: 57 | if lu.mail: 58 | if user.email != lu.mail[0]: 59 | user.email = lu.mail[0] 60 | user.save() 61 | except Exception as e: 62 | user = get_user_model().objects.create(dn=lu.dn, 63 | #username=scoped_username, 64 | username = lu.uid, 65 | first_name=lu.cn, 66 | last_name=lu.sn) 67 | if lu.mail: 68 | user.email = lu.mail[0] 69 | user.save() 70 | 71 | # disconnect already created session, only a session per user is allowed 72 | # get all the active sessions 73 | if not settings.MULTIPLE_USER_AUTH_SESSIONS: 74 | for session in Session.objects.all(): 75 | try: 76 | if int(session.get_decoded().get('_auth_user_id')) == user.pk: 77 | session.delete() 78 | except (KeyError, TypeError, ValueError): 79 | pass 80 | 81 | return user 82 | -------------------------------------------------------------------------------- /ldap_peoples/form_fields.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core import validators 3 | from django.utils.translation import ugettext_lazy as _ 4 | 5 | 6 | class ListField(forms.Field): 7 | def has_changed(self, initial, data): 8 | """Return True if data differs from initial.""" 9 | # Always return False if the field is disabled since self.bound_data 10 | # always uses the initial value in this case. 11 | # print(sorted(initial), sorted(data)) 12 | if self.disabled: 13 | return False 14 | try: 15 | data = self.to_python(data) 16 | if hasattr(self, '_coerce'): 17 | return self._coerce(data) != self._coerce(initial) 18 | except ValidationError: 19 | return True 20 | # For purposes of seeing whether something has changed, None is 21 | # the same as an empty string, if the data or initial value we get 22 | # is None, replace it with ''. 23 | initial_value = initial if initial is not None else '' 24 | data_value = data if data is not None else '' 25 | return sorted(initial_value) != sorted(data_value) 26 | 27 | 28 | class EmailListField(ListField): 29 | pass 30 | #def validate_email_list(value): 31 | #print('EXEC') 32 | #if not isinstance(value, list): 33 | #raise ValidationError( 34 | #_('%(value)s is not a list'), 35 | #params={'value': value}, 36 | #) 37 | #for item in value: 38 | #validation = validators.validate_email(item) 39 | #print(validation, item) 40 | 41 | # TODO 42 | #def validate(self, value): 43 | #"""Check if value consists only of valid emails.""" 44 | #print(value) 45 | #super().validate(value) 46 | #for email in value: 47 | #validate_email(email) 48 | 49 | #default_validators = [validate_email_list] 50 | 51 | 52 | class ScopedListField(ListField): 53 | pass 54 | 55 | # TODO 56 | # class TimeStampField(forms.SplitDateTimeField): 57 | # widget = DateTimeInput 58 | # input_formats = formats.get_format_lazy('DATETIME_INPUT_FORMATS') 59 | # default_error_messages = { 60 | # 'invalid': _('Enter a valid date/time.'), 61 | # } 62 | 63 | # def clean(self, value): 64 | # value = self.to_python(value) 65 | # self.validate(value) 66 | # self.run_validators(value) 67 | # print(self.__dict__, value) 68 | # return value 69 | -------------------------------------------------------------------------------- /ldap_peoples/forms.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import re 4 | 5 | 6 | from copy import copy 7 | from django import forms 8 | from django.conf import settings 9 | from django.contrib.admin.helpers import ActionForm 10 | from django.core import validators 11 | from django.core.exceptions import ValidationError 12 | 13 | from django.utils import timezone 14 | from django.utils.functional import cached_property 15 | from django.utils.translation import ugettext_lazy as _ 16 | 17 | from . form_fields import ListField, EmailListField, ScopedListField 18 | from . models import LdapAcademiaUser 19 | from . widgets import (SplitJSONWidget, 20 | SchacPersonalUniqueIdWidget, 21 | SchacPersonalUniqueCodeWidget, 22 | eduPersonAffiliationWidget, 23 | eduPersonScopedAffiliationWidget, 24 | SchacHomeOrganizationTypeWidget, 25 | TitleWidget) 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class LdapMultiValuedForm(forms.ModelForm): 31 | 32 | def clean_ListField(self, field, data): 33 | # TODO run regexp validator here or add validators=[] in model field 34 | regexp = '{}\[\d+\]'.format(field) 35 | value_list = [] 36 | for key in data: 37 | if re.match(regexp, key): 38 | value_list.append(self.data[key]) 39 | if isinstance(self.fields[field], EmailListField): 40 | try: 41 | validators.validate_email(self.data[key]) 42 | except: 43 | # TODO: sarebbe meglio fare to_python eppoi... 44 | # print("Manage exception here: {} {}".format(field, self.data[key])) 45 | msg = _('{} is not a valid email format!') 46 | self.add_error(field, msg.format(self.data[key])) 47 | elif isinstance(self.fields[field], ScopedListField): 48 | if not re.match('.+@.+', self.data[key]): 49 | msg = _('{} is not valid: please use "value@scope"') 50 | self.add_error(field, msg.format(self.data[key])) 51 | return value_list 52 | 53 | @cached_property 54 | def changed_data(self): 55 | data = [] 56 | for name, field in self.fields.items(): 57 | prefixed_name = self.add_prefix(name) 58 | data_value = field.widget.value_from_datadict(self.data, 59 | self.files, 60 | prefixed_name) 61 | if not field.show_hidden_initial: 62 | # Use the BoundField's initial as this is the value passed to 63 | # the widget. 64 | initial_value = self[name].initial 65 | else: 66 | initial_prefixed_name = self.add_initial_prefix(name) 67 | hidden_widget = field.hidden_widget() 68 | try: 69 | initial_value = field.to_python(hidden_widget.value_from_datadict( 70 | self.data, self.files, 71 | initial_prefixed_name)) 72 | except ValidationError: 73 | # Always assume data has changed if validation fails. 74 | data.append(name) 75 | continue 76 | if field.has_changed(initial_value, data_value): 77 | data.append(name) 78 | return data 79 | 80 | 81 | class LdapUserAdminPasswordBaseForm(forms.Form): 82 | _min_len_help_text = 'The secret must at least {} digits, '.format(settings.PPOLICY_PASSWD_MIN_LEN) 83 | _extended_help_text = ('contains lowercase' 84 | ' and uppercase characters, ' 85 | ' number and at least one of these symbols:' 86 | '! % - _ + = [ ] { } : , . ? < > ( ) ; ') 87 | _secret_help_text = _min_len_help_text + _extended_help_text 88 | 89 | # custom field not backed by database 90 | new_passwd = forms.CharField(label=_('New password'), 91 | required=False, 92 | min_length=settings.PPOLICY_PASSWD_MIN_LEN, 93 | max_length=settings.PPOLICY_PASSWD_MAX_LEN, 94 | widget=forms.PasswordInput(), 95 | help_text=_secret_help_text) 96 | 97 | def clean_new_passwd(self): 98 | if not self.data['new_passwd']: 99 | return None 100 | for regexp in settings.SECRET_FIELD_VALIDATORS.values(): 101 | found = re.findall(regexp, self.data['new_passwd']) 102 | if not found: 103 | raise ValidationError(self._secret_help_text) 104 | return self.cleaned_data['new_passwd'] 105 | 106 | 107 | class LdapUserAdminPasswordForm(LdapUserAdminPasswordBaseForm): 108 | password_encoding = forms.ChoiceField(choices=[(i,i) for i in settings.SECRET_PASSWD_TYPE\ 109 | if i not in settings.DISABLED_SECRET_TYPES], 110 | required=False, 111 | initial=settings.DEFAULT_SECRET_TYPE) 112 | 113 | def clean_attribute(self): 114 | if self.data['attribute'] not in settings.DISABLED_SECRET_TYPES: 115 | return self.cleaned_data['attribute'] 116 | 117 | 118 | class LdapAcademiaUserAdminForm(LdapMultiValuedForm, LdapUserAdminPasswordBaseForm): 119 | # define your customization here 120 | # schacPersonalUniqueID = forms.CharField(required=False, widget=SchacPersonalUniqueIdWidget) 121 | 122 | def clean_schacExpiryDate(self): 123 | """ 124 | This kind of field need a better generalization 125 | 126 | The clean_ hooks in the forms API are for doing additional validation. 127 | It is called after the field calls its clean method. 128 | If the field does not consider the input valid then the clean_ is not called. 129 | This is noted in the custom validation docs: 130 | https://docs.djangoproject.com/en/2.1/ref/forms/validation/ 131 | """ 132 | datestr = ' '.join((self.data['schacExpiryDate_0'], 133 | self.data['schacExpiryDate_1'])) 134 | value = None 135 | for date_format in settings.DATETIME_INPUT_FORMATS: 136 | try: 137 | value = datetime.datetime.strptime(datestr, date_format) 138 | break 139 | except ValueError as e: 140 | logger.debug('clean_schacExpiryDate: {}'.format(e)) 141 | # if not value: return 142 | # value = timezone.make_aware(value, timezone.pytz.utc) 143 | self.cleaned_data['schacExpiryDate'] = value 144 | return self.cleaned_data['schacExpiryDate'] 145 | 146 | def clean_MultiValueWidget(self, field, data, 147 | default_prefix, count, 148 | sep=':'): 149 | logger.debug(field) 150 | regexp = '{}_(?P\d+)_\[(?P\d+)\]'.format(field) 151 | value_list = [] 152 | field_dict = {} 153 | for key in data: 154 | reg_test = re.match(regexp, key) 155 | if reg_test: 156 | order_id = int(reg_test.groupdict()['order']) 157 | key_id = int(reg_test.groupdict()['key']) 158 | if not isinstance(field_dict.get(key_id), dict): 159 | field_dict[key_id] = {} 160 | field_dict[key_id][order_id] = self.data[key] 161 | for row_dict in field_dict.values(): 162 | field_group = [default_prefix,] if default_prefix else [] 163 | cnt = 0 164 | for item in sorted(row_dict.keys()): 165 | if not row_dict[item]: continue 166 | cnt += 1 167 | if cnt < count: 168 | field_group.append(row_dict[item]) 169 | continue 170 | 171 | field_group.append(row_dict[item]) 172 | new_value = sep.join(field_group) 173 | value_list.append(new_value) 174 | cnt = 0 175 | field_group = [default_prefix,] if default_prefix else [] 176 | field_group.append(row_dict[item]) 177 | #print(field_dict[item], cnt, field_group, value_list) 178 | return value_list 179 | 180 | def clean_eduPersonAffiliation_custom(self, field, data): 181 | # print(field, data) 182 | regexp = '{}_(?P\d+)_\[(?P\d+)\]'.format(field) 183 | value_list = [] 184 | field_dict = {} 185 | for key in data: 186 | reg_test = re.match(regexp, key) 187 | # print(reg_test, key) 188 | if reg_test: 189 | order_id = int(reg_test.groupdict()['order']) 190 | key_id = int(reg_test.groupdict()['key']) 191 | if not isinstance(field_dict.get(key_id), dict): 192 | field_dict[key_id] = {} 193 | field_dict[key_id][order_id] = self.data[key] 194 | # print(field_dict) 195 | for row_dict in field_dict.values(): 196 | field_group = [] 197 | cnt = 0 198 | for item in sorted(row_dict.keys()): 199 | if not row_dict[item]: continue 200 | cnt += 1 201 | # print(row_dict) 202 | # print(row_dict[item], cnt, field_group) 203 | if cnt < 1: 204 | field_group.append(row_dict[item]) 205 | continue 206 | 207 | field_group.append(row_dict[item]) 208 | new_value = ''.join(field_group) 209 | value_list.append(new_value) 210 | cnt = 0 211 | field_group = [] 212 | field_group.append(row_dict[item]) 213 | # print(field_dict[item], cnt, field_group, value_list) 214 | return value_list 215 | 216 | def clean(self): 217 | """ 218 | Detect for any ListField widget 219 | """ 220 | #super().clean() 221 | data = copy(self.data) 222 | for field in self.fields: 223 | if isinstance(self.fields[field].widget, SchacPersonalUniqueCodeWidget): 224 | data[field] = self.clean_MultiValueWidget(field, data, 225 | settings.SCHAC_PERSONALUNIQUECODE_DEFAULT_PREFIX, 226 | 2) 227 | elif isinstance(self.fields[field].widget, SchacHomeOrganizationTypeWidget): 228 | data[field] = self.clean_MultiValueWidget(field, data, 229 | settings.SCHAC_HOMEORGANIZATIONTYPE_DEFAULT_PREFIX, 230 | 2) 231 | elif isinstance(self.fields[field].widget, eduPersonScopedAffiliationWidget): 232 | data[field] = self.clean_MultiValueWidget(field, data, 233 | None, 2, sep='') 234 | elif isinstance(self.fields[field].widget, eduPersonAffiliationWidget): 235 | data[field] = self.clean_MultiValueWidget(field, data, 236 | None, 1) 237 | elif isinstance(self.fields[field].widget, TitleWidget): 238 | data[field] = self.clean_MultiValueWidget(field, data, 239 | None, 1) 240 | elif isinstance(self.fields[field].widget, SchacPersonalUniqueIdWidget): 241 | data[field] = self.clean_MultiValueWidget(field, data, 242 | settings.SCHAC_PERSONALUNIQUEID_DEFAULT_PREFIX, 243 | 3) 244 | # clean error manually 245 | if self._errors.get(field): 246 | del(self._errors[field]) 247 | elif isinstance(self.fields[field].widget, SplitJSONWidget): 248 | data[field] = self.clean_ListField(field, data) 249 | elif field == 'schacDateOfBirth': 250 | # per generalizzare questo agire nel metodo to_python del field 251 | date_in = None 252 | if not data[field]: 253 | data[field] = None 254 | continue 255 | for date_format in settings.DATE_INPUT_FORMATS: 256 | try: 257 | date_in = datetime.datetime.strptime(data[field], date_format) 258 | except ValueError as e: 259 | logger.debug(date_format, e) 260 | pass 261 | if date_in: 262 | data[field] = date_in 263 | break 264 | # print(data[field]) 265 | 266 | elif field == 'schacExpiryDate': 267 | data[field] = self.clean_schacExpiryDate() 268 | 269 | # this is automatically updated 270 | # elif field == 'eduPersonPrincipalName': 271 | # if not re.match(settings.EPPN_VALIDATOR, data[field]): 272 | # msg = _('{} is not valid: please use "value@scope"') 273 | # self.add_error(field, msg.format(data[field])) 274 | 275 | self.data = data 276 | return data 277 | 278 | 279 | class LdapGroupAdminMultiValuedForm(LdapMultiValuedForm): 280 | # define your customization here 281 | def clean(self): 282 | data = copy(self.data) 283 | for field in self.fields: 284 | if isinstance(self.fields[field].widget, SplitJSONWidget): 285 | data[field] = self.clean_ListField(field, data) 286 | # TODO: regex validation here: 287 | # clean error manually 288 | if self._errors.get(field): 289 | del(self._errors[field]) 290 | self.data = data 291 | return data 292 | -------------------------------------------------------------------------------- /ldap_peoples/hash_functions.py: -------------------------------------------------------------------------------- 1 | import crypt 2 | 3 | from base64 import encodestring 4 | try: 5 | from django.conf import settings 6 | _CHARSET = settings.DEFAULT_CHARSET 7 | _LDAP_SALT_LENGHT = settings.LDAP_PASSWORD_SALT_SIZE 8 | except: 9 | _CHARSET = 'utf-8' 10 | _LDAP_SALT_LENGHT = 8 11 | from hashlib import (sha1, 12 | sha256, 13 | sha384, 14 | sha512) 15 | from passlib.hash import (ldap_plaintext, 16 | lmhash, 17 | nthash, 18 | ldap_md5, 19 | ldap_md5_crypt, 20 | ldap_salted_md5, 21 | ldap_sha1, 22 | ldap_salted_sha1, 23 | atlassian_pbkdf2_sha1, 24 | ldap_md5_crypt, 25 | ldap_sha256_crypt, 26 | ldap_sha512_crypt) 27 | from os import urandom 28 | 29 | # how many bytes the salt is long 30 | 31 | def encode_secret(enc, new_value=None): 32 | """ 33 | https://docs.python.org/3.5/library/hashlib.html 34 | http://passlib.readthedocs.io/en/stable/lib/passlib.hash.ldap_std.html 35 | """ 36 | password_renewed = None 37 | if enc == 'Plaintext': 38 | password_renewed = ldap_plaintext.hash(new_value) 39 | elif enc == 'NT': 40 | password_renewed = nthash.hash(new_value) 41 | elif enc == 'LM': 42 | password_renewed = lmhash.hash(new_value) 43 | elif enc == 'MD5': 44 | password_renewed = ldap_md5.hash(new_value.encode(_CHARSET)) 45 | elif enc == 'SMD5': 46 | password_renewed = ldap_salted_md5.hash(new_value.encode(_CHARSET)) 47 | elif enc == 'SHA': 48 | password_renewed = ldap_sha1.hash(new_value.encode(_CHARSET)) 49 | elif enc == 'SSHA': 50 | salt = urandom(8) 51 | hash = sha1(new_value.encode(_CHARSET)) 52 | hash.update(salt) 53 | hash_encoded = encodestring(hash.digest() + salt) 54 | password_renewed = hash_encoded.decode(_CHARSET)[:-1] 55 | password_renewed = '{%s}%s' % (enc, password_renewed) 56 | elif enc == 'SHA256': 57 | password_renewed = sha256(new_value.encode(_CHARSET)).digest() 58 | password_renewed = '{%s}%s' % (enc, encodestring(password_renewed).decode(_CHARSET)[:-1]) 59 | elif enc == 'SSHA256': 60 | salt = urandom(_LDAP_SALT_LENGHT) 61 | hash = sha256(new_value.encode(_CHARSET)) 62 | hash.update(salt) 63 | hash_encoded = encodestring(hash.digest() + salt) 64 | password_renewed = hash_encoded.decode(_CHARSET)[:-1] 65 | password_renewed = '{%s}%s' % (enc, password_renewed) 66 | elif enc == 'SHA384': 67 | password_renewed = sha384(new_value.encode(_CHARSET)).digest() 68 | password_renewed = '{%s}%s' % (enc, encodestring(password_renewed).decode(_CHARSET)[:-1]) 69 | elif enc == 'SSHA384': 70 | salt = urandom(_LDAP_SALT_LENGHT) 71 | hash = sha384(new_value.encode(_CHARSET)) 72 | hash.update(salt) 73 | hash_encoded = encodestring(hash.digest() + salt) 74 | password_renewed = hash_encoded.decode(_CHARSET)[:-1] 75 | password_renewed = '{%s}%s' % (enc, password_renewed) 76 | elif enc == 'SHA512': 77 | password_renewed = sha512(new_value.encode(_CHARSET)).digest() 78 | password_renewed = '{%s}%s' % (enc, encodestring(password_renewed).decode(_CHARSET)[:-1]) 79 | elif enc == 'SSHA512': 80 | salt = urandom(_LDAP_SALT_LENGHT) 81 | hash = sha512(new_value.encode(_CHARSET)) 82 | hash.update(salt) 83 | hash_encoded = encodestring(hash.digest() + salt) 84 | password_renewed = hash_encoded.decode(_CHARSET)[:-1] 85 | password_renewed = '{%s}%s' % (enc, password_renewed) 86 | elif enc == 'PKCS5S2': 87 | return atlassian_pbkdf2_sha1.encrypt(new_value) 88 | elif enc == 'CRYPT': 89 | password_renewed = crypt.crypt(new_value, crypt.mksalt(crypt.METHOD_CRYPT)) 90 | password_renewed = '{%s}%s' % (enc, password_renewed) 91 | elif enc == 'CRYPT-MD5': 92 | # this worked too 93 | # return ldap_md5_crypt.encrypt(new_value) 94 | password_renewed = crypt.crypt(new_value, crypt.mksalt(crypt.METHOD_MD5)) 95 | password_renewed = '{CRYPT}%s' % (password_renewed) 96 | elif enc == 'CRYPT-SHA-256': 97 | password_renewed = crypt.crypt(new_value, crypt.mksalt(crypt.METHOD_SHA256)) 98 | password_renewed = '{CRYPT}%s' % (password_renewed) 99 | elif enc == 'CRYPT-SHA-512': 100 | password_renewed = crypt.crypt(new_value, crypt.mksalt(crypt.METHOD_SHA512)) 101 | password_renewed = '{CRYPT}%s' % (password_renewed) 102 | return password_renewed 103 | 104 | def test_encoding_secrets(): 105 | for i in settings.SECRET_PASSWD_TYPE: 106 | p = encode_secret(i, 'zio') 107 | print(i, ':', p) 108 | # additionals 109 | for i in ['NT', 'LM']: 110 | p = encode_secret(i, 'zio') 111 | print(i, ':', p) 112 | 113 | if __name__ == '__main__': 114 | test_encoding_secrets() 115 | -------------------------------------------------------------------------------- /ldap_peoples/idem_affiliation_mapping.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | from collections import OrderedDict 3 | 4 | DEFAULT_AFFILIATION = {_("student [student, member]") : ["student", "member"]} 5 | 6 | idem_affiliation_map_extended = { 7 | _("assistente universitario [staff, member]"): ["staff", "member"], 8 | _("associato (ad es. CNR) [member]"): ["member"], 9 | _("cessato"): [], 10 | _("collaboratore coordinato continuativo [staff, member]"): ["staff", "member"], 11 | _("collaboratore linguistico [staff, member]"): ["staff", "member"], 12 | _("consorziato (membro del consorzio a cui l'ente appartiene) [member]"): ["member"], 13 | _("convenzionato (cliente delle convenzioni) [affiliate]"): ["affiliate"], 14 | _("cultore della materia [staff, member]"): ["staff", "member"], 15 | _("dipendente [staff, member]"): ["staff", "member"], 16 | _("dipendente altra università [member]"): ["member"], 17 | _("dipendente altro ente di ricerca [member]"): ["member"], 18 | _("dipendente azienda ospedaliera/policlinico [member]"): ["member"], 19 | _("dipendente di altra azienda sanitaria [member]"): ["member"], 20 | _("direttore amministrativo [staff, member]"): ["staff", "member"], 21 | _("dirigente [staff, member]"): ["staff", "member"], 22 | _("dirigente a contratto [staff, member]"): ["staff", "member"], 23 | _("dirigente di ricerca [staff, member]"): ["staff", "member"], 24 | _("dirigente tecnologo [staff, member]"): ["staff", "member"], 25 | _("docente a contratto [staff, member]"): ["staff", "member"], 26 | _("dottorando [staff, member, student]"): ["staff", "member", "student"], 27 | _("dottorando di altra università (consorziata) [member]"): ["member"], 28 | _("esperto linguistico [staff, member]"): ["staff", "member"], 29 | _("fornitore (dipendente o titolare delle ditte fornitrici) [affiliate]"): ["affiliate"], 30 | _("interinale [staff, member]"): ["staff", "member"], 31 | _("ispettore generale [affiliate]"): ["affiliate"], 32 | _("laureato frequentatore/collaboratore di ricerca (a titolo gratuito) [member]"): ["member"], 33 | _("lavoratore occasionale (con contratto personale senza partita iva) [staff, member]"): ["staff","member"], 34 | _("lettore di scambio [member]"): ["member"], 35 | _("libero professionista (con contratto personale con partita iva) [staff, member]"): ["staff","member"], 36 | _("ospite / visitatore [affiliate]"): ["affiliate"], 37 | _("personale tecnico-amministrativo [staff, member]"): ["staff", "member"], 38 | _("personale tecnico-amministrativo a tempo determinato [staff, member]"): ["staff","member"], 39 | _("primo ricercatore [staff, member]"): ["staff", "member"], 40 | _("primo tecnologo [staff, member]"): ["staff", "member"], 41 | _("professore associato [staff, member]"): ["staff", "member"], 42 | _("professore emerito [member]"): ["member"], 43 | _("professore incaricato esterno [staff, member]"): ["staff", "member"], 44 | _("professore incaricato interno [staff, member]"): ["staff", "member"], 45 | _("professore ordinario [staff, member]"): ["staff", "member"], 46 | _("ricercatore [staff, member]"): ["staff", "member"], 47 | _("specializzando [staff, member, student]"): ["staff", "member", "student"], 48 | _("studente [student, member]"): ["student", "member"], 49 | _("studente erasmus in ingresso [student]"): ["student"], 50 | _("studente fuori sede (tesista, tirocinante, ...) [student, member]"): ["student","member"], 51 | _("studente laurea specialistica [student, member]"): ["student", "member"], 52 | _("studente master [student, member]"): ["student", "member"], 53 | _("studente siss [student, member]"): ["student", "member"], 54 | _("supervisore siss [staff, member]"): ["staff", "member"], 55 | _("supplente docente [staff, member]"): ["staff", "member"], 56 | _("tecnologo [staff, member]"): ["staff", "member"], 57 | _("titolare di assegno di ricerca [staff, member]"): ["staff", "member"], 58 | _("titolare di borsa di studio [member]"): ["member"], 59 | _("tutor [staff, member]"): ["staff", "member"], 60 | _("volontario servizio civile nazionale [member]"): ["member"] 61 | } 62 | 63 | 64 | idem_affiliation_map = { 65 | _("dipendente, \ 66 | professore, ricercatore, \ 67 | titolare di assegno di ricerca, \ 68 | tutor, \ 69 | assistente universitario, \ 70 | collaboratore coordinato continuativo, \ 71 | collaboratore linguistico, \ 72 | cultore della materia [staff, member]"): ["staff", "member"], 73 | 74 | _("associato (ad es. CNR), \ 75 | consorziato (membro del consorzio a cui l\"ente appartiene), \ 76 | dipendente altra università o ente di \ 77 | ricerca o azienda sanitaria/ospedaliera/policlinico, \ 78 | dottorando di altra università (consorziata), \ 79 | laureato frequentatore/collaboratore di ricerca (a titolo gratuito) \ 80 | [member]"): ["member"], 81 | 82 | _("cessato"): [], 83 | 84 | _("convenzionato (cliente delle convenzioni), \ 85 | fornitore (dipendente o titolare delle ditte fornitrici), \ 86 | ispettore, ospite / visitatore [affiliate]"): ["affiliate"], 87 | 88 | _("lettore di scambio, \ 89 | titolare di borsa di studio, \ 90 | volontario servizio civile nazionale [member]"): ["member"], 91 | 92 | _("studente erasmus in ingresso [student]"): ["student"], 93 | 94 | _("dottorando, specializzando [staff, member, student]"): ["staff", "member", "student"], 95 | 96 | _("studente, \ 97 | studente fuori sede (tesista, tirocinante, ...), \ 98 | studente laurea specialistica, \ 99 | studente master, \ 100 | studente siss [member, student]"): ["student", "member"], 101 | } 102 | 103 | 104 | IDEM_AFFILIATION_MAP = OrderedDict(DEFAULT_AFFILIATION) 105 | IDEM_AFFILIATION_MAP.update(OrderedDict(sorted(idem_affiliation_map.items(), key=lambda t: t[0]))) 106 | -------------------------------------------------------------------------------- /ldap_peoples/ldap_utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from datetime import datetime, timedelta 4 | from django.conf import settings 5 | from django.utils import timezone 6 | 7 | 8 | def parse_time(timestr, tformat): 9 | time_struct = time.strptime(timestr, tformat) 10 | timestamp = time.mktime(time_struct) 11 | value = timezone.datetime.fromtimestamp(timestamp) 12 | utc = timezone.make_aware(value, timezone.pytz.utc) 13 | return utc.astimezone(timezone.get_default_timezone()) 14 | 15 | 16 | def parse_generalized_time(timestr): 17 | return parse_time(timestr, settings.LDAP_DATETIME_FORMAT) 18 | 19 | 20 | def parse_pwdfailure_time(timestr): 21 | return parse_time(timestr, 22 | settings.LDAP_DATETIME_MILLISECONDS_FORMAT) 23 | 24 | 25 | def format_generalized_time(dt): 26 | """ 27 | from datetime to zulu timestamp like 20180708095609Z 28 | """ 29 | return dt.strftime(settings.LDAP_DATETIME_FORMAT) 30 | 31 | 32 | def get_expiration_date(): 33 | return datetime.now() + timedelta(days=settings.SHAC_EXPIRY_DURATION_DAYS) 34 | -------------------------------------------------------------------------------- /ldap_peoples/ldif.py: -------------------------------------------------------------------------------- 1 | """ 2 | ldif - generate and parse LDIF data (see RFC 2849) 3 | 4 | See https://www.python-ldap.org/ for details. 5 | """ 6 | 7 | from __future__ import unicode_literals 8 | 9 | __version__ = '3.1.0-pplnx' 10 | 11 | __all__ = [ 12 | # constants 13 | 'ldif_pattern', 14 | # functions 15 | 'CreateLDIF','ParseLDIF', 16 | # classes 17 | 'LDIFWriter', 18 | 'LDIFParser', 19 | 'LDIFRecordList', 20 | 'LDIFCopy', 21 | ] 22 | 23 | import re 24 | from base64 import b64encode, b64decode 25 | from io import StringIO 26 | import warnings 27 | 28 | from ldap.compat import urlparse, urlopen 29 | 30 | attrtype_pattern = r'[\w;.-]+(;[\w_-]+)*' 31 | attrvalue_pattern = r'(([^,]|\\,)+|".*?")' 32 | attrtypeandvalue_pattern = attrtype_pattern + r'[ ]*=[ ]*' + attrvalue_pattern 33 | rdn_pattern = attrtypeandvalue_pattern + r'([ ]*\+[ ]*' + attrtypeandvalue_pattern + r')*[ ]*' 34 | dn_pattern = rdn_pattern + r'([ ]*,[ ]*' + rdn_pattern + r')*[ ]*' 35 | dn_regex = re.compile('^%s$' % dn_pattern) 36 | 37 | ldif_pattern = '^((dn(:|::) %(dn_pattern)s)|(%(attrtype_pattern)s(:|::) .*)$)+' % vars() 38 | 39 | MOD_OP_INTEGER = { 40 | 'add':0, # ldap.MOD_ADD 41 | 'delete':1, # ldap.MOD_DELETE 42 | 'replace':2, # ldap.MOD_REPLACE 43 | 'increment':3, # ldap.MOD_INCREMENT 44 | } 45 | 46 | MOD_OP_STR = { 47 | 0:'add',1:'delete',2:'replace',3:'increment' 48 | } 49 | 50 | CHANGE_TYPES = ['add','delete','modify','modrdn'] 51 | valid_changetype_dict = {} 52 | for c in CHANGE_TYPES: 53 | valid_changetype_dict[c]=None 54 | 55 | 56 | def is_dn(s): 57 | """ 58 | returns 1 if s is a LDAP DN 59 | """ 60 | if s=='': 61 | return 1 62 | rm = dn_regex.match(s) 63 | return rm!=None and rm.group(0)==s 64 | 65 | 66 | SAFE_STRING_PATTERN = b'(^(\000|\n|\r| |:|<)|[\000\n\r\200-\377]+|[ ]+$)' 67 | safe_string_re = re.compile(SAFE_STRING_PATTERN) 68 | 69 | def list_dict(l): 70 | """ 71 | return a dictionary with all items of l being the keys of the dictionary 72 | """ 73 | return {i: None for i in l} 74 | 75 | 76 | class LDIFWriter: 77 | """ 78 | Write LDIF entry or change records to file object 79 | Copy LDIF input to a file output object containing all data retrieved 80 | via URLs 81 | """ 82 | 83 | def __init__(self,output_file,base64_attrs=None,cols=76,line_sep='\n'): 84 | """ 85 | output_file 86 | file object for output; should be opened in *text* mode 87 | base64_attrs 88 | list of attribute types to be base64-encoded in any case 89 | cols 90 | Specifies how many columns a line may have before it's 91 | folded into many lines. 92 | line_sep 93 | String used as line separator 94 | """ 95 | self._output_file = output_file 96 | self._base64_attrs = list_dict([a.lower() for a in (base64_attrs or [])]) 97 | self._cols = cols 98 | self._last_line_sep = line_sep 99 | self.records_written = 0 100 | 101 | def _unfold_lines(self,line): 102 | """ 103 | Write string line as one or more folded lines 104 | """ 105 | # Check maximum line length 106 | line_len = len(line) 107 | if line_len<=self._cols: 108 | self._output_file.write(line) 109 | self._output_file.write(self._last_line_sep) 110 | else: 111 | # Fold line 112 | pos = self._cols 113 | self._output_file.write(line[0:min(line_len,self._cols)]) 114 | self._output_file.write(self._last_line_sep) 115 | while pos=%s' % (lhs, rhs) 399 | 400 | 401 | class TimeStampFieldGtLookup(AbstractTimeStampLookup): 402 | lookup_name = 'gt' 403 | 404 | def _as_ldap(self, lhs, rhs): 405 | return '%s>=%s' % (lhs, rhs) 406 | 407 | 408 | class TimeStampFieldLteLookup(AbstractTimeStampLookup): 409 | lookup_name = 'lte' 410 | 411 | def _as_ldap(self, lhs, rhs): 412 | return '%s<=%s' % (lhs, rhs) 413 | 414 | 415 | class TimeStampFieldLtLookup(AbstractTimeStampLookup): 416 | lookup_name = 'lt' 417 | 418 | def _as_ldap(self, lhs, rhs): 419 | return '%s<=%s' % (lhs, rhs) 420 | 421 | 422 | TimeStampField.register_lookup(ExactLookup) 423 | TimeStampField.register_lookup(TimeStampFieldGteLookup) 424 | TimeStampField.register_lookup(TimeStampFieldGtLookup) 425 | TimeStampField.register_lookup(TimeStampFieldLteLookup) 426 | TimeStampField.register_lookup(TimeStampFieldLtLookup) 427 | -------------------------------------------------------------------------------- /ldap_peoples/models.py: -------------------------------------------------------------------------------- 1 | import ldap 2 | import ldapdb.models 3 | import logging 4 | import os 5 | 6 | from django.conf import settings 7 | from django.db import connections 8 | from django.db.models import fields 9 | from django.utils import timezone 10 | 11 | from django.db import models 12 | from django.utils.translation import gettext as _ 13 | from ldapdb.models.fields import (CharField, 14 | DateTimeField, 15 | ImageField, 16 | IntegerField, 17 | TimestampField) 18 | from pySSHA import ssha 19 | from .hash_functions import encode_secret 20 | from . ldap_utils import (parse_generalized_time, 21 | parse_pwdfailure_time, 22 | get_expiration_date, 23 | format_generalized_time) 24 | from . model_fields import (TimeStampField, 25 | DateField, 26 | MultiValueField, 27 | ListField, 28 | EmailListField, 29 | SchacPersonalUniqueIdListField, 30 | SchacPersonalUniqueCodeListField, 31 | ScopedListField, 32 | eduPersonAffiliationListField, 33 | eduPersonScopedAffiliationListField, 34 | SchacHomeOrganizationTypeListField, 35 | TitleField) 36 | from . serializers import LdapSerializer 37 | 38 | 39 | logger = logging.getLogger(__name__) 40 | 41 | 42 | class LdapGroup(ldapdb.models.Model): 43 | """ 44 | Class for representing an LDAP group entry. 45 | This is a memberOf bind 46 | http://www.openldap.org/software/man.cgi?query=slapo-memberof&sektion=5&apropos=0&manpath=OpenLDAP+2.4-Release 47 | """ 48 | # LDAP meta-data 49 | base_dn = "ou=groups,{}".format(settings.LDAP_BASEDN) 50 | object_classes = ['groupOfNames',] 51 | 52 | cn = CharField(db_column='cn', 53 | primary_key=True) 54 | member = ListField(db_column='member', default=[]) 55 | 56 | 57 | class Meta: 58 | verbose_name = _('LDAP MemberOf Group') 59 | verbose_name_plural = _('LDAP MemberOf Groups') 60 | 61 | def add_member(self, member_dn_obj): 62 | members = self.member.split(os.linesep) 63 | if not member_dn_obj in members: 64 | members.append(member_dn_obj.dn.strip()) 65 | self.member = os.linesep.join(members) 66 | self.save() 67 | 68 | def remove_member(self, member_dn_obj): 69 | members = self.member.split(os.linesep) 70 | if member_dn_obj.dn in members: 71 | members = [i for i in members if i != member_dn_obj.dn ] 72 | self.member = os.linesep.join(members) 73 | self.save() 74 | 75 | def __str__(self): 76 | return self.cn 77 | 78 | 79 | class LdapAcademiaUser(ldapdb.models.Model, LdapSerializer): 80 | """ 81 | Class for representing an LDAP user entry. 82 | """ 83 | # LDAP meta-data 84 | base_dn = "{}".format(settings.LDAP_PEOPLE_DN) 85 | 86 | object_classes = ['inetOrgPerson', 87 | 'organizationalPerson', 88 | 'person', 89 | # WARNING: only who have these OC will be filtered 90 | 'userSecurityInformation', 91 | 'eduPerson', 92 | 'radiusprofile', 93 | 'sambaSamAccount', 94 | 'schacContactLocation', 95 | 'schacEmployeeInfo', 96 | 'schacEntryConfidentiality', 97 | 'schacEntryMetadata', 98 | 'schacExperimentalOC', 99 | 'schacGroupMembership', 100 | 'schacLinkageIdentifiers', 101 | 'schacPersonalCharacteristics', 102 | 'schacUserEntitlements'] 103 | 104 | # inetOrgPerson 105 | uid = CharField(db_column='uid', 106 | verbose_name="User ID", 107 | help_text="uid", 108 | primary_key=True) 109 | cn = CharField(db_column='cn', 110 | verbose_name=_("Common Name"), 111 | help_text='cn', 112 | blank=False) 113 | givenName = CharField(db_column='givenName', 114 | help_text="givenName", 115 | verbose_name=_("First Name"), 116 | blank=True, null=True) 117 | sn = CharField("Last name", db_column='sn', 118 | help_text='sn', 119 | blank=False) 120 | displayName = CharField(db_column='displayName', 121 | help_text='displayName', 122 | blank=True, null=True) 123 | title = TitleField(db_column='title', 124 | help_text='title', 125 | blank=True, null=True) 126 | telephoneNumber = ListField(db_column='telephoneNumber', 127 | blank=True) 128 | mail = EmailListField(db_column='mail', 129 | default='', 130 | blank=True, null=True) 131 | userPassword = CharField(db_column='userPassword', 132 | verbose_name="LDAP Password", 133 | blank=True, null=True) 134 | # editable=False) 135 | sambaNTPassword = CharField(db_column='sambaNTPassword', 136 | help_text=_("SAMBA NT Password (freeRadius PEAP)"), 137 | blank=True, null=True,) 138 | sambaSID = CharField(db_column='sambaSID', 139 | help_text=_("Microsoft Network unique identificator"), 140 | blank=True, null=True) 141 | # academia 142 | eduPersonPrincipalName = CharField(db_column='eduPersonPrincipalName', 143 | help_text=_("A scoped identifier for a person"), 144 | verbose_name='ePPN, Eduperson PrincipalName', 145 | blank=True, null=True) 146 | eduPersonAffiliation = eduPersonAffiliationListField(db_column='eduPersonAffiliation', 147 | help_text=_("Membership and " 148 | "affiliation organization"), 149 | verbose_name='Eduperson Affiliation', 150 | blank=True, null=True) 151 | eduPersonScopedAffiliation = eduPersonScopedAffiliationListField(db_column='eduPersonScopedAffiliation', 152 | help_text=_("Membership and scoped" 153 | "affiliation organization." 154 | "Es: affliation@istitution"), 155 | verbose_name='ScopedAffiliation', 156 | blank=True, null=True) 157 | eduPersonEntitlement = ListField(db_column='eduPersonEntitlement', 158 | help_text=("eduPersonEntitlement"), 159 | verbose_name='eduPersonEntitlement', 160 | #default=settings.DEFAULT_EDUPERSON_ENTITLEMENT, 161 | blank=True, null=True) 162 | eduPersonOrcid = CharField(db_column='eduPersonOrcid', 163 | verbose_name='EduPerson Orcid', 164 | help_text=_("ORCID user identifier released and managed by orcid.org"), 165 | blank=True, null=True) 166 | eduPersonAssurance = CharField(db_column='eduPersonAssurance', 167 | verbose_name='EduPerson Assurance', 168 | choices = settings.EDUPERSON_ASSURANCES, 169 | default = settings.EDUPERSON_DEFAULT_ASSURANCE, 170 | help_text=_("Identity proofing and credential issuance (LoA)"), 171 | blank=True, null=True) 172 | # SCHAC 2015 173 | schacHomeOrganization = CharField(db_column='schacHomeOrganization', 174 | help_text=_(("The persons home organization " 175 | "using the domain of the organization.")), 176 | # default=settings.SCHAC_HOMEORGANIZATION_DEFAULT, 177 | verbose_name='schacHomeOrganization', 178 | blank=True, null=True) 179 | schacHomeOrganizationType = SchacHomeOrganizationTypeListField(db_column='schacHomeOrganizationType', 180 | help_text=_("Type of a Home Organization"), 181 | blank=True, null=True) 182 | schacPersonalUniqueID = SchacPersonalUniqueIdListField(db_column='schacPersonalUniqueID', 183 | verbose_name="schacPersonalUniqueID", 184 | help_text=_(("Unique Legal Identifier of " 185 | "a person, es: codice fiscale")), 186 | blank=True, null=True, ) 187 | schacPersonalUniqueCode = SchacPersonalUniqueCodeListField(db_column='schacPersonalUniqueCode', 188 | verbose_name="schacPersonalUniqueCode", 189 | help_text=_(('Specifies a "unique code" ' 190 | 'for the subject it is associated with')), 191 | blank=True, null=True) 192 | schacGender = CharField(db_column='schacGender', default='0', 193 | choices=(('0', _('Not know')), 194 | ('1', _('Male')), 195 | ('2', _('Female')), 196 | ('9', _('Not specified'))), 197 | help_text=_("OID: 1.3.6.1.4.1.25178.1.2.2"), 198 | verbose_name='schacGender', 199 | blank=True, null=True) 200 | schacDateOfBirth = DateField(db_column='schacDateOfBirth', 201 | format="%Y%m%d", # from_ldap format 202 | help_text=_("OID 1.3.6.1.4.1.1466.115.121.1.36"), 203 | verbose_name='schacDateOfBirth', 204 | blank=True, null=True) 205 | schacPlaceOfBirth = CharField(db_column='schacPlaceOfBirth', 206 | help_text=_("OID: 1.3.6.1.4.1.1466.115.121.1.15"), 207 | verbose_name='schacPlaceOfBirth', 208 | blank=True, null=True) 209 | schacExpiryDate = TimeStampField(db_column='schacExpiryDate', 210 | help_text=_(('Date from which the set of ' 211 | 'data is to be considered invalid')), 212 | default=get_expiration_date, 213 | format=settings.DATETIME_FORMAT, 214 | blank=False, null=True) 215 | # readonly 216 | memberOf = MultiValueField(db_column='memberOf', editable=False, null=True) 217 | createTimestamp = DateTimeField(db_column='createTimestamp', editable=False, null=True) 218 | modifyTimestamp = DateTimeField(db_column='modifyTimestamp', editable=False, null=True) 219 | creatorsName = CharField(db_column='creatorsName', editable=False, null=True) 220 | modifiersName = CharField(db_column='modifiersName', editable=False, null=True) 221 | 222 | # If pwdAccountLockedTime is set to 000001010000Z, the user's account has been permanently locked and may only be unlocked by an administrator. 223 | # Note that account locking only takes effect when the pwdLockout password policy attribute is set to "TRUE". 224 | pwdAccountLockedTime = CharField(db_column='pwdAccountLockedTime') 225 | pwdFailureTime = MultiValueField(db_column='pwdFailureTime', editable=False) 226 | pwdChangedTime = TimeStampField(db_column='pwdChangedTime', editable=False) 227 | pwdHistory = ListField(db_column='pwdHistory', editable=False) 228 | 229 | class Meta: 230 | verbose_name = _('LDAP Academia User') 231 | verbose_name_plural = _('LDAP Academia Users') 232 | 233 | def distinguished_name(self): 234 | return 'uid={},{}'.format(self.uid, self.base_dn) 235 | 236 | def is_active(self): 237 | if self.pwdAccountLockedTime: return False 238 | if self.schacExpiryDate: 239 | if self.schacExpiryDate < timezone.localtime(): return False 240 | return True 241 | 242 | def is_renewable(self): 243 | return self.pwdAccountLockedTime != settings.PPOLICY_PERMANENT_LOCKED_TIME 244 | 245 | def lock(self): 246 | self.pwdAccountLockedTime = settings.PPOLICY_PERMANENT_LOCKED_TIME 247 | self.save() 248 | logger.debug('Locked {} with {}'.format(self.uid, self.pwdAccountLockedTime)) 249 | return self.pwdAccountLockedTime 250 | 251 | def disable(self): 252 | self.pwdAccountLockedTime = format_generalized_time(timezone.localtime()) 253 | self.save() 254 | logger.debug('Disabled {} with {}'.format(self.uid, self.pwdAccountLockedTime)) 255 | return self.pwdAccountLockedTime 256 | 257 | def enable(self): 258 | self.pwdAccountLockedTime = None 259 | self.save() 260 | logger.debug('Enabled {} with {}'.format(self.uid, 'pwdAccountLockedTime = None')) 261 | 262 | def locked_time(self): 263 | if self.pwdAccountLockedTime == settings.PPOLICY_PERMANENT_LOCKED_TIME: 264 | return '{}: locked by admin'.format(settings.PPOLICY_PERMANENT_LOCKED_TIME) 265 | elif self.pwdAccountLockedTime: 266 | return parse_generalized_time(self.pwdAccountLockedTime) 267 | 268 | def failure_times(self): 269 | if not self.pwdFailureTime: return 270 | times = self.pwdFailureTime.split(os.linesep) 271 | failures = [parse_pwdfailure_time(i).strftime(settings.DATETIME_FORMAT) for i in times] 272 | parsed = os.linesep.join(failures) 273 | return parsed 274 | 275 | def set_schacPersonalUniqueID(self, value, save=False, 276 | doc_type=settings.SCHAC_PERSONALUNIQUEID_DEFAULT_DOCUMENT_CODE, 277 | country_code=settings.SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE): 278 | 279 | if settings.SCHAC_PERSONALUNIQUEID_DEFAULT_PREFIX not in value: 280 | unique_id = ':'.join((settings.SCHAC_PERSONALUNIQUEID_DEFAULT_PREFIX, 281 | country_code, 282 | doc_type, value)) 283 | if self.schacPersonalUniqueID: 284 | if unique_id not in self.schacPersonalUniqueID: 285 | self.schacPersonalUniqueID.append(unique_id) 286 | else: 287 | self.schacPersonalUniqueID = [unique_id] 288 | if save: 289 | self.save() 290 | 291 | def set_default_schacHomeOrganization(self, save=False): 292 | if not self.schacHomeOrganization: 293 | self.schacHomeOrganization = settings.SCHAC_HOMEORGANIZATION_DEFAULT 294 | if save: 295 | self.save() 296 | 297 | def set_default_schacHomeOrganizationType(self, save=False, 298 | country_code=settings.SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE): 299 | if not self.schacHomeOrganization: 300 | logger.warn('Cannot set schacHomeOrganizationType without schacHomeOrganization') 301 | return 302 | if not self.schacHomeOrganizationType: 303 | self.schacHomeOrganizationType = settings.SCHAC_HOMEORGANIZATIONTYPE_DEFAULT 304 | if save: 305 | self.save() 306 | 307 | def set_default_eppn(self, save=False): 308 | if not self.schacHomeOrganization: 309 | logger.warn('Cannot set eduPersonPrincipalName without schacHomeOrganization') 310 | return 311 | self.eduPersonPrincipalName = '@'.join((self.uid, self.schacHomeOrganization)) 312 | if save: 313 | self.save() 314 | return self.eduPersonPrincipalName 315 | 316 | def update_eduPersonScopedAffiliation(self, save=False): 317 | if not self.schacHomeOrganization: 318 | logger.warn('Cannot set ScopedAffiliations without schacHomeOrganization') 319 | return 320 | 321 | updated = [ele for ele in self.eduPersonScopedAffiliation] 322 | updated.extend(['@'.join((ele, self.schacHomeOrganization)) 323 | for ele in self.eduPersonAffiliation]) 324 | updated = list(set(updated)) 325 | if self.eduPersonScopedAffiliation != updated: 326 | self.eduPersonScopedAffiliation = updated 327 | if save: 328 | self.save() 329 | return self.eduPersonScopedAffiliation 330 | 331 | def membership(self): 332 | if self.memberOf: return self.memberOf 333 | # memberOf fill fields in people entries only if a change/write happens in its definitions 334 | try: 335 | membership = LdapGroup.objects.filter(member__contains=self.dn) 336 | if membership: 337 | # return os.linesep.join([m.dn for m in membership]) 338 | return [i.cn for i in membership] 339 | except ldap.FILTER_ERROR as e: 340 | logger.warn('No membership found: {}'.format(e)) 341 | return [] 342 | 343 | def check_pwdHistory(self, password): 344 | """ 345 | if returns True means that this password was already used in the past 346 | """ 347 | res = None 348 | for e in self.pwdHistory: 349 | old_pwd = e.split('#')[-1] 350 | res = ssha.checkPassword(password, 351 | old_pwd, 352 | settings.LDAP_PASSWORD_SALT_SIZE, 353 | 'suffixed') 354 | if res: break 355 | return res 356 | 357 | def set_password(self, password, old_password=None): 358 | ldap_conn = connections['ldap'] 359 | ldap_conn.ensure_connection() 360 | ldap_conn.connection.passwd_s(user = self.dn, 361 | oldpw = old_password, 362 | newpw = password.encode(settings.FILE_CHARSET)) 363 | ldap_conn.connection.unbind_s() 364 | self.refresh_from_db() 365 | logger.info('{} changed password'.format(self.uid)) 366 | return True 367 | 368 | def set_password_custom(self, password, hashtype=settings.DEFAULT_SECRET_TYPE): 369 | """ 370 | EXPERIMENTAL - do not use in production 371 | encode the password, this could not works on some LDAP servers 372 | """ 373 | # password encoding 374 | if password: 375 | self.userPassword = encode_secret(hashtype, password) 376 | # additional password fields encoding 377 | enc_map = settings.PASSWD_FIELDS_MAP 378 | for field in enc_map: 379 | if not hasattr(self, field): 380 | continue 381 | enc_value = encode_secret(enc_map[field], password) 382 | setattr(self, field, enc_value) 383 | self.save() 384 | logger.info('{} changed password'.format(self.uid)) 385 | return self.userPassword 386 | 387 | def set_default_schacExpiryDate(self, save=False): 388 | # set a default ExpiryDate if not available 389 | if self.pwdChangedTime: 390 | self.schacExpiryDate = self.pwdChangedTime + timezone.timedelta(days=settings.SHAC_EXPIRY_DURATION_DAYS) 391 | else: 392 | self.schacExpiryDate = timezone.localtime() + timezone.timedelta(days=settings.SHAC_EXPIRY_DURATION_DAYS) 393 | if save: 394 | self.save() 395 | logger.debug('{} set default schacExpiryDate'.format(self.uid)) 396 | return self.schacExpiryDate 397 | 398 | # def save(self, *args, **kwargs): 399 | # """ 400 | # Just check and update eppn 401 | # """ 402 | # if not self.eduPersonPrincipalName or \ 403 | # self.uid not in self.eduPersonPrincipalName: 404 | # self.set_default_eppn() 405 | # super().save(*args, **kwargs) 406 | 407 | def __str__(self): 408 | return self.dn 409 | -------------------------------------------------------------------------------- /ldap_peoples/serializers.py: -------------------------------------------------------------------------------- 1 | import chardet 2 | import copy 3 | import io 4 | import json 5 | import ldap_peoples.ldif as ldif 6 | import logging 7 | 8 | from django.apps import apps 9 | from django.conf import settings 10 | from django.db import connections 11 | from django.utils import timezone 12 | from collections import OrderedDict 13 | from ldap import modlist 14 | 15 | from . ldap_utils import (format_generalized_time, 16 | parse_generalized_time) 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class LdapImportExport(object): 23 | @staticmethod 24 | def clean_entry_dict(d): 25 | entry = {} 26 | for k,v in d.items(): 27 | if k not in settings.READONLY_FIELDS: 28 | entry[k] = v 29 | return entry 30 | 31 | @staticmethod 32 | def export_entry_to_json(entry): 33 | """ 34 | entry is a dict 35 | """ 36 | out = io.StringIO() 37 | if isinstance(entry, dict): 38 | out.write(json.dumps(entry)) 39 | out.write('\n') 40 | out.seek(0) 41 | return out.read() 42 | else: 43 | raise Exception('dict required') 44 | 45 | @staticmethod 46 | def export_entry_to_ldif(dn, entry): 47 | """ 48 | raw example: 49 | from ldap_peoples.models import LdapAcademiaUser 50 | lu = LdapAcademiaUser.objects.filter(cn='peppe').first() 51 | 52 | import sys, ldap_peoples.ldif as ldif 53 | ldif_writer=ldif.LDIFWriter(sys.stdout) 54 | 55 | entry=lu.serialize(elements_as_list=1, encoding='utf-8') 56 | dn=entry['dn'][0] 57 | del entry['dn'] 58 | ldif_writer.unparse(dn,entry) 59 | """ 60 | out = io.StringIO() 61 | if isinstance(entry, dict): 62 | entry = LdapImportExport.clean_entry_dict(entry) 63 | ldif_writer=ldif.LDIFWriter(out) 64 | ldif_writer.unparse(dn, entry) 65 | out.seek(0) 66 | return out.read() 67 | else: 68 | raise Exception('dict required') 69 | 70 | @staticmethod 71 | def import_entries_from_ldif(fopen): 72 | # http://www.python-ldap.org/en/latest/reference/ldif.html#ldif.LDIFRecordList 73 | # Get a LDIFRecordList object of posix.ldif file 74 | ldif_rec = ldif.LDIFRecordList(fopen) 75 | # Read the LDIF file 76 | ldif_rec.parse() 77 | # get the first LDAP router 78 | ldap_conn = connections['ldap'] 79 | for dn, entry in ldif_rec.all_records: 80 | add_modlist = modlist.addModlist(entry) 81 | ldap_conn.add_s(dn, add_modlist) 82 | logger.debug('Imported {} with LDIF'.format(dn)) 83 | return True 84 | 85 | @staticmethod 86 | def import_entries_from_json(fopen): 87 | content = fopen.read() 88 | if isinstance(content, bytes): 89 | encoding = chardet.detect(content)["encoding"] 90 | obj = json.loads(content.decode(encoding)) 91 | else: 92 | obj = json.loads(content) 93 | 94 | model_name = obj['model'] 95 | app_name = obj['app'] 96 | app_model = apps.get_model(app_label=app_name, 97 | model_name=model_name) 98 | for i in obj['entries']: 99 | entry = LdapImportExport.clean_entry_dict(i) 100 | del(entry['objectclass']) 101 | # try/catch here with messages! 102 | uid = entry['uid'] 103 | fields = i.keys() 104 | lu = app_model.objects.filter(uid=uid).first() 105 | # if available 4 update 106 | if entry.get('schacDateOfBirth'): 107 | entry['schacDateOfBirth'] = timezone.datetime.strptime(entry['schacDateOfBirth'], 108 | settings.SCHAC_DATEOFBIRTH_FORMAT) 109 | if entry.get('schacExpiryDate'): 110 | entry['schacExpiryDate'] = parse_generalized_time(entry['schacExpiryDate']) 111 | if lu: 112 | # update values 113 | for k,v in entry.items(): 114 | setattr(lu, k, v) 115 | lu.save() 116 | else: 117 | lu = app_model.objects.create(**entry) 118 | logger.debug('Imported {} with JSON'.format(entry.get(lu.dn))) 119 | return True 120 | 121 | 122 | class LdapSerializer(object): 123 | def serialize(self, elements_as_list = False, encoding=None): 124 | d = OrderedDict() 125 | if self.object_classes: 126 | d['objectclass'] = [] 127 | for i in self.object_classes: 128 | if encoding: 129 | d['objectclass'].append(i.encode(encoding)) 130 | else: 131 | d['objectclass'].append(i) 132 | for ele in self._meta.get_fields(): 133 | #if ele in settings.READONLY_FIELDS: continue 134 | value = getattr(self, ele.attname) 135 | if not value: continue 136 | 137 | # TODO better code here! 138 | if isinstance(value, list): 139 | if encoding: 140 | d[ele.attname] = [i.encode(encoding) for i in value] 141 | else: 142 | d[ele.attname] = [i for i in value] 143 | elif ele.attname in ('schacExpiryDate', 'pwdChangedTime', 144 | 'createTimestamp', 'modifyTimestamp'): 145 | d[ele.attname] = format_generalized_time(value) 146 | if encoding: 147 | d[ele.attname] = d[ele.attname].encode(encoding) 148 | elif ele.attname in ('schacDateOfBirth',): 149 | d[ele.attname] = value.strftime(settings.SCHAC_DATEOFBIRTH_FORMAT) 150 | if encoding: 151 | d[ele.attname] = d[ele.attname].encode(encoding) 152 | else: 153 | if encoding: 154 | d[ele.attname] = ele.value_to_string(self).encode(encoding) 155 | else: 156 | d[ele.attname] = ele.value_to_string(self) 157 | 158 | if elements_as_list and not isinstance(value, list): 159 | d[ele.attname] = [d[ele.attname]] 160 | return d 161 | 162 | def ldif(self): 163 | d = self.serialize(elements_as_list = True, 164 | encoding=settings.FILE_CHARSET) 165 | del d['dn'] 166 | return LdapImportExport.export_entry_to_ldif(self.dn, d) 167 | 168 | def json(self): 169 | return LdapImportExport.export_entry_to_json(self.serialize()) 170 | 171 | def json_ext(self): 172 | """ 173 | add app and model definition to a single json entry 174 | """ 175 | d = {'app': self._meta.app_label, 176 | 'model': self._meta.model_name, 177 | 'entries': [self.serialize()]} 178 | return json.dumps(d) 179 | -------------------------------------------------------------------------------- /ldap_peoples/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | 5 | 6 | LDAP_DATETIME_FORMAT = '%Y%m%d%H%M%SZ' 7 | LDAP_DATETIME_MILLISECONDS_FORMAT = '%Y%m%d%H%M%S.%fZ' 8 | DEFAULT_EDUPERSON_ENTITLEMENT = ['urn:mace:dir:entitlement:common-lib-terms', 9 | 'urn:mace:terena.org:tcs:personal-user', 10 | 'urn:mace:terena.org:tcs:escience-user'] 11 | 12 | # If pwdAccountLockedTime is set to 000001010000Z, the user's account 13 | # has been permanently locked and may only be unlocked by an administrator. 14 | PPOLICY_PERMANENT_LOCKED_TIME = '000001010000Z' 15 | PPOLICY_PASSWD_MIN_LEN = 8 16 | PPOLICY_PASSWD_MAX_LEN = 32 17 | 18 | LDAP_PASSWORD_SALT_SIZE = 8 19 | # an account must be renewed every 6 months 20 | SHAC_EXPIRY_DURATION_DAYS = 183 21 | 22 | # THIS ONLY USED WITH encode_password_custom() 23 | # if pw-sha2 overlay is present on the server additional passwd type could be used 24 | PWSHA2_OVERLAY = True 25 | PWSHA2_OVERLAY_PASSWD_TYPE = ['SHA256', 26 | 'SSHA256', 27 | 'SHA384', 28 | 'SSHA384', 29 | 'SHA512', 30 | 'SSHA512'] 31 | 32 | DEFAULT_SECRET_TYPE = 'SSHA512' 33 | 34 | # additional field to be filled on save password trigger 35 | # key is the model field, value is the Hash used in hash_functions.encode_secret 36 | PASSWD_FIELDS_MAP = { 37 | 'sambaNTPassword': 'NT', 38 | } 39 | 40 | # values matches specialized calls in hash_functions.py 41 | SECRET_PASSWD_TYPE = ['Plaintext', 42 | 'SHA', 43 | 'SSHA', 44 | 'MD5', 45 | 'SMD5', 46 | 'PKCS5S2', 47 | 'CRYPT', 48 | 'CRYPT-MD5', 49 | 'CRYPT-SHA-256', 50 | 'CRYPT-SHA-512'] 51 | 52 | if PWSHA2_OVERLAY: 53 | SECRET_PASSWD_TYPE.extend(PWSHA2_OVERLAY_PASSWD_TYPE) 54 | 55 | # these are too weak 56 | DISABLED_SECRET_TYPES = ['Plaintext', 57 | 'MD5', 58 | 'SMD5', 59 | 'PKCS5S2'] 60 | # encode_password_custom end 61 | 62 | # Password validation on user web form input field 63 | SECRET_FIELD_VALIDATORS = {'regexp_lowercase': '[a-z]+', 64 | 'regexp_uppercase': '[A-Z]+', 65 | 'regexp_number': '[0-9]+', 66 | 'regexp_special': '[\!\%\-_+=\[\]{\}\:\,\.\?\<\>\(\)\;]+'} 67 | 68 | EPPN_VALIDATOR = '[a-zA-Z\.\_\:\-0-9]+@[a-zA-Z\-\.\_]+' 69 | 70 | EDUPERSON_ASSURANCES = (('https://refeds.org/assurance/IAP/low', 'low'), 71 | ('https://refeds.org/assurance/IAP/medium', 'medium'), 72 | ('https://refeds.org/assurance/IAP/high', 'high'),) 73 | EDUPERSON_DEFAULT_ASSURANCE = 'https://refeds.org/assurance/IAP/low' 74 | 75 | # https://www.internet2.edu/products-services/trust-identity/mace-registries/urnmace-namespace/ 76 | SCHAC_PERSONALUNIQUECODE_DEFAULT_PREFIX = 'urn:schac:personalUniqueCode' 77 | 78 | SCHAC_HOMEORGANIZATIONTYPE_DEFAULT_PREFIX = 'urn:schac:homeOrganizationType' 79 | SCHAC_HOMEORGANIZATIONTYPE_DEFAULT = [SCHAC_HOMEORGANIZATIONTYPE_DEFAULT_PREFIX+':int:university', 80 | SCHAC_HOMEORGANIZATIONTYPE_DEFAULT_PREFIX+':eu:higherEducationInstitution'] 81 | 82 | SCHAC_HOMEORGANIZATION_DEFAULT = getattr(settings, 'LDAP_BASE_DOMAIN', None) 83 | SCHAC_PERSONALUNIQUEID_DEFAULT_PREFIX = 'urn:schac:personalUniqueID' 84 | SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE = 'it' 85 | SCHAC_PERSONALUNIQUEID_DEFAULT_DOCUMENT_CODE = 'CF' 86 | SCHAC_PERSONALUNIQUEID_DEFAULT_PREFIX_COMPLETE = ':'.join((SCHAC_PERSONALUNIQUEID_DEFAULT_PREFIX, 87 | SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE, 88 | SCHAC_PERSONALUNIQUEID_DEFAULT_DOCUMENT_CODE)) 89 | SCHAC_PERSONALUNIQUEID_DOCUMENT_CODE = [SCHAC_PERSONALUNIQUEID_DEFAULT_DOCUMENT_CODE, 90 | 'ID', 'CI', 'TIN', 'NIF', 'FIC', 'NIN'] 91 | 92 | RFC3339_DATE_FORMAT = "%Y%m%d" 93 | SCHAC_DATEOFBIRTH_FORMAT = RFC3339_DATE_FORMAT 94 | 95 | READONLY_FIELDS = ['memberOf', 96 | 'creatorsName', 97 | 'modifiersName', 98 | # 'userPassword', 99 | # 'sambaNTPassword', 100 | 'createTimestamp', 101 | 'modifyTimestamp', 102 | 'pwdChangedTime', 103 | 'pwdFailureTime' 104 | ] 105 | 106 | AFFILIATION = ( 107 | # ('faculty', 'faculty'), deprecated 108 | ('student', 'student'), 109 | ('staff', 'staff'), 110 | ('alum', 'alum'), 111 | ('member', 'member'), 112 | ('affiliate', 'affiliate'), 113 | ('library-walk-in', 'library-walk-in'), 114 | ) 115 | 116 | LDAP_PEOPLES_TITLES = ( 117 | ('student', 'student'), 118 | ('phd', 'phd'), 119 | ('prof.', 'prof.'), 120 | ('dott.', 'dott.'), 121 | ) 122 | 123 | # this option deactive previous auth sessions when a new LDAP auth occours 124 | MULTIPLE_USER_AUTH_SESSIONS = False 125 | -------------------------------------------------------------------------------- /ldap_peoples/static/js/given_display_name_autofill.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @author Giuseppe De Marco 3 | */ 4 | /*jslint browser:true */ 5 | function input_by_name(nome){ 6 | return "input[type='text'][name='" + nome +"']" 7 | } 8 | 9 | django.jQuery( document ).ready(function() { 10 | 11 | django.jQuery(input_by_name("cn")).change(function() { 12 | var value = django.jQuery(this).val(); 13 | // console.log(value); 14 | django.jQuery(input_by_name("givenName")).val(value); 15 | django.jQuery(input_by_name("displayName")).val(value); 16 | }); 17 | 18 | django.jQuery(input_by_name("sn")).change(function() { 19 | var value = django.jQuery(this).val(); 20 | // console.log(value); 21 | django.jQuery(input_by_name("displayName")).val(django.jQuery(this).val() + ' ' + value); 22 | }); 23 | 24 | }) 25 | -------------------------------------------------------------------------------- /ldap_peoples/static/js/textarea-autosize.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @author Giuseppe De Marco 3 | */ 4 | /*jslint browser:true */ 5 | django.jQuery(function ($) { 6 | 'use strict'; 7 | var textarea_selector = "textarea"; 8 | function adaptiveheight(a) { 9 | django.jQuery(a).height(0); 10 | 11 | var scrollval = django.jQuery(a)[0].scrollHeight; 12 | django.jQuery(a).height(scrollval); 13 | if (parseInt(a.style.height, 10) > django.jQuery(window).height()) { 14 | var i = a.selectionEnd; 15 | if (i >= 0) { 16 | django.jQuery(document).scrollTop(parseInt(a.style.height, 10)); 17 | } else { 18 | django.jQuery(document).scrollTop(0); 19 | } 20 | } 21 | } 22 | django.jQuery(textarea_selector).click(function (e) { 23 | adaptiveheight(this); 24 | }); 25 | django.jQuery(textarea_selector).keyup(function (e) { 26 | adaptiveheight(this); 27 | }); 28 | // init 29 | django.jQuery(textarea_selector).each(function () { 30 | adaptiveheight(this); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /ldap_peoples/templates/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static admin_modify %} 3 | 4 | {% block extrahead %}{{ block.super }} 5 | 6 | {{ media }} 7 | {% endblock %} 8 | 9 | {% block extrastyle %}{{ block.super }}{% endblock %} 10 | 11 | {% block coltype %}colM{% endblock %} 12 | 13 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} 14 | 15 | {% if not is_popup %} 16 | {% block breadcrumbs %} 17 | 23 | {% endblock %} 24 | {% endif %} 25 | 26 | 27 | {% block content %} 28 | 29 |
30 | {% block object-tools %} 31 | {% if change %}{% if not is_popup %} 32 |
    33 | {% block object-tools-items %} 34 |
  • 35 | {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} 36 | {% trans "History" %} 37 |
  • 38 | {% if has_absolute_url %}
  • {% trans "View on site" %}
  • {% endif %} 39 | {% endblock %} 40 |
41 | {% endif %}{% endif %} 42 | {% endblock %} 43 |
{% csrf_token %}{% block form_top %}{% endblock %} 44 | 45 | 48 |
49 | {% submit_row %} 50 |
51 | 52 |
53 | {% if is_popup %}{% endif %} 54 | {% if to_field %}{% endif %} 55 | {% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} 56 | {% if errors %} 57 |

58 | {% if errors|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} 59 |

60 | {{ adminform.form.non_field_errors }} 61 | {% endif %} 62 | 63 | {% block field_sets %} 64 | {% for fieldset in adminform %} 65 | {% include "admin/includes/fieldset.html" %} 66 | {% endfor %} 67 | {% endblock %} 68 | 69 | {% block after_field_sets %}{% endblock %} 70 | 71 | {% block inline_field_sets %} 72 | {% for inline_admin_formset in inline_admin_formsets %} 73 | {% include inline_admin_formset.opts.template %} 74 | {% endfor %} 75 | {% endblock %} 76 | 77 | {% block after_related_objects %}{% endblock %} 78 | 79 | {% block submit_buttons_bottom %}{% submit_row %}{% endblock %} 80 | 81 | {% block admin_change_form_document_ready %} 82 | 89 | {% endblock %} 90 | 91 | {# JavaScript for prepopulated fields #} 92 | {% prepopulated_fields_js %} 93 | 94 |
95 |
96 | {% endblock %} 97 | -------------------------------------------------------------------------------- /ldap_peoples/templates/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n admin_urls %} 3 | {% load static %} 4 | 5 | {% block extrastyle %} 6 | {{ block.super }} 7 | 30 | {% endblock %} 31 | 32 | {% block object-tools-items %} 33 | 38 | {{ block.super }} 39 | {% if has_absolute_url %} 40 |
  • 41 | {% trans "View on site" %} 42 |
  • 43 | {% endif %} 44 | {% endblock %} 45 | 46 | {% block search %} 47 | {{ block.super }} 48 |
    49 |

    {% trans "Import form" %}

    50 |
    51 |
    52 | {% csrf_token %} 53 | 58 | 61 | 62 | 68 | 69 | 70 |
    71 |
    72 |
    73 | {% endblock %} 74 | -------------------------------------------------------------------------------- /ldap_peoples/templates/filters/custom_search.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls %} 2 | {% load static %} 3 | 4 | 98 | 99 |
    100 | 101 | 102 |
    103 | 108 | 109 | 117 | 118 | 124 | 125 | 126 |
    127 |
    128 | 129 |
    130 | 131 | 132 |
    133 | -------------------------------------------------------------------------------- /ldap_peoples/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | import random 4 | import string 5 | 6 | from django.conf import settings 7 | from django.test import TestCase 8 | from django.utils import timezone 9 | from ldap_peoples.models import LdapAcademiaUser 10 | from ldap_peoples.serializers import LdapImportExport 11 | 12 | from . settings import LDAP_DATETIME_FORMAT 13 | 14 | 15 | def randomString(stringLength=10): 16 | """Generate a random string of fixed length """ 17 | letters = string.ascii_lowercase 18 | return ''.join(random.choice(letters) for i in range(stringLength)) 19 | 20 | def get_test_guy(): 21 | _test_uid = 'unit_test_user_{}'.format(randomString()) 22 | _test_guy = { "uid": _test_uid, 23 | "cn": _test_uid, 24 | "givenName": _test_uid, 25 | "sn": _test_uid, 26 | "displayName": _test_uid, 27 | "mail": [ 28 | "{}@testunical.it".format(_test_uid), 29 | "{}@edu.testunical.it".format(_test_uid) 30 | ], 31 | "userPassword": "{SSHA512}Z9jkoG3nyFzVzZy/8sVNLEFhUhPTeI15n0VHoMuA1bKwqbvxnm9IzU4ftErYtBkB20GcjGR+cihWs6s7jW1Mfq4v9pLFjlqg", 32 | "sambaNTPassword": "448075d48c550dca6937175e16df1dc6", 33 | "eduPersonPrincipalName": "{}@unical".format(_test_uid), 34 | "eduPersonAffiliation": [ 35 | "faculty", 36 | "member" 37 | ], 38 | "eduPersonScopedAffiliation": [ 39 | "member@testunical.it", 40 | "staff@testunical.it" 41 | ], 42 | "eduPersonEntitlement": [ 43 | "urn:mace:terena.org:tcs:escience-user", 44 | "urn:mace:terena.org:tcs:personal-user" 45 | ], 46 | "schacHomeOrganization": "testunical.it", 47 | "schacHomeOrganizationType": [ 48 | "urn:schac:homeOrganizationType:it:university" 49 | ], 50 | "schacPersonalUniqueID": [ 51 | "urn:schac:personalUniqueID:it:CF:{}".format(_test_uid) 52 | ], 53 | "schacDateOfBirth": "20190213", 54 | "schacExpiryDate": (timezone.localtime()+datetime.timedelta(minutes=60)).strftime(LDAP_DATETIME_FORMAT), 55 | "createTimestamp": "20190211161620Z", 56 | "modifyTimestamp": "20190211161629Z", 57 | "creatorsName": "cn=admin,dc=testunical,dc=it", 58 | "modifiersName": "cn=admin,dc=testunical,dc=it"} 59 | return _test_guy 60 | 61 | 62 | LAST_GUY = None 63 | TEST_GUYS = [] 64 | for i in range(1): 65 | LAST_GUY = get_test_guy() 66 | LAST_UID = LAST_GUY['uid'] 67 | print(i, LAST_UID) 68 | TEST_GUYS.append(LAST_UID) 69 | LdapAcademiaUser.objects.create(**LAST_GUY) 70 | 71 | 72 | class LdapAcademiaUserTestCase(TestCase): 73 | databases = ["default", "ldap"] 74 | 75 | def setUp(self): 76 | """test user creation""" 77 | self.test_uid = LAST_UID 78 | 79 | def test_ldif_import_export(self): 80 | d = LdapAcademiaUser.objects.get(uid=self.test_uid) 81 | out = io.StringIO() 82 | out.write(d.ldif()) 83 | out.seek(0) 84 | d.delete() 85 | imp = LdapImportExport.import_entries_from_ldif(out) 86 | self.assertIs(imp, True) 87 | 88 | def test_json_import_export(self): 89 | d = LdapAcademiaUser.objects.get(uid=self.test_uid) 90 | out = io.StringIO() 91 | out.write(d.json_ext()) 92 | out.seek(0) 93 | d.delete() 94 | imp = LdapImportExport.import_entries_from_json(out) 95 | self.assertIs(imp, True) 96 | 97 | # def tearDown(self): 98 | # d = LdapAcademiaUser.objects.filter(uid=self.test_uid).first() 99 | # if d: d.delete() 100 | -------------------------------------------------------------------------------- /ldap_peoples/tests/create_delete_academia_user.py: -------------------------------------------------------------------------------- 1 | from ldap_peoples.models import LdapAcademiaUser 2 | import datetime 3 | 4 | d = {'cn': 'pedppe', 5 | 'displayName': 'peppde Rossi', 6 | 'eduPersonAffiliation': ['faculty', 'member'], 7 | 'eduPersonEntitlement': ['urn:mace:terena.org:tcs:escience-user', 8 | 'urn:mace:terena.org:tcs:personal-user'], 9 | 'eduPersonOrcid': '', 10 | 'eduPersonPrincipalName': 'grodsfssi@unical', 11 | 'eduPersonScopedAffiliation': ['member@testunical.it', 'staff@testunical.it'], 12 | 'givenName': 'peppe', 13 | 'mail': ['peppe44.grossi@testunical.it', 'pgros44si@edu.testunical.it'], 14 | 'sambaNTPassword': 'a2137530237ad733fdc26d5d7157d43f', 15 | 'schacHomeOrganization': 'testunical.it', 16 | 'schacHomeOrganizationType': ['educationInstitution', 'university'], 17 | 'schacPersonalUniqueID': ['urn:schac:personalUniqueID:it:CF:CODICEFISCALEpe3245ppe'], 18 | 'schacPlaceOfBirth': '', 19 | 'sn': 'grossi', 20 | 'telephoneNumber': [], 21 | 'uid': 'perrrppe', 22 | 'userPassword': '{SHA512}oMKZtxqeWdXrsHkX5wYBo1cKoQPpmnu2WljngOyQd7GQLR3tsxsUV77aWV/k1x13m2ypytR2JmzAdZDjHYSyBg=='} 23 | 24 | u = LdapAcademiaUser.objects.create(**d) 25 | u.delete() 26 | 27 | 28 | entry = {'givenName': 'Giuseppe', 'cn': 'Giuseppe', 'sn': 'De Marco', 'schacPlaceOfBirth': 'IT,Cosenza', 29 | 'schacDateOfBirth': datetime.date(1983, 8, 27), 'displayName': 'Giuseppe De Marco', 30 | 'mail': ['ingoalla@testunical.it'], 'telephoneNumber': ['0984496945'], 'uid': 'peppelinux27', 'schacHomeOrganization': 'testunical.it'} 31 | u = LdapAcademiaUser.objects.create(**entry) 32 | -------------------------------------------------------------------------------- /ldap_peoples/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import * 3 | 4 | app_name="ldap_peoples" 5 | 6 | urlpatterns = [ 7 | path('{}/import'.format(app_name), 8 | import_file, name='import_file'), 9 | ] 10 | -------------------------------------------------------------------------------- /ldap_peoples/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.6.dev0' 2 | 3 | VERSION = __version__ 4 | -------------------------------------------------------------------------------- /ldap_peoples/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required, user_passes_test 2 | from django.http.response import HttpResponse, HttpResponseRedirect 3 | from django.views.decorators.http import require_http_methods 4 | from django.shortcuts import render 5 | from django.urls import reverse 6 | 7 | from . models import LdapAcademiaUser 8 | from . serializers import LdapImportExport 9 | 10 | 11 | @user_passes_test(lambda u: u.is_staff) 12 | def import_file(request): 13 | file_format = request.POST.get('file_format') 14 | file_to_import = request.FILES.get('file_to_import') 15 | # content here 16 | url = reverse('admin:ldap_peoples_ldapacademiauser_changelist') 17 | if not file_to_import: 18 | return HttpResponseRedirect(url) 19 | if not file_format or not file_to_import: 20 | # scrivi un messaggio di errore 21 | pass 22 | response = False 23 | if file_format == 'json': 24 | response = LdapImportExport.import_entries_from_json(file_to_import) 25 | elif file_format == 'ldif': 26 | response = LdapImportExport.import_entries_from_ldif(file_to_import) 27 | return HttpResponseRedirect(url) 28 | -------------------------------------------------------------------------------- /ldap_peoples/widgets.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import pycountry 4 | 5 | from django.conf import settings 6 | from django import get_version, forms 7 | from django import utils 8 | from django.forms import Widget 9 | from django.forms.utils import flatatt 10 | 11 | 12 | class SplitJSONWidgetBase(forms.Widget): 13 | css_textfield_class = 'vTextField' 14 | del_btn = ('') 19 | add_btn_tmpl = """ 20 |
    21 | 22 |
    23 | """ 24 | li_row_tmpl = """ 25 | if (window.js_cnt == undefined) {{ 26 | window.js_cnt = Math.floor(Math.random()*10000000) + 1; 27 | }} 28 | window.js_cnt += 1; 29 | init_id = '{}'; 30 | name = '{}'; 31 | new_li_id = 'li_'+name+'_'+window.js_cnt; 32 | name_regexp = /{}\[\d*\]/g; 33 | row_li = \'{}\'.replace(init_id, new_li_id).replace(name_regexp, name+'['+ window.js_cnt +']'); 34 | django.jQuery('#{}').append(row_li);return 0 35 | """ 36 | source_label = ' {}' 37 | 38 | 39 | class SplitJSONWidget(SplitJSONWidgetBase): 40 | def __init__(self, attrs=None, kwargs=None, debug=True): 41 | self.debug = debug 42 | self.id_cnt = 0 43 | super().__init__(attrs) 44 | 45 | def _embed_js(self, value): 46 | return value.replace('"', "'").replace("'", "\\'") 47 | 48 | def _get_id_cnt(self): 49 | self.id_cnt += 1 50 | return self.id_cnt 51 | 52 | def _get_name_prefix(self, name): 53 | return 'ul_{}'.format(name) 54 | 55 | def _as_text_field(self, name, value): 56 | attrs = self.build_attrs(self.attrs) 57 | if value: 58 | attrs['value'] = value 59 | if name: 60 | attrs['class'] = "{} input_{}".format(self.css_textfield_class, 61 | name) 62 | attrs['name'] = '{}[{}]'.format(name, self._get_id_cnt()) 63 | f = flatatt(attrs) 64 | return ''.format(f) 65 | 66 | def _get_single_fields(self, name, value): 67 | inputs = [] 68 | if isinstance(value, list): 69 | for v in sorted(value): 70 | inputs.append(self._as_text_field(name, 71 | v)) 72 | elif isinstance(value, (str, int, float)): 73 | inputs.append(self._as_text_field(name, value)) 74 | elif value is None: 75 | inputs.append(self._as_text_field(name, '')) 76 | return inputs 77 | 78 | def _prepare_as_ul(self, name, l): 79 | ul_id = self._get_name_prefix(name) 80 | result = '
      {}
    ' 81 | row = '' 82 | cnt = 0 83 | for el in l: 84 | row_id = 'li_{}_{}'.format(name, cnt) 85 | row += '
  • {} {}
  • '.format(row_id , el, self.del_btn) 86 | cnt += 1 87 | return result.format(ul_id, row) 88 | 89 | def _get_add_btn(self, name): 90 | # add button render. TODO: get it from a method! 91 | ul_id = self._get_name_prefix(name) 92 | input_field = self._as_text_field(name, '') 93 | del_btn = self.del_btn 94 | init_id = 'init_id' 95 | li = """
  • {}{}
  • """.format(init_id, input_field, del_btn) 96 | cleaned_li = self._embed_js(li) 97 | add_li_js = self.li_row_tmpl.format(init_id, 98 | name, name, 99 | cleaned_li, 100 | ul_id).replace('\n', '').replace(' '*2, ' ') 101 | add_btn = self.add_btn_tmpl.format(add_li_js, name.title().replace('_', ' ')) 102 | # end add button 103 | return add_btn 104 | 105 | def render(self, name, value, attrs=None, renderer=None): 106 | add_btn = self._get_add_btn(name) 107 | inputs = self._get_single_fields(name, value or {}) 108 | result = self._prepare_as_ul(name, inputs) 109 | 110 | if self.debug: 111 | # render json as well 112 | source_data = self.source_label.format(value) 113 | result = '{}{}'.format(result, source_data) 114 | result += add_btn 115 | return utils.safestring.mark_safe(result) 116 | 117 | 118 | class SchacPersonalUniqueIdWidget(SplitJSONWidget, forms.Widget): 119 | """ 120 | urn:schac:personalUniqueID:it:CF: 121 | """ 122 | li_row_tmpl = """ 123 | if (window.js_cnt == undefined) {{ 124 | window.js_cnt = Math.floor(Math.random()*10000000) + 1; 125 | }} 126 | window.js_cnt += 1; 127 | init_id = '{}'; 128 | name = '{}'; 129 | new_li_id = 'li_'+name+'_'+window.js_cnt; 130 | name_regexp = /\[(\d*)\]/g; 131 | row_li = \'{}\'; 132 | row_li_changed = row_li.replace(init_id, new_li_id).replace(name_regexp, '['+ window.js_cnt +']'); 133 | django.jQuery('#{}').append(row_li_changed);return 0 134 | """ 135 | 136 | def _get_add_btn(self, name): 137 | # add button render. TODO: get it from a method! 138 | ul_id = self._get_name_prefix(name) 139 | input_field = self._as_text_field(name, '') 140 | del_btn = self.del_btn 141 | init_id = 'init_id' 142 | li = """
  • {}{}
  • """.format(init_id, input_field, del_btn) 143 | cleaned_li = self._embed_js(li) 144 | add_li_js = self.li_row_tmpl.format(init_id, 145 | name, 146 | cleaned_li, 147 | ul_id).replace('\n', '').replace(' '*2, ' ') 148 | add_btn = self.add_btn_tmpl.format(add_li_js, name.title().replace('_', ' ')) 149 | # end add button 150 | return add_btn 151 | 152 | def _as_text_field(self, name, value): 153 | attrs = self.build_attrs(self.attrs) 154 | l_value = [settings.SCHAC_PERSONALUNIQUEID_DEFAULT_PREFIX, 155 | settings.SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE, 156 | settings.SCHAC_PERSONALUNIQUEID_DOCUMENT_CODE[0]] 157 | if value: 158 | sv = value.split(':') 159 | if len(sv) > 4: 160 | l_value.append(sv[-1]) 161 | l_value[1] = sv[-3] 162 | l_value[2] = sv[-2] 163 | value = l_value[3] 164 | else: 165 | value = '' 166 | row_id = self._get_id_cnt() 167 | static_prefix = "".format(l_value[0], 168 | name, 169 | row_id) 170 | select_1_tmpl = """""" 173 | option_1_tmpl = """ 174 | """ 175 | select_1_options_list = [''.format(l_value[1], 176 | l_value[1]),] 177 | 178 | fout_countries = [e for e in pycountry.countries if e != settings.SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE] 179 | select_1_options_list.extend([option_1_tmpl.format(i.alpha_2, i.alpha_2) for i in fout_countries]) 180 | select_1_options_list.extend([option_1_tmpl.format(ele, ele) for ele in ('EU', 'INT')]) 181 | select_1 = select_1_tmpl.format('{}_2_[{}]'.format(name, row_id), '', ''.join(select_1_options_list)) 182 | 183 | select_2_tmpl = """""" 186 | option_2_tmpl = """ 187 | """ 188 | select_2_options_list = [''.format(l_value[2], 189 | l_value[2]),] 190 | select_2_options_list.extend([option_2_tmpl.format(i, i) for i in settings.SCHAC_PERSONALUNIQUEID_DOCUMENT_CODE[1:]]) 191 | select_2 = select_2_tmpl.format('{}_3_[{}]'.format(name, row_id), '', ''.join(select_2_options_list)) 192 | 193 | input_suffix = "".format(value, 194 | name, 195 | row_id) 196 | return static_prefix+select_1+select_2+input_suffix 197 | 198 | def _get_single_fields(self, name, value): 199 | inputs = [] 200 | if isinstance(value, list): 201 | for v in sorted(value): 202 | inputs.append(self._as_text_field(name, 203 | v)) 204 | elif isinstance(value, (str, int, float)): 205 | inputs.append(self._as_text_field(name, value)) 206 | elif value is None: 207 | inputs.append(self._as_text_field(name, '')) 208 | return inputs 209 | 210 | def render(self, name, value, attrs=None, renderer=None): 211 | add_btn = self._get_add_btn(name) 212 | inputs = self._get_single_fields(name, value or {}) 213 | result = self._prepare_as_ul(name, inputs) 214 | 215 | if self.debug: 216 | # render json as well 217 | source_data = self.source_label.format(value) 218 | result = '{}{}'.format(result, source_data) 219 | result += add_btn 220 | return utils.safestring.mark_safe(result) 221 | 222 | 223 | class SchacPersonalUniqueCodeWidget(SchacPersonalUniqueIdWidget): 224 | """ 225 | # Example: schacPersonalUniqueCode: urn:mace:terena.org:schac:personalUniqueCode:fi:tut.fi:student:165934 226 | # schacPersonalUniqueCode: urn:mace:terena.org:schac:personalUniqueCode:es:uma:estudiante:a3b123c12 227 | # schacPersonalUniqueCode: urn:mace:terena.org:schac:personalUniqueCode:se:LIN:87654321 228 | """ 229 | 230 | def _as_text_field(self, name, value): 231 | attrs = self.build_attrs(self.attrs) 232 | l_value = [settings.SCHAC_PERSONALUNIQUECODE_DEFAULT_PREFIX, 233 | settings.SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE, 234 | ] 235 | 236 | value = value.replace(settings.SCHAC_PERSONALUNIQUECODE_DEFAULT_PREFIX, '')[1:] 237 | sv = value.split(':') 238 | if len(sv) > 2: 239 | if len(sv) > 2: 240 | l_value.append(sv[-1]) 241 | l_value[1] = sv[0] 242 | value = ':'.join(sv[1:]) 243 | l_value[2] = value 244 | else: 245 | value = '' 246 | 247 | row_id = self._get_id_cnt() 248 | static_prefix = "".format(l_value[0], 249 | name, 250 | row_id) 251 | select_1_tmpl = """""" 254 | option_1_tmpl = """ 255 | """ 256 | select_1_options_list = [''.format(l_value[1], 257 | l_value[1]),] 258 | 259 | fout_countries = [e for e in pycountry.countries if e != settings.SCHAC_PERSONALUNIQUECODE_DEFAULT_PREFIX ] 260 | select_1_options_list.extend([option_1_tmpl.format(i.alpha_2, i.alpha_2) for i in fout_countries]) 261 | select_1_options_list.extend([option_1_tmpl.format(ele, ele) for ele in ('EU', 'INT')]) 262 | select_1 = select_1_tmpl.format('{}_2_[{}]'.format(name, row_id), '', ''.join(select_1_options_list)) 263 | 264 | input_suffix = "".format(value, 265 | name, 266 | row_id) 267 | return static_prefix+select_1+input_suffix 268 | 269 | 270 | class SchacHomeOrganizationTypeWidget(SchacPersonalUniqueIdWidget): 271 | """ 272 | urn:schac:homeOrganizationType::university (SCHAC) - SWITCHaai(CH) 273 | """ 274 | 275 | def _as_text_field(self, name, value): 276 | attrs = self.build_attrs(self.attrs) 277 | l_value = [settings.SCHAC_HOMEORGANIZATIONTYPE_DEFAULT_PREFIX, 278 | settings.SCHAC_PERSONALUNIQUEID_DEFAULT_COUNTRYCODE, 279 | ] 280 | 281 | if value: 282 | sv = value.replace(settings.SCHAC_HOMEORGANIZATIONTYPE_DEFAULT_PREFIX, '').split(':')[1:] 283 | if len(sv) > 1: 284 | l_value.append(sv[-1]) 285 | l_value[1] = sv[0] 286 | value = sv[1] 287 | else: 288 | value = '' 289 | 290 | row_id = self._get_id_cnt() 291 | static_prefix = "".format(l_value[0], 292 | name, 293 | row_id) 294 | select_1_tmpl = """""" 297 | option_1_tmpl = """ 298 | """ 299 | select_1_options_list = [''.format(l_value[1], 300 | l_value[1]),] 301 | 302 | fout_countries = [e for e in pycountry.countries if e != settings.SCHAC_HOMEORGANIZATIONTYPE_DEFAULT_PREFIX ] 303 | select_1_options_list.extend([option_1_tmpl.format(i.alpha_2, i.alpha_2) for i in fout_countries]) 304 | select_1_options_list.extend([option_1_tmpl.format(ele, ele) for ele in ('EU', 'INT')]) 305 | select_1 = select_1_tmpl.format('{}_2_[{}]'.format(name, row_id), '', ''.join(select_1_options_list)) 306 | 307 | input_suffix = "".format(value, 308 | name, 309 | row_id) 310 | return static_prefix+select_1+input_suffix 311 | 312 | 313 | class eduPersonAffiliationWidget(SchacPersonalUniqueIdWidget): 314 | """ 315 | faculty, student, staff, alum, member, affiliate, employee, library-walk-in 316 | """ 317 | 318 | def _as_text_field(self, name, value): 319 | attrs = self.build_attrs(self.attrs) 320 | l_value = [] 321 | if not value: 322 | value = '' 323 | row_id = self._get_id_cnt() 324 | select_1_tmpl = """""" 327 | option_1_tmpl = """ 328 | """ 329 | select_1_options_list = [''.format(value, value)] 330 | select_1_options_list.extend([option_1_tmpl.format(i[0], i[0]) for i in settings.AFFILIATION]) 331 | select_1 = select_1_tmpl.format('{}_1_[{}]'.format(name, row_id), '', ''.join(select_1_options_list)) 332 | return select_1 333 | 334 | 335 | class eduPersonScopedAffiliationWidget(SchacPersonalUniqueIdWidget): 336 | """ 337 | faculty, student, staff, alum, member, affiliate, employee, library-walk-in 338 | """ 339 | scoped_symbol = '@' 340 | def _as_text_field(self, name, value): 341 | attrs = self.build_attrs(self.attrs) 342 | if value: 343 | l_value = value.split(self.scoped_symbol) 344 | rendered_value = self.scoped_symbol.join((l_value[0], l_value[1])) 345 | select_1_options_list = [''\ 346 | .format(l_value[0]+self.scoped_symbol, l_value[0]+self.scoped_symbol)] 347 | else: 348 | l_value = ['', ''] 349 | rendered_value = '' 350 | select_1_options_list = [''\ 351 | .format(l_value[0], l_value[0])] 352 | 353 | row_id = self._get_id_cnt() 354 | select_1_tmpl = """""" 357 | option_1_tmpl = """ 358 | """ 359 | 360 | select_1_options_list.extend([option_1_tmpl.format(i[0]+self.scoped_symbol, i[0]+self.scoped_symbol) for i in settings.AFFILIATION]) 361 | select_1 = select_1_tmpl.format('{}_1_[{}]'.format(name, row_id), '', ''.join(select_1_options_list)) 362 | 363 | input_suffix = "".format(l_value[1], 364 | name, 365 | row_id) 366 | return select_1+input_suffix 367 | 368 | 369 | class TitleWidget(SchacPersonalUniqueIdWidget): 370 | """ 371 | one of settings.LDAP_PEOPLES_TITLES 372 | """ 373 | 374 | def _as_text_field(self, name, value): 375 | attrs = self.build_attrs(self.attrs) 376 | l_value = [] 377 | if not value: 378 | value = '' 379 | row_id = self._get_id_cnt() 380 | select_1_tmpl = """""" 383 | option_1_tmpl = """ 384 | """ 385 | select_1_options_list = [''.format(value, value)] 386 | select_1_options_list.extend([option_1_tmpl.format(i[0], i[0]) for i in settings.LDAP_PEOPLES_TITLES]) 387 | select_1 = select_1_tmpl.format('{}_1_[{}]'.format(name, row_id), '', ''.join(select_1_options_list)) 388 | return select_1 389 | -------------------------------------------------------------------------------- /publiccode.yml: -------------------------------------------------------------------------------- 1 | # This repository adheres to the publiccode.yml standard by including this 2 | # metadata file that makes public software easily discoverable. 3 | # More info at https://github.com/italia/publiccode.yml 4 | 5 | publiccodeYmlVersion: '0.2' 6 | name: django-ldap-academia-ou-manager 7 | releaseDate: '2019-02-19' 8 | softwareVersion: v0.8.2 9 | url: 'https://github.com/UniversitaDellaCalabria/django-ldap-academia-ou-manager' 10 | developmentStatus: stable 11 | landingURL: 'https://github.com/UniversitaDellaCalabria/django-ldap-academia-ou-manager' 12 | softwareType: standalone/web 13 | platforms: 14 | - linux 15 | categories: 16 | - accounting 17 | maintenance: 18 | type: community 19 | contacts: 20 | - name: Giuseppe De Marco 21 | email: giuseppe.demarco@unical.it 22 | affiliation: unical.it 23 | legal: 24 | license: BSD-2-Clause 25 | localisation: 26 | localisationReady: no 27 | availableLanguages: 28 | - it 29 | it: 30 | countryExtensionVersion: '0.2' 31 | riuso: 32 | codiceIPA: unical 33 | description: 34 | it: 35 | genericName: Django admin LDAP manager for Acade 36 | shortDescription: Django admin LDAP manager for Academia OU 37 | longDescription: > 38 | ## **Django admin LDAP manager for Academia OU** 39 | 40 | 41 | Django Admin manager for Academia Users, usable with a OpenLDAP Server 42 | configured with eduPerson, SCHAC (SCHema for ACademia) and Samba schema. 43 | It also needs PPolicy overlay. and other modules and overlay as configured 44 | in: 45 | [https://github.com/peppelinux/ansible-slapd-eduperson2016](https://github.com/peppelinux/ansible-slapd-eduperson2016), 46 | as follow: 47 | 48 | 49 | ​ 50 | 51 | 52 | - MDB backend 53 | 54 | - eduperson2016 schema 55 | 56 | - schac-2015 schema 57 | 58 | - memberOf overlay 59 | 60 | - ppolicy overlay 61 | 62 | - pw-sha2 module for SSHA-512, SSHA-384, SSHA-256, SHA-512, SHA-384 and 63 | SHA-256 passwords 64 | 65 | - Monitor backend 66 | 67 | - Unique overlay (default field: mail) 68 | 69 | - smbk5pwd overlay 70 | 71 | - accesslog module (for delta replications) 72 | 73 | - syncprov (synrepl) with or without delta replication (delta can be 74 | enabled together with accesslog) 75 | 76 | - Unit test for ACL and Password Policy overlay 77 | 78 | 79 | ​ 80 | 81 | 82 | ​ 83 | 84 | 85 | ​ 86 | features: 87 | - LDAP manager 88 | screenshots: 89 | - >- 90 | https://github.com/UniversitaDellaCalabria/django-ldap-academia-ou-manager/blob/master/img/django-academia-ou-manager.png 91 | - >- 92 | https://github.com/UniversitaDellaCalabria/django-ldap-academia-ou-manager/blob/master/img/preview.png 93 | - >- 94 | https://github.com/UniversitaDellaCalabria/django-ldap-academia-ou-manager/blob/master/img/search.png 95 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=3.0.5,<4 2 | bcrypt 3 | # nt hashing dep 4 | passlib 5 | 6 | # ldap storage 7 | # apt install libsasl2-dev python3-dev libldap2-dev libssl-dev 8 | #git+https://github.com/django-ldapdb/django-ldapdb.git 9 | git+https://github.com/peppelinux/python-ldap.git 10 | git+https://github.com/peppelinux/django-ldapdb.git 11 | git+https://github.com/peppelinux/pySSHA-slapd.git 12 | 13 | ldap3 14 | 15 | # rangefilter 16 | git+https://github.com/silentsokolov/django-admin-rangefilter.git 17 | 18 | chardet 19 | 20 | django_admin_multiple_choice_list_filter 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | from setuptools import find_packages, setup 3 | 4 | def readme(): 5 | with open('README.md') as f: 6 | return f.read() 7 | 8 | setup(name='django-ldap-academia-ou-manager', 9 | version='v0.9.3', 10 | description=('Django Admin manager for Academia Users ' 11 | 'with eduPerson schema and ' 12 | 'SCHAC (SCHema for ACademia).'), 13 | long_description=readme(), 14 | long_description_content_type='text/markdown', 15 | classifiers=[ 16 | 'Development Status :: 5 - Production/Stable', 17 | 'License :: OSI Approved :: BSD License', 18 | 'Programming Language :: Python :: 3 :: Only', 19 | 'Operating System :: POSIX :: Linux' 20 | ], 21 | url='https://github.com/peppelinux/django-ldap-academia-ou-manager', 22 | author='Giuseppe De Marco', 23 | author_email='giuseppe.demarco@unical.it', 24 | license='BSD', 25 | packages=find_packages(), 26 | package_data={'': ['*.html']}, 27 | data_files=[ 28 | ('', glob('ldap_peoples/templates/*/*/*.html')), 29 | ], 30 | include_package_data=True, 31 | dependency_links=['https://github.com/peppelinux/django-ldapdb/tarball/master#egg=peppelinux_django_ldapdb-1.4',], 32 | install_requires=[ 33 | 'bcrypt>=3.1.4', 34 | 'Django>3,<4', 35 | 'django-admin-rangefilter>=0.3.9', 36 | 'passlib>=1.7.1', 37 | 'chardet', 38 | ], 39 | ) 40 | --------------------------------------------------------------------------------