├── .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 | 
29 | 
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 = '
'
4 | if isinstance(data, str):
5 | data = data.splitlines()
6 | for i in data:
7 | value += '- {}
'.format(i)
8 | 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 |
41 | {% endif %}{% endif %}
42 | {% endblock %}
43 |
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 |
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 |
LDAP {% trans "Search" %}
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 |
--------------------------------------------------------------------------------