diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8daf0c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM php:7.0-apache + +COPY www/ /opt/ldap_user_manager +COPY entrypoint /usr/local/bin/entrypoint + +RUN apt-get update && apt-get install -y --no-install-recommends libldb-dev libldap2-dev && rm -rf /var/lib/apt/lists/* && ln -s /usr/lib/x86_64-linux-gnu/libldap.so /usr/lib/libldap.so \ +&& ln -s /usr/lib/x86_64-linux-gnu/liblber.so /usr/lib/liblber.so +RUN docker-php-source extract && docker-php-ext-install -j$(nproc) ldap && docker-php-source delete + +RUN chmod a+x /usr/local/bin/entrypoint +RUN a2enmod rewrite ssl +RUN a2dissite 000-default default-ssl + +EXPOSE 80 +EXPOSE 443 + +CMD ["apache2-foreground"] +ENTRYPOINT ["/usr/local/bin/entrypoint"] diff --git a/README.md b/README.md index 8c98bbd..6339743 100644 --- a/README.md +++ b/README.md @@ -1 +1,103 @@ -A PHP GUI admin interface for LDAP account management, designed to be run in a container. +LDAP User Manager +-- + +A PHP web-based interface for LDAP user account management and self-service password change. + + +Quick start +--- + +``` +docker run \ + --detach \ + --name=lum \ + -p 80:80 \ + -p 443:443 \ + -e SERVER_HOSTNAME=lum.example.com \ + -e LDAP_URI=ldap://ldap.example.com \ + -e LDAP_BASE_DN=dc=example,dc=com \ + -e LDAP_STARTTLS=TRUE \ + -e LDAP_ADMINS_GROUP=admins \ + -e LDAP_ADMIN_BIND_DN="cn=admin,dc=example,dc=com" \ + -e LDAP_ADMIN_BIND_PWD=secret\ + otrl/ldap-user-manager +``` +Now go to https://lum.example.com/setup. + + +Purpose +--- + +This presents a simple-to-use interface for setting up a new LDAP directory and managing user accounts and groups, as well as providing a way for users to change their own password. It's designed to complement OpenLDAP servers such as *osixia/openldap* (https://hub.docker.com/r/osixia/openldap/). + +*WARNING*: This interface should be used with populated LDAP directories with caution and at your own risk. + +This tool needs to bind to LDAP as a user with permissions to modify everything under the base DN. However, only members of a specific LDAP group will be able to access the management module. Other users will be able to change their LDAP password. + + +Configuration +--- + +Configuration is via environmental variables. + +Mandatory: +---- + +* `LDAP_URI`: The URI of the LDAP server. e.g. *ldap://ldap.example.com* or *ldaps://ldap.example.com* +* `LDAP_BASE_DN`: The base DN for your organisation. e.g. *dc=example,dc=com` +* `LDAP_ADMIN_BIND_DN`: The DN for the user with permission to modify all records under `LDAP_BASE_DN`. e.g. `cn=admin,dc=example,dc=com` +* `LDAPADMIN_BIND_PWD`: The password for `LDAP_ADMIN_BIND_DN` +* `LDAP_ADMINS_GROUP`: The name of the group used to define accounts that can use this tool to manage LDAP accounts. e.g. `admins` + +Optional: +---- + +* `LDAP_USER_OU` (default: *people*): The name of the OU used to store user accounts (without the base DN appended). + +* `LDAP_GROUP_OU` (default: *groups*): The name of the OU used to store groups (without the base DN appended). +* `LDAP_GROUP_MEMBERSHIP_ATTRIBUTE` (default: *uniqueMember*): The attribute used when adding a user to a group. +* `LDAP_GROUP_MEMBERSHIP_USES_UID`(default: *FALSE*): If *TRUE* then the entry for a member of a group will be just the username. Otherwise it's the member's full DN. + +* `LDAP_ACCOUNT_ATTRIBUTE` (default: *uid*): The attribute used to identify account usernames. + +* `LDAP_REQUIRE_STARTTLS` (default: *TRUE*): If *TRUE* then a TLS connection is required for this interface to work. If set to *FALSE* then the interface will work without STARTTLS, but a warning will be displayed on the page. + +* `DEFAULT_USER_GROUP` (default: *everybody*): The group that new accounts are automatically added to when created. *NOTE*: If this group doesn't exist then a group is created with the same name as the username and the user is added to that group. +* `DEFAULT_USER_SHELL` (default: */bin/bash*): The shell that will be launched when the user logs into a server. +* `EMAIL_DOMAIN` (no default): The domain name to append to the email address when creating an account (username@email_domain). If unset then the mail attribute won't be set. + +* `USERNAME_FORMAT` (default: *{first_name}.{last_name}*): The template used to dynamically generate usernames. See the _Usernames_ section below. +* `USERNAME_REGEX` (default: *^[a-z][a-zA-Z0-9\._-]{3,32}$*): The regular expression used to ensure a username (and group name) is valid. See the _Usernames_ section below. + +* `LOGIN_TIMEOUT_MINS` (default: 10 minutes): How long before an idle session will be timed out. + +* `SITE_NAME` (default: *LDAP user manager*): Change this to replace the title in the menu. e.g. "My Company" + + +Initial setup +--- + +Ideally you'll be using this against an empty LDAP directory. You can use the setup utility to create the LDAP structures that this tool needs in order to create accounts and groups. Go to https://_website-hostname_/setup to get started. You need to log in with the password for the admin user as set by `LDAP_ADMIN_BIND_DN`. +The setup utility will create the user and account trees, records that store the last UID and GID used when creating a user account or group, a group for admins and the initial admin account. + + +Username format +--- + +When entering the user's first and last names a bit of JavaScript automatically generates the username. The way it generates is it based on a template format defined by `USERNAME_FORMAT`. This is basically a string in which predefined macros are replaced by the formatted first and/or last name. +The default is `{first_name}-{last_name}` with which *Jonathan Testperson*'s username would be *jonathan-testperson*. +Currently the available macros are: + +* `{first_name}` : the first name in lowercase +* `{first_name_initial}` : the first letter of the first name in lowercase +* `{last_name}`: the last name in lowercase +* '{last_name_initial}`: the first initial of the last name in lowercase + +Anything else in the `USERNAME_FORMAT` string is left as defined, but the username is also checked for validity against `USERNAME_REGEX`. This is to ensure that there aren't any characters forbidden by other systems (i.e. email or Linux/Unix accounts). + + +Details on accounts and groups +--- + +This interface will create POSIX user accounts and groups, which allows you to use your LDAP directory for Linux/Unix accounts. +Groups are also created as a `groupOfUniqueNames` type in case you want to use the `memberOf` LDAP module. diff --git a/entrypoint b/entrypoint new file mode 100644 index 0000000..ba8e563 --- /dev/null +++ b/entrypoint @@ -0,0 +1,88 @@ +#!/bin/sh +set -e + +if [ ! "$SERVER_HOSTNAME" ]; then export SERVER_HOSTNAME=example.com; fi + +######################## +#If there aren't any SSL certs then create a self-signed certificate. + +if [ ! -f "/opt/ssl/server.key" ] && [ ! -f "/opt/ssl/server.crt" ]; then + + + ######################## + #Create self-signed cert + + mkdir -p /opt/ssl + + cat </opt/ssl/config +[req] +distinguished_name = req_distinguished_name +x509_extensions = v3_req +prompt = no +[req_distinguished_name] +C = GB +ST = London +L = London +O = LUM +OU = LUM +CN = $SERVER_HOSTNAME +[v3_req] +keyUsage = critical, digitalSignature, keyAgreement +extendedKeyUsage = serverAuth +subjectAltName = @alt_names +[alt_names] +DNS.1 = $SERVER_HOSTNAME +EoS + + /usr/bin/openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /opt/ssl/server.key -out /opt/ssl/server.crt -config /opt/ssl/config -sha256 + +fi + +######################## +#Create Apache config + + +if [ -f "/opt/tls/chain.pem" ]; then $ssl_chain="SSLCertificateChainFile /opt/tls/chain.pem"; fi + +cat </etc/apache2/sites-enabled/lum.conf + +Listen 443 + + + + RewriteEngine On + RewriteRule ^/?(.*) https://%{SERVER_NAME}/\$1 [R,L] + + + + + + ServerName $SERVER_HOSTNAME + DocumentRoot /opt/ldap_user_manager + + DirectoryIndex index.php index.html + + + Require all granted + + + SSLEngine On + SSLCertificateFile /opt/ssl/server.crt + SSLCertificateKeyFile /opt/ssl/server.key + $ssl_chain + + php_value include_path "/opt/ldap_user_manager/includes" + + +EoC + + +######################## +#Run Apache + +# first arg is `-f` or `--some-option` +if [ "${1#-}" != "$1" ]; then + set -- apache2-foreground "$@" +fi + +exec "$@" diff --git a/ldap_manager/groups.php b/www/account_manager/groups.php similarity index 95% rename from ldap_manager/groups.php rename to www/account_manager/groups.php index 63a1622..d9f5bfc 100644 --- a/ldap_manager/groups.php +++ b/www/account_manager/groups.php @@ -1,10 +1,9 @@ @@ -161,7 +160,7 @@ ldap_close($ldap_connection); $('.list-right ul li.active').removeClass('active'); actives.remove(); } - $("#submit_members").prop("disabled", false); + $("#submit_members").prop("disabled", FALSE); }); $('.dual-list .selector').click(function () { var $checkBox = $(this); diff --git a/ldap_manager/show_user.php b/www/account_manager/show_user.php similarity index 96% rename from ldap_manager/show_user.php rename to www/account_manager/show_user.php index db50aad..404a131 100644 --- a/ldap_manager/show_user.php +++ b/www/account_manager/show_user.php @@ -3,15 +3,15 @@ include_once("web_functions.inc.php"); include_once("ldap_functions.inc.php"); include_once("module_functions.inc.php"); - set_page_access("admin"); -render_header($WEBSITE_NAME); +render_header(); +render_submenu(); -$invalid_password = False; -$mismatched_passwords = False; -$invalid_username = False; -$weak_password = False; +$invalid_password = FALSE; +$mismatched_passwords = FALSE; +$invalid_username = FALSE; +$weak_password = FALSE; $attribute_map = array( "givenname" => "First name", "sn" => "Last name", @@ -78,10 +78,10 @@ if ($ldap_search) { $password = $_POST['password']; - if (!is_numeric($_POST['pass_score']) or $_POST['pass_score'] < 3) { $weak_password = True; } - if (preg_match("/\"|'/",$password)) { $invalid_password = True; } - if ($_POST['password'] != $_POST['password_match']) { $mismatched_passwords = True; } - if (!preg_match("/$USERNAME_REGEX/",$username)) { $invalid_username = True; } + if (!is_numeric($_POST['pass_score']) or $_POST['pass_score'] < 3) { $weak_password = TRUE; } + if (preg_match("/\"|'/",$password)) { $invalid_password = TRUE; } + if ($_POST['password'] != $_POST['password_match']) { $mismatched_passwords = TRUE; } + if (!preg_match("/$USERNAME_REGEX/",$username)) { $invalid_username = TRUE; } if ( !$mismatched_passwords and !$weak_password @@ -102,7 +102,7 @@ if ($ldap_search) { }, 4000); @@ -288,7 +288,7 @@ if ($ldap_search) { $('.list-right ul li.active').removeClass('active'); actives.remove(); } - $("#submit_members").prop("disabled", false); + $("#submit_members").prop("disabled", FALSE); }); $('.dual-list .selector').click(function () { var $checkBox = $(this); diff --git a/change_password/README.md b/www/change_password/README.md similarity index 100% rename from change_password/README.md rename to www/change_password/README.md diff --git a/change_password/index.php b/www/change_password/index.php similarity index 100% rename from change_password/index.php rename to www/change_password/index.php diff --git a/includes/config.inc.php b/www/includes/config.inc.php similarity index 84% rename from includes/config.inc.php rename to www/includes/config.inc.php index dd01641..996d920 100644 --- a/includes/config.inc.php +++ b/www/includes/config.inc.php @@ -15,19 +15,19 @@ $LDAP['user_ou'] = (getenv('LDAP_USER_OU') ? getenv('LDAP_USER_OU') : 'people'); $LDAP['group_membership_attribute'] = (getenv('LDAP_GROUP_MEMBERSHIP_ATTRIBUTE') ? getenv('LDAP_GROUP_MEMBERSHIP_ATTRIBUTE') : 'uniquemember'); - $LDAP['group_membership_uses_uid'] = (getenv('LDAP_GROUP_MEMBERSHIP_USES_UID') ? TRUE : FALSE); + $LDAP['group_membership_uses_uid'] = ((strcmp(getenv('LDAP_GROUP_MEMBERSHIP_USES_UID'),'TRUE') == 0) ? TRUE : FALSE); $LDAP['account_attribute'] = (getenv('LDAP_ACCOUNT_ATTRIBUTE') ? getenv('LDAP_ACCOUNT_ATTRIBUTE') : 'uid'); - $LDAP['starttls'] = (getenv('LDAP_STARTTLS') ? TRUE : FALSE); + $LDAP['require_starttls'] = ((strcmp(getenv('LDAP_REQUIRE_STARTTLS'),'TRUE') == 0) ? TRUE : FALSE); $DEFAULT_USER_GROUP = (getenv('DEFAULT_USER_GROUP') ? getenv('DEFAULT_USER_GROUP') : 'everybody'); $DEFAULT_USER_SHELL = (getenv('DEFAULT_USER_SHELL') ? getenv('DEFAULT_SHELL') : '/bin/bash'); $EMAIL_DOMAIN = (getenv('EMAIL_DOMAIN') ? getenv('EMAIL_DOMAIN') : Null); $LOGIN_TIMEOUT_MINS = (getenv('SESSION_TIMEOUT') ? getenv('SESSION_TIMEOUT') : 10); - $WEBSITE_NAME = (getenv('SITE_NAME') ? getenv('SITE_NAME') : 'LDAP user manager'); + $SITE_NAME = (getenv('SITE_NAME') ? getenv('SITE_NAME') : 'LDAP user manager'); - $USERNAME_FORMAT = (getenv('USERNAME_SEPARATOR') ? getenv('USERNAME_SEPARATOR') : '{first_name}.{last_name}'); + $USERNAME_FORMAT = (getenv('USERNAME+FORMAT') ? getenv('USERNAME_FORMAT') : '{first_name}-{last_name}'); $USERNAME_REGEX = '^[a-z][a-zA-Z0-9\._-]{3,32}$'; #We'll use the username regex for groups too. diff --git a/includes/ldap_functions.inc.php b/www/includes/ldap_functions.inc.php similarity index 95% rename from includes/ldap_functions.inc.php rename to www/includes/ldap_functions.inc.php index 4168eb2..2d37f4a 100644 --- a/includes/ldap_functions.inc.php +++ b/www/includes/ldap_functions.inc.php @@ -6,7 +6,7 @@ $log_prefix = date('Y-m-d H:i:s') . " - LDAP manager - $USER_ID - "; function open_ldap_connection() { - global $log_prefix, $LDAP, $ENCRYPTED; + global $log_prefix, $LDAP, $LDAP_CONNECTION_WARNING; $ldap_connection = ldap_connect($LDAP['uri']); @@ -18,6 +18,29 @@ function open_ldap_connection() { ldap_set_option($ldap_connection, LDAP_OPT_PROTOCOL_VERSION, 3); + + if (!preg_match("/^ldaps:/", $LDAP['uri'])) { + + $tls_result = ldap_start_tls($ldap_connection); + + if ($tls_result != TRUE) { + + error_log("$log_prefix Failed to start STARTTLS connection to ${LDAP['uri']}",0); + + if ($LDAP["require_starttls"] == TRUE) { + print "
Fatal: Couldn't create a secure connection to ${LDAP['uri']} and LDAP_REQUIRE_STARTTLS is TRUE.
"; + exit(0); + } + else { + print "
WARNING: Insecure LDAP connection to ${LDAP['uri']}
"; + + ldap_close($ldap_connection); + $ldap_connection = ldap_connect($LDAP['uri']); + ldap_set_option($ldap_connection, LDAP_OPT_PROTOCOL_VERSION, 3); + } + } + } + $bind_result = ldap_bind( $ldap_connection, $LDAP['admin_bind_dn'], $LDAP['admin_bind_pwd']); if ($bind_result != TRUE) { @@ -26,16 +49,6 @@ function open_ldap_connection() { exit(1); } - if ($LDAP["starttls"]) { - $tls_result = ldap_start_tls($ldap_connection); - if ($tls_result != TRUE) { - error_log("$log_prefix Failed to start STARTTLS connection to ${LDAP['uri']}",0); - } - else { - $ENCRYPTED=TRUE; - } - } - return $ldap_connection; } @@ -96,30 +109,6 @@ function ldap_setup_auth($ldap_connection, $password) { } -################################### - -function ldap_check_is_admin($username, $ldap_connection) { - - #Checks to see if $username is in the group defined by $LDAP['admins_group'] - - global $log_prefix, $LDAP; - - ##Check via memberOf. - ##TODO: check via parsing group membership otherwise. - - $this_filter="(&(${LDAP['account_attribute']}=${username})(memberOf=cn=${LDAP['admins_group']},${LDAP['group_dn']}))"; - $ldap_search = ldap_search( $ldap_connection, $LDAP['base_dn'], $this_filter); - $no_results = ldap_count_entries($ldap_connection,$ldap_search); - - if ($no_results == 1) { - return TRUE; - } - else { - return FALSE; - } -} - - ################################## diff --git a/includes/modules.inc.php b/www/includes/modules.inc.php similarity index 88% rename from includes/modules.inc.php rename to www/includes/modules.inc.php index ec3e2ef..ae94cc2 100644 --- a/includes/modules.inc.php +++ b/www/includes/modules.inc.php @@ -10,7 +10,7 @@ $MODULES = array( 'log_in' => 'hidden_on_login', 'change_password' => 'auth', - 'ldap_manager' => 'admin', + 'account_manager' => 'admin', 'log_out' => 'auth' ); diff --git a/includes/web_functions.inc.php b/www/includes/web_functions.inc.php similarity index 82% rename from includes/web_functions.inc.php rename to www/includes/web_functions.inc.php index 864aacb..0c54e72 100644 --- a/includes/web_functions.inc.php +++ b/www/includes/web_functions.inc.php @@ -2,10 +2,9 @@ #Security level vars -$VALIDATED = False; -$IS_ADMIN = False; -$IS_SETUP_ADMIN = False; -$ENCRYPTED = False; +$VALIDATED = FALSE; +$IS_ADMIN = FALSE; +$IS_SETUP_ADMIN = FALSE; $ACCESS_LEVEL_NAME = array('account','admin'); unset($USER_ID); $CURRENT_PAGE=htmlentities($_SERVER['PHP_SELF']); @@ -30,7 +29,7 @@ function generate_passkey() { $rnd2 = rand(10000000,100000000000); $rnd3 = rand(10000000,100000000000); return sprintf("%0x",$rnd1) . sprintf("%0x",$rnd2) . sprintf("%0x",$rnd3); - + } @@ -42,20 +41,20 @@ function set_passkey_cookie($user_id,$is_admin) { global $LOGIN_TIMEOUT_MINS, $VALIDATED, $USER_ID, $IS_ADMIN; - + $passkey = generate_passkey(); $this_time=time(); $admin_val = 0; - if ($is_admin == True ) { + if ($is_admin == TRUE ) { $admin_val = 1; - $IS_ADMIN = True; + $IS_ADMIN = TRUE; } $filename = preg_replace('/[^a-zA-Z0-9]/','_', $user_id); file_put_contents("/tmp/$filename","$passkey:$admin_val:$this_time"); setcookie('orf_cookie', "$user_id:$passkey", $this_time+(60 * $LOGIN_TIMEOUT_MINS), '/', $_SERVER["HTTP_HOST"]); - $VALIDATED = True; + $VALIDATED = TRUE; } @@ -71,15 +70,21 @@ function validate_passkey_cookie() { list($user_id,$c_passkey) = explode(":",$_COOKIE['orf_cookie']); $filename = preg_replace('/[^a-zA-Z0-9]/','_', $user_id); $session_file = file_get_contents("/tmp/$filename"); - list($f_passkey,$f_is_admin,$f_time) = explode(":",$session_file); - $this_time=time(); - if (!empty($c_passkey) and $f_passkey == $c_passkey and $this_time < $f_time+(60 * $LOGIN_TIMEOUT_MINS)) { - if ($f_is_admin == 1) { $IS_ADMIN = True; } - $VALIDATED = True; - $USER_ID=$user_id; - set_passkey_cookie($USER_ID,$IS_ADMIN); + if (!$session_file) { + $VALIDATED = FALSE; + unset($USER_ID); + $IS_ADMIN = FALSE; + } + else { + list($f_passkey,$f_is_admin,$f_time) = explode(":",$session_file); + $this_time=time(); + if (!empty($c_passkey) and $f_passkey == $c_passkey and $this_time < $f_time+(60 * $LOGIN_TIMEOUT_MINS)) { + if ($f_is_admin == 1) { $IS_ADMIN = TRUE; } + $VALIDATED = TRUE; + $USER_ID=$user_id; + set_passkey_cookie($USER_ID,$IS_ADMIN); + } } - } } @@ -95,7 +100,7 @@ function set_setup_cookie() { $passkey = generate_passkey(); $this_time=time(); - $IS_SETUP_ADMIN = True; + $IS_SETUP_ADMIN = TRUE; file_put_contents("/tmp/ldap_setup","$passkey:$this_time"); setcookie('setup_cookie', "$passkey", $this_time+(60 * $LOGIN_TIMEOUT_MINS), '/', $_SERVER["HTTP_HOST"]); @@ -113,10 +118,13 @@ function validate_setup_cookie() { $c_passkey = $_COOKIE['setup_cookie']; $session_file = file_get_contents("/tmp/ldap_setup"); + if (!$session_file) { + $IS_SETUP_ADMIN = FALSE; + } list($f_passkey,$f_time) = explode(":",$session_file); $this_time=time(); if (!empty($c_passkey) and $f_passkey == $c_passkey and $this_time < $f_time+(60 * $LOGIN_TIMEOUT_MINS)) { - $IS_SETUP_ADMIN = True; + $IS_SETUP_ADMIN = TRUE; set_setup_cookie(); } @@ -148,9 +156,9 @@ function log_out($method='normal') { function render_header($title="",$menu=TRUE) { - global $WEBSITE_NAME, $IS_ADMIN; + global $SITE_NAME, $IS_ADMIN, $LDAP_CONNECTION_WARNING; - if (empty($title)) { $title = $WEBSITE_NAME; } + if (empty($title)) { $title = $SITE_NAME; } #Initialise the HTML output for the page. @@ -167,7 +175,7 @@ function render_header($title="",$menu=TRUE) {