Repository: jirutka/ldap-passwd-webui Branch: master Commit: 72fc5b06eda1 Files: 10 Total size: 14.6 KB Directory structure: gitextract_s9vf694b/ ├── .editorconfig ├── .gitignore ├── CHANGELOG.adoc ├── LICENSE ├── README.adoc ├── app.py ├── index.tpl ├── requirements.txt ├── settings.ini.example └── static/ └── style.css ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space indent_size = 4 [*.{css,tpl}] indent_size = 2 ================================================ FILE: .gitignore ================================================ /settings.ini ================================================ FILE: CHANGELOG.adoc ================================================ = Changelog :repo-uri: https://github.com/jirutka/ldap-passwd-webui :issues: {repo-uri}/issues :pulls: {repo-uri}/pull :tags: {repo-uri}/releases/tag == link:{tags}/v2.1.0[2.1.0] (2019-01-09) * Add support for changing password in multiple LDAPs at once. == link:{tags}/v2.0.0[2.0.0] (2017-07-14) * Update for ldap3 2.x. * Rename project to ldap-passwd-webui. * Use `logging` module for logging instead of `print()`. * Allow to enable bottle debug mode by setting environment variable `DEBUG`. == link:{tags}/v1.0.0[1.0.0] (2017-06-06) * First stable release. ================================================ FILE: LICENSE ================================================ The MIT License Copyright 2015-2017 Jakub Jirutka . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.adoc ================================================ = Web UI for changing LDAP password Jakub Jirutka //custom :proj-name: ldap-passwd-webui :gh-name: jirutka/{proj-name} :wikip-url: https://en.wikipedia.org/wiki :pypi-url: https://pypi.python.org/pypi The aim of this project is to provide a very simple web form for users to be able to change their password stored in LDAP or Active Directory (Samba 4 AD). It’s built with http://bottlepy.org[Bottle], a WSGI micro web-framework for Python. == Installation === Alpine Linux . Install package *ldap-passwd-webui-waitress* from the Alpine’s community repository: + [source, sh] apk add ldap-passwd-webui-waitress + IMPORTANT: This package is in Alpine stable since v3.7. You can also install it from _edge_ (unstable) branch. . Adjust configuration in `/etc/ldap-passwd-webui.ini` and `/etc/conf.d/`. . Start service ldap-passwd-webui: + [source] /etc/init.d/ldap-passwd-webui start === Manually Clone this repository and install dependencies: [source, sh, subs="+attributes"] ---- git clone git@github.com:{gh-name}.git cd {proj-name} pip install -r requirements.txt ---- Read the next sections to learn how to run it. === Requirements * Python 3.x * {pypi-url}/bottle/[bottle] * {pypi-url}/ldap3[ldap3] 2.x == Configuration Configuration is read from the file link:settings.ini.example[settings.ini]. You may change location of the settings file using the environment variable `CONF_FILE`. If you have Active Directory (or Samba 4 AD), then you *must* use encrypted connection (i.e. LDAPS or StartTLS) – AD doesn’t allow changing password via unencrypted connection. == Run it There are multiple ways how to run it: * with the built-in default WSGI server based on https://docs.python.org/3/library/wsgiref.html#module-wsgiref.simple_server[wsgiref], * under a {wikip-url}/Web_Server_Gateway_Interface[WSGI] server like https://uwsgi-docs.readthedocs.org[uWSGI], https://docs.pylonsproject.org/projects/waitress[Waitress], http://gunicorn.org[Gunicorn], … (recommended) * as a {wikip-url}/Common_Gateway_Interface[CGI] script. === Run with the built-in server Simply execute the `app.py`: [source, python] python3 app.py Then you can access the app on http://localhost:8080. The port and host may be changed in link:settings.ini.example[settings.ini]. === Run with Waitress [source, sh, subs="+attributes"] ---- cd {proj-name} waitress-serve --listen=*:8080 app:application ---- === Run with uWSGI and nginx If you have many micro-apps like this, it’s IMO kinda overkill to run each in a separate uWSGI process, isn’t it? It’s not so well known, but uWSGI allows to “mount” multiple application in a single uWSGI process and with a single socket. [source, ini, subs="+attributes"] .Sample uWSGI configuration: ---- [uwsgi] plugins = python3 socket = /run/uwsgi/main.sock chdir = /var/www/scripts logger = file:/var/log/uwsgi/main.log processes = 1 threads = 2 # map URI paths to applications mount = /admin/{proj-name}={proj-name}/app.py #mount = /admin/change-world=change-world/app.py manage-script-name = true ---- [source, nginx] .Sample nginx configuration as a reverse proxy in front of uWSGI: ---- server { listen 443 ssl; server_name example.org; ssl_certificate /etc/ssl/nginx/nginx.crt; ssl_certificate_key /etc/ssl/nginx/nginx.key; # uWSGI scripts location /admin/ { uwsgi_pass unix:/run/uwsgi/main.sock; include uwsgi_params; } } ---- == Screenshot image::doc/screenshot.png[] == License This project is licensed under http://opensource.org/licenses/MIT/[MIT License]. For the full text of the license, see the link:LICENSE[LICENSE] file. ================================================ FILE: app.py ================================================ #!/usr/bin/env python3 import bottle from bottle import get, post, static_file, request, route, template from bottle import SimpleTemplate from configparser import ConfigParser from ldap3 import Connection, Server from ldap3 import SIMPLE, SUBTREE from ldap3.core.exceptions import LDAPBindError, LDAPConstraintViolationResult, \ LDAPInvalidCredentialsResult, LDAPUserNameIsMandatoryError, \ LDAPSocketOpenError, LDAPExceptionError import logging import os from os import environ, path BASE_DIR = path.dirname(__file__) LOG = logging.getLogger(__name__) LOG_FORMAT = '%(asctime)s %(levelname)s: %(message)s' VERSION = '2.1.0' @get('/') def get_index(): return index_tpl() @post('/') def post_index(): form = request.forms.getunicode def error(msg): return index_tpl(username=form('username'), alerts=[('error', msg)]) if form('new-password') != form('confirm-password'): return error("Password doesn't match the confirmation!") if len(form('new-password')) < 8: return error("Password must be at least 8 characters long!") try: change_passwords(form('username'), form('old-password'), form('new-password')) except Error as e: LOG.warning("Unsuccessful attempt to change password for %s: %s" % (form('username'), e)) return error(str(e)) LOG.info("Password successfully changed for: %s" % form('username')) return index_tpl(alerts=[('success', "Password has been changed")]) @route('/static/', name='static') def serve_static(filename): return static_file(filename, root=path.join(BASE_DIR, 'static')) def index_tpl(**kwargs): return template('index', **kwargs) def connect_ldap(conf, **kwargs): server = Server(host=conf['host'], port=conf.getint('port', None), use_ssl=conf.getboolean('use_ssl', False), connect_timeout=5) return Connection(server, raise_exceptions=True, **kwargs) def change_passwords(username, old_pass, new_pass): changed = [] for key in (key for key in CONF.sections() if key == 'ldap' or key.startswith('ldap:')): LOG.debug("Changing password in %s for %s" % (key, username)) try: change_password(CONF[key], username, old_pass, new_pass) changed.append(key) except Error as e: for key in reversed(changed): LOG.info("Reverting password change in %s for %s" % (key, username)) try: change_password(CONF[key], username, new_pass, old_pass) except Error as e2: LOG.error('{}: {!s}'.format(e.__class__.__name__, e2)) raise e def change_password(conf, *args): try: if conf.get('type') == 'ad': change_password_ad(conf, *args) else: change_password_ldap(conf, *args) except (LDAPBindError, LDAPInvalidCredentialsResult, LDAPUserNameIsMandatoryError): raise Error('Username or password is incorrect!') except LDAPConstraintViolationResult as e: # Extract useful part of the error message (for Samba 4 / AD). msg = e.message.split('check_password_restrictions: ')[-1].capitalize() raise Error(msg) except LDAPSocketOpenError as e: LOG.error('{}: {!s}'.format(e.__class__.__name__, e)) raise Error('Unable to connect to the remote server.') except LDAPExceptionError as e: LOG.error('{}: {!s}'.format(e.__class__.__name__, e)) raise Error('Encountered an unexpected error while communicating with the remote server.') def change_password_ldap(conf, username, old_pass, new_pass): with connect_ldap(conf) as c: user_dn = find_user_dn(conf, c, username) # Note: raises LDAPUserNameIsMandatoryError when user_dn is None. with connect_ldap(conf, authentication=SIMPLE, user=user_dn, password=old_pass) as c: c.bind() c.extend.standard.modify_password(user_dn, old_pass, new_pass) def change_password_ad(conf, username, old_pass, new_pass): user = username + '@' + conf['ad_domain'] with connect_ldap(conf, authentication=SIMPLE, user=user, password=old_pass) as c: c.bind() user_dn = find_user_dn(conf, c, username) c.extend.microsoft.modify_password(user_dn, new_pass, old_pass) def find_user_dn(conf, conn, uid): search_filter = conf['search_filter'].replace('{uid}', uid) conn.search(conf['base'], "(%s)" % search_filter, SUBTREE) return conn.response[0]['dn'] if conn.response else None def read_config(): config = ConfigParser() config.read([path.join(BASE_DIR, 'settings.ini'), os.getenv('CONF_FILE', '')]) return config class Error(Exception): pass if environ.get('DEBUG'): bottle.debug(True) # Set up logging. logging.basicConfig(format=LOG_FORMAT) LOG.setLevel(logging.INFO) LOG.info("Starting ldap-passwd-webui %s" % VERSION) CONF = read_config() bottle.TEMPLATE_PATH = [BASE_DIR] # Set default attributes to pass into templates. SimpleTemplate.defaults = dict(CONF['html']) SimpleTemplate.defaults['url'] = bottle.url # Run bottle internal server when invoked directly (mainly for development). if __name__ == '__main__': bottle.run(**CONF['server']) # Run bottle in application mode (in production under uWSGI server). else: application = bottle.default_app() ================================================ FILE: index.tpl ================================================ {{ page_title }}

{{ page_title }}

%for type, text in get('alerts', []):
{{ text }}
%end
================================================ FILE: requirements.txt ================================================ bottle >= 0.12.8 ldap3 >= 2.0, < 3.0 configparser; python_version < '3.3' ================================================ FILE: settings.ini.example ================================================ [html] page_title = Change your password on example.org [ldap:0] host = localhost port = 636 use_ssl = true base = ou=People,dc=example,dc=org search_filter = uid={uid} # Uncomment for AD / Samba 4 #type = ad #ad_domain = ad.example.org #search_filter = sAMAccountName={uid} # You may specify multiple LDAPs, the password will be changed in all. # If one fails, the previous password changes are reverted. #[ldap:1] #host = localhost #base = ou=People,dc=example,dc=org #search_filter = uid={uid} [server] server = auto host = localhost port = 8080 ================================================ FILE: static/style.css ================================================ /* TODO make it cooler! */ body { font-family: sans-serif; color: #333; } main { margin: 0 auto; } h1 { font-size: 2em; margin-bottom: 2.5em; margin-top: 2em; text-align: center; } form { border-radius: 0.2rem; border: 1px solid #CCC; margin: 0 auto; max-width: 16rem; padding: 2rem 2.5rem 1.5rem 2.5rem; } input { background-color: #FAFAFA; border-radius: 0.2rem; border: 1px solid #CCC; box-shadow: inset 0 1px 3px #DDD; box-sizing: border-box; display: block; font-size: 1em; padding: 0.4em 0.6em; vertical-align: middle; width: 100%; } input:focus { background-color: #FFF; border-color: #51A7E8; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075) inset, 0 0 5px rgba(81, 167, 232, 0.5); outline: 0; } label { color: #666; display: block; font-size: 0.9em; font-weight: bold; margin: 1em 0 0.25em 0; } button { background-color: #60B044; background-image: linear-gradient(#8ADD6D, #60B044); border-radius: 0.2rem; border: 1px solid #5CA941; box-sizing: border-box; color: #fff; cursor: pointer; display: block; font-size: 0.9em; font-weight: bold; margin: 2em 0 0.5em 0; padding: 0.5em 0.7em; text-align: center; text-decoration: none; text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); user-select: none; vertical-align: middle; white-space: nowrap; } button:focus, button:hover { background-color: #569E3D; background-image: linear-gradient(#79D858, #569E3D); border-color: #4A993E; } .alerts { margin: 2rem auto 0 auto; max-width: 30rem; } .alert { border-radius: 0.2rem; border: 1px solid; color: #fff; padding: 0.7em 1.5em; } .alert.error { background-color: #E74C3C; border-color: #C0392B; } .alert.success { background-color: #60B044; border-color: #5CA941; } @media only screen and (max-width: 480px) { form { border: 0; } }