[
  {
    "path": ".editorconfig",
    "content": "[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_style = space\nindent_size = 4\n\n[*.{css,tpl}]\nindent_size = 2\n"
  },
  {
    "path": ".gitignore",
    "content": "/settings.ini\n"
  },
  {
    "path": "CHANGELOG.adoc",
    "content": "= Changelog\n:repo-uri: https://github.com/jirutka/ldap-passwd-webui\n:issues: {repo-uri}/issues\n:pulls: {repo-uri}/pull\n:tags: {repo-uri}/releases/tag\n\n\n== link:{tags}/v2.1.0[2.1.0] (2019-01-09)\n\n* Add support for changing password in multiple LDAPs at once.\n\n\n== link:{tags}/v2.0.0[2.0.0] (2017-07-14)\n\n* Update for ldap3 2.x.\n* Rename project to ldap-passwd-webui.\n* Use `logging` module for logging instead of `print()`.\n* Allow to enable bottle debug mode by setting environment variable `DEBUG`.\n\n\n== link:{tags}/v1.0.0[1.0.0] (2017-06-06)\n\n* First stable release.\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License\n\nCopyright 2015-2017 Jakub Jirutka <jakub@jirutka.cz>.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.adoc",
    "content": "= Web UI for changing LDAP password\nJakub Jirutka <https://github.com/jirutka[@jirutka]>\n//custom\n:proj-name: ldap-passwd-webui\n:gh-name: jirutka/{proj-name}\n:wikip-url: https://en.wikipedia.org/wiki\n:pypi-url: https://pypi.python.org/pypi\n\nThe 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).\nIt’s built with http://bottlepy.org[Bottle], a WSGI micro web-framework for Python.\n\n\n== Installation\n\n=== Alpine Linux\n\n. Install package *ldap-passwd-webui-waitress* from the Alpine’s community repository:\n+\n[source, sh]\napk add ldap-passwd-webui-waitress\n+\nIMPORTANT: This package is in Alpine stable since v3.7. You can also install it from _edge_ (unstable) branch.\n\n. Adjust configuration in `/etc/ldap-passwd-webui.ini` and `/etc/conf.d/`.\n\n. Start service ldap-passwd-webui:\n+\n[source]\n/etc/init.d/ldap-passwd-webui start\n\n=== Manually\n\nClone this repository and install dependencies:\n\n[source, sh, subs=\"+attributes\"]\n----\ngit clone git@github.com:{gh-name}.git\ncd {proj-name}\npip install -r requirements.txt\n----\n\nRead the next sections to learn how to run it.\n\n=== Requirements\n\n* Python 3.x\n* {pypi-url}/bottle/[bottle]\n* {pypi-url}/ldap3[ldap3] 2.x\n\n\n== Configuration\n\nConfiguration is read from the file link:settings.ini.example[settings.ini].\nYou may change location of the settings file using the environment variable `CONF_FILE`.\n\nIf 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.\n\n\n== Run it\n\nThere are multiple ways how to run it:\n\n* with the built-in default WSGI server based on https://docs.python.org/3/library/wsgiref.html#module-wsgiref.simple_server[wsgiref],\n* 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)\n* as a {wikip-url}/Common_Gateway_Interface[CGI] script.\n\n=== Run with the built-in server\n\nSimply execute the `app.py`:\n\n[source, python]\npython3 app.py\n\nThen you can access the app on http://localhost:8080.\nThe port and host may be changed in link:settings.ini.example[settings.ini].\n\n\n=== Run with Waitress\n\n[source, sh, subs=\"+attributes\"]\n----\ncd {proj-name}\nwaitress-serve --listen=*:8080 app:application\n----\n\n=== Run with uWSGI and nginx\n\nIf you have many micro-apps like this, it’s IMO kinda overkill to run each in a separate uWSGI process, isn’t it?\nIt’s not so well known, but uWSGI allows to “mount” multiple application in a single uWSGI process and with a single socket.\n\n[source, ini, subs=\"+attributes\"]\n.Sample uWSGI configuration:\n----\n[uwsgi]\nplugins = python3\nsocket = /run/uwsgi/main.sock\nchdir = /var/www/scripts\nlogger = file:/var/log/uwsgi/main.log\nprocesses = 1\nthreads = 2\n# map URI paths to applications\nmount = /admin/{proj-name}={proj-name}/app.py\n#mount = /admin/change-world=change-world/app.py\nmanage-script-name = true\n----\n\n[source, nginx]\n.Sample nginx configuration as a reverse proxy in front of uWSGI:\n----\nserver {\n    listen 443 ssl;\n    server_name example.org;\n\n    ssl_certificate     /etc/ssl/nginx/nginx.crt;\n    ssl_certificate_key /etc/ssl/nginx/nginx.key;\n\n    # uWSGI scripts\n    location /admin/ {\n        uwsgi_pass  unix:/run/uwsgi/main.sock;\n        include     uwsgi_params;\n    }\n}\n----\n\n== Screenshot\n\nimage::doc/screenshot.png[]\n\n\n== License\n\nThis project is licensed under http://opensource.org/licenses/MIT/[MIT License].\nFor the full text of the license, see the link:LICENSE[LICENSE] file.\n"
  },
  {
    "path": "app.py",
    "content": "#!/usr/bin/env python3\n\nimport bottle\nfrom bottle import get, post, static_file, request, route, template\nfrom bottle import SimpleTemplate\nfrom configparser import ConfigParser\nfrom ldap3 import Connection, Server\nfrom ldap3 import SIMPLE, SUBTREE\nfrom ldap3.core.exceptions import LDAPBindError, LDAPConstraintViolationResult, \\\n    LDAPInvalidCredentialsResult, LDAPUserNameIsMandatoryError, \\\n    LDAPSocketOpenError, LDAPExceptionError\nimport logging\nimport os\nfrom os import environ, path\n\n\nBASE_DIR = path.dirname(__file__)\nLOG = logging.getLogger(__name__)\nLOG_FORMAT = '%(asctime)s %(levelname)s: %(message)s'\nVERSION = '2.1.0'\n\n\n@get('/')\ndef get_index():\n    return index_tpl()\n\n\n@post('/')\ndef post_index():\n    form = request.forms.getunicode\n\n    def error(msg):\n        return index_tpl(username=form('username'), alerts=[('error', msg)])\n\n    if form('new-password') != form('confirm-password'):\n        return error(\"Password doesn't match the confirmation!\")\n\n    if len(form('new-password')) < 8:\n        return error(\"Password must be at least 8 characters long!\")\n\n    try:\n        change_passwords(form('username'), form('old-password'), form('new-password'))\n    except Error as e:\n        LOG.warning(\"Unsuccessful attempt to change password for %s: %s\" % (form('username'), e))\n        return error(str(e))\n\n    LOG.info(\"Password successfully changed for: %s\" % form('username'))\n\n    return index_tpl(alerts=[('success', \"Password has been changed\")])\n\n\n@route('/static/<filename>', name='static')\ndef serve_static(filename):\n    return static_file(filename, root=path.join(BASE_DIR, 'static'))\n\n\ndef index_tpl(**kwargs):\n    return template('index', **kwargs)\n\n\ndef connect_ldap(conf, **kwargs):\n    server = Server(host=conf['host'],\n                    port=conf.getint('port', None),\n                    use_ssl=conf.getboolean('use_ssl', False),\n                    connect_timeout=5)\n\n    return Connection(server, raise_exceptions=True, **kwargs)\n\n\ndef change_passwords(username, old_pass, new_pass):\n    changed = []\n\n    for key in (key for key in CONF.sections()\n                if key == 'ldap' or key.startswith('ldap:')):\n\n        LOG.debug(\"Changing password in %s for %s\" % (key, username))\n        try:\n            change_password(CONF[key], username, old_pass, new_pass)\n            changed.append(key)\n        except Error as e:\n            for key in reversed(changed):\n                LOG.info(\"Reverting password change in %s for %s\" % (key, username))\n                try:\n                    change_password(CONF[key], username, new_pass, old_pass)\n                except Error as e2:\n                    LOG.error('{}: {!s}'.format(e.__class__.__name__, e2))\n            raise e\n\n\ndef change_password(conf, *args):\n    try:\n        if conf.get('type') == 'ad':\n            change_password_ad(conf, *args)\n        else:\n            change_password_ldap(conf, *args)\n\n    except (LDAPBindError, LDAPInvalidCredentialsResult, LDAPUserNameIsMandatoryError):\n        raise Error('Username or password is incorrect!')\n\n    except LDAPConstraintViolationResult as e:\n        # Extract useful part of the error message (for Samba 4 / AD).\n        msg = e.message.split('check_password_restrictions: ')[-1].capitalize()\n        raise Error(msg)\n\n    except LDAPSocketOpenError as e:\n        LOG.error('{}: {!s}'.format(e.__class__.__name__, e))\n        raise Error('Unable to connect to the remote server.')\n\n    except LDAPExceptionError as e:\n        LOG.error('{}: {!s}'.format(e.__class__.__name__, e))\n        raise Error('Encountered an unexpected error while communicating with the remote server.')\n\n\ndef change_password_ldap(conf, username, old_pass, new_pass):\n    with connect_ldap(conf) as c:\n        user_dn = find_user_dn(conf, c, username)\n\n    # Note: raises LDAPUserNameIsMandatoryError when user_dn is None.\n    with connect_ldap(conf, authentication=SIMPLE, user=user_dn, password=old_pass) as c:\n        c.bind()\n        c.extend.standard.modify_password(user_dn, old_pass, new_pass)\n\n\ndef change_password_ad(conf, username, old_pass, new_pass):\n    user = username + '@' + conf['ad_domain']\n\n    with connect_ldap(conf, authentication=SIMPLE, user=user, password=old_pass) as c:\n        c.bind()\n        user_dn = find_user_dn(conf, c, username)\n        c.extend.microsoft.modify_password(user_dn, new_pass, old_pass)\n\n\ndef find_user_dn(conf, conn, uid):\n    search_filter = conf['search_filter'].replace('{uid}', uid)\n    conn.search(conf['base'], \"(%s)\" % search_filter, SUBTREE)\n\n    return conn.response[0]['dn'] if conn.response else None\n\n\ndef read_config():\n    config = ConfigParser()\n    config.read([path.join(BASE_DIR, 'settings.ini'), os.getenv('CONF_FILE', '')])\n\n    return config\n\n\nclass Error(Exception):\n    pass\n\n\nif environ.get('DEBUG'):\n    bottle.debug(True)\n\n# Set up logging.\nlogging.basicConfig(format=LOG_FORMAT)\nLOG.setLevel(logging.INFO)\nLOG.info(\"Starting ldap-passwd-webui %s\" % VERSION)\n\nCONF = read_config()\n\nbottle.TEMPLATE_PATH = [BASE_DIR]\n\n# Set default attributes to pass into templates.\nSimpleTemplate.defaults = dict(CONF['html'])\nSimpleTemplate.defaults['url'] = bottle.url\n\n\n# Run bottle internal server when invoked directly (mainly for development).\nif __name__ == '__main__':\n    bottle.run(**CONF['server'])\n# Run bottle in application mode (in production under uWSGI server).\nelse:\n    application = bottle.default_app()\n"
  },
  {
    "path": "index.tpl",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n\n    <title>{{ page_title }}</title>\n\n    <link rel=\"stylesheet\" href=\"{{ url('static', filename='style.css') }}\">\n  </head>\n\n  <body>\n    <main>\n      <h1>{{ page_title }}</h1>\n\n      <form method=\"post\">\n        <label for=\"username\">Username</label>\n        <input id=\"username\" name=\"username\" value=\"{{ get('username', '') }}\" type=\"text\" required autofocus>\n\n        <label for=\"old-password\">Old password</label>\n        <input id=\"old-password\" name=\"old-password\" type=\"password\" required>\n\n        <label for=\"new-password\">New password</label>\n        <input id=\"new-password\" name=\"new-password\" type=\"password\"\n            pattern=\".{8,}\" oninvalid=\"SetCustomValidity('Password must be at least 8 characters long.')\" required>\n\n        <label for=\"confirm-password\">Confirm new password</label>\n        <input id=\"confirm-password\" name=\"confirm-password\" type=\"password\"\n            pattern=\".{8,}\" oninvalid=\"SetCustomValidity('Password must be at least 8 characters long.')\" required>\n\n        <button type=\"submit\">Update password</button>\n      </form>\n\n      <div class=\"alerts\">\n        %for type, text in get('alerts', []):\n          <div class=\"alert {{ type }}\">{{ text }}</div>\n        %end\n      </div>\n    </main>\n  </body>\n</html>\n"
  },
  {
    "path": "requirements.txt",
    "content": "bottle >= 0.12.8\nldap3 >= 2.0, < 3.0\nconfigparser; python_version < '3.3'\n"
  },
  {
    "path": "settings.ini.example",
    "content": "[html]\npage_title = Change your password on example.org\n\n[ldap:0]\nhost = localhost\nport = 636\nuse_ssl = true\nbase = ou=People,dc=example,dc=org\nsearch_filter = uid={uid}\n\n# Uncomment for AD / Samba 4\n#type = ad\n#ad_domain = ad.example.org\n#search_filter = sAMAccountName={uid}\n\n# You may specify multiple LDAPs, the password will be changed in all.\n# If one fails, the previous password changes are reverted.\n#[ldap:1]\n#host = localhost\n#base = ou=People,dc=example,dc=org\n#search_filter = uid={uid}\n\n[server]\nserver = auto\nhost = localhost\nport = 8080\n"
  },
  {
    "path": "static/style.css",
    "content": "/* TODO make it cooler! */\n\nbody {\n  font-family: sans-serif;\n  color: #333;\n}\n\nmain {\n  margin: 0 auto;\n}\n\nh1 {\n  font-size: 2em;\n  margin-bottom: 2.5em;\n  margin-top: 2em;\n  text-align: center;\n}\n\nform {\n  border-radius: 0.2rem;\n  border: 1px solid #CCC;\n  margin: 0 auto;\n  max-width: 16rem;\n  padding: 2rem 2.5rem 1.5rem 2.5rem;\n}\n\ninput {\n  background-color: #FAFAFA;\n  border-radius: 0.2rem;\n  border: 1px solid #CCC;\n  box-shadow: inset 0 1px 3px #DDD;\n  box-sizing: border-box;\n  display: block;\n  font-size: 1em;\n  padding: 0.4em 0.6em;\n  vertical-align: middle;\n  width: 100%;\n}\n\ninput:focus {\n  background-color: #FFF;\n  border-color: #51A7E8;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075) inset, 0 0 5px rgba(81, 167, 232, 0.5);\n  outline: 0;\n}\n\nlabel {\n  color: #666;\n  display: block;\n  font-size: 0.9em;\n  font-weight: bold;\n  margin: 1em 0 0.25em 0;\n}\n\nbutton {\n  background-color: #60B044;\n  background-image: linear-gradient(#8ADD6D, #60B044);\n  border-radius: 0.2rem;\n  border: 1px solid #5CA941;\n  box-sizing: border-box;\n  color: #fff;\n  cursor: pointer;\n  display: block;\n  font-size: 0.9em;\n  font-weight: bold;\n  margin: 2em 0 0.5em 0;\n  padding: 0.5em 0.7em;\n  text-align: center;\n  text-decoration: none;\n  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3);\n  user-select: none;\n  vertical-align: middle;\n  white-space: nowrap;\n}\n\nbutton:focus,\nbutton:hover {\n  background-color: #569E3D;\n  background-image: linear-gradient(#79D858, #569E3D);\n  border-color: #4A993E;\n}\n\n.alerts {\n  margin: 2rem auto 0 auto;\n  max-width: 30rem;\n}\n\n.alert {\n  border-radius: 0.2rem;\n  border: 1px solid;\n  color: #fff;\n  padding: 0.7em 1.5em;\n}\n\n.alert.error {\n  background-color: #E74C3C;\n  border-color: #C0392B;\n}\n\n.alert.success {\n  background-color: #60B044;\n  border-color: #5CA941;\n}\n\n\n@media only screen and (max-width: 480px) {\n\n  form {\n    border: 0;\n  }\n}\n"
  }
]